Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ vetiver-testing/rsconnect_api_keys.json
# license files should not be commited to this repository
*.lic
/site/
/rsconnect/version.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this leftover from something?

8 changes: 8 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 34 additions & 10 deletions rsconnect/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not following the distinction between logger and console_logger



: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):
Expand Down
70 changes: 56 additions & 14 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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] = []
Expand Down
8 changes: 2 additions & 6 deletions rsconnect/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
25 changes: 8 additions & 17 deletions rsconnect/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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",
)
Expand All @@ -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."
)


Expand All @@ -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:
Expand All @@ -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."
)
19 changes: 18 additions & 1 deletion rsconnect/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading