From b811bcf73504efb9818c49447b3303ec6cfbed06 Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Tue, 21 Jul 2026 15:06:09 -0700 Subject: [PATCH 1/6] quiet console --- docs/CHANGELOG.md | 8 ++ docs/deploying.md | 15 +++ rsconnect/actions.py | 42 ++++++-- rsconnect/api.py | 70 +++++++++++--- rsconnect/bundle.py | 8 +- rsconnect/environment.py | 25 ++--- rsconnect/log.py | 19 +++- rsconnect/main.py | 85 ++++++++++++---- tests/test_actions.py | 50 +++++++++- tests/test_main.py | 203 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 456 insertions(+), 69 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b8ac49533..63f3e6b04 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 60642075c..0cbdde665 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 (Scripting) + +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, which is convenient in scripts and CI: + +```bash +URL=$(rsconnect deploy notebook --quiet -n myserver notebook.ipynb) +echo "Deployed to $URL" +``` + +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 164180885..672cefe98 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 +def set_verbosity(verbose: int, quiet: bool = False): + """Set the verbosity level based on the passed flags - :param verbose: boolean specifying verbose or not + :param verbose: verbosity count (0 = normal, 1 = verbose, 2+ = debug) + :param quiet: suppress all output except errors and the final content URL """ - if verbose == 0: - logger.setLevel(logging.INFO) - elif verbose == 1: - logger.setLevel(VERBOSE) + if quiet: + # Enter quiet mode before validating the flags so that even the + # flag-conflict error below lands on stderr, not stdout. + logger.set_quiet(True) + # Silence step/progress lines from RSLogger (e.g. output_params) while + # still allowing warnings and errors through. + logger.setLevel(logging.WARNING) + # Silence the @cls_logged step labels and [OK] lines, which go through + # console_logger; [ERROR] lines still surface. + console_logger.setLevel(logging.ERROR) + if verbose: + raise RSConnectException("--quiet cannot be used together with -v/--verbose.") else: - logger.setLevel(logging.DEBUG) + # Undo any quiet mode left over from an earlier invocation in the same + # process (library use, test suites); this state is module-global. + logger.set_quiet(False) + console_logger.setLevel(logging.DEBUG) + if verbose == 0: + logger.setLevel(logging.INFO) + elif verbose == 1: + logger.setLevel(VERBOSE) + else: + logger.setLevel(logging.DEBUG) def _verify_server(connect_server: api.RSConnectServer): diff --git a/rsconnect/api.py b/rsconnect/api.py index de19647a4..29b0f2381 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 00ef2bde4..f3d896c6c 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 6b12b8455..85bd10370 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 52f0cd6d7..87c4eaaaa 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 6f669291a..43ae33574 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="Suppress all output except the final content URL (printed to stdout). " + "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( @@ -2321,6 +2348,7 @@ def deploy_git( if not no_verify: ce.verify_deployment() + ce.emit_content_url() # noinspection SpellCheckingInspection,DuplicatedCode @@ -2399,10 +2427,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 +2463,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 +2542,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 +2581,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 +2607,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 +2654,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 +2689,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 +2719,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 +2784,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 +2898,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 +2938,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 +3024,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 +3099,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 +3132,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 +3190,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 aca947cfd..d5e1a08a3 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 85d9a4583..85630de4e 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,198 @@ 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() + 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() + 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() + 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() == "" + @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 From 6159be4d23bbe785c1da802ee0e0197bf675e934 Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Tue, 21 Jul 2026 15:25:01 -0700 Subject: [PATCH 2/6] rebase --- rsconnect/version.py | 24 ++++++++++++++++++++++++ tests/test_main.py | 6 +++--- 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 rsconnect/version.py diff --git a/rsconnect/version.py b/rsconnect/version.py new file mode 100644 index 000000000..6117e6da9 --- /dev/null +++ b/rsconnect/version.py @@ -0,0 +1,24 @@ +# file generated by vcs-versioning +# don't change, don't track in version control +from __future__ import annotations + +__all__ = [ + "__version__", + "__version_tuple__", + "version", + "version_tuple", + "__commit_id__", + "commit_id", +] + +version: str +__version__: str +__version_tuple__: tuple[int | str, ...] +version_tuple: tuple[int | str, ...] +commit_id: str | None +__commit_id__: str | None + +__version__ = version = '1.29.1.dev12+g0498cfb2b' +__version_tuple__ = version_tuple = (1, 29, 1, 'dev12', 'g0498cfb2b') + +__commit_id__ = commit_id = 'g0498cfb2b' diff --git a/tests/test_main.py b/tests/test_main.py index 85630de4e..4799e6a5c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -773,7 +773,7 @@ def test_deploy_quiet_failure_shows_task_log(self): ) target = get_dir(join("pip1", "dummy.ipynb")) try: - runner = CliRunner() + 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( @@ -809,7 +809,7 @@ def test_deploy_quiet_verify_failure_keeps_stdout_clean(self): ) target = get_dir(join("pip1", "dummy.ipynb")) try: - runner = CliRunner() + 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( @@ -829,7 +829,7 @@ def test_deploy_quiet_verify_failure_keeps_stdout_clean(self): def test_deploy_quiet_verbose_conflict(self): target = get_dir(join("pip1", "dummy.ipynb")) - runner = CliRunner() + 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) From 3e5ec09806ecf4e1f22079aef169e9ef91409b06 Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Wed, 22 Jul 2026 08:30:28 -0700 Subject: [PATCH 3/6] stop tracking version.py --- .gitignore | 1 + rsconnect/version.py | 24 ------------------------ 2 files changed, 1 insertion(+), 24 deletions(-) delete mode 100644 rsconnect/version.py diff --git a/.gitignore b/.gitignore index 4caf6f9b3..83ad36f46 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/rsconnect/version.py b/rsconnect/version.py deleted file mode 100644 index 6117e6da9..000000000 --- a/rsconnect/version.py +++ /dev/null @@ -1,24 +0,0 @@ -# file generated by vcs-versioning -# don't change, don't track in version control -from __future__ import annotations - -__all__ = [ - "__version__", - "__version_tuple__", - "version", - "version_tuple", - "__commit_id__", - "commit_id", -] - -version: str -__version__: str -__version_tuple__: tuple[int | str, ...] -version_tuple: tuple[int | str, ...] -commit_id: str | None -__commit_id__: str | None - -__version__ = version = '1.29.1.dev12+g0498cfb2b' -__version_tuple__ = version_tuple = (1, 29, 1, 'dev12', 'g0498cfb2b') - -__commit_id__ = commit_id = 'g0498cfb2b' From 87b3b7cd54a7b8d5226f76db2d82298354913092 Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Wed, 22 Jul 2026 09:40:11 -0700 Subject: [PATCH 4/6] refactor verbosity level and add tests --- docs/deploying.md | 4 ++-- rsconnect/actions.py | 46 ++++++++++++++++++++++---------------------- rsconnect/main.py | 4 +++- tests/test_main.py | 16 +++++++++++++++ 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/docs/deploying.md b/docs/deploying.md index 0cbdde665..a99f38680 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -412,11 +412,11 @@ 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 (Scripting) +#### 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, which is convenient in scripts and CI: +URL to standard output. ```bash URL=$(rsconnect deploy notebook --quiet -n myserver notebook.ipynb) diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 672cefe98..71eed5056 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -91,35 +91,35 @@ def failed(err: str): logger.set_in_feedback(False) +# 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), +} + + 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: suppress all output except errors and the final content URL """ - if quiet: - # Enter quiet mode before validating the flags so that even the - # flag-conflict error below lands on stderr, not stdout. - logger.set_quiet(True) - # Silence step/progress lines from RSLogger (e.g. output_params) while - # still allowing warnings and errors through. - logger.setLevel(logging.WARNING) - # Silence the @cls_logged step labels and [OK] lines, which go through - # console_logger; [ERROR] lines still surface. - console_logger.setLevel(logging.ERROR) - if verbose: - raise RSConnectException("--quiet cannot be used together with -v/--verbose.") - else: - # Undo any quiet mode left over from an earlier invocation in the same - # process (library use, test suites); this state is module-global. - logger.set_quiet(False) - console_logger.setLevel(logging.DEBUG) - 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/main.py b/rsconnect/main.py index 43ae33574..ed81315d3 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2294,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], @@ -2316,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: diff --git a/tests/test_main.py b/tests/test_main.py index 4799e6a5c..2ad2e6666 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -838,6 +838,22 @@ def test_deploy_quiet_verbose_conflict(self): # 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 From b71382eecba7bf759faf9fc8bb4bc49619e499c4 Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Wed, 22 Jul 2026 11:29:58 -0700 Subject: [PATCH 5/6] clarify comments --- docs/deploying.md | 6 +++--- rsconnect/actions.py | 2 +- rsconnect/main.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/deploying.md b/docs/deploying.md index a99f38680..6b05a26a0 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -423,9 +423,9 @@ URL=$(rsconnect deploy notebook --quiet -n myserver notebook.ipynb) echo "Deployed to $URL" ``` -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`. +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 diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 71eed5056..fd2ca85a8 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -108,7 +108,7 @@ 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: suppress all output except errors and the final content URL + :param quiet: emit only the final content URL on stdout; warnings and errors still go to stderr """ level = -1 if quiet else min(verbose, 2) logger_level, console_level = _VERBOSITY_LEVELS[level] diff --git a/rsconnect/main.py b/rsconnect/main.py index ed81315d3..2202872fe 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -248,8 +248,8 @@ def quiet_arg(func: Callable[P, T]) -> Callable[P, T]: @click.option( "--quiet", is_flag=True, - help="Suppress all output except the final content URL (printed to stdout). " - "Errors still go to stderr. Cannot be combined with -v/--verbose.", + 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): From 3975a469ddca605cf1bec23613aa84bbfaf17172 Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Fri, 24 Jul 2026 07:33:24 -0700 Subject: [PATCH 6/6] fix console_logger setting and clarify comment --- .gitignore | 1 - rsconnect/actions.py | 11 ++++++----- tests/test_actions.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 83ad36f46..4caf6f9b3 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,3 @@ vetiver-testing/rsconnect_api_keys.json # license files should not be commited to this repository *.lic /site/ -/rsconnect/version.py diff --git a/rsconnect/actions.py b/rsconnect/actions.py index fd2ca85a8..46e2d155e 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -92,12 +92,13 @@ def failed(err: str): # 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. +# Level -1 is quiet: RSLogger drops its diagnostic lines below WARNING and +# console_logger drops the @cls_logged step labels and [OK] lines (emitted at +# INFO/DEBUG) below WARNING, while still letting its warnings (e.g. credential +# overlap) through to stderr, 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), + -1: (logging.WARNING, logging.WARNING), 0: (logging.INFO, logging.DEBUG), 1: (VERBOSE, logging.DEBUG), 2: (logging.DEBUG, logging.DEBUG), diff --git a/tests/test_actions.py b/tests/test_actions.py index d5e1a08a3..91212b6c4 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -65,7 +65,7 @@ def test_warn_user_writes_to_stderr_in_quiet_mode(quiet_mode, capsys): def test_set_verbosity_resets_quiet_state(quiet_mode): set_verbosity(0, quiet=True) assert logger.quiet - assert console_logger.level == logging.ERROR + assert console_logger.level == logging.WARNING # A later non-quiet invocation in the same process must fully undo quiet mode. set_verbosity(0)