Skip to content

Commit 04be902

Browse files
authored
deploy command support for drafts (#679)
* draft argument to deploy command
1 parent 1108457 commit 04be902

File tree

7 files changed

+221
-14
lines changed

7 files changed

+221
-14
lines changed

docs/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [??] - ??
99

10+
### Added
11+
12+
- Added support for the `--draft` option when deploying content,
13+
this allows to deploy a new bundle for the content without exposing
14+
it as a the activated one.
15+
1016
## [1.26.0] - 2025-05-28
1117

1218
### Added

rsconnect/api.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: O
363363
def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse:
364364
return (
365365
response.json_data
366-
if response.status and response.status == 200 and response.json_data is not None
366+
if response.status and response.status >= 200 and response.status <= 299 and response.json_data is not None
367367
else response
368368
)
369369

@@ -466,10 +466,32 @@ def content_get(self, content_guid: str) -> ContentItemV1:
466466
response = self._server.handle_bad_response(response)
467467
return response
468468

469-
def content_build(self, content_guid: str, bundle_id: Optional[str] = None) -> BuildOutputDTO:
469+
def content_build(
470+
self, content_guid: str, bundle_id: Optional[str] = None, activate: bool = True
471+
) -> BuildOutputDTO:
472+
body = {"bundle_id": bundle_id}
473+
if not activate:
474+
# The default behavior is to activate the app after building.
475+
# So we only pass the parameter if we want to deactivate it.
476+
# That way we can keep the API backwards compatible.
477+
body["activate"] = False
470478
response = cast(
471479
Union[BuildOutputDTO, HTTPResponse],
472-
self.post("v1/content/%s/build" % content_guid, body={"bundle_id": bundle_id}),
480+
self.post("v1/content/%s/build" % content_guid, body=body),
481+
)
482+
response = self._server.handle_bad_response(response)
483+
return response
484+
485+
def content_deploy(self, app_guid: str, bundle_id: Optional[int] = None, activate: bool = True) -> BuildOutputDTO:
486+
body = {"bundle_id": str(bundle_id)}
487+
if not activate:
488+
# The default behavior is to activate the app after deploying.
489+
# So we only pass the parameter if we want to deactivate it.
490+
# That way we can keep the API backwards compatible.
491+
body["activate"] = False
492+
response = cast(
493+
Union[BuildOutputDTO, HTTPResponse],
494+
self.post("v1/content/%s/deploy" % app_guid, body=body),
473495
)
474496
response = self._server.handle_bad_response(response)
475497
return response
@@ -514,6 +536,7 @@ def deploy(
514536
title_is_default: bool,
515537
tarball: IO[bytes],
516538
env_vars: Optional[dict[str, str]] = None,
539+
activate: bool = True,
517540
) -> RSConnectClientDeployResult:
518541
if app_id is None:
519542
if app_name is None:
@@ -544,10 +567,10 @@ def deploy(
544567

545568
app_bundle = self.app_upload(app_id, tarball)
546569

547-
task = self.app_deploy(app_id, app_bundle["id"])
570+
task = self.content_deploy(app_guid, app_bundle["id"], activate=activate)
548571

549572
return {
550-
"task_id": task["id"],
573+
"task_id": task["task_id"],
551574
"app_id": app_id,
552575
"app_guid": app["guid"],
553576
"app_url": app["url"],
@@ -1000,7 +1023,7 @@ def upload_posit_bundle(self, prepare_deploy_result: PrepareDeployResult, bundle
10001023
upload_result = S3Server(upload_url).handle_bad_response(upload_result, is_httpresponse=True)
10011024

10021025
@cls_logged("Deploying bundle ...")
1003-
def deploy_bundle(self):
1026+
def deploy_bundle(self, activate: bool = True):
10041027
if self.deployment_name is None:
10051028
raise RSConnectException("A deployment name must be created before deploying a bundle.")
10061029
if self.bundle is None:
@@ -1016,6 +1039,7 @@ def deploy_bundle(self):
10161039
self.title_is_default,
10171040
self.bundle,
10181041
self.env_vars,
1042+
activate=activate,
10191043
)
10201044
self.deployed_info = result
10211045
return self

rsconnect/http_support.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,8 @@ def _do_request(
379379
logger.debug("Headers:")
380380
for key, value in headers.items():
381381
logger.debug("--> %s: %s" % (key, value))
382+
logger.debug("Body:")
383+
logger.debug("--> %s" % (body if body is not None else "<no body>"))
382384

383385
# if we weren't called under a `with` statement, we'll need to manage the
384386
# connection here.
@@ -402,6 +404,7 @@ def _do_request(
402404
logger.debug("Headers:")
403405
for key, value in response.getheaders():
404406
logger.debug("--> %s: %s" % (key, value))
407+
logger.debug("Body:")
405408
logger.debug("--> %s" % response_body)
406409
finally:
407410
if local_connection:

rsconnect/main.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,14 @@ def content_args(func: Callable[P, T]) -> Callable[P, T]:
287287
is_flag=True,
288288
help="Don't access the deployed content to verify that it started correctly.",
289289
)
290+
@click.option(
291+
"--draft",
292+
is_flag=True,
293+
help=(
294+
"Deploy the application as a draft. "
295+
"Previous bundle will continue to be served until the draft is published."
296+
),
297+
)
290298
@functools.wraps(func)
291299
def wrapper(*args: P.args, **kwargs: P.kwargs):
292300
return func(*args, **kwargs)
@@ -918,6 +926,7 @@ def deploy_notebook(
918926
disable_env_management: Optional[bool],
919927
env_management_py: Optional[bool],
920928
env_management_r: Optional[bool],
929+
draft: bool,
921930
no_verify: bool = False,
922931
):
923932
set_verbosity(verbose)
@@ -977,7 +986,7 @@ def deploy_notebook(
977986
env_management_py=env_management_py,
978987
env_management_r=env_management_r,
979988
)
980-
ce.deploy_bundle().save_deployed_info().emit_task_log()
989+
ce.deploy_bundle(activate=not draft).save_deployed_info().emit_task_log()
981990
if not no_verify:
982991
ce.verify_deployment()
983992

@@ -1068,6 +1077,7 @@ def deploy_voila(
10681077
cacert: Optional[str],
10691078
multi_notebook: bool,
10701079
no_verify: bool,
1080+
draft: bool = False,
10711081
connect_server: Optional[api.RSConnectServer] = None, # TODO: This appears to be unused
10721082
):
10731083
set_verbosity(verbose)
@@ -1106,7 +1116,7 @@ def deploy_voila(
11061116
env_management_py=env_management_py,
11071117
env_management_r=env_management_r,
11081118
multi_notebook=multi_notebook,
1109-
).deploy_bundle().save_deployed_info().emit_task_log()
1119+
).deploy_bundle(activate=not draft).save_deployed_info().emit_task_log()
11101120
if not no_verify:
11111121
ce.verify_deployment()
11121122

@@ -1149,6 +1159,7 @@ def deploy_manifest(
11491159
env_vars: dict[str, str],
11501160
visibility: Optional[str],
11511161
no_verify: bool,
1162+
draft: bool,
11521163
):
11531164
set_verbosity(verbose)
11541165
output_params(ctx, locals().items())
@@ -1182,7 +1193,7 @@ def deploy_manifest(
11821193
make_manifest_bundle,
11831194
file_name,
11841195
)
1185-
.deploy_bundle()
1196+
.deploy_bundle(activate=not draft)
11861197
.save_deployed_info()
11871198
.emit_task_log()
11881199
)
@@ -1276,6 +1287,7 @@ def deploy_quarto(
12761287
env_management_py: bool,
12771288
env_management_r: bool,
12781289
no_verify: bool,
1290+
draft: bool,
12791291
):
12801292
set_verbosity(verbose)
12811293
output_params(ctx, locals().items())
@@ -1331,7 +1343,7 @@ def deploy_quarto(
13311343
env_management_py=env_management_py,
13321344
env_management_r=env_management_r,
13331345
)
1334-
.deploy_bundle()
1346+
.deploy_bundle(activate=not draft)
13351347
.save_deployed_info()
13361348
.emit_task_log()
13371349
)
@@ -1395,6 +1407,7 @@ def deploy_tensorflow(
13951407
env_vars: dict[str, str],
13961408
image: Optional[str],
13971409
no_verify: bool,
1410+
draft: bool,
13981411
):
13991412
set_verbosity(verbose)
14001413
output_params(ctx, locals().items())
@@ -1426,7 +1439,7 @@ def deploy_tensorflow(
14261439
exclude,
14271440
image=image,
14281441
)
1429-
.deploy_bundle()
1442+
.deploy_bundle(activate=not draft)
14301443
.save_deployed_info()
14311444
.emit_task_log()
14321445
)
@@ -1489,6 +1502,7 @@ def deploy_html(
14891502
token: Optional[str],
14901503
secret: Optional[str],
14911504
no_verify: bool,
1505+
draft: bool,
14921506
connect_server: Optional[api.RSConnectServer] = None,
14931507
):
14941508
set_verbosity(verbose)
@@ -1539,7 +1553,7 @@ def deploy_html(
15391553
extra_files,
15401554
exclude,
15411555
)
1542-
.deploy_bundle()
1556+
.deploy_bundle(activate=not draft)
15431557
.save_deployed_info()
15441558
.emit_task_log()
15451559
)
@@ -1644,6 +1658,7 @@ def deploy_app(
16441658
token: Optional[str],
16451659
secret: Optional[str],
16461660
no_verify: bool,
1661+
draft: bool,
16471662
):
16481663
set_verbosity(verbose)
16491664
entrypoint = validate_entry_point(entrypoint, directory)
@@ -1700,7 +1715,7 @@ def deploy_app(
17001715
env_management_py=env_management_py,
17011716
env_management_r=env_management_r,
17021717
)
1703-
ce.deploy_bundle()
1718+
ce.deploy_bundle(activate=not draft)
17041719
ce.save_deployed_info()
17051720
ce.emit_task_log()
17061721

tests/test_environment.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ def fake_inspect_environment(
272272
assert environment.python_interpreter == expected_python
273273
assert environment == expected_environment
274274

275+
275276
class TestEnvironmentDeprecations:
276277
def test_override_python_version(self):
277278
with mock.patch.object(rsconnect.environment.logger, "warning") as mock_warning:

0 commit comments

Comments
 (0)