diff --git a/.gitignore b/.gitignore index 4caf6f9b..83ad36f4 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ vetiver-testing/rsconnect_api_keys.json # license files should not be commited to this repository *.lic /site/ +/rsconnect/version.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b8ac4953..63f3e6b0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `rsconnect deploy` subcommands now accept `--quiet`, which suppresses the + step-by-step progress lines and the streamed server build log, printing only + the deployed content URL to stdout so it can be captured with + `URL=$(rsconnect deploy ... --quiet)`. Errors still go to stderr, and on a + failed deploy the server task log is emitted to stderr so failures remain + diagnosable. `--quiet` cannot be combined with `-v/--verbose`, and for + shinyapps.io deploys it also skips opening a browser. + ## [1.30.0] - 2026-07-16 - Fixed a bug where `rsconnect deploy notebook --static` failed with `Unable to diff --git a/docs/deploying.md b/docs/deploying.md index 60642075..6b05a26a 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -412,6 +412,21 @@ considered successful if there isn't a 5xx code returned. Errors like 400 Bad Request or 405 Method Not Allowed because not all apps support `GET /`. For cases where this is not desired, use the `--no-verify` flag on the command line. +#### Quiet Output + +By default a deploy prints step-by-step progress and streams the server build +log. Pass `--quiet` to suppress all of that and print only the deployed content +URL to standard output. + +```bash +URL=$(rsconnect deploy notebook --quiet -n myserver notebook.ipynb) +echo "Deployed to $URL" +``` + +Warnings and errors are still written to standard error, and if a deploy fails +the server task log is emitted to standard error so the failure can be +diagnosed. `--quiet` cannot be combined with `-v`/`--verbose`. + ### Environment variables You can set environment variables during deployment. Their names and values will be diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 16418088..fd2ca85a 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -39,7 +39,7 @@ from .environment import Environment from .environment_r import REnvironment from .exception import RSConnectException -from .log import VERBOSE, logger +from .log import VERBOSE, console_logger, logger from .models import AppMode, AppModes line_width = 45 @@ -58,6 +58,12 @@ def cli_feedback(label: str, stderr: bool = False): vs. internal errors (prefixed with 'Internal Error'). In verbose mode, tracebacks will be emitted for internal errors. """ + if logger.quiet: + # Quiet mode: suppress the step label and [OK] line so stdout stays + # reserved for the content URL; errors are routed to stderr. + label = "" + stderr = True + if label: pad = line_width - len(label) click.secho(label + "... " + " " * pad, nl=False, err=stderr) @@ -85,17 +91,35 @@ def failed(err: str): logger.set_in_feedback(False) -def set_verbosity(verbose: int): - """Set the verbosity level based on a passed flag +# Maps a verbosity level to the (RSLogger level, console_logger level) it sets. +# Level -1 is quiet: RSLogger drops step/progress lines below WARNING and +# console_logger drops @cls_logged step labels and [OK] lines below ERROR, so +# only warnings/errors and the final content URL survive. Levels 0/1/2 are +# normal/verbose/debug; anything above 2 clamps to debug. +_VERBOSITY_LEVELS = { + -1: (logging.WARNING, logging.ERROR), + 0: (logging.INFO, logging.DEBUG), + 1: (VERBOSE, logging.DEBUG), + 2: (logging.DEBUG, logging.DEBUG), +} + - :param verbose: boolean specifying verbose or not +def set_verbosity(verbose: int, quiet: bool = False): + """Set the verbosity level based on the passed flags + + :param verbose: verbosity count (0 = normal, 1 = verbose, 2+ = debug) + :param quiet: emit only the final content URL on stdout; warnings and errors still go to stderr """ - if verbose == 0: - logger.setLevel(logging.INFO) - elif verbose == 1: - logger.setLevel(VERBOSE) - else: - logger.setLevel(logging.DEBUG) + level = -1 if quiet else min(verbose, 2) + logger_level, console_level = _VERBOSITY_LEVELS[level] + # Set quiet state (also undoing any left over from an earlier invocation in + # the same process) before validating the flags so that even the + # flag-conflict error below lands on stderr, not stdout. + logger.set_quiet(quiet) + logger.setLevel(logger_level) + console_logger.setLevel(console_level) + if quiet and verbose: + raise RSConnectException("--quiet cannot be used together with -v/--verbose.") def _verify_server(connect_server: api.RSConnectServer): diff --git a/rsconnect/api.py b/rsconnect/api.py index de19647a..29b0f238 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1684,8 +1684,9 @@ def deploy_bundle(self, activate: bool = True): # type: ignore[arg-type] - PrepareDeployResult uses int, but format() accepts it shinyapps_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) - print(f"Application successfully deployed to {prepare_deploy_result.app_url}") - webbrowser.open_new(prepare_deploy_result.app_url) + if not logger.quiet: + print(f"Application successfully deployed to {prepare_deploy_result.app_url}") + webbrowser.open_new(prepare_deploy_result.app_url) self.deployed_info = RSConnectClientDeployResult( app_url=prepare_deploy_result.app_url, @@ -1759,16 +1760,30 @@ def emit_task_log( if not isinstance(self.client, RSConnectClient): raise RSConnectException("To emit task log, client must be a RSConnectClient.") - log_lines, _ = self.client.wait_for_task( - self.deployed_info["task_id"], - log_callback.info, - abort_func, - timeout, - poll_wait, - raise_on_error, - ) + # In quiet mode, buffer the task log rather than streaming it; on + # success only the content URL goes to stdout, but on failure we + # flush the buffered log to stderr so the deployment stays diagnosable. + buffered: list[str] = [] + try: + log_lines, _ = self.client.wait_for_task( + self.deployed_info["task_id"], + buffered.append if logger.quiet else log_callback.info, + abort_func, + timeout, + poll_wait, + raise_on_error, + ) + except RSConnectException: + for line in buffered: + click.echo(line, err=True) + raise log_lines = self.remote_server.handle_bad_response(log_lines) + if logger.quiet: + # The content URL is printed by emit_content_url() once the + # whole deploy (including verification) has succeeded. + return self + log_callback.info("Deployment completed successfully.") if self.deployed_info.get("draft_url"): log_callback.info("\t Draft content URL: %s", self.deployed_info["draft_url"]) @@ -1865,6 +1880,16 @@ def activate_deployment(self): deployed_info["draft_url"] = None return self + def emit_content_url(self): + """In quiet mode, print the deployed content URL as the only stdout output. + + This must be the last step of a deploy — after verification — so that a + failed deploy never leaves a URL on stdout for `URL=$(...)` to capture. + """ + if logger.quiet and self.deployed_info: + click.echo(self.deployed_info.get("draft_url") or self.deployed_info["app_url"]) + return self + @cls_logged("Validating app mode...") def validate_app_mode(self, app_mode: AppMode): path = self.path @@ -2294,8 +2319,23 @@ def get_current_user(self): return response def wait_until_task_is_successful(self, task_id: str, timeout: int = get_task_timeout()) -> None: - print() - print(f"Waiting for task: {task_id}") + # In quiet mode, buffer progress lines and flush them to stderr only if + # the task fails, so successful deploys stay silent on stdout. + buffered: list[str] = [] + + def emit(msg: str = "") -> None: + if logger.quiet: + buffered.append(msg) + else: + print(msg) + + def flush_on_failure() -> None: + if logger.quiet: + for line in buffered: + click.echo(line, err=True) + + emit() + emit(f"Waiting for task: {task_id}") start_time = time.time() finished: bool | None = None @@ -2313,16 +2353,18 @@ def wait_until_task_is_successful(self, task_id: str, timeout: int = get_task_ti if finished: break - print(f" {status} - {description}") + emit(f" {status} - {description}") time.sleep(2) if not finished: + flush_on_failure() raise RSConnectException(get_task_timeout_help_message(timeout)) if status != "success": + flush_on_failure() raise DeploymentFailedException(f"Application deployment failed with error: {error}") - print(f"Task done: {description}") + emit(f"Task done: {description}") def get_applications_like_name(self, name: str) -> list[str]: applications: list[PositClientApp] = [] diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 00ef2bde..f3d896c6 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -50,13 +50,12 @@ else: from typing_extensions import NotRequired, TypedDict -import click from .environment import Environment, list_environment_dirs, is_environment_dir from .environment_node import NodeEnvironment from .environment_r import REnvironment from .exception import RSConnectException -from .log import VERBOSE, logger +from .log import VERBOSE, logger, warn_user from .models import AppMode, AppModes, GlobSet from .shiny_express import escape_to_var_name, is_express_app @@ -1925,10 +1924,7 @@ def validate_node_entry_point(entry_point: str | None, directory: str) -> str: def _warn_on_ignored_entrypoint(entrypoint: Optional[str]) -> None: if entrypoint: - click.secho( - " Warning: entrypoint will not be used or considered for multi-notebook mode.", - fg="yellow", - ) + warn_user(" Warning: entrypoint will not be used or considered for multi-notebook mode.") def create_notebook_manifest_and_environment_file( diff --git a/rsconnect/environment.py b/rsconnect/environment.py index 6b12b845..85bd1037 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -18,11 +18,10 @@ import enum from . import pyproject -from .log import logger +from .log import logger, warn_user from .exception import RSConnectException from .subprocesses.inspect_environment import EnvironmentData, MakeEnvironmentData as _MakeEnvironmentData -import click try: from enum import StrEnum @@ -312,10 +311,7 @@ def _warn_on_ignored_manifest(directory: str) -> None: :param directory: the directory to check in. """ if os.path.exists(os.path.join(directory, "manifest.json")): - click.secho( - " Warning: the existing manifest.json file will not be used or considered.", - fg="yellow", - ) + warn_user(" Warning: the existing manifest.json file will not be used or considered.") def _check_requirements_file(directory: str, requirements_file: typing.Optional[str]) -> None: @@ -331,7 +327,7 @@ def _check_requirements_file(directory: str, requirements_file: typing.Optional[ directory_path = pathlib.Path(directory) requirements_file_path = directory_path / pathlib.Path(requirements_file) if directory_path not in requirements_file_path.parents: - click.secho( + warn_user( " Warning: The requirements file '%s' is outside of the deployment directory.\n" % requirements_file, fg="red", ) @@ -352,10 +348,9 @@ def _warn_if_environment_directory(directory: typing.Union[str, pathlib.Path]) - :param directory: the directory to check in. """ if is_environment_dir(directory): - click.secho( + warn_user( " Warning: The deployment directory appears to be a python virtual environment.\n" - " Python libraries and binaries will be excluded from the deployment.", - fg="yellow", + " Python libraries and binaries will be excluded from the deployment." ) @@ -368,10 +363,7 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str) - :param requirements_file_name: the name of the requirements file. """ if os.path.exists(os.path.join(directory, requirements_file_name)): - click.secho( - " Warning: the existing %s file will not be used or considered." % requirements_file_name, - fg="yellow", - ) + warn_user(" Warning: the existing %s file will not be used or considered." % requirements_file_name) def _warn_on_missing_python_version(version_constraint: typing.Optional[str]) -> None: @@ -382,9 +374,8 @@ def _warn_on_missing_python_version(version_constraint: typing.Optional[str]) -> :param version_constraint: the version constraint in the project. """ if version_constraint is None: - click.secho( + warn_user( " Warning: Python version constraint missing from pyproject.toml, setup.cfg or .python-version\n" " Connect will guess the version to use based on local environment.\n" - " Consider specifying a Python version constraint.", - fg="yellow", + " Consider specifying a Python version constraint." ) diff --git a/rsconnect/log.py b/rsconnect/log.py index 52f0cd6d..87c4eaaa 100644 --- a/rsconnect/log.py +++ b/rsconnect/log.py @@ -109,6 +109,7 @@ def __init__(self): self._in_feedback = False self._have_feedback_output = False self._log_format = LogOutputFormat.DEFAULT + self._quiet = False def addHandler(self, handler: logging.Handler): self.logger.addHandler(handler) @@ -117,6 +118,13 @@ def set_in_feedback(self, value: bool): self._in_feedback = value self._have_feedback_output = False + def set_quiet(self, value: bool): + self._quiet = value + + @property + def quiet(self) -> bool: + return self._quiet + def set_log_output_format(self, value: LogOutputFormat.All): self._log_format = value if self._log_format == LogOutputFormat.JSON: @@ -185,6 +193,15 @@ def format(self, record: logging.LogRecord): console_logger.addHandler(console_handler) +def warn_user(message: str, fg: str = "yellow") -> None: + """Print a warning for the user. + + In quiet mode the warning is routed to stderr so that stdout stays + reserved for the content URL. + """ + click.secho(message, fg=fg, err=logger.quiet) + + def logged(logger: logging.Logger, label: str): def decorator(f: Callable[P, T]) -> Callable[P, T]: @wraps(f) @@ -229,7 +246,7 @@ def wrapper(self: HasLoggerMethodT, *args: P.args, **kw: P.kwargs): if logger: logger.error(msg.format(str(exc))) else: - print(msg) + print(msg.format(str(exc)), file=sys.stderr) raise if logger: logger.debug(" \t[OK]\n") diff --git a/rsconnect/main.py b/rsconnect/main.py index 6f669291..2202872f 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -128,7 +128,7 @@ read_secret_key, validate_hs256_secret_key, ) -from .log import VERBOSE, LogOutputFormat, logger +from .log import VERBOSE, LogOutputFormat, logger, warn_user from .metadata import AppStore, ServerStore from .models import ( AppMode, @@ -160,7 +160,9 @@ def cli_exception_handler(func: Callable[P, T]) -> Callable[P, T]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs): def failed(err: str) -> Never: - click.secho(str(err), fg="bright_red", err=False) + # In quiet mode, keep stdout reserved for the content URL so that + # `URL=$(rsconnect deploy ... --quiet)` never captures an error. + click.secho(str(err), fg="bright_red", err=logger.quiet) sys.exit(1) try: @@ -184,7 +186,7 @@ def output_params( if click.__version__ >= "8.0.0" and sys.version_info >= (3, 7): logger.log(VERBOSE, "Detected the following inputs:") for k, v in vars: - if k in {"ctx", "verbose", "kwargs"}: + if k in {"ctx", "verbose", "quiet", "kwargs"}: continue if v is not None: val = v @@ -242,6 +244,20 @@ def wrapper(*args: P.args, **kwargs: P.kwargs): return wrapper +def quiet_arg(func: Callable[P, T]) -> Callable[P, T]: + @click.option( + "--quiet", + is_flag=True, + help="Print only the final content URL to stdout. " + "Warnings and errors still go to stderr. Cannot be combined with -v/--verbose.", + ) + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs): + return func(*args, **kwargs) + + return wrapper + + def cloud_shinyapps_args(func: Callable[P, T]) -> Callable[P, T]: @click.option( "--account", @@ -1441,6 +1457,8 @@ def deploy(ctx: click.Context): checker.start() def _print_version_warning() -> None: + if logger.quiet: + return message = checker.get_warning_message() if message: click.secho(message, fg="yellow", err=True) @@ -1458,10 +1476,7 @@ def _warn_on_ignored_manifest(directory: str): :param directory: the directory to check in. """ if exists(join(directory, "manifest.json")): - click.secho( - " Warning: the existing manifest.json file will not be used or considered.", - fg="yellow", - ) + warn_user(" Warning: the existing manifest.json file will not be used or considered.") def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): @@ -1473,10 +1488,7 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): :param requirements_file_name: the name of the requirements file. """ if exists(join(directory, requirements_file_name)): - click.secho( - " Warning: the existing %s file will not be used or considered." % requirements_file_name, - fg="yellow", - ) + warn_user(" Warning: the existing %s file will not be used or considered." % requirements_file_name) # noinspection SpellCheckingInspection,DuplicatedCode @@ -1551,10 +1563,12 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@quiet_arg @cli_exception_handler @click.pass_context def deploy_notebook( ctx: click.Context, + quiet: bool, name: Optional[str], server: Optional[str], api_key: Optional[str], @@ -1586,7 +1600,7 @@ def deploy_notebook( metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) output_params(ctx, locals().items()) # TODO: This used to save a value in kwargs["extra_files"] which would get passed to @@ -1658,6 +1672,7 @@ def deploy_notebook( if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() # noinspection SpellCheckingInspection,DuplicatedCode @@ -1735,10 +1750,12 @@ def deploy_notebook( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@quiet_arg @cli_exception_handler @click.pass_context def deploy_voila( ctx: click.Context, + quiet: bool, path: str, entrypoint: Optional[str], python: Optional[str], @@ -1771,7 +1788,7 @@ def deploy_voila( metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) output_params(ctx, locals().items()) app_mode = AppModes.JUPYTER_VOILA base_dir = path if isdir(path) else dirname(path) @@ -1829,6 +1846,7 @@ def deploy_voila( if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() # noinspection SpellCheckingInspection,DuplicatedCode @@ -1848,10 +1866,12 @@ def deploy_voila( @cloud_shinyapps_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @shinyapps_deploy_args +@quiet_arg @cli_exception_handler @click.pass_context def deploy_manifest( ctx: click.Context, + quiet: bool, name: Optional[str], server: Optional[str], api_key: Optional[str], @@ -1873,7 +1893,7 @@ def deploy_manifest( metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) output_params(ctx, locals().items()) file_name = validate_manifest_file(file) @@ -1923,6 +1943,7 @@ def deploy_manifest( if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() @deploy.command( @@ -1942,10 +1963,12 @@ def deploy_manifest( @cloud_shinyapps_args @click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) @shinyapps_deploy_args +@quiet_arg @cli_exception_handler @click.pass_context def deploy_bundle( ctx: click.Context, + quiet: bool, name: Optional[str], server: Optional[str], api_key: Optional[str], @@ -1967,7 +1990,7 @@ def deploy_bundle( metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) output_params(ctx, locals().items()) app_mode = read_bundle_app_mode(file) @@ -2016,6 +2039,7 @@ def deploy_bundle( if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() @deploy.command( @@ -2054,10 +2078,12 @@ def deploy_bundle( help="Skip renv.lock detection. R dependencies will not be added to the manifest, " "even when an renv.lock file is present (in the content directory or at RENV_PATHS_LOCKFILE).", ) +@quiet_arg @cli_exception_handler @click.pass_context def deploy_pyproject( ctx: click.Context, + quiet: bool, name: Optional[str], server: Optional[str], api_key: Optional[str], @@ -2081,7 +2107,7 @@ def deploy_pyproject( metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) output_params(ctx, locals().items()) def quickstart_hint() -> str: @@ -2227,6 +2253,7 @@ def quickstart_hint() -> str: if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() @deploy.command( @@ -2267,10 +2294,12 @@ def quickstart_hint() -> str: default=True, help="Enable/disable regular polling of the repository for updates. [default: enabled]", ) +@quiet_arg @cli_exception_handler @click.pass_context def deploy_git( ctx: click.Context, + quiet: bool, name: Optional[str], server: Optional[str], api_key: Optional[str], @@ -2289,7 +2318,7 @@ def deploy_git( subdirectory: str, polling: bool, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) output_params(ctx, locals().items()) if new and app_id: @@ -2321,6 +2350,7 @@ def deploy_git( if not no_verify: ce.verify_deployment() + ce.emit_content_url() # noinspection SpellCheckingInspection,DuplicatedCode @@ -2399,10 +2429,12 @@ def deploy_git( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@quiet_arg @cli_exception_handler @click.pass_context def deploy_quarto( ctx: click.Context, + quiet: bool, name: Optional[str], server: Optional[str], api_key: Optional[str], @@ -2433,7 +2465,7 @@ def deploy_quarto( metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) output_params(ctx, locals().items()) base_dir = file_or_directory @@ -2512,6 +2544,7 @@ def deploy_quarto( if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() # noinspection SpellCheckingInspection,DuplicatedCode @@ -2550,10 +2583,12 @@ def deploy_quarto( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@quiet_arg @cli_exception_handler @click.pass_context def deploy_tensorflow( ctx: click.Context, + quiet: bool, name: Optional[str], server: Optional[str], api_key: Optional[str], @@ -2574,7 +2609,7 @@ def deploy_tensorflow( metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) output_params(ctx, locals().items()) _warn_on_ignored_manifest(directory) @@ -2621,6 +2656,7 @@ def deploy_tensorflow( if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() # noinspection SpellCheckingInspection,DuplicatedCode @@ -2655,10 +2691,12 @@ def deploy_tensorflow( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@quiet_arg @cli_exception_handler @click.pass_context def deploy_html( ctx: click.Context, + quiet: bool, path: str, entrypoint: Optional[str], extra_files: tuple[str, ...], @@ -2683,7 +2721,7 @@ def deploy_html( metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) output_params(ctx, locals().items()) if connect_server: @@ -2748,6 +2786,7 @@ def deploy_html( if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() def resolve_requirements_file(directory: str, requirements_file: Optional[str], force_generate: bool) -> Optional[str]: @@ -2861,10 +2900,12 @@ def generate_deploy_python( type=click.Path(exists=True, dir_okay=False, file_okay=True), ) @shinyapps_deploy_args + @quiet_arg @cli_exception_handler @click.pass_context def deploy_app( ctx: click.Context, + quiet: bool, name: Optional[str], server: Optional[str], api_key: Optional[str], @@ -2899,7 +2940,7 @@ def deploy_app( metadata: tuple[str, ...], no_metadata: bool, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) entrypoint = validate_entry_point(entrypoint, directory) extra_files_list = validate_extra_files(directory, extra_files) requirements_file = resolve_requirements_file(directory, requirements_file, force_generate) @@ -2985,6 +3026,7 @@ def deploy_app( if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() return deploy_app @@ -3059,10 +3101,12 @@ def deploy_app( type=click.Path(exists=True, dir_okay=False, file_okay=True), ) @shinyapps_deploy_args +@quiet_arg @cli_exception_handler @click.pass_context def deploy_nodejs( ctx: click.Context, + quiet: bool, name: Optional[str], server: Optional[str], api_key: Optional[str], @@ -3090,7 +3134,7 @@ def deploy_nodejs( metadata: tuple[str, ...], no_metadata: bool, ): - set_verbosity(verbose) + set_verbosity(verbose, quiet) entrypoint = validate_node_entry_point(entrypoint, directory) extra_files_list = validate_extra_files(directory, extra_files) node_environment = NodeEnvironment.create(directory, node_executable=node) @@ -3148,6 +3192,7 @@ def deploy_nodejs( if not draft and ce.supports_verify_before_activate: # The draft bundle verified successfully, so activate it. ce.activate_deployment().emit_task_log() + ce.emit_content_url() @deploy.command( diff --git a/tests/test_actions.py b/tests/test_actions.py index aca947cf..d5e1a08a 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,9 +1,13 @@ +import logging import os from unittest import TestCase -from rsconnect.actions import _verify_server +import pytest + +from rsconnect.actions import _verify_server, cli_feedback, set_verbosity from rsconnect.api import RSConnectServer from rsconnect.exception import RSConnectException +from rsconnect.log import console_logger, logger, warn_user class TestActions(TestCase): @@ -23,3 +27,47 @@ def fake_cap(details): def fake_cap_with_doc(details): """A docstring.""" return False + + +@pytest.fixture +def quiet_mode(): + logger.set_quiet(True) + yield + logger.set_quiet(False) + logger.setLevel(logging.INFO) + console_logger.setLevel(logging.DEBUG) + + +def test_cli_feedback_quiet_suppresses_step_labels(quiet_mode, capsys): + with cli_feedback("Some step"): + pass + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_cli_feedback_quiet_routes_error_to_stderr(quiet_mode, capsys): + with pytest.raises(SystemExit): + with cli_feedback("Some step"): + raise RSConnectException("boom") + captured = capsys.readouterr() + assert captured.out == "" + assert "boom" in captured.err + + +def test_warn_user_writes_to_stderr_in_quiet_mode(quiet_mode, capsys): + warn_user("careful") + captured = capsys.readouterr() + assert captured.out == "" + assert "careful" in captured.err + + +def test_set_verbosity_resets_quiet_state(quiet_mode): + set_verbosity(0, quiet=True) + assert logger.quiet + assert console_logger.level == logging.ERROR + + # A later non-quiet invocation in the same process must fully undo quiet mode. + set_verbosity(0) + assert not logger.quiet + assert console_logger.level == logging.DEBUG diff --git a/tests/test_main.py b/tests/test_main.py index 85d9a458..2ad2e666 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,5 @@ import json +import logging import os import re import shutil @@ -15,6 +16,7 @@ from rsconnect import VERSION from rsconnect.api import RSConnectClient, RSConnectServer from rsconnect.json_web_token import SECRET_KEY_ENV +from rsconnect.log import console_logger, logger as rs_logger from rsconnect.main import cli, env_management_callback, make_notebook_html_bundle from .utils import ( @@ -62,6 +64,15 @@ def teardown_method(self): os.environ["HOME"] = self._saved_home shutil.rmtree("test-home", ignore_errors=True) + @pytest.fixture(autouse=True) + def _reset_logger_state(self): + # ``--quiet`` mutates module-global logger levels that would otherwise + # leak across CliRunner invocations and silence later tests. + yield + rs_logger.set_quiet(False) + rs_logger.setLevel(logging.INFO) + console_logger.setLevel(logging.DEBUG) + @staticmethod def optional_target(default): return os.environ.get("CONNECT_DEPLOY_TARGET", default) @@ -635,6 +646,214 @@ def post_application_deploy_callback(request, uri, response_headers): if original_server_value: os.environ["CONNECT_SERVER"] = original_server_value + def _register_quiet_deploy_routes(self, task_body): + """Register httpretty routes for a standard Connect notebook deploy. + + ``task_body`` is the JSON body returned by the task-status endpoint and + controls whether the deploy succeeds (``code`` 0) or fails. + """ + content_body = json.dumps( + { + "id": "1234", + "guid": "1234-5678-9012-3456", + "title": "app5", + "content_url": "http://fake_server/content/1234-5678-9012-3456", + "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + } + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/server_settings", + body=json.dumps({"version": "9999.99.99"}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/user", + body=open("tests/testdata/connect-responses/me.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/content?name=app5", + body=json.dumps([]), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content", + body=content_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.PATCH, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + body=content_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + body=content_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", + body=json.dumps({"id": "FAKE_BUNDLE_ID"}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + body=json.dumps({"task_id": "FAKE_TASK_ID"}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/tasks/FAKE_TASK_ID?wait=1", + body=task_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + @pytest.mark.parametrize("draft", [False, True]) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_quiet(self, draft): + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + self._register_quiet_deploy_routes( + json.dumps({"output": ["FAKE_OUTPUT"], "last": "FAKE_LAST", "finished": True, "code": 0}) + ) + target = get_dir(join("pip1", "dummy.ipynb")) + expected_url = ( + "http://fake_server/connect/#/apps/1234-5678-9012-3456/draft/FAKE_BUNDLE_ID" + if draft + else "http://fake_server/content/1234-5678-9012-3456" + ) + try: + runner = CliRunner() + args = apply_common_args(["deploy", "notebook", target], server="http://fake_server", key="FAKE_API_KEY") + args += ["--no-verify", "--quiet"] + if draft: + args.append("--draft") + with mock.patch( + "rsconnect.api.RSConnectExecutor.validate_app_mode", + new=lambda self_, *a, **k: self_, + ): + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + # Only the content URL is written to stdout. + assert result.stdout.strip() == expected_url + # Step lines and the streamed task log are suppressed. + assert "FAKE_OUTPUT" not in result.output + assert "Deployment completed successfully." not in result.output + finally: + if original_api_key_value: + os.environ["CONNECT_API_KEY"] = original_api_key_value + if original_server_value: + os.environ["CONNECT_SERVER"] = original_server_value + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_quiet_failure_shows_task_log(self): + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + self._register_quiet_deploy_routes( + json.dumps({"output": ["FAKE_OUTPUT"], "last": "FAKE_LAST", "finished": True, "code": 1}) + ) + target = get_dir(join("pip1", "dummy.ipynb")) + try: + runner = CliRunner(mix_stderr=False) + args = apply_common_args(["deploy", "notebook", target], server="http://fake_server", key="FAKE_API_KEY") + args += ["--no-verify", "--quiet"] + with mock.patch( + "rsconnect.api.RSConnectExecutor.validate_app_mode", + new=lambda self_, *a, **k: self_, + ): + result = runner.invoke(cli, args) + assert result.exit_code == 1 + # On failure the buffered task log and the error are flushed to stderr. + assert "FAKE_OUTPUT" in result.stderr + assert "Error:" in result.stderr + # stdout stays clean so `URL=$(...)` never captures an error. + assert result.stdout.strip() == "" + finally: + if original_api_key_value: + os.environ["CONNECT_API_KEY"] = original_api_key_value + if original_server_value: + os.environ["CONNECT_SERVER"] = original_server_value + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_quiet_verify_failure_keeps_stdout_clean(self): + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + self._register_quiet_deploy_routes( + json.dumps({"output": ["FAKE_OUTPUT"], "last": "FAKE_LAST", "finished": True, "code": 0}) + ) + httpretty.register_uri( + httpretty.GET, + re.compile(r"http://fake_server/content/1234-5678-9012-3456/"), + body="", + status=502, + ) + target = get_dir(join("pip1", "dummy.ipynb")) + try: + runner = CliRunner(mix_stderr=False) + args = apply_common_args(["deploy", "notebook", target], server="http://fake_server", key="FAKE_API_KEY") + args += ["--quiet"] + with mock.patch( + "rsconnect.api.RSConnectExecutor.validate_app_mode", + new=lambda self_, *a, **k: self_, + ): + result = runner.invoke(cli, args) + # A failed verification must not leave a URL on stdout. + assert result.exit_code == 1 + assert result.stdout.strip() == "" + assert "Error:" in result.stderr + finally: + if original_api_key_value: + os.environ["CONNECT_API_KEY"] = original_api_key_value + if original_server_value: + os.environ["CONNECT_SERVER"] = original_server_value + + def test_deploy_quiet_verbose_conflict(self): + target = get_dir(join("pip1", "dummy.ipynb")) + runner = CliRunner(mix_stderr=False) + args = apply_common_args(["deploy", "notebook", target], server="http://fake_server", key="FAKE_API_KEY") + args += ["--quiet", "-v"] + result = runner.invoke(cli, args) + assert result.exit_code != 0 + assert "--quiet cannot be used together with -v/--verbose." in result.stderr + # Even the flag-conflict error must not pollute stdout in quiet mode. + assert result.stdout.strip() == "" + + def test_every_deploy_subcommand_accepts_quiet(self): + # Guard against adding a new `deploy` subcommand that performs a real + # deployment but forgets `@quiet_arg` (and the matching + # ``set_verbosity(verbose, quiet)`` / ``emit_content_url()`` wiring). + # ``other-content`` is only a help stub, so it is exempt. Hidden + # commands (e.g. the ``_version_check_test_*`` stubs registered by the + # test suite) are not real deploy commands and are likewise exempt. + deploy_group = cli.commands["deploy"] + exempt = {"other-content"} + missing = [ + name + for name, command in deploy_group.commands.items() + if name not in exempt and not command.hidden and not any(param.name == "quiet" for param in command.params) + ] + assert missing == [], f"deploy subcommands missing --quiet: {missing}" + @httpretty.activate(verbose=True, allow_net_connect=False) def test_deploy_bundle(self, caplog): # Deploying a downloaded bundle should upload the tarball as-is (no