diff --git a/rsconnect/api.py b/rsconnect/api.py index a3e4ee41..20c973a6 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -19,6 +19,7 @@ import re from warnings import warn import gc +import click from . import validation from .certificates import read_certificate_file @@ -389,6 +390,7 @@ def output_task_log(task_status, last_status, log_callback): class RSConnectExecutor: def __init__( self, + cli_ctx: click.Context = None, name: str = None, url: str = None, api_key: str = None, @@ -406,7 +408,9 @@ def __init__( self.reset() self._d = kwargs self.logger = logger + self.cli_ctx = cli_ctx self.setup_remote_server( + ctx=cli_ctx, name=name, url=url or kwargs.get("server"), api_key=api_key, @@ -442,8 +446,32 @@ def drop_context(self): gc.collect() return self + def output_overlap_header(self, previous): + if self.logger and not previous: + self.logger.warning( + "Connect detected CLI commands and/or environment variables that overlap with stored credential.\n" + ) + self.logger.warning( + "Check your environment variables (e.g. CONNECT_API_KEY) to make sure you want them to be used.\n" + ) + self.logger.warning( + "Credential parameters are taken with the following precedence: stored > CLI > environment.\n" + ) + self.logger.warning( + "To ignore an environment variable, override it in the CLI with an empty string (e.g. -k '').\n" + ) + return True + + def output_overlap_details(self, cli_param, previous): + new_previous = self.output_overlap_header(previous) + if self.cli_ctx: + source = self.cli_ctx.get_parameter_source(cli_param) + self.logger.warning(f"stored {cli_param} value overrides the {cli_param} value from {source.name}") + return new_previous + def setup_remote_server( self, + ctx: click.Context, name: str = None, url: str = None, api_key: str = None, @@ -455,6 +483,7 @@ def setup_remote_server( secret: str = None, ): validation.validate_connection_options( + ctx=ctx, url=url, api_key=api_key, insecure=insecure, @@ -464,6 +493,7 @@ def setup_remote_server( secret=secret, name=name, ) + header_output = False if cacert and not ca_data: ca_data = read_certificate_file(cacert) @@ -471,38 +501,20 @@ def setup_remote_server( server_data = ServerStore().resolve(name, url) if server_data.from_store: url = server_data.url - if ( - server_data.api_key - and api_key - or server_data.insecure - and insecure - or server_data.ca_data - and ca_data - or server_data.account_name - and account_name - or server_data.token - and token - or server_data.secret - and secret - ) and self.logger: - self.logger.warning( - "Connect detected CLI commands and/or environment variables that overlap with stored credential.\n" - ) - self.logger.warning( - "Check your environment variables (e.g. CONNECT_API_KEY) to make sure you want them to be used.\n" - ) - self.logger.warning( - "Credential paremeters are taken with the following precedence: stored > CLI > environment.\n" - ) - self.logger.warning( - "To ignore an environment variable, override it in the CLI with an empty string (e.g. -k '').\n" - ) - api_key = server_data.api_key or api_key - insecure = server_data.insecure or insecure - ca_data = server_data.ca_data or ca_data - account_name = server_data.account_name or account_name - token = server_data.token or token - secret = server_data.secret or secret + if self.logger: + if server_data.api_key and api_key: + header_output = self.output_overlap_details("api-key", header_output) + if server_data.insecure and insecure: + header_output = self.output_overlap_details("insecure", header_output) + if server_data.ca_data and ca_data: + header_output = self.output_overlap_details("cacert", header_output) + if server_data.account_name and account_name: + header_output = self.output_overlap_details("account", header_output) + if server_data.token and token: + header_output = self.output_overlap_details("token", header_output) + if server_data.secret and secret: + header_output = self.output_overlap_details("secret", header_output) + self.is_server_from_store = server_data.from_store if api_key: @@ -571,7 +583,7 @@ def validate_connect_server( :param url: the URL, if any, specified by the user. :param api_key: the API key, if any, specified by the user. :param insecure: a flag noting whether TLS host/validation should be skipped. - :param cacert: the file object of a CA certs file containing certificates to use. + :param cacert: the file path of a CA certs file containing certificates to use. :param api_key_is_required: a flag that notes whether the API key is required or may be omitted. :param token: The shinyapps.io authentication token. diff --git a/rsconnect/certificates.py b/rsconnect/certificates.py index 3cf79247..72bc7c64 100644 --- a/rsconnect/certificates.py +++ b/rsconnect/certificates.py @@ -21,12 +21,12 @@ def read_certificate_file(location: str): suffix = path.suffix if suffix in BINARY_ENCODED_FILETYPES: - with open(path, "rb") as file: - return file.read() + with open(path, "rb") as bFile: + return bFile.read() if suffix in TEXT_ENCODED_FILETYPES: - with open(path, "r") as file: - return file.read() + with open(path, "r") as tFile: + return tFile.read() types = BINARY_ENCODED_FILETYPES + TEXT_ENCODED_FILETYPES types = sorted(types) diff --git a/rsconnect/main.py b/rsconnect/main.py index e5bb0ed9..0e9191a2 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -115,27 +115,30 @@ def server_args(func): "--server", "-s", envvar="CONNECT_SERVER", - help="The URL for the Posit Connect server to deploy to.", + help="The URL for the Posit Connect server to deploy to. \ +(Also settable via CONNECT_SERVER environment variable.)", ) @click.option( "--api-key", "-k", envvar="CONNECT_API_KEY", - help="The API key to use to authenticate with Posit Connect.", + help="The API key to use to authenticate with Posit Connect. \ +(Also settable via CONNECT_API_KEY environment variable.)", ) @click.option( "--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, - help="Disable TLS certification/host validation.", + help="Disable TLS certification/host validation. (Also settable via CONNECT_INSECURE environment variable.)", ) @click.option( "--cacert", "-c", envvar="CONNECT_CA_CERTIFICATE", type=click.Path(exists=True, file_okay=True, dir_okay=False), - help="The path to trusted TLS CA certificates.", + help="The path to trusted TLS CA certificates. (Also settable via \ +CONNECT_CA_CERTIFICATE environment variable.)", ) @click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.") @functools.wraps(func) @@ -150,19 +153,22 @@ def cloud_shinyapps_args(func): "--account", "-A", envvar=["SHINYAPPS_ACCOUNT"], - help="The shinyapps.io/Posit Cloud account name.", + help="The shinyapps.io/Posit Cloud account name. (Also settable via \ +SHINYAPPS_ACCOUNT environment variable.)", ) @click.option( "--token", "-T", envvar=["SHINYAPPS_TOKEN", "RSCLOUD_TOKEN"], - help="The shinyapps.io/Posit Cloud token.", + help="The shinyapps.io/Posit Cloud token. (Also settable via \ +SHINYAPPS_TOKEN or RSCLOUD_TOKEN environment variables.)", ) @click.option( "--secret", "-S", envvar=["SHINYAPPS_SECRET", "RSCLOUD_SECRET"], - help="The shinyapps.io/Posit Cloud token secret.", + help="The shinyapps.io/Posit Cloud token secret. \ +(Also settable via SHINYAPPS_SECRET or RSCLOUD_SECRET environment variables.)", ) @functools.wraps(func) def wrapper(*args, **kwargs): @@ -385,21 +391,21 @@ def _test_rstudio_creds(server: api.PositServer): "-s", envvar="CONNECT_SERVER", required=True, - help="The URL for the RStudio Connect server.", + help="The URL for the RStudio Connect server. (Also settable via CONNECT_SERVER environment variable.)", ) @click.option( "--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, - help="Disable TLS certification/host validation.", + help="Disable TLS certification/host validation. (Also settable via CONNECT_INSECURE environment variable.)", ) @click.option( "--cacert", "-c", envvar="CONNECT_CA_CERTIFICATE", type=click.Path(exists=True, file_okay=True, dir_okay=False), - help="The path to trusted TLS CA certificates.", + help="The path to trusted TLS CA certificates. (Also settable via CONNECT_CA_CERTIFICATE environment variable.)", ) @click.option( "--jwt-keypath", @@ -468,27 +474,30 @@ def bootstrap( "--server", "-s", envvar="CONNECT_SERVER", - help="The URL for the Posit Connect server to deploy to, OR rstudio.cloud OR shinyapps.io.", + help="The URL for the Posit Connect server to deploy to, OR \ +rstudio.cloud OR shinyapps.io. (Also settable via CONNECT_SERVER \ +environment variable.)", ) @click.option( "--api-key", "-k", envvar="CONNECT_API_KEY", - help="The API key to use to authenticate with Posit Connect.", + help="The API key to use to authenticate with Posit Connect. \ +(Also settable via CONNECT_API_KEY environment variable.)", ) @click.option( "--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, - help="Disable TLS certification/host validation.", + help="Disable TLS certification/host validation. (Also settable via CONNECT_INSECURE environment variable.)", ) @click.option( "--cacert", "-c", envvar="CONNECT_CA_CERTIFICATE", type=click.Path(exists=True, file_okay=True, dir_okay=False), - help="The path to trusted TLS CA certificates.", + help="The path to trusted TLS CA certificates. (Also settable via CONNECT_CA_CERTIFICATE environment variable.)", ) @click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.") @cloud_shinyapps_args @@ -504,6 +513,7 @@ def add(ctx, name, server, api_key, insecure, cacert, account, token, secret, ve click.echo(" {}: {}".format(k, ctx.get_parameter_source(k).name)) validation.validate_connection_options( + ctx=ctx, url=server, api_key=api_key, insecure=insecure, @@ -594,10 +604,19 @@ def list_servers(verbose): ) @server_args @cli_exception_handler -def details(name, server, api_key, insecure, cacert, verbose): +@click.pass_context +def details( + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + verbose: int, +): set_verbosity(verbose) - ce = RSConnectExecutor(name, server, api_key, insecure, cacert).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert).validate_server() click.echo(" Posit Connect URL: %s" % ce.remote_server.url) @@ -834,12 +853,14 @@ def _warn_on_ignored_requirements(directory, requirements_file_name): type=click.Path(exists=True, dir_okay=False, file_okay=True), ) @cli_exception_handler +@click.pass_context def deploy_notebook( + cli_ctx: click.Context, name: str, server: str, api_key: str, insecure: bool, - cacert: typing.IO, + cacert: str, static: bool, new: bool, app_id: str, @@ -956,7 +977,9 @@ def deploy_notebook( type=click.Path(exists=True, dir_okay=False, file_okay=True), ) @cli_exception_handler +@click.pass_context def deploy_voila( + cli_ctx: click.Context, path: str = None, entrypoint: str = None, python=None, @@ -1024,12 +1047,14 @@ def deploy_voila( @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @shinyapps_deploy_args @cli_exception_handler +@click.pass_context def deploy_manifest( + cli_ctx: click.Context, name: str, server: str, api_key: str, insecure: bool, - cacert: typing.IO, + cacert: str, account: str, token: str, secret: str, @@ -1124,7 +1149,7 @@ def deploy_quarto( server: str, api_key: str, insecure: bool, - cacert: typing.IO, + cacert: str, new: bool, app_id: str, title: str, @@ -1227,7 +1252,9 @@ def deploy_quarto( type=click.Path(exists=True, dir_okay=False, file_okay=True), ) @cli_exception_handler +@click.pass_context def deploy_html( + cli_ctx: click.Context, connect_server: api.RSConnectServer = None, path: str = None, entrypoint: str = None, @@ -1338,12 +1365,14 @@ def generate_deploy_python(app_mode: AppMode, alias: str, min_version: str, desc ) @shinyapps_deploy_args @cli_exception_handler + @click.pass_context def deploy_app( + cli_ctx: click.Context, name: str, server: str, api_key: str, insecure: bool, - cacert: typing.IO, + cacert: str, entrypoint, exclude, new: bool, @@ -1958,7 +1987,9 @@ def content(): ) # todo: --format option (json, text) @cli_exception_handler +@click.pass_context def content_search( + cli_ctx: click.Context, name, server, api_key, @@ -1975,7 +2006,7 @@ def content_search( ): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() result = search_content( ce.remote_server, published, unpublished, content_type, r_version, py_version, title_contains, order_by ) @@ -1998,10 +2029,20 @@ def content_search( help="The GUID of a content item to describe. This flag can be passed multiple times.", ) # todo: --format option (json, text) -def content_describe(name, server, api_key, insecure, cacert, guid, verbose): +@click.pass_context +def content_describe( + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + guid: str, + verbose: int, +): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() result = get_content(ce.remote_server, guid) json.dump(result, sys.stdout, indent=2) @@ -2032,10 +2073,22 @@ def content_describe(name, server, api_key, insecure, cacert, guid, verbose): is_flag=True, help="Overwrite the output file if it already exists.", ) -def content_bundle_download(name, server, api_key, insecure, cacert, guid, output, overwrite, verbose): +@click.pass_context +def content_bundle_download( + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + guid: str, + output: str, + overwrite: bool, + verbose: int, +): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() if exists(output) and not overwrite: raise RSConnectException("The output file already exists: %s" % output) @@ -2063,10 +2116,20 @@ def build(): metavar="GUID[,BUNDLE_ID]", help="Add a content item by its guid. This flag can be passed multiple times.", ) -def add_content_build(name, server, api_key, insecure, cacert, guid, verbose): +@click.pass_context +def add_content_build( + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + guid: str, + verbose: int, +): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() build_add_content(ce.remote_server, guid) if len(guid) == 1: logger.info('Added "%s".' % guid[0]) @@ -2100,10 +2163,22 @@ def add_content_build(name, server, api_key, insecure, cacert, guid, verbose): is_flag=True, help="Remove build history and log files from the local filesystem.", ) -def remove_content_build(name, server, api_key, insecure, cacert, guid, all, purge, verbose): +@click.pass_context +def remove_content_build( + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + guid: str, + all: bool, + purge: bool, + verbose: int, +): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() _validate_build_rm_args(guid, all, purge) guids = build_remove_content(ce.remote_server, guid, all, purge) if len(guids) == 1: @@ -2117,7 +2192,11 @@ def remove_content_build(name, server, api_key, insecure, cacert, guid, all, pur name="ls", short_help="List the content items that are being tracked for build on a given Connect server." ) @server_args -@click.option("--status", type=click.Choice(BuildStatus._all), help="Filter results by status of the build operation.") +@click.option( + "--status", + type=click.Choice(BuildStatus._all), + help="Filter results by status of the build operation.", +) @click.option( "--guid", "-g", @@ -2128,10 +2207,21 @@ def remove_content_build(name, server, api_key, insecure, cacert, guid, all, pur ) @click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.") # todo: --format option (json, text) -def list_content_build(name, server, api_key, insecure, cacert, status, guid, verbose): +@click.pass_context +def list_content_build( + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + status: str, + guid: str, + verbose: int, +): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() result = build_list_content(ce.remote_server, guid, status) json.dump(result, sys.stdout, indent=2) @@ -2148,10 +2238,20 @@ def list_content_build(name, server, api_key, insecure, cacert, status, guid, ve help="The guid of the content item.", ) # todo: --format option (json, text) -def get_build_history(name, server, api_key, insecure, cacert, guid, verbose): +@click.pass_context +def get_build_history( + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + guid: str, + verbose: int, +): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert) + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert) ce.validate_server() result = build_history(ce.remote_server, guid) json.dump(result, sys.stdout, indent=2) @@ -2185,10 +2285,22 @@ def get_build_history(name, server, api_key, insecure, cacert, guid, verbose): default=LogOutputFormat.DEFAULT, help="The output format of the logs. Defaults to text.", ) -def get_build_logs(name, server, api_key, insecure, cacert, guid, task_id, format, verbose): +@click.pass_context +def get_build_logs( + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + guid: str, + task_id: str, + format: str, + verbose: int, +): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() for line in emit_build_log(ce.remote_server, guid, format, task_id): sys.stdout.write(line) @@ -2221,14 +2333,32 @@ def get_build_logs(name, server, api_key, insecure, cacert, guid, task_id, forma default=LogOutputFormat.DEFAULT, help="The output format of the logs. Defaults to text.", ) -@click.option("--debug", is_flag=True, help="Log stacktraces from exceptions during background operations.") +@click.option( + "--debug", + is_flag=True, + help="Log stacktraces from exceptions during background operations.", +) +@click.pass_context def start_content_build( - name, server, api_key, insecure, cacert, parallelism, aborted, error, all, poll_wait, format, debug, verbose + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + parallelism: int, + aborted: bool, + error: bool, + all: bool, + poll_wait: float, + format: str, + debug: bool, + verbose: int, ): set_verbosity(verbose) logger.set_log_output_format(format) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() build_start(ce.remote_server, parallelism, aborted, error, all, poll_wait, debug) @@ -2251,7 +2381,7 @@ def caches(): def system_caches_list(name, server, api_key, insecure, cacert, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(None, name, server, api_key, insecure, cacert, logger=None).validate_server() result = ce.runtime_caches json.dump(result, sys.stdout, indent=2) @@ -2284,10 +2414,23 @@ def system_caches_list(name, server, api_key, insecure, cacert, verbose): is_flag=True, help="If true, verify that deletion would occur, but do not delete.", ) -def system_caches_delete(name, server, api_key, insecure, cacert, verbose, language, version, image_name, dry_run): +@click.pass_context +def system_caches_delete( + cli_ctx: click.Context, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + verbose: int, + language: str, + version: str, + image_name: str, + dry_run: bool, +): set_verbosity(verbose) with cli_feedback("", stderr=True): - ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + ce = RSConnectExecutor(cli_ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() ce.delete_runtime_cache(language, version, image_name, dry_run) diff --git a/rsconnect/validation.py b/rsconnect/validation.py index 06880f4e..63f0fb18 100644 --- a/rsconnect/validation.py +++ b/rsconnect/validation.py @@ -1,46 +1,119 @@ import typing +import click + from rsconnect.exception import RSConnectException -def _get_present_options(options: typing.Dict[str, typing.Optional[str]]) -> typing.List[str]: - return [k for k, v in options.items() if v] +def _get_present_options( + options: typing.Dict[str, typing.Optional[typing.Any]], + ctx: click.Context = None, +) -> typing.List[str]: + result: typing.List[str] = [] + for k, v in options.items(): + if v: + parts = k.split("--") + if ctx and len(parts) == 2: + source = ctx.get_parameter_source(parts[1]).name # type: ignore + result.append(f"{k} (from {source})") + else: + result.append(f"{k}") + return result -def validate_connection_options(url, api_key, insecure, cacert, account_name, token, secret, name=None): +def validate_connection_options( + ctx: click.Context, + url: str, + api_key: str, + insecure: bool, + cacert: str, + account_name: str, + token: str, + secret: str, + name: str = None, +): """ Validates provided Connect or shinyapps.io connection options and returns which target to use given the provided options. + + rsconnect deploy api --name localhost ./python-bottle-py3 + should fail w/ + -s/--server or CONNECT_SERVER + -T/--token or SHINYAPPS_TOKEN or RSCLOUD_TOKEN + -S/--secret or SHINYAPPS_SECRET or RSCLOUD_SECRET + -A/--account or SHINYAPPS_ACCOUNT + + FAILURE if not any of: + -n/--name + -s/--server or CONNECT_SERVER + -T/--token or SHINYAPPS_TOKEN or RSCLOUD_TOKEN + -S/--secret or SHINYAPPS_SECRET or RSCLOUD_SECRET + -A/--account or SHINYAPPS_ACCOUNT + + FAILURE if any of: + -k/--api-key or CONNECT_API_KEY + -i/--insecure or CONNECT_INSECURE + -c/--cacert or CONNECT_CA_CERTIFICATE + AND any of: + -T/--token or SHINYAPPS_TOKEN or RSCLOUD_TOKEN + -S/--secret or SHINYAPPS_SECRET or RSCLOUD_SECRET + -A/--account or SHINYAPPS_ACCOUNT + + FAILURE if specify -s/--server or CONNECT_SERVER and it includes "posit.cloud" or "rstudio.cloud" + and not specified all of following: + -T/--token or SHINYAPPS_TOKEN or RSCLOUD_TOKEN + -S/--secret or SHINYAPPS_SECRET or RSCLOUD_SECRET + + FAILURE if any of following are specified, without the rest: + -T/--token or SHINYAPPS_TOKEN or RSCLOUD_TOKEN + -S/--secret or SHINYAPPS_SECRET or RSCLOUD_SECRET + -A/--account or SHINYAPPS_ACCOUNT """ connect_options = {"-k/--api-key": api_key, "-i/--insecure": insecure, "-c/--cacert": cacert} shinyapps_options = {"-T/--token": token, "-S/--secret": secret, "-A/--account": account_name} cloud_options = {"-T/--token": token, "-S/--secret": secret} options_mutually_exclusive_with_name = {"-s/--server": url, **shinyapps_options} - present_options_mutually_exclusive_with_name = _get_present_options(options_mutually_exclusive_with_name) + present_options_mutually_exclusive_with_name = _get_present_options(options_mutually_exclusive_with_name, ctx) if name and present_options_mutually_exclusive_with_name: - raise RSConnectException( - "-n/--name cannot be specified in conjunction with options {}".format( - ", ".join(present_options_mutually_exclusive_with_name) + if ctx: + name_source = ctx.get_parameter_source("name").name # type: ignore + raise RSConnectException( + f"-n/--name (from {name_source}) cannot be specified in conjunction with options \ +{', '.join(present_options_mutually_exclusive_with_name)}. See command help for further details." ) - ) + else: + raise RSConnectException( + f"-n/--name cannot be specified in conjunction with options \ +{', '.join(present_options_mutually_exclusive_with_name)}. See command help for further details." + ) + if not name and not url and not shinyapps_options: - raise RSConnectException("You must specify one of -n/--name OR -s/--server OR T/--token, -S/--secret.") + raise RSConnectException( + "You must specify one of -n/--name OR -s/--server OR T/--token, -S/--secret, \ +either via command options or environment variables. See command help for further details." + ) - present_connect_options = _get_present_options(connect_options) - present_shinyapps_options = _get_present_options(shinyapps_options) - present_cloud_options = _get_present_options(cloud_options) + present_connect_options = _get_present_options(connect_options, ctx) + present_shinyapps_options = _get_present_options(shinyapps_options, ctx) + present_cloud_options = _get_present_options(cloud_options, ctx) if present_connect_options and present_shinyapps_options: raise RSConnectException( - "Connect options ({}) may not be passed alongside shinyapps.io or Posit Cloud options ({}).".format( - ", ".join(present_connect_options), ", ".join(present_shinyapps_options) - ) + f"Connect options ({', '.join(present_connect_options)}) may not be passed \ +alongside shinyapps.io or Posit Cloud options ({', '.join(present_shinyapps_options)}). \ +See command help for further details." ) if url and ("posit.cloud" in url or "rstudio.cloud" in url): if len(present_cloud_options) != len(cloud_options): - raise RSConnectException("-T/--token and -S/--secret must be provided for Posit Cloud.") + raise RSConnectException( + "-T/--token and -S/--secret must be provided for Posit Cloud. \ + See command help for further details." + ) elif present_shinyapps_options: if len(present_shinyapps_options) != len(shinyapps_options): - raise RSConnectException("-A/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io.") + raise RSConnectException( + "-A/--account, -T/--token, and -S/--secret must all be provided \ +for shinyapps.io. See command help for further details." + ) diff --git a/tests/test_api.py b/tests/test_api.py index 9b71a4b0..6a7aa3c9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -31,7 +31,7 @@ class TestAPI(TestCase): def test_executor_init(self): connect_server = require_connect() api_key = require_api_key() - ce = RSConnectExecutor(None, connect_server, api_key, True, None) + ce = RSConnectExecutor(None, None, connect_server, api_key, True, None) self.assertEqual(ce.remote_server.url, connect_server) def test_output_task_log(self): @@ -70,7 +70,7 @@ def test_to_server_check_list(self): def test_make_deployment_name(self): connect_server = require_connect() api_key = require_api_key() - ce = RSConnectExecutor(None, connect_server, api_key, True, None) + ce = RSConnectExecutor(None, None, connect_server, api_key, True, None) self.assertEqual(ce.make_deployment_name("title", False), "title") self.assertEqual(ce.make_deployment_name("Title", False), "title") self.assertEqual(ce.make_deployment_name("My Title", False), "my_title") @@ -100,7 +100,7 @@ class TestSystemRuntimeCachesAPI(TestCase): # RSConnectExecutor.runtime_caches returns the resulting JSON from the server. @httpretty.activate(verbose=True, allow_net_connect=False) def test_client_system_caches_runtime_list(self): - ce = RSConnectExecutor(None, "http://test-server/", "api_key") + ce = RSConnectExecutor(None, None, "http://test-server/", "api_key") mocked_response = { "caches": [ {"language": "R", "version": "3.6.3", "image_name": "Local"}, @@ -121,7 +121,7 @@ def test_client_system_caches_runtime_list(self): # RSConnectExecutor.delete_runtime_cache() dry run prints expected messages @httpretty.activate(verbose=True, allow_net_connect=False) def test_executor_delete_runtime_cache_dry_run(self): - ce = RSConnectExecutor(None, "http://test-server/", "api_key") + ce = RSConnectExecutor(None, None, "http://test-server/", "api_key") mocked_output = {"language": "Python", "version": "1.2.3", "image_name": "teapot", "task_id": None} httpretty.register_uri( @@ -148,7 +148,7 @@ def test_executor_delete_runtime_cache_dry_run(self): # RSConnectExecutor.delete_runtime_cache() wet run prints expected messages @httpretty.activate(verbose=True, allow_net_connect=False) def test_executor_delete_runtime_cache_wet_run(self): - ce = RSConnectExecutor(None, "http://test-server/", "api_key") + ce = RSConnectExecutor(None, None, "http://test-server/", "api_key") mocked_delete_output = { "language": "Python", "version": "1.2.3", @@ -197,7 +197,7 @@ def test_executor_delete_runtime_cache_wet_run(self): # RSConnectExecutor.delete_runtime_cache() raises the correct error @httpretty.activate(verbose=True, allow_net_connect=False) def test_executor_delete_runtime_cache_error(self): - ce = RSConnectExecutor(None, "http://test-server/", "api_key") + ce = RSConnectExecutor(None, None, "http://test-server/", "api_key") mocked_delete_output = {"code": 4, "error": "Cache does not exist", "payload": None} httpretty.register_uri( httpretty.DELETE, diff --git a/tests/test_main.py b/tests/test_main.py index fb160a00..97f07be6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -918,7 +918,8 @@ def test_add_shinyapps_missing_options(self): assert result.exit_code == 1, result.output assert ( str(result.exception) - == "-A/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io." + == "-A/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io. \ +See command help for further details." ) finally: if original_api_key_value: