From 3860846b23d8afa2f961ecd9bdde797af27bc7aa Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 6 Jun 2025 16:04:52 +0200 Subject: [PATCH 01/14] draft argument to deploy command --- rsconnect/api.py | 17 ++++-- rsconnect/main.py | 29 ++++++--- tests/test_main.py | 145 ++++++++++++++++++++++++++++++++++++++++++++- tests/utils.py | 1 + 4 files changed, 180 insertions(+), 12 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 6ce11723..f6b8948f 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -419,10 +419,17 @@ def app_add_environment_vars(self, app_guid: str, env_vars: list[tuple[str, str] env_body = [dict(name=kv[0], value=kv[1]) for kv in env_vars] return self.patch("v1/content/%s/environment" % app_guid, body=env_body) - def app_deploy(self, app_id: str, bundle_id: Optional[int] = None) -> TaskStatusV0: + def app_deploy(self, app_id: str, bundle_id: Optional[int] = None, activate: bool = True) -> TaskStatusV0: + body = {"bundle": bundle_id} + if not activate: + # The default behavior is to activate the app after deployment. + # 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[TaskStatusV0, HTTPResponse], - self.post("applications/%s/deploy" % app_id, body={"bundle": bundle_id}), + self.post("applications/%s/deploy" % app_id, body=body), ) response = self._server.handle_bad_response(response) return response @@ -514,6 +521,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,7 +552,7 @@ def deploy( app_bundle = self.app_upload(app_id, tarball) - task = self.app_deploy(app_id, app_bundle["id"]) + task = self.app_deploy(app_id, app_bundle["id"], activate=activate) return { "task_id": task["id"], @@ -1000,7 +1008,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 +1024,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/main.py b/rsconnect/main.py index 6be82794..6d40d450 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_main.py b/tests/test_main.py index 499cdbf5..4f3bc1d1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,8 +1,11 @@ import json +from math import exp import os import shutil +import itertools from os.path import join -from unittest import TestCase +from unittest import TestCase, mock + import click import httpretty @@ -94,6 +97,146 @@ 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"))], + ]], + ) + @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({}), + 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 + def post_application_deploy_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + expectation = {'bundle': 'FAKE_BUNDLE_ID'} + if not expected_activate: + expectation['activate'] = False + assert parsed_request == expectation + return [ + 200, + {"Content-Type": "application/json"}, + json.dumps({"id": "FAKE_TASK_ID"}) + ] + + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/applications/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={}): + result = runner.invoke(cli, args) + assert result.exit_code == 0, 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 + # 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): From ec1d245785ae7a565ecb8711f2deea011c044b3f Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 6 Jun 2025 16:14:11 +0200 Subject: [PATCH 02/14] format --- rsconnect/api.py | 4 +- rsconnect/main.py | 6 +-- tests/test_environment.py | 1 + tests/test_main.py | 94 +++++++++++++++++++++------------------ 4 files changed, 56 insertions(+), 49 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index f6b8948f..5951084d 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -521,7 +521,7 @@ def deploy( title_is_default: bool, tarball: IO[bytes], env_vars: Optional[dict[str, str]] = None, - activate: bool = True + activate: bool = True, ) -> RSConnectClientDeployResult: if app_id is None: if app_name is None: @@ -1008,7 +1008,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, activate: bool=True): + 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: diff --git a/rsconnect/main.py b/rsconnect/main.py index 6d40d450..bec4604f 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -293,7 +293,7 @@ def content_args(func: Callable[P, T]) -> Callable[P, T]: 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): @@ -1407,7 +1407,7 @@ def deploy_tensorflow( env_vars: dict[str, str], image: Optional[str], no_verify: bool, - draft: bool + draft: bool, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1658,7 +1658,7 @@ def deploy_app( token: Optional[str], secret: Optional[str], no_verify: bool, - draft: bool + draft: bool, ): set_verbosity(verbose) entrypoint = validate_entry_point(entrypoint, directory) 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 4f3bc1d1..aed85228 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -99,14 +99,18 @@ def test_deploy(self): @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"))], - ]], + [ + 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"))], + ] + ], ) @httpretty.activate(verbose=True, allow_net_connect=False) def test_deploy_draft(self, command, target, expected_activate): @@ -129,8 +133,7 @@ def test_deploy_draft(self, command, target, expected_activate): ) httpretty.register_uri( httpretty.GET, - "http://fake_server/__api__/applications" - "?search=app5&count=100", + "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, @@ -138,36 +141,42 @@ def test_deploy_draft(self, command, target, expected_activate): 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", - }), + 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", - }), + 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" - }), + 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, ) @@ -175,9 +184,11 @@ def test_deploy_draft(self, command, target, expected_activate): httpretty.register_uri( httpretty.POST, "http://fake_server/__api__/applications/1234-5678-9012-3456/upload", - body=json.dumps({ - "id": "FAKE_BUNDLE_ID", - }), + body=json.dumps( + { + "id": "FAKE_BUNDLE_ID", + } + ), adding_headers={"Content-Type": "application/json"}, status=200, ) @@ -186,27 +197,22 @@ def test_deploy_draft(self, command, target, expected_activate): # We can check that the process actually submits the draft def post_application_deploy_callback(request, uri, response_headers): parsed_request = _load_json(request.body) - expectation = {'bundle': 'FAKE_BUNDLE_ID'} + expectation = {"bundle": "FAKE_BUNDLE_ID"} if not expected_activate: - expectation['activate'] = False + expectation["activate"] = False assert parsed_request == expectation - return [ - 200, - {"Content-Type": "application/json"}, - json.dumps({"id": "FAKE_TASK_ID"}) - ] + return [200, {"Content-Type": "application/json"}, json.dumps({"id": "FAKE_TASK_ID"})] httpretty.register_uri( httpretty.POST, "http://fake_server/__api__/applications/1234-5678-9012-3456/deploy", - body=post_application_deploy_callback + 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", + "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, @@ -220,15 +226,15 @@ def post_application_deploy_callback(request, uri, response_headers): 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={}): + with mock.patch("rsconnect.main.which_quarto", return_value=None), mock.patch( + "rsconnect.main.quarto_inspect", return_value={} + ): result = runner.invoke(cli, args) assert result.exit_code == 0, result.output finally: From 06aceb69bb9ed8095b21aae162ed9b95cd1dfc38 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 6 Jun 2025 16:14:52 +0200 Subject: [PATCH 03/14] remove unused imports --- tests/test_main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index aed85228..52d13fc5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,8 +1,6 @@ import json -from math import exp import os import shutil -import itertools from os.path import join from unittest import TestCase, mock From cfcf55ac761e45e5d9954f66ccb313fbe559953e Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 6 Jun 2025 16:27:22 +0200 Subject: [PATCH 04/14] Confirm deploy API was actually invoked --- tests/test_main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 52d13fc5..bfe18f60 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -193,12 +193,14 @@ def test_deploy_draft(self, command, target, expected_activate): # 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": "FAKE_BUNDLE_ID"} if not expected_activate: expectation["activate"] = False assert parsed_request == expectation + deploy_api_invoked.append(True) return [200, {"Content-Type": "application/json"}, json.dumps({"id": "FAKE_TASK_ID"})] httpretty.register_uri( @@ -234,6 +236,7 @@ def post_application_deploy_callback(request, uri, response_headers): "rsconnect.main.quarto_inspect", return_value={} ): result = runner.invoke(cli, args) + assert deploy_api_invoked == [True] assert result.exit_code == 0, result.output finally: if original_api_key_value: From acbb944ed536686d20601bc2f04a747fc4e75a83 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 6 Jun 2025 16:30:21 +0200 Subject: [PATCH 05/14] format --- tests/test_main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_main.py b/tests/test_main.py index bfe18f60..a013bacc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -194,6 +194,7 @@ def test_deploy_draft(self, command, target, expected_activate): # 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": "FAKE_BUNDLE_ID"} From 5ec10c43aa53b9652b1b8aa6129bd75c71868689 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 6 Jun 2025 16:36:51 +0200 Subject: [PATCH 06/14] Do not validate, so we can use any directory --- tests/test_main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index a013bacc..d43bbd5e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -235,10 +235,12 @@ def post_application_deploy_callback(request, uri, response_headers): args.append("--draft") with mock.patch("rsconnect.main.which_quarto", return_value=None), mock.patch( "rsconnect.main.quarto_inspect", return_value={} + ), mock.patch( + "rsconnect.api.RSConnectExecutor.validate_app_mode", new=lambda self_, *args, **kwargs: self_ ): result = runner.invoke(cli, args) - assert deploy_api_invoked == [True] 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 From 5a2c5dc38e60493353ae895cdc6e3fe90bf83685 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 6 Jun 2025 17:10:15 +0200 Subject: [PATCH 07/14] Add CHANGELOD --- docs/CHANGELOG.md | 6 ++++++ tests/test_main.py | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) 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/tests/test_main.py b/tests/test_main.py index d43bbd5e..44e0387c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -236,7 +236,9 @@ def post_application_deploy_callback(request, uri, response_headers): with mock.patch("rsconnect.main.which_quarto", return_value=None), mock.patch( "rsconnect.main.quarto_inspect", return_value={} ), mock.patch( - "rsconnect.api.RSConnectExecutor.validate_app_mode", new=lambda self_, *args, **kwargs: self_ + # 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 From dfcc5ccb39b87ff64411c764c26b1b61de08eda9 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 6 Jun 2025 17:20:35 +0200 Subject: [PATCH 08/14] Test generated deploys too --- tests/test_main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 44e0387c..c56589cb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -107,6 +107,8 @@ def test_deploy(self): ["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", "")], ] ], ) @@ -118,7 +120,7 @@ def test_deploy_draft(self, command, target, expected_activate): httpretty.register_uri( httpretty.GET, "http://fake_server/__api__/server_settings", - body=json.dumps({}), + body=json.dumps({"version": "9999.99.99"}), adding_headers={"Content-Type": "application/json"}, status=200, ) From 2de9faaccb57cbb3a9bebcb6ec0f3a5a970de4af Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 10 Jun 2025 13:50:42 +0200 Subject: [PATCH 09/14] switch deploy to use v1 apis --- rsconnect/api.py | 14 ++++++++++---- rsconnect/http_support.py | 2 ++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 5951084d..01766967 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -473,10 +473,16 @@ 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 @@ -552,10 +558,10 @@ def deploy( app_bundle = self.app_upload(app_id, tarball) - task = self.app_deploy(app_id, app_bundle["id"], activate=activate) + task = self.content_build(app_guid, str(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"], diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index 2bf6bd47..9812fb32 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. From 79c8d7064c3e1cfb781db6f13cc704bbe92ec032 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 10 Jun 2025 13:51:27 +0200 Subject: [PATCH 10/14] Revert changes to v0 api --- rsconnect/api.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 01766967..d8f98e72 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -419,17 +419,10 @@ def app_add_environment_vars(self, app_guid: str, env_vars: list[tuple[str, str] env_body = [dict(name=kv[0], value=kv[1]) for kv in env_vars] return self.patch("v1/content/%s/environment" % app_guid, body=env_body) - def app_deploy(self, app_id: str, bundle_id: Optional[int] = None, activate: bool = True) -> TaskStatusV0: - body = {"bundle": bundle_id} - if not activate: - # The default behavior is to activate the app after deployment. - # So we only pass the parameter if we want to deactivate it. - # That way we can keep the API backwards compatible. - body["activate"] = False - + def app_deploy(self, app_id: str, bundle_id: Optional[int] = None) -> TaskStatusV0: response = cast( Union[TaskStatusV0, HTTPResponse], - self.post("applications/%s/deploy" % app_id, body=body), + self.post("applications/%s/deploy" % app_id, body={"bundle": bundle_id}), ) response = self._server.handle_bad_response(response) return response From 421f391dc6bd17d3840f1b08e0080fce0ce09364 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 10 Jun 2025 13:54:01 +0200 Subject: [PATCH 11/14] update deploy_draft tests --- tests/test_main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index c56589cb..628fb86b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -199,16 +199,16 @@ def test_deploy_draft(self, command, target, expected_activate): def post_application_deploy_callback(request, uri, response_headers): parsed_request = _load_json(request.body) - expectation = {"bundle": "FAKE_BUNDLE_ID"} + expectation = {"bundle_id": "FAKE_BUNDLE_ID"} if not expected_activate: expectation["activate"] = False assert parsed_request == expectation deploy_api_invoked.append(True) - return [200, {"Content-Type": "application/json"}, json.dumps({"id": "FAKE_TASK_ID"})] + return [200, {"Content-Type": "application/json"}, json.dumps({"task_id": "FAKE_TASK_ID"})] httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/applications/1234-5678-9012-3456/deploy", + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/build", body=post_application_deploy_callback, ) From 4e7b148d0b2fd6bc2a6b2a9538242053fe93b1dd Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 10 Jun 2025 13:57:55 +0200 Subject: [PATCH 12/14] format --- rsconnect/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index d8f98e72..0669e8d2 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -466,7 +466,9 @@ 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, activate: bool = True) -> 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. From ae19041f63d58ec3daec6420f7cb2147bafa8c13 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 10 Jun 2025 16:04:11 +0200 Subject: [PATCH 13/14] Use v1/content/deploy --- rsconnect/api.py | 18 ++++++++++++++++-- rsconnect/http_support.py | 1 + tests/test_main.py | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 0669e8d2..9cd3fb86 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 ) @@ -482,6 +482,20 @@ def content_build( response = self._server.handle_bad_response(response) return response + def content_deploy(self, app_guid: str, bundle_id: Optional[int] = None, activate: bool = True) -> TaskStatusV0: + 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[TaskStatusV1, HTTPResponse], + self.post("v1/content/%s/deploy" % app_guid, body=body), + ) + response = self._server.handle_bad_response(response) + return response + def system_caches_runtime_list(self) -> list[ListEntryOutputDTO]: response = cast(Union[List[ListEntryOutputDTO], HTTPResponse], self.get("v1/system/caches/runtime")) response = self._server.handle_bad_response(response) @@ -553,7 +567,7 @@ def deploy( app_bundle = self.app_upload(app_id, tarball) - task = self.content_build(app_guid, str(app_bundle["id"]), activate=activate) + task = self.content_deploy(app_guid, app_bundle["id"], activate=activate) return { "task_id": task["task_id"], diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index 9812fb32..29f04540 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -404,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/tests/test_main.py b/tests/test_main.py index 628fb86b..6cb36407 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -204,11 +204,11 @@ def post_application_deploy_callback(request, uri, response_headers): expectation["activate"] = False assert parsed_request == expectation deploy_api_invoked.append(True) - return [200, {"Content-Type": "application/json"}, json.dumps({"task_id": "FAKE_TASK_ID"})] + 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/build", + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", body=post_application_deploy_callback, ) From 5be397a64ccd34345d43b3765f107b272fb4ea3f Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 11 Jun 2025 16:27:37 +0200 Subject: [PATCH 14/14] v1 deploy actually reuses the DTO of build --- rsconnect/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 9cd3fb86..1e621e88 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -482,7 +482,7 @@ def content_build( response = self._server.handle_bad_response(response) return response - def content_deploy(self, app_guid: str, bundle_id: Optional[int] = None, activate: bool = True) -> TaskStatusV0: + 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. @@ -490,7 +490,7 @@ def content_deploy(self, app_guid: str, bundle_id: Optional[int] = None, activat # That way we can keep the API backwards compatible. body["activate"] = False response = cast( - Union[TaskStatusV1, HTTPResponse], + Union[BuildOutputDTO, HTTPResponse], self.post("v1/content/%s/deploy" % app_guid, body=body), ) response = self._server.handle_bad_response(response)