diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 52d1a805..72712c95 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/rsconnect/api.py b/rsconnect/api.py index 6ce11723..1e621e88 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -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 else response ) @@ -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 @@ -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: @@ -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) return { - "task_id": task["id"], + "task_id": task["task_id"], "app_id": app_id, "app_guid": app["guid"], "app_url": app["url"], @@ -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: @@ -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 diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index 2bf6bd47..29f04540 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -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 "")) # if we weren't called under a `with` statement, we'll need to manage the # connection here. @@ -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: diff --git a/rsconnect/main.py b/rsconnect/main.py index 6be82794..bec4604f 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -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) @@ -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) @@ -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() @@ -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) @@ -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() @@ -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()) @@ -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() ) @@ -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()) @@ -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() ) @@ -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()) @@ -1426,7 +1439,7 @@ def deploy_tensorflow( exclude, image=image, ) - .deploy_bundle() + .deploy_bundle(activate=not draft) .save_deployed_info() .emit_task_log() ) @@ -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) @@ -1539,7 +1553,7 @@ def deploy_html( extra_files, exclude, ) - .deploy_bundle() + .deploy_bundle(activate=not draft) .save_deployed_info() .emit_task_log() ) @@ -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) @@ -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() diff --git a/tests/test_environment.py b/tests/test_environment.py index 1a1c5a95..4da52db7 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -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: diff --git a/tests/test_main.py b/tests/test_main.py index 499cdbf5..6cb36407 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,7 +2,8 @@ import os import shutil from os.path import join -from unittest import TestCase +from unittest import TestCase, mock + import click import httpretty @@ -94,6 +95,162 @@ def test_deploy(self): result = runner.invoke(cli, args) assert result.exit_code == 0, result.output + @pytest.mark.parametrize( + "command, target,expected_activate", + [ + args + [flag] + for flag in [True, False] + for args in [ + ["notebook", get_dir(join("pip1", "dummy.ipynb"))], + ["html", get_manifest_path("pyshiny_with_manifest", "")], + ["manifest", get_manifest_path("pyshiny_with_manifest", "")], + ["quarto", get_manifest_path("pyshiny_with_manifest", "")], + ["tensorflow", get_api_path("pyshiny_with_manifest", "")], + ["voila", get_dir(join("pip1", "dummy.ipynb"))], + # This covers all deploys generated by generate_deploy_python + ["fastapi", get_api_path("stock-api-fastapi", "")], + ] + ], + ) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_draft(self, command, target, expected_activate): + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + 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__/me", + body=open("tests/testdata/rstudio-responses/get-user.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/applications?search=app5&count=100", + body=open("tests/testdata/rstudio-responses/get-applications.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/applications", + body=json.dumps( + { + "id": "1234-5678-9012-3456", + "guid": "1234-5678-9012-3456", + "title": "app5", + "url": "http://fake_server/apps/1234-5678-9012-3456", + } + ), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/applications/1234-5678-9012-3456", + body=json.dumps( + { + "id": "1234-5678-9012-3456", + "guid": "1234-5678-9012-3456", + "title": "app5", + "url": "http://fake_server/apps/1234-5678-9012-3456", + } + ), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/applications/1234-5678-9012-3456", + body=json.dumps( + { + "id": "1234-5678-9012-3456", + "guid": "1234-5678-9012-3456", + "title": "app5", + "url": "http://fake_server/apps/1234-5678-9012-3456", + } + ), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/applications/1234-5678-9012-3456/upload", + body=json.dumps( + { + "id": "FAKE_BUNDLE_ID", + } + ), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + # This is the important part for the draft deployment + # We can check that the process actually submits the draft + deploy_api_invoked = [] + + def post_application_deploy_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + expectation = {"bundle_id": "FAKE_BUNDLE_ID"} + if not expected_activate: + expectation["activate"] = False + assert parsed_request == expectation + deploy_api_invoked.append(True) + return [201, {"Content-Type": "application/json"}, json.dumps({"task_id": "FAKE_TASK_ID"})] + + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + body=post_application_deploy_callback, + ) + + # Fake deploy task completion + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/tasks/FAKE_TASK_ID" "?wait=1", + body=json.dumps({"output": ["FAKE_OUTPUT"], "last": "FAKE_LAST", "finished": True, "code": 0}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/applications/1234-5678-9012-3456/config", + body=json.dumps({}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + try: + runner = CliRunner() + args = apply_common_args(["deploy", command, target], server="http://fake_server", key="FAKE_API_KEY") + args.append("--no-verify") + if not expected_activate: + args.append("--draft") + with mock.patch("rsconnect.main.which_quarto", return_value=None), mock.patch( + "rsconnect.main.quarto_inspect", return_value={} + ), mock.patch( + # Do not validate app mode, so that the "target" content doesn't matter. + "rsconnect.api.RSConnectExecutor.validate_app_mode", + new=lambda self_, *args, **kwargs: self_, + ): + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + assert deploy_api_invoked == [True] + 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 + # noinspection SpellCheckingInspection def test_deploy_manifest(self): target = optional_target(get_manifest_path("shinyapp")) diff --git a/tests/utils.py b/tests/utils.py index e183b952..1dfdcb78 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -16,6 +16,7 @@ def apply_common_args(args: list, server=None, key=None, cacert=None, insecure=F args.extend(["--cacert", cacert]) if insecure: args.extend(["--insecure"]) + return args def optional_target(default):