From 15162921b44cd8c50e36d42488653cb007c02c78 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Mon, 1 Dec 2025 16:19:25 -0500 Subject: [PATCH 1/5] Remove app_config --- rsconnect/api.py | 79 +++++++++++++++++++++------------------------ rsconnect/models.py | 5 --- 2 files changed, 36 insertions(+), 48 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 144cc82e..d83f9ad4 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -65,7 +65,6 @@ BootstrapOutputDTO, BuildOutputDTO, BundleMetadata, - ConfigureResult, ContentItemV0, ContentItemV1, DeleteInputDTO, @@ -450,11 +449,6 @@ 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_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) - return response - def is_app_failed_response(self, response: HTTPResponse | JsonData) -> bool: return isinstance(response, HTTPResponse) and response.status >= 500 @@ -465,10 +459,13 @@ def app_access(self, app_guid: str) -> None: response = self._do_request(method, path, None, None, 3, {}, False) if self.is_app_failed_response(response): + # Get content metadata to construct logs URL + content = self.content_get(app_guid) + logs_url = content["dashboard_url"] + "/logs" raise RSConnectException( "Could not access the deployed content. " + "The app might not have started successfully." - + f"\n\t For more information: {self.app_config(app_guid).get('logs_url')}" + + f"\n\t For more information: {logs_url}" ) def bundle_download(self, content_guid: str, bundle_id: str) -> HTTPResponse: @@ -489,6 +486,22 @@ def content_get(self, content_guid: str) -> ContentItemV1: response = self._server.handle_bad_response(response) return response + def get_content_by_id(self, app_id: str) -> ContentItemV1: + """ + Get content by ID, which can be either a numeric ID (legacy) or GUID. + + :param app_id: Either a numeric ID (e.g., "1234") or GUID (e.g., "abc-def-123") + :return: ContentItemV1 data + """ + # Check if it looks like a GUID (contains hyphens) + if "-" in str(app_id): + return self.content_get(app_id) + else: + # Legacy numeric ID - get v0 content first to get GUID + # TODO: deprecation warning + app_v0 = self.app_get(app_id) + return self.content_get(app_v0["guid"]) + 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) @@ -589,15 +602,8 @@ def deploy( else: # assume content exists. if it was deleted then Connect will raise an error try: - # 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"]) + # app_id could be a numeric ID (legacy) or GUID + app = self.get_content_by_id(app_id) except RSConnectException as e: raise RSConnectException(f"{e} Try setting the --new flag to overwrite the previous deployment.") from e @@ -1996,21 +2002,6 @@ def get_posit_app_info(server: PositServer, app_id: str): return response["source"] -def get_app_config(connect_server: Union[RSConnectServer, SPCSConnectServer], app_id: str): - """ - Return the configuration information for an application that has been created - in Connect. - - :param connect_server: the Connect server information. - :param app_id: the ID (numeric or GUID) of the application to get the info for. - :return: the Python installation information from Connect. - """ - with RSConnectClient(connect_server) as client: - result = client.app_config(app_id) - result = connect_server.handle_bad_response(result) - return result - - def emit_task_log( connect_server: Union[RSConnectServer, SPCSConnectServer], app_id: str, @@ -2041,9 +2032,9 @@ def emit_task_log( with RSConnectClient(connect_server) as client: result = client.wait_for_task(task_id, log_callback, abort_func, timeout, poll_wait, raise_on_error) result = connect_server.handle_bad_response(result) - app_config = client.app_config(app_id) - connect_server.handle_bad_response(app_config) - app_url = app_config.get("config_url") + # Get content (handles both numeric IDs and GUIDs) + content = client.get_content_by_id(app_id) + app_url = content["dashboard_url"] return (app_url, *result) @@ -2136,13 +2127,12 @@ def override_title_search(connect_server: Union[RSConnectServer, SPCSConnectServ URL and dashboard URL. """ - def map_app(app: ContentItemV0, config: ConfigureResult) -> AbbreviatedAppItem: + def map_app(app: ContentItemV0, content: ContentItemV1) -> AbbreviatedAppItem: """ - Creates the abbreviated data dictionary for the specified app and config - information. + Creates the abbreviated data dictionary for the specified app and content. :param app: the raw app data to start with. - :param config: the configuration data to use. + :param content: the V1 content data with dashboard_url. :return: the abbreviated app data dictionary. """ return { @@ -2151,7 +2141,7 @@ def map_app(app: ContentItemV0, config: ConfigureResult) -> AbbreviatedAppItem: "title": app["title"], "app_mode": AppModes.get_by_ordinal(app["app_mode"]).name(), "url": app["url"], - "config_url": config["config_url"], + "config_url": content["dashboard_url"], } def mapping_filter(client: RSConnectClient, app: ContentItemV0) -> AbbreviatedAppItem | None: @@ -2169,10 +2159,10 @@ def mapping_filter(client: RSConnectClient, app: ContentItemV0) -> AbbreviatedAp if app_mode not in (AppModes.STATIC, AppModes.JUPYTER_NOTEBOOK): return None - config = client.app_config(str(app["id"])) - config = connect_server.handle_bad_response(config) + content = client.content_get(app["guid"]) + content = connect_server.handle_bad_response(content) - return map_app(app, config) + return map_app(app, content) apps = retrieve_matching_apps( connect_server, @@ -2189,7 +2179,10 @@ def mapping_filter(client: RSConnectClient, app: ContentItemV0) -> AbbreviatedAp app = get_app_info(connect_server, app_id) mode = AppModes.get_by_ordinal(app["app_mode"]) if mode in (AppModes.STATIC, AppModes.JUPYTER_NOTEBOOK): - apps.append(map_app(app, get_app_config(connect_server, app_id))) + with RSConnectClient(connect_server) as client: + content = client.content_get(app["guid"]) + content = connect_server.handle_bad_response(content) + apps.append(map_app(app, content)) except RSConnectException: logger.debug('Error getting info for previous app_id "%s", skipping.', app_id) diff --git a/rsconnect/models.py b/rsconnect/models.py index 0044adcc..c7aa9c70 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -602,11 +602,6 @@ class DeleteOutputDTO(TypedDict): task_id: str | None -class ConfigureResult(TypedDict): - config_url: str - logs_url: str - - class UserRecord(TypedDict): email: str username: str From c919ff2fd89b8aba217b71fec409111dcd22fda7 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Mon, 1 Dec 2025 16:23:23 -0500 Subject: [PATCH 2/5] More simplifications --- rsconnect/api.py | 52 +++++++++++++++++------------------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index d83f9ad4..0cda93ae 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1250,11 +1250,9 @@ def validate_app_mode(self, app_mode: AppMode): # to get this from the remote. if isinstance(self.remote_server, RSConnectServer): try: - app = get_app_info(self.remote_server, app_id) - # TODO: verify that this is correct. The previous code seemed - # incorrect. It passed an arg to app.get(), which would have - # been ignored. - existing_app_mode = AppModes.get_by_ordinal(app["app_mode"], True) + with RSConnectClient(self.remote_server) as client: + content = client.get_content_by_id(app_id) + existing_app_mode = AppModes.get_by_ordinal(content["app_mode"], True) except RSConnectException as e: raise RSConnectException( f"{e} Try setting the --new flag to overwrite the previous deployment." @@ -1981,18 +1979,6 @@ def get_python_info(connect_server: Union[RSConnectServer, SPCSConnectServer]): return result -def get_app_info(connect_server: Union[RSConnectServer, SPCSConnectServer], app_id: str): - """ - Return information about an application that has been created in Connect. - - :param connect_server: the Connect server information. - :param app_id: the ID (numeric or GUID) of the application to get info for. - :return: the Python installation information from Connect. - """ - with RSConnectClient(connect_server) as client: - return client.app_get(app_id) - - def get_posit_app_info(server: PositServer, app_id: str): with PositClient(server) as client: if isinstance(server, ShinyappsServer): @@ -2127,20 +2113,19 @@ def override_title_search(connect_server: Union[RSConnectServer, SPCSConnectServ URL and dashboard URL. """ - def map_app(app: ContentItemV0, content: ContentItemV1) -> AbbreviatedAppItem: + def map_app(content: ContentItemV1) -> AbbreviatedAppItem: """ - Creates the abbreviated data dictionary for the specified app and content. + Creates the abbreviated data dictionary for the specified content. - :param app: the raw app data to start with. - :param content: the V1 content data with dashboard_url. + :param content: the V1 content data. :return: the abbreviated app data dictionary. """ return { - "id": app["id"], - "name": app["name"], - "title": app["title"], - "app_mode": AppModes.get_by_ordinal(app["app_mode"]).name(), - "url": app["url"], + "id": int(content["id"]), # Convert str to int for backwards compatibility + "name": content["name"], + "title": content["title"], + "app_mode": AppModes.get_by_ordinal(content["app_mode"]).name(), + "url": content["content_url"], "config_url": content["dashboard_url"], } @@ -2162,7 +2147,7 @@ def mapping_filter(client: RSConnectClient, app: ContentItemV0) -> AbbreviatedAp content = client.content_get(app["guid"]) content = connect_server.handle_bad_response(content) - return map_app(app, content) + return map_app(content) apps = retrieve_matching_apps( connect_server, @@ -2176,13 +2161,12 @@ def mapping_filter(client: RSConnectClient, app: ContentItemV0) -> AbbreviatedAp if not found: try: - app = get_app_info(connect_server, app_id) - mode = AppModes.get_by_ordinal(app["app_mode"]) - if mode in (AppModes.STATIC, AppModes.JUPYTER_NOTEBOOK): - with RSConnectClient(connect_server) as client: - content = client.content_get(app["guid"]) - content = connect_server.handle_bad_response(content) - apps.append(map_app(app, content)) + with RSConnectClient(connect_server) as client: + content = client.get_content_by_id(app_id) + content = connect_server.handle_bad_response(content) + mode = AppModes.get_by_ordinal(content["app_mode"]) + if mode in (AppModes.STATIC, AppModes.JUPYTER_NOTEBOOK): + apps.append(map_app(content)) except RSConnectException: logger.debug('Error getting info for previous app_id "%s", skipping.', app_id) From a9224af17eb5c3750eb6fa83539e92ae72057073 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Mon, 1 Dec 2025 16:25:22 -0500 Subject: [PATCH 3/5] Remove unused override_title_search --- rsconnect/api.py | 72 ------------------------------------------------ 1 file changed, 72 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 0cda93ae..7d7f8254 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -2101,78 +2101,6 @@ class AbbreviatedAppItem(TypedDict): config_url: str -def override_title_search(connect_server: Union[RSConnectServer, SPCSConnectServer], app_id: str, app_title: str): - """ - Returns a list of abbreviated app data that contains apps with a title - that matches the given one and/or the specific app noted by its ID. - - :param connect_server: the Connect server information. - :param app_id: the ID of a specific app to look for, if any. - :param app_title: the title to search for. - :return: the list of matching apps, each trimmed to ID, name, title, mode - URL and dashboard URL. - """ - - def map_app(content: ContentItemV1) -> AbbreviatedAppItem: - """ - Creates the abbreviated data dictionary for the specified content. - - :param content: the V1 content data. - :return: the abbreviated app data dictionary. - """ - return { - "id": int(content["id"]), # Convert str to int for backwards compatibility - "name": content["name"], - "title": content["title"], - "app_mode": AppModes.get_by_ordinal(content["app_mode"]).name(), - "url": content["content_url"], - "config_url": content["dashboard_url"], - } - - def mapping_filter(client: RSConnectClient, app: ContentItemV0) -> AbbreviatedAppItem | None: - """ - Mapping/filter function for retrieving apps. We only keep apps - that have an app mode of static or Jupyter notebook. The data - for the apps we keep is an abbreviated subset. - - :param client: the client object to use for Posit Connect calls. - :param app: the current app from Connect. - :return: the abbreviated data for the app or None. - """ - # Only keep apps that match our app modes. - app_mode = AppModes.get_by_ordinal(app["app_mode"]) - if app_mode not in (AppModes.STATIC, AppModes.JUPYTER_NOTEBOOK): - return None - - content = client.content_get(app["guid"]) - content = connect_server.handle_bad_response(content) - - return map_app(content) - - apps = retrieve_matching_apps( - connect_server, - filters={"filter": "min_role:editor", "search": app_title}, - mapping_function=mapping_filter, - limit=5, - ) - - if app_id: - found = next((app for app in apps if app["id"] == app_id), None) - - if not found: - try: - with RSConnectClient(connect_server) as client: - content = client.get_content_by_id(app_id) - content = connect_server.handle_bad_response(content) - mode = AppModes.get_by_ordinal(content["app_mode"]) - if mode in (AppModes.STATIC, AppModes.JUPYTER_NOTEBOOK): - apps.append(map_app(content)) - except RSConnectException: - logger.debug('Error getting info for previous app_id "%s", skipping.', app_id) - - return apps - - def find_unique_name(remote_server: TargetableServer, name: str): """ Poll through existing apps to see if anything with a similar name exists. From ab09cc6dcd04a9a7d7ae2e4cfccf560fbe8250ef Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Mon, 1 Dec 2025 16:39:45 -0500 Subject: [PATCH 4/5] Remove v0 applications search --- rsconnect/api.py | 122 +++++++++---------------------------- rsconnect/models.py | 7 --- tests/test_main.py | 5 +- tests/test_vetiver_pins.py | 2 +- 4 files changed, 34 insertions(+), 102 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 7d7f8254..a0ec1517 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -61,7 +61,6 @@ from .models import ( AppMode, AppModes, - AppSearchResults, BootstrapOutputDTO, BuildOutputDTO, BundleMetadata, @@ -435,11 +434,6 @@ def python_settings(self) -> PyInfo: response = self._server.handle_bad_response(response) return response - def app_search(self, filters: Optional[Mapping[str, JsonData]]) -> AppSearchResults: - response = cast(Union[AppSearchResults, HTTPResponse], self.get("applications", query_params=filters)) - 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) @@ -476,8 +470,8 @@ def bundle_download(self, content_guid: str, bundle_id: str) -> HTTPResponse: response = self._server.handle_bad_response(response, is_httpresponse=True) return response - def content_search(self) -> list[ContentItemV1]: - response = cast(Union[List[ContentItemV1], HTTPResponse], self.get("v1/content")) + def content_list(self, filters: Optional[Mapping[str, JsonData]] = None) -> list[ContentItemV1]: + response = cast(Union[List[ContentItemV1], HTTPResponse], self.get("v1/content", query_params=filters)) response = self._server.handle_bad_response(response) return response @@ -638,7 +632,7 @@ def download_bundle(self, content_guid: str, bundle_id: str) -> HTTPResponse: return results def search_content(self) -> list[ContentItemV1]: - results = self.content_search() + results = self.content_list() return results def get_content(self, content_guid: str) -> ContentItemV1: @@ -2024,74 +2018,6 @@ def emit_task_log( return (app_url, *result) -def retrieve_matching_apps( - connect_server: Union[RSConnectServer, SPCSConnectServer], - filters: Optional[dict[str, str | int]] = None, - limit: Optional[int] = None, - mapping_function: Optional[Callable[[RSConnectClient, ContentItemV0], AbbreviatedAppItem | None]] = None, -) -> list[ContentItemV0 | AbbreviatedAppItem]: - """ - Retrieves all the app names that start with the given default name. The main - point for this function is that it handles all the necessary paging logic. - - If a mapping function is provided, it must be a callable that accepts 2 - arguments. The first will be an `RSConnect` client, in the event extra calls - per app are required. The second will be the current app. If the function - returns None, then the app will be discarded and not appear in the result. - - :param connect_server: the Connect server information. - :param filters: the filters to use for isolating the set of desired apps. - :param limit: the maximum number of apps to retrieve. If this is None, - then all matching apps are returned. - :param mapping_function: an optional function that may transform or filter - each app to return to something the caller wants. - :return: the list of existing names that start with the proposed one. - """ - page_size = 100 - result: list[ContentItemV0 | AbbreviatedAppItem] = [] - search_filters: dict[str, str | int] = filters.copy() if filters else {} - search_filters["count"] = min(limit, page_size) if limit else page_size - total_returned = 0 - maximum = limit - finished = False - - with RSConnectClient(connect_server) as client: - while not finished: - response = client.app_search(search_filters) - - if not maximum: - maximum = response["total"] - else: - maximum = min(maximum, response["total"]) - - applications = response["applications"] - returned = response["count"] - delta = maximum - (total_returned + returned) - # If more came back than we need, drop the rest. - if delta < 0: - applications = applications[: abs(delta)] - total_returned = total_returned + len(applications) - - if mapping_function: - applications = [mapping_function(client, app) for app in applications] - # Now filter out the None values that represent the apps the - # function told us to drop. - applications = [app for app in applications if app is not None] - - result.extend(applications) - - if total_returned < maximum: - search_filters = { - "start": total_returned, - "count": page_size, - "cont": response["continuation"], - } - else: - finished = True - - return result - - class AbbreviatedAppItem(TypedDict): id: int name: str @@ -2111,24 +2037,36 @@ def find_unique_name(remote_server: TargetableServer, name: str): :return: the name, potentially with a suffixed number to guarantee uniqueness. """ if isinstance(remote_server, (RSConnectServer, SPCSConnectServer)): - existing_names = retrieve_matching_apps( - remote_server, - filters={"search": name}, - mapping_function=lambda client, app: app["name"], - ) + # Use v1/content API with name query parameter + with RSConnectClient(remote_server) as client: + results = client.content_list(filters={"name": name}) + + # If name exists, append suffix and try again + if len(results) > 0: + suffix = 1 + test_name = "%s%d" % (name, suffix) + while True: + results = client.content_list(filters={"name": test_name}) + if len(results) == 0: + return test_name + suffix = suffix + 1 + test_name = "%s%d" % (name, suffix) + + return name + elif isinstance(remote_server, ShinyappsServer): client = PositClient(remote_server) existing_names = client.get_applications_like_name(name) - else: - # non-unique names are permitted in cloud - return name - if name in existing_names: - suffix = 1 - test = "%s%d" % (name, suffix) - while test in existing_names: - suffix = suffix + 1 + if name in existing_names: + suffix = 1 test = "%s%d" % (name, suffix) - name = test + while test in existing_names: + suffix = suffix + 1 + test = "%s%d" % (name, suffix) + name = test - return name + return name + else: + # non-unique names are permitted in cloud + return name diff --git a/rsconnect/models.py b/rsconnect/models.py index c7aa9c70..44bd97f1 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -525,13 +525,6 @@ def convert( self.fail("Failed to parse version filter %s" % value) -class AppSearchResults(TypedDict): - total: int - applications: list[ContentItemV0] - count: int - continuation: int - - class TaskStatusResult(TypedDict): type: str data: object # Don't know the structure of this type yet diff --git a/tests/test_main.py b/tests/test_main.py index 6353c281..a9f2d79e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -130,10 +130,11 @@ def test_deploy_draft(self, command, target, expected_activate, caplog): adding_headers={"Content-Type": "application/json"}, status=200, ) + # Mock v1/content search for unique name checking 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(), + "http://fake_server/__api__/v1/content?name=app5", + body=json.dumps([]), # Empty array means name is available adding_headers={"Content-Type": "application/json"}, status=200, ) diff --git a/tests/test_vetiver_pins.py b/tests/test_vetiver_pins.py index 5157489e..5b187794 100644 --- a/tests/test_vetiver_pins.py +++ b/tests/test_vetiver_pins.py @@ -81,7 +81,7 @@ def test_deploy(rsc_short): # get url of where content lives client = RSConnectClient(connect_server) - dicts = client.content_search() + dicts = client.content_list() rsc_api = list(filter(lambda x: x["title"] == "testapivetiver", dicts)) content_url = rsc_api[0].get("content_url") From c9acc8ee5e23e16c5f9a9f70b700e3f32d1e1a6d Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Mon, 1 Dec 2025 16:50:34 -0500 Subject: [PATCH 5/5] Add to changelog --- docs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1b0937f2..4a58e476 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `rsconnect list` now properly functions when a stored server has no nickname. +### Changed + +- Most callers of internal, undocumented Connect APIs have been updated to use + documented v1 APIs. + ## [1.28.0] - 2025-11-06 ### Added