diff --git a/rsconnect/api.py b/rsconnect/api.py index cb220200..144cc82e 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -64,6 +64,7 @@ AppSearchResults, BootstrapOutputDTO, BuildOutputDTO, + BundleMetadata, ConfigureResult, ContentItemV0, ContentItemV1, @@ -72,7 +73,6 @@ ListEntryOutputDTO, PyInfo, ServerSettings, - TaskStatusV0, TaskStatusV1, UserRecord, ) @@ -377,6 +377,7 @@ class RSConnectClientDeployResult(TypedDict): app_id: str app_guid: str | None app_url: str + dashboard_url: str draft_url: str | None title: str | None @@ -440,38 +441,15 @@ def app_search(self, filters: Optional[Mapping[str, JsonData]]) -> AppSearchResu response = self._server.handle_bad_response(response) return response - def app_create(self, name: str) -> ContentItemV0: - response = cast(Union[ContentItemV0, HTTPResponse], self.post("applications", body={"name": name})) - response = self._server.handle_bad_response(response) - return response - def app_get(self, app_id: str) -> ContentItemV0: response = cast(Union[ContentItemV0, HTTPResponse], self.get("applications/%s" % app_id)) response = self._server.handle_bad_response(response) return response - def app_upload(self, app_id: str, tarball: typing.IO[bytes]) -> ContentItemV0: - response = cast(Union[ContentItemV0, HTTPResponse], self.post("applications/%s/upload" % app_id, body=tarball)) - response = self._server.handle_bad_response(response) - return response - - def app_update(self, app_id: str, updates: Mapping[str, str | None]) -> ContentItemV0: - response = cast(Union[ContentItemV0, HTTPResponse], self.post("applications/%s" % app_id, body=updates)) - response = self._server.handle_bad_response(response) - return response - 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: - response = cast( - Union[TaskStatusV0, HTTPResponse], - self.post("applications/%s/deploy" % app_id, body={"bundle": bundle_id}), - ) - response = self._server.handle_bad_response(response) - return response - def app_config(self, app_id: str) -> ConfigureResult: response = cast(Union[ConfigureResult, HTTPResponse], self.get("applications/%s/config" % app_id)) response = self._server.handle_bad_response(response) @@ -511,10 +489,27 @@ def content_get(self, content_guid: str) -> ContentItemV1: response = self._server.handle_bad_response(response) return response + def content_create(self, name: str) -> ContentItemV1: + response = cast(Union[ContentItemV1, HTTPResponse], self.post("v1/content", body={"name": name})) + response = self._server.handle_bad_response(response) + return response + + def content_upload_bundle(self, content_guid: str, tarball: typing.IO[bytes]) -> BundleMetadata: + response = cast( + Union[BundleMetadata, HTTPResponse], self.post("v1/content/%s/bundles" % content_guid, body=tarball) + ) + response = self._server.handle_bad_response(response) + return response + + def content_update(self, content_guid: str, updates: Mapping[str, str | None]) -> ContentItemV1: + response = cast(Union[ContentItemV1, HTTPResponse], self.patch("v1/content/%s" % content_guid, body=updates)) + 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: - body = {"bundle_id": bundle_id} + body: dict[str, str | bool | None] = {"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. @@ -527,8 +522,8 @@ 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) -> BuildOutputDTO: - body = {"bundle_id": str(bundle_id)} + def content_deploy(self, app_guid: str, bundle_id: Optional[str] = None, activate: bool = True) -> BuildOutputDTO: + body: dict[str, str | bool | None] = {"bundle_id": 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. @@ -586,17 +581,23 @@ def deploy( if app_id is None: if app_name is None: raise RSConnectException("An app ID or name is required to deploy an app.") - # create an app if id is not provided - app = self.app_create(app_name) - app_id = str(app["id"]) + # create content if id is not provided + app = self.content_create(app_name) # Force the title to update. title_is_default = False else: - # assume app exists. if it was deleted then Connect will - # raise an error + # assume content exists. if it was deleted then Connect will raise an error try: - app = self.app_get(app_id) + # app_id could be a numeric ID (legacy) or GUID. Try to get it as content. + # If it's a numeric ID, app_get will work; if GUID, content_get will work. + # We'll use content_get if it looks like a GUID (contains hyphens), otherwise app_get. + if "-" in str(app_id): + app = self.content_get(app_id) + else: + # Legacy numeric ID - get v0 content and convert to use GUID + app_v0 = self.app_get(app_id) + app = self.content_get(app_v0["guid"]) except RSConnectException as e: raise RSConnectException(f"{e} Try setting the --new flag to overwrite the previous deployment.") from e @@ -606,24 +607,22 @@ def deploy( result = self._server.handle_bad_response(result) if app["title"] != app_title and not title_is_default: - result = self.app_update(app_id, {"title": app_title}) + result = self.content_update(app_guid, {"title": app_title}) result = self._server.handle_bad_response(result) app["title"] = app_title - app_bundle = self.app_upload(app_id, tarball) + app_bundle = self.content_upload_bundle(app_guid, tarball) task = self.content_deploy(app_guid, app_bundle["id"], activate=activate) - # http://ADDRESS/DASHBOARD-PATH/#/apps/GUID/draft/BUNDLE_ID_TO_PREVIEW - # Pulling v1 content to get the full dashboard URL - app_v1 = self.content_get(app["guid"]) - draft_url = app_v1["dashboard_url"] + f"/draft/{app_bundle['id']}" + draft_url = app["dashboard_url"] + f"/draft/{app_bundle['id']}" return { "task_id": task["task_id"], - "app_id": app_id, + "app_id": app["id"], "app_guid": app["guid"], - "app_url": app["url"], + "app_url": app["content_url"], + "dashboard_url": app["dashboard_url"], "draft_url": draft_url if not activate else None, "title": app["title"], } @@ -1112,6 +1111,7 @@ def deploy_bundle(self, activate: bool = True): self.visibility, ) self.upload_posit_bundle(prepare_deploy_result, bundle_size, contents) + # type: ignore[arg-type] - PrepareDeployResult uses int, but format() accepts it shinyapps_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) else: cloud_service = CloudService(self.client, self.remote_server, os.getenv("LUCID_APPLICATION_ID")) @@ -1125,6 +1125,7 @@ def deploy_bundle(self, activate: bool = True): app_store_version, ) self.upload_posit_bundle(prepare_deploy_result, bundle_size, contents) + # type: ignore[arg-type] - PrepareDeployResult uses int, but format() accepts it cloud_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.application_id) print("Application successfully deployed to {}".format(prepare_deploy_result.app_url)) @@ -1180,10 +1181,7 @@ def emit_task_log( if self.deployed_info.get("draft_url"): log_callback.info("\t Draft content URL: %s", self.deployed_info["draft_url"]) else: - app_config = self.client.app_config(self.deployed_info["app_id"]) - app_config = self.remote_server.handle_bad_response(app_config) - app_dashboard_url = app_config.get("config_url") - log_callback.info("\t Dashboard content URL: %s", app_dashboard_url) + log_callback.info("\t Dashboard content URL: %s", self.deployed_info["dashboard_url"]) log_callback.info("\t Direct content URL: %s", self.deployed_info["app_url"]) return self @@ -2171,7 +2169,7 @@ def mapping_filter(client: RSConnectClient, app: ContentItemV0) -> AbbreviatedAp if app_mode not in (AppModes.STATIC, AppModes.JUPYTER_NOTEBOOK): return None - config = client.app_config(app["id"]) + config = client.app_config(str(app["id"])) config = connect_server.handle_bad_response(config) return map_app(app, config) diff --git a/rsconnect/models.py b/rsconnect/models.py index d8574bd2..0044adcc 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -537,17 +537,6 @@ class TaskStatusResult(TypedDict): data: object # Don't know the structure of this type yet -class TaskStatusV0(TypedDict): - id: str - status: list[str] - finished: bool - code: int - error: str - last_status: int - user_id: int - result: TaskStatusResult | None - - # https://docs.posit.co/connect/api/#get-/v1/tasks/-id- class TaskStatusV1(TypedDict): id: str @@ -589,6 +578,10 @@ class BuildOutputDTO(TypedDict): task_id: str +class BundleMetadata(TypedDict): + id: str + + class ListEntryOutputDTO(TypedDict): language: str version: str diff --git a/tests/test_main.py b/tests/test_main.py index a4692a74..6353c281 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -139,27 +139,29 @@ def test_deploy_draft(self, command, target, expected_activate, caplog): ) httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/applications", + "http://fake_server/__api__/v1/content", body=json.dumps( { - "id": "1234-5678-9012-3456", + "id": "1234", "guid": "1234-5678-9012-3456", "title": "app5", - "url": "http://fake_server/content/1234-5678-9012-3456", + "content_url": "http://fake_server/content/1234-5678-9012-3456", + "dashboard_url": "http://fake_server/connect/#/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", + httpretty.PATCH, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456", body=json.dumps( { - "id": "1234-5678-9012-3456", + "id": "1234", "guid": "1234-5678-9012-3456", "title": "app5", - "url": "http://fake_server/apps/1234-5678-9012-3456", + "content_url": "http://fake_server/content/1234-5678-9012-3456", + "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", } ), adding_headers={"Content-Type": "application/json"}, @@ -179,10 +181,25 @@ def test_deploy_draft(self, command, target, expected_activate, caplog): adding_headers={"Content-Type": "application/json"}, status=200, ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + body=json.dumps( + { + "id": "1234", + "guid": "1234-5678-9012-3456", + "title": "app5", + "content_url": "http://fake_server/content/1234-5678-9012-3456", + "dashboard_url": "http://fake_server/connect/#/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", + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", body=json.dumps( { "id": "FAKE_BUNDLE_ID", diff --git a/tests/test_metadata.py b/tests/test_metadata.py index c144626f..68406007 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -75,7 +75,9 @@ def test_add(self): self.assertEqual( self.server_store.get_by_name("qux"), - dict(name="qux", url="https://example.snowflakecomputing.app", snowflake_connection_name="dev", api_key=None), + dict( + name="qux", url="https://example.snowflakecomputing.app", snowflake_connection_name="dev", api_key=None + ), ) def test_remove_by_name(self):