From 673dae4bc1679fbcf388247500f4af9d2f7b864e Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Tue, 11 Apr 2023 11:11:13 -0500 Subject: [PATCH 1/8] Add support for specifying visibility for Posit Cloud and shinyapps.io deploys --- CHANGELOG.md | 5 +++++ rsconnect/api.py | 52 ++++++++++++++++++++++++++++++++++++++++++---- rsconnect/main.py | 16 ++++++++++++++ tests/test_main.py | 25 +++++++++++++++++++++- 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d11bc9ff..570240ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.17.0] - 2023-xx-xx + +### Added +- Deploys for Posit Cloud and shinyapps.io now accept the `--visibility` flag. + ## [1.16.0] - 2023-03-27 ### Added diff --git a/rsconnect/api.py b/rsconnect/api.py index 9a177119..5f91c69c 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -696,6 +696,7 @@ def deploy_bundle( title_is_default: bool = False, bundle: IO = None, env_vars=None, + visibility=None, ): app_id = app_id or self.get("app_id") deployment_name = deployment_name or self.get("deployment_name") @@ -703,6 +704,7 @@ def deploy_bundle( title_is_default = title_is_default or self.get("title_is_default") bundle = bundle or self.get("bundle") env_vars = env_vars or self.get("env_vars") + visibility = visibility or self.get("visibility") if isinstance(self.remote_server, RSConnectServer): result = self.client.deploy( @@ -728,6 +730,7 @@ def deploy_bundle( deployment_name, bundle_size, bundle_hash, + visibility, ) self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) shinyapps_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) @@ -738,6 +741,7 @@ def deploy_bundle( deployment_name, bundle_size, bundle_hash, + visibility, ) self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) cloud_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) @@ -1072,6 +1076,9 @@ def get_extra_headers(self, url, method, body): def get_application(self, application_id): return self.get("/v1/applications/{}".format(application_id)) + def update_application_property(self, application_id: int, property: str, value: str): + return self.put("/v1/applications/{}/properties/{}".format(application_id, property), body={"value": value}) + def get_content(self, content_id): return self.get("/v1/content/{}".format(content_id)) @@ -1083,14 +1090,19 @@ def create_application(self, account_id, application_name): } return self.post("/v1/applications/", body=application_data) - def create_output(self, name, project_id=None, space_id=None): + def create_output(self, name: str, project_id: int, space_id: int, visibility: typing.Optional[str]): data = { "name": name, "space": space_id, "project": project_id, } + if visibility is not None: + data["visibility"] = visibility return self.post("/v1/outputs/", body=data) + def update_output(self, output_id: int, output_data: dict): + return self.patch("/v1/outputs/{}".format(output_id), body=output_data) + def get_accounts(self): return self.get("/v1/accounts/") @@ -1173,7 +1185,14 @@ def __init__(self, rstudio_client: PositClient, server: ShinyappsServer): self._rstudio_client = rstudio_client self._server = server - def prepare_deploy(self, app_id: typing.Optional[int], app_name: str, bundle_size: int, bundle_hash: str): + def prepare_deploy( + self, + app_id: typing.Optional[int], + app_name: str, + bundle_size: int, + bundle_hash: str, + visibility: typing.Optional[str], + ): accounts = self._rstudio_client.get_accounts() self._server.handle_bad_response(accounts) account = next( @@ -1187,9 +1206,28 @@ def prepare_deploy(self, app_id: typing.Optional[int], app_name: str, bundle_siz if app_id is None: application = self._rstudio_client.create_application(account["id"], app_name) + self._server.handle_bad_response(application) + if visibility is not None: + property_update = self._rstudio_client.update_application_property( + application.json_data["id"], "application.visibility", visibility + ) + self._server.handle_bad_response(property_update) + else: application = self._rstudio_client.get_application(app_id) - self._server.handle_bad_response(application) + self._server.handle_bad_response(application) + + if visibility is not None: + print(f"application: {application.json_data}") + print(f"deployment: {application.json_data['deployment']}") + print(f"properties: {application.json_data['deployment']['properties']}") + print(f"visibility: {application.json_data['deployment']['properties']['application.visibility']}") + if visibility != application.json_data["deployment"]["properties"]["application.visibility"]: + property_update = self._rstudio_client.update_application_property( + application.json_data["id"], "application.visibility", visibility + ) + self._server.handle_bad_response(property_update) + app_id_int = application.json_data["id"] app_url = application.json_data["url"] @@ -1228,6 +1266,7 @@ def prepare_deploy( app_name: str, bundle_size: int, bundle_hash: str, + visibility: typing.Optional[str], ): if app_id is None: project_application_id = os.getenv("LUCID_APPLICATION_ID") @@ -1242,7 +1281,9 @@ def prepare_deploy( project_id = None space_id = None - output = self._rstudio_client.create_output(name=app_name, project_id=project_id, space_id=space_id) + output = self._rstudio_client.create_output( + name=app_name, project_id=project_id, space_id=space_id, visibility=visibility + ) self._server.handle_bad_response(output) app_id = output.json_data["source_id"] application = self._rstudio_client.get_application(app_id) @@ -1252,6 +1293,9 @@ def prepare_deploy( self._server.handle_bad_response(application) output = self._rstudio_client.get_content(application.json_data["content_id"]) self._server.handle_bad_response(output) + if visibility is not None and output.json_data["visibility"] != visibility: + update_output = self._rstudio_client.update_output(output.json_data["id"], {"visibility": visibility}) + self._server.handle_bad_response(update_output) app_id_int = application.json_data["id"] app_url = output.json_data["url"] diff --git a/rsconnect/main.py b/rsconnect/main.py index d575ee14..308da2c9 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -961,6 +961,13 @@ def deploy_voila( @content_args @rstudio_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) +@click.option( + "--visibility", + "-V", + type=click.Choice(['public', 'private']), + help="The visibility of the resource being deployed. (public or private, Posit Cloud and shinyapps.io only. " + "Cloud defaults to public; shinyapps.io defaults to private.)", +) @cli_exception_handler def deploy_manifest( name: str, @@ -977,6 +984,7 @@ def deploy_manifest( verbose: bool, file: str, env_vars: typing.Dict[str, str], + visibility: typing.Optional[str], ): kwargs = locals() set_verbosity(verbose) @@ -1268,6 +1276,13 @@ def generate_deploy_python(app_mode, alias, min_version): nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) + @click.option( + "--visibility", + "-V", + type=click.Choice(['public', 'private']), + help="The visibility of the resource being deployed. (public or private, Posit Cloud and shinyapps.io only. " + "Cloud defaults to public; shinyapps.io defaults to private.)", + ) @cli_exception_handler def deploy_app( name: str, @@ -1286,6 +1301,7 @@ def deploy_app( verbose: bool, directory, extra_files, + visibility: typing.Optional[str], env_vars: typing.Dict[str, str], image: str, account: str = None, diff --git a/tests/test_main.py b/tests/test_main.py index ae43e44c..7fcdb651 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -149,6 +149,25 @@ def post_application_callback(request, uri, response_headers): status=200, ) + def post_application_property_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + try: + assert parsed_request == {"value": "private"} + except AssertionError as e: + return _error_to_response(e) + return [ + 201, + {}, + b"", + ] + + httpretty.register_uri( + httpretty.PUT, + "https://api.shinyapps.io/v1/applications/8442/properties/application.visibility", + body=post_application_property_callback, + status=200, + ) + def post_bundle_callback(request, uri, response_headers): parsed_request = _load_json(request.body) del parsed_request["checksum"] @@ -246,6 +265,8 @@ def post_deploy_callback(request, uri, response_headers): "c29tZVNlY3JldAo=", "--title", "myApp", + "--visibility", + "private" ] try: result = runner.invoke(cli, args) @@ -317,7 +338,7 @@ def post_output_callback(request, uri, response_headers): space_id = 917733 if project_application_id else None parsed_request = _load_json(request.body) try: - assert parsed_request == {"name": "myapp", "space": space_id, "project": project_id} + assert parsed_request == {"name": "myapp", "space": space_id, "project": project_id, "visibility": "public"} except AssertionError as e: return _error_to_response(e) return [ @@ -439,6 +460,8 @@ def post_deploy_callback(request, uri, response_headers): "c29tZVNlY3JldAo=", "--title", "myApp", + "--visibility", + "public" ] try: result = runner.invoke(cli, args) From a55300b75d7212e3eadea722167af05d6b31cd5e Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Tue, 11 Apr 2023 11:33:45 -0500 Subject: [PATCH 2/8] mark changes as "Unspecified" in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 570240ea..28ae2314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.17.0] - 2023-xx-xx +## Unreleased ### Added - Deploys for Posit Cloud and shinyapps.io now accept the `--visibility` flag. From 05174238e80f39df9f045edeaea6c9b6eda60d11 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Tue, 11 Apr 2023 11:35:01 -0500 Subject: [PATCH 3/8] remove extraneous logging --- rsconnect/api.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 5f91c69c..e79aa169 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1218,10 +1218,6 @@ def prepare_deploy( self._server.handle_bad_response(application) if visibility is not None: - print(f"application: {application.json_data}") - print(f"deployment: {application.json_data['deployment']}") - print(f"properties: {application.json_data['deployment']['properties']}") - print(f"visibility: {application.json_data['deployment']['properties']['application.visibility']}") if visibility != application.json_data["deployment"]["properties"]["application.visibility"]: property_update = self._rstudio_client.update_application_property( application.json_data["id"], "application.visibility", visibility From 13ef4fc0ea831b1fe49ce3f90436c000ad517776 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Tue, 11 Apr 2023 11:40:02 -0500 Subject: [PATCH 4/8] refactor visibility argument --- rsconnect/main.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 308da2c9..acf63d0f 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -171,6 +171,21 @@ def wrapper(*args, **kwargs): return wrapper +def rstudio_deploy_args(func): + @click.option( + "--visibility", + "-V", + type=click.Choice(["public", "private"]), + help="The visibility of the resource being deployed. (public or private, Posit Cloud and shinyapps.io only. " + "Cloud defaults to public; shinyapps.io defaults to private.)", + ) + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + def _passthrough(func): @functools.wraps(func) def wrapper(*args, **kwargs): @@ -961,13 +976,7 @@ def deploy_voila( @content_args @rstudio_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) -@click.option( - "--visibility", - "-V", - type=click.Choice(['public', 'private']), - help="The visibility of the resource being deployed. (public or private, Posit Cloud and shinyapps.io only. " - "Cloud defaults to public; shinyapps.io defaults to private.)", -) +@rstudio_deploy_args @cli_exception_handler def deploy_manifest( name: str, @@ -1276,13 +1285,7 @@ def generate_deploy_python(app_mode, alias, min_version): nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) - @click.option( - "--visibility", - "-V", - type=click.Choice(['public', 'private']), - help="The visibility of the resource being deployed. (public or private, Posit Cloud and shinyapps.io only. " - "Cloud defaults to public; shinyapps.io defaults to private.)", - ) + @rstudio_deploy_args @cli_exception_handler def deploy_app( name: str, From 537aba0e06f6263b8701cdbf40d76f6fe99d171b Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Mon, 5 Jun 2023 11:08:41 -0500 Subject: [PATCH 5/8] refactor shinyapps_deploy_args names and fix merge --- rsconnect/api.py | 2 +- rsconnect/main.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index a75a9efe..ac79c93a 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1282,7 +1282,7 @@ def prepare_deploy( self._server.handle_bad_response(application) if visibility is not None: - if visibility != application.json_data["deployment"]["properties"]["application.visibility"]: + if visibility != application["deployment"]["properties"]["application.visibility"]: self._rstudio_client.update_application_property( application["id"], "application.visibility", visibility ) diff --git a/rsconnect/main.py b/rsconnect/main.py index c95e47f5..e230140b 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -171,13 +171,12 @@ def wrapper(*args, **kwargs): return wrapper -def rstudio_deploy_args(func): +def shinyapps_deploy_args(func): @click.option( "--visibility", "-V", type=click.Choice(["public", "private"]), - help="The visibility of the resource being deployed. (public or private, Posit Cloud and shinyapps.io only. " - "Cloud defaults to public; shinyapps.io defaults to private.)", + help="The visibility of the resource being deployed. (shinyapps.io only; must be public (default) or private)", ) @functools.wraps(func) def wrapper(*args, **kwargs): @@ -978,7 +977,7 @@ def deploy_voila( @content_args @cloud_shinyapps_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) -@rstudio_deploy_args +@shinyapps_deploy_args @cli_exception_handler def deploy_manifest( name: str, @@ -1291,7 +1290,7 @@ def generate_deploy_python(app_mode, alias, min_version): nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) - @rstudio_deploy_args + @shinyapps_deploy_args @cli_exception_handler def deploy_app( name: str, From f845309b7434a9f71a8e43d370897c4d69e5adf5 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Mon, 5 Jun 2023 12:02:05 -0500 Subject: [PATCH 6/8] remove visibility from CloudService --- rsconnect/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index ac79c93a..f00a4573 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1324,7 +1324,6 @@ def prepare_deploy( bundle_size: int, bundle_hash: str, app_mode: AppMode, - visibility: typing.Optional[str], app_store_version: typing.Optional[int], ) -> PrepareDeployOutputResult: application_type = "static" if app_mode == AppModes.STATIC else "connect" From 2f157d9c2943f47b274df9c44237604fe628414a Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Mon, 5 Jun 2023 12:03:45 -0500 Subject: [PATCH 7/8] remove visibility from CloudService --- rsconnect/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index f00a4573..1fd7239b 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -750,7 +750,7 @@ def deploy_bundle( cloud_service = CloudService(self.client, self.remote_server, os.getenv("LUCID_APPLICATION_ID")) app_store_version = self.get("app_store_version") prepare_deploy_result = cloud_service.prepare_deploy( - app_id, deployment_name, bundle_size, bundle_hash, app_mode, visibility, app_store_version + app_id, deployment_name, bundle_size, bundle_hash, app_mode, app_store_version ) self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) cloud_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.application_id) From 790f3c47c8647693c8e4066e36dce69ff1a3272a Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Tue, 20 Jun 2023 13:47:39 -0500 Subject: [PATCH 8/8] use handle_bad_response in update_application_property --- rsconnect/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 5946571e..b0f75040 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1095,7 +1095,9 @@ def get_application(self, application_id): return response def update_application_property(self, application_id: int, property: str, value: str): - return self.put("/v1/applications/{}/properties/{}".format(application_id, property), body={"value": value}) + response = self.put("/v1/applications/{}/properties/{}".format(application_id, property), body={"value": value}) + self._server.handle_bad_response(response) + return response def get_content(self, content_id): response = self.get("/v1/content/{}".format(content_id))