Skip to content
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [??] - ??

### Added

- Added support for the `--draft` option when deploying content,
this allows to deploy a new bundle for the content without exposing
it as a the activated one.

## [1.26.0] - 2025-05-28

### Added
Expand Down
36 changes: 30 additions & 6 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: O
def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse:
return (
response.json_data
if response.status and response.status == 200 and response.json_data is not None
if response.status and response.status >= 200 and response.status <= 299 and response.json_data is not None
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

/content/X/deploy responds with a 201 and a JSON body

else response
)

Expand Down Expand Up @@ -466,10 +466,32 @@ def content_get(self, content_guid: str) -> ContentItemV1:
response = self._server.handle_bad_response(response)
return response

def content_build(self, content_guid: str, bundle_id: Optional[str] = None) -> BuildOutputDTO:
def content_build(
self, content_guid: str, bundle_id: Optional[str] = None, activate: bool = True
) -> BuildOutputDTO:
body = {"bundle_id": bundle_id}
if not activate:
# The default behavior is to activate the app after building.
# So we only pass the parameter if we want to deactivate it.
# That way we can keep the API backwards compatible.
body["activate"] = False
response = cast(
Union[BuildOutputDTO, HTTPResponse],
self.post("v1/content/%s/build" % content_guid, body={"bundle_id": bundle_id}),
self.post("v1/content/%s/build" % content_guid, body=body),
)
response = self._server.handle_bad_response(response)
return response

def content_deploy(self, app_guid: str, bundle_id: Optional[int] = None, activate: bool = True) -> BuildOutputDTO:
body = {"bundle_id": str(bundle_id)}
if not activate:
# The default behavior is to activate the app after deploying.
# So we only pass the parameter if we want to deactivate it.
# That way we can keep the API backwards compatible.
body["activate"] = False
response = cast(
Union[BuildOutputDTO, HTTPResponse],
self.post("v1/content/%s/deploy" % app_guid, body=body),
)
response = self._server.handle_bad_response(response)
return response
Expand Down Expand Up @@ -514,6 +536,7 @@ def deploy(
title_is_default: bool,
tarball: IO[bytes],
env_vars: Optional[dict[str, str]] = None,
activate: bool = True,
) -> RSConnectClientDeployResult:
if app_id is None:
if app_name is None:
Expand Down Expand Up @@ -544,10 +567,10 @@ def deploy(

app_bundle = self.app_upload(app_id, tarball)

task = self.app_deploy(app_id, app_bundle["id"])
task = self.content_deploy(app_guid, app_bundle["id"], activate=activate)
Copy link
Contributor

Choose a reason for hiding this comment

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

This was the only use of app_deploy, it might be worth clearing it up

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

app_deploy is in api, there were other APIs that were not used. I think it makes sense to cover all apis in the API module, independently from the ones we use. But if we are strong about removing the ones we don't use I'm ok with removing them.


return {
"task_id": task["id"],
"task_id": task["task_id"],
"app_id": app_id,
"app_guid": app["guid"],
"app_url": app["url"],
Expand Down Expand Up @@ -1000,7 +1023,7 @@ def upload_posit_bundle(self, prepare_deploy_result: PrepareDeployResult, bundle
upload_result = S3Server(upload_url).handle_bad_response(upload_result, is_httpresponse=True)

@cls_logged("Deploying bundle ...")
def deploy_bundle(self):
def deploy_bundle(self, activate: bool = True):
if self.deployment_name is None:
raise RSConnectException("A deployment name must be created before deploying a bundle.")
if self.bundle is None:
Expand All @@ -1016,6 +1039,7 @@ def deploy_bundle(self):
self.title_is_default,
self.bundle,
self.env_vars,
activate=activate,
)
self.deployed_info = result
return self
Expand Down
3 changes: 3 additions & 0 deletions rsconnect/http_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@ def _do_request(
logger.debug("Headers:")
for key, value in headers.items():
logger.debug("--> %s: %s" % (key, value))
logger.debug("Body:")
logger.debug("--> %s" % (body if body is not None else "<no body>"))
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added this extra logging of the Request body when in debug (-vvv) for convenient to see what the client exchanged with the server.


# if we weren't called under a `with` statement, we'll need to manage the
# connection here.
Expand All @@ -402,6 +404,7 @@ def _do_request(
logger.debug("Headers:")
for key, value in response.getheaders():
logger.debug("--> %s: %s" % (key, value))
logger.debug("Body:")
logger.debug("--> %s" % response_body)
finally:
if local_connection:
Expand Down
29 changes: 22 additions & 7 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,14 @@ def content_args(func: Callable[P, T]) -> Callable[P, T]:
is_flag=True,
help="Don't access the deployed content to verify that it started correctly.",
)
@click.option(
"--draft",
is_flag=True,
help=(
"Deploy the application as a draft. "
"Previous bundle will continue to be served until the draft is published."
),
)
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs):
return func(*args, **kwargs)
Expand Down Expand Up @@ -918,6 +926,7 @@ def deploy_notebook(
disable_env_management: Optional[bool],
env_management_py: Optional[bool],
env_management_r: Optional[bool],
draft: bool,
no_verify: bool = False,
):
set_verbosity(verbose)
Expand Down Expand Up @@ -977,7 +986,7 @@ def deploy_notebook(
env_management_py=env_management_py,
env_management_r=env_management_r,
)
ce.deploy_bundle().save_deployed_info().emit_task_log()
ce.deploy_bundle(activate=not draft).save_deployed_info().emit_task_log()
if not no_verify:
ce.verify_deployment()

Expand Down Expand Up @@ -1068,6 +1077,7 @@ def deploy_voila(
cacert: Optional[str],
multi_notebook: bool,
no_verify: bool,
draft: bool = False,
connect_server: Optional[api.RSConnectServer] = None, # TODO: This appears to be unused
):
set_verbosity(verbose)
Expand Down Expand Up @@ -1106,7 +1116,7 @@ def deploy_voila(
env_management_py=env_management_py,
env_management_r=env_management_r,
multi_notebook=multi_notebook,
).deploy_bundle().save_deployed_info().emit_task_log()
).deploy_bundle(activate=not draft).save_deployed_info().emit_task_log()
if not no_verify:
ce.verify_deployment()

Expand Down Expand Up @@ -1149,6 +1159,7 @@ def deploy_manifest(
env_vars: dict[str, str],
visibility: Optional[str],
no_verify: bool,
draft: bool,
):
set_verbosity(verbose)
output_params(ctx, locals().items())
Expand Down Expand Up @@ -1182,7 +1193,7 @@ def deploy_manifest(
make_manifest_bundle,
file_name,
)
.deploy_bundle()
.deploy_bundle(activate=not draft)
.save_deployed_info()
.emit_task_log()
)
Expand Down Expand Up @@ -1276,6 +1287,7 @@ def deploy_quarto(
env_management_py: bool,
env_management_r: bool,
no_verify: bool,
draft: bool,
):
set_verbosity(verbose)
output_params(ctx, locals().items())
Expand Down Expand Up @@ -1331,7 +1343,7 @@ def deploy_quarto(
env_management_py=env_management_py,
env_management_r=env_management_r,
)
.deploy_bundle()
.deploy_bundle(activate=not draft)
.save_deployed_info()
.emit_task_log()
)
Expand Down Expand Up @@ -1395,6 +1407,7 @@ def deploy_tensorflow(
env_vars: dict[str, str],
image: Optional[str],
no_verify: bool,
draft: bool,
):
set_verbosity(verbose)
output_params(ctx, locals().items())
Expand Down Expand Up @@ -1426,7 +1439,7 @@ def deploy_tensorflow(
exclude,
image=image,
)
.deploy_bundle()
.deploy_bundle(activate=not draft)
.save_deployed_info()
.emit_task_log()
)
Expand Down Expand Up @@ -1489,6 +1502,7 @@ def deploy_html(
token: Optional[str],
secret: Optional[str],
no_verify: bool,
draft: bool,
connect_server: Optional[api.RSConnectServer] = None,
):
set_verbosity(verbose)
Expand Down Expand Up @@ -1539,7 +1553,7 @@ def deploy_html(
extra_files,
exclude,
)
.deploy_bundle()
.deploy_bundle(activate=not draft)
.save_deployed_info()
.emit_task_log()
)
Expand Down Expand Up @@ -1644,6 +1658,7 @@ def deploy_app(
token: Optional[str],
secret: Optional[str],
no_verify: bool,
draft: bool,
):
set_verbosity(verbose)
entrypoint = validate_entry_point(entrypoint, directory)
Expand Down Expand Up @@ -1700,7 +1715,7 @@ def deploy_app(
env_management_py=env_management_py,
env_management_r=env_management_r,
)
ce.deploy_bundle()
ce.deploy_bundle(activate=not draft)
ce.save_deployed_info()
ce.emit_task_log()

Expand Down
1 change: 1 addition & 0 deletions tests/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ def fake_inspect_environment(
assert environment.python_interpreter == expected_python
assert environment == expected_environment


class TestEnvironmentDeprecations:
def test_override_python_version(self):
with mock.patch.object(rsconnect.environment.logger, "warning") as mock_warning:
Expand Down
Loading
Loading