Skip to content

Commit 8f9e8cb

Browse files
refactor: remove more undocumented API usage (#734)
* Remove app_config * More simplifications * Remove unused override_title_search * Remove v0 applications search * Add to changelog
1 parent c39d02e commit 8f9e8cb

File tree

5 files changed

+67
-230
lines changed

5 files changed

+67
-230
lines changed

docs/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- `rsconnect list` now properly functions when a stored server has no nickname.
1313

14+
### Changed
15+
16+
- Most callers of internal, undocumented Connect APIs have been updated to use
17+
documented v1 APIs.
18+
1419
## [1.28.0] - 2025-11-06
1520

1621
### Added

rsconnect/api.py

Lines changed: 58 additions & 215 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,9 @@
6161
from .models import (
6262
AppMode,
6363
AppModes,
64-
AppSearchResults,
6564
BootstrapOutputDTO,
6665
BuildOutputDTO,
6766
BundleMetadata,
68-
ConfigureResult,
6967
ContentItemV0,
7068
ContentItemV1,
7169
DeleteInputDTO,
@@ -436,11 +434,6 @@ def python_settings(self) -> PyInfo:
436434
response = self._server.handle_bad_response(response)
437435
return response
438436

439-
def app_search(self, filters: Optional[Mapping[str, JsonData]]) -> AppSearchResults:
440-
response = cast(Union[AppSearchResults, HTTPResponse], self.get("applications", query_params=filters))
441-
response = self._server.handle_bad_response(response)
442-
return response
443-
444437
def app_get(self, app_id: str) -> ContentItemV0:
445438
response = cast(Union[ContentItemV0, HTTPResponse], self.get("applications/%s" % app_id))
446439
response = self._server.handle_bad_response(response)
@@ -450,11 +443,6 @@ def app_add_environment_vars(self, app_guid: str, env_vars: list[tuple[str, str]
450443
env_body = [dict(name=kv[0], value=kv[1]) for kv in env_vars]
451444
return self.patch("v1/content/%s/environment" % app_guid, body=env_body)
452445

453-
def app_config(self, app_id: str) -> ConfigureResult:
454-
response = cast(Union[ConfigureResult, HTTPResponse], self.get("applications/%s/config" % app_id))
455-
response = self._server.handle_bad_response(response)
456-
return response
457-
458446
def is_app_failed_response(self, response: HTTPResponse | JsonData) -> bool:
459447
return isinstance(response, HTTPResponse) and response.status >= 500
460448

@@ -465,10 +453,13 @@ def app_access(self, app_guid: str) -> None:
465453
response = self._do_request(method, path, None, None, 3, {}, False)
466454

467455
if self.is_app_failed_response(response):
456+
# Get content metadata to construct logs URL
457+
content = self.content_get(app_guid)
458+
logs_url = content["dashboard_url"] + "/logs"
468459
raise RSConnectException(
469460
"Could not access the deployed content. "
470461
+ "The app might not have started successfully."
471-
+ f"\n\t For more information: {self.app_config(app_guid).get('logs_url')}"
462+
+ f"\n\t For more information: {logs_url}"
472463
)
473464

474465
def bundle_download(self, content_guid: str, bundle_id: str) -> HTTPResponse:
@@ -479,8 +470,8 @@ def bundle_download(self, content_guid: str, bundle_id: str) -> HTTPResponse:
479470
response = self._server.handle_bad_response(response, is_httpresponse=True)
480471
return response
481472

482-
def content_search(self) -> list[ContentItemV1]:
483-
response = cast(Union[List[ContentItemV1], HTTPResponse], self.get("v1/content"))
473+
def content_list(self, filters: Optional[Mapping[str, JsonData]] = None) -> list[ContentItemV1]:
474+
response = cast(Union[List[ContentItemV1], HTTPResponse], self.get("v1/content", query_params=filters))
484475
response = self._server.handle_bad_response(response)
485476
return response
486477

@@ -489,6 +480,22 @@ def content_get(self, content_guid: str) -> ContentItemV1:
489480
response = self._server.handle_bad_response(response)
490481
return response
491482

483+
def get_content_by_id(self, app_id: str) -> ContentItemV1:
484+
"""
485+
Get content by ID, which can be either a numeric ID (legacy) or GUID.
486+
487+
:param app_id: Either a numeric ID (e.g., "1234") or GUID (e.g., "abc-def-123")
488+
:return: ContentItemV1 data
489+
"""
490+
# Check if it looks like a GUID (contains hyphens)
491+
if "-" in str(app_id):
492+
return self.content_get(app_id)
493+
else:
494+
# Legacy numeric ID - get v0 content first to get GUID
495+
# TODO: deprecation warning
496+
app_v0 = self.app_get(app_id)
497+
return self.content_get(app_v0["guid"])
498+
492499
def content_create(self, name: str) -> ContentItemV1:
493500
response = cast(Union[ContentItemV1, HTTPResponse], self.post("v1/content", body={"name": name}))
494501
response = self._server.handle_bad_response(response)
@@ -589,15 +596,8 @@ def deploy(
589596
else:
590597
# assume content exists. if it was deleted then Connect will raise an error
591598
try:
592-
# app_id could be a numeric ID (legacy) or GUID. Try to get it as content.
593-
# If it's a numeric ID, app_get will work; if GUID, content_get will work.
594-
# We'll use content_get if it looks like a GUID (contains hyphens), otherwise app_get.
595-
if "-" in str(app_id):
596-
app = self.content_get(app_id)
597-
else:
598-
# Legacy numeric ID - get v0 content and convert to use GUID
599-
app_v0 = self.app_get(app_id)
600-
app = self.content_get(app_v0["guid"])
599+
# app_id could be a numeric ID (legacy) or GUID
600+
app = self.get_content_by_id(app_id)
601601
except RSConnectException as e:
602602
raise RSConnectException(f"{e} Try setting the --new flag to overwrite the previous deployment.") from e
603603

@@ -632,7 +632,7 @@ def download_bundle(self, content_guid: str, bundle_id: str) -> HTTPResponse:
632632
return results
633633

634634
def search_content(self) -> list[ContentItemV1]:
635-
results = self.content_search()
635+
results = self.content_list()
636636
return results
637637

638638
def get_content(self, content_guid: str) -> ContentItemV1:
@@ -1244,11 +1244,9 @@ def validate_app_mode(self, app_mode: AppMode):
12441244
# to get this from the remote.
12451245
if isinstance(self.remote_server, RSConnectServer):
12461246
try:
1247-
app = get_app_info(self.remote_server, app_id)
1248-
# TODO: verify that this is correct. The previous code seemed
1249-
# incorrect. It passed an arg to app.get(), which would have
1250-
# been ignored.
1251-
existing_app_mode = AppModes.get_by_ordinal(app["app_mode"], True)
1247+
with RSConnectClient(self.remote_server) as client:
1248+
content = client.get_content_by_id(app_id)
1249+
existing_app_mode = AppModes.get_by_ordinal(content["app_mode"], True)
12521250
except RSConnectException as e:
12531251
raise RSConnectException(
12541252
f"{e} Try setting the --new flag to overwrite the previous deployment."
@@ -1975,18 +1973,6 @@ def get_python_info(connect_server: Union[RSConnectServer, SPCSConnectServer]):
19751973
return result
19761974

19771975

1978-
def get_app_info(connect_server: Union[RSConnectServer, SPCSConnectServer], app_id: str):
1979-
"""
1980-
Return information about an application that has been created in Connect.
1981-
1982-
:param connect_server: the Connect server information.
1983-
:param app_id: the ID (numeric or GUID) of the application to get info for.
1984-
:return: the Python installation information from Connect.
1985-
"""
1986-
with RSConnectClient(connect_server) as client:
1987-
return client.app_get(app_id)
1988-
1989-
19901976
def get_posit_app_info(server: PositServer, app_id: str):
19911977
with PositClient(server) as client:
19921978
if isinstance(server, ShinyappsServer):
@@ -1996,21 +1982,6 @@ def get_posit_app_info(server: PositServer, app_id: str):
19961982
return response["source"]
19971983

19981984

1999-
def get_app_config(connect_server: Union[RSConnectServer, SPCSConnectServer], app_id: str):
2000-
"""
2001-
Return the configuration information for an application that has been created
2002-
in Connect.
2003-
2004-
:param connect_server: the Connect server information.
2005-
:param app_id: the ID (numeric or GUID) of the application to get the info for.
2006-
:return: the Python installation information from Connect.
2007-
"""
2008-
with RSConnectClient(connect_server) as client:
2009-
result = client.app_config(app_id)
2010-
result = connect_server.handle_bad_response(result)
2011-
return result
2012-
2013-
20141985
def emit_task_log(
20151986
connect_server: Union[RSConnectServer, SPCSConnectServer],
20161987
app_id: str,
@@ -2041,80 +2012,12 @@ def emit_task_log(
20412012
with RSConnectClient(connect_server) as client:
20422013
result = client.wait_for_task(task_id, log_callback, abort_func, timeout, poll_wait, raise_on_error)
20432014
result = connect_server.handle_bad_response(result)
2044-
app_config = client.app_config(app_id)
2045-
connect_server.handle_bad_response(app_config)
2046-
app_url = app_config.get("config_url")
2015+
# Get content (handles both numeric IDs and GUIDs)
2016+
content = client.get_content_by_id(app_id)
2017+
app_url = content["dashboard_url"]
20472018
return (app_url, *result)
20482019

20492020

2050-
def retrieve_matching_apps(
2051-
connect_server: Union[RSConnectServer, SPCSConnectServer],
2052-
filters: Optional[dict[str, str | int]] = None,
2053-
limit: Optional[int] = None,
2054-
mapping_function: Optional[Callable[[RSConnectClient, ContentItemV0], AbbreviatedAppItem | None]] = None,
2055-
) -> list[ContentItemV0 | AbbreviatedAppItem]:
2056-
"""
2057-
Retrieves all the app names that start with the given default name. The main
2058-
point for this function is that it handles all the necessary paging logic.
2059-
2060-
If a mapping function is provided, it must be a callable that accepts 2
2061-
arguments. The first will be an `RSConnect` client, in the event extra calls
2062-
per app are required. The second will be the current app. If the function
2063-
returns None, then the app will be discarded and not appear in the result.
2064-
2065-
:param connect_server: the Connect server information.
2066-
:param filters: the filters to use for isolating the set of desired apps.
2067-
:param limit: the maximum number of apps to retrieve. If this is None,
2068-
then all matching apps are returned.
2069-
:param mapping_function: an optional function that may transform or filter
2070-
each app to return to something the caller wants.
2071-
:return: the list of existing names that start with the proposed one.
2072-
"""
2073-
page_size = 100
2074-
result: list[ContentItemV0 | AbbreviatedAppItem] = []
2075-
search_filters: dict[str, str | int] = filters.copy() if filters else {}
2076-
search_filters["count"] = min(limit, page_size) if limit else page_size
2077-
total_returned = 0
2078-
maximum = limit
2079-
finished = False
2080-
2081-
with RSConnectClient(connect_server) as client:
2082-
while not finished:
2083-
response = client.app_search(search_filters)
2084-
2085-
if not maximum:
2086-
maximum = response["total"]
2087-
else:
2088-
maximum = min(maximum, response["total"])
2089-
2090-
applications = response["applications"]
2091-
returned = response["count"]
2092-
delta = maximum - (total_returned + returned)
2093-
# If more came back than we need, drop the rest.
2094-
if delta < 0:
2095-
applications = applications[: abs(delta)]
2096-
total_returned = total_returned + len(applications)
2097-
2098-
if mapping_function:
2099-
applications = [mapping_function(client, app) for app in applications]
2100-
# Now filter out the None values that represent the apps the
2101-
# function told us to drop.
2102-
applications = [app for app in applications if app is not None]
2103-
2104-
result.extend(applications)
2105-
2106-
if total_returned < maximum:
2107-
search_filters = {
2108-
"start": total_returned,
2109-
"count": page_size,
2110-
"cont": response["continuation"],
2111-
}
2112-
else:
2113-
finished = True
2114-
2115-
return result
2116-
2117-
21182021
class AbbreviatedAppItem(TypedDict):
21192022
id: int
21202023
name: str
@@ -2124,78 +2027,6 @@ class AbbreviatedAppItem(TypedDict):
21242027
config_url: str
21252028

21262029

2127-
def override_title_search(connect_server: Union[RSConnectServer, SPCSConnectServer], app_id: str, app_title: str):
2128-
"""
2129-
Returns a list of abbreviated app data that contains apps with a title
2130-
that matches the given one and/or the specific app noted by its ID.
2131-
2132-
:param connect_server: the Connect server information.
2133-
:param app_id: the ID of a specific app to look for, if any.
2134-
:param app_title: the title to search for.
2135-
:return: the list of matching apps, each trimmed to ID, name, title, mode
2136-
URL and dashboard URL.
2137-
"""
2138-
2139-
def map_app(app: ContentItemV0, config: ConfigureResult) -> AbbreviatedAppItem:
2140-
"""
2141-
Creates the abbreviated data dictionary for the specified app and config
2142-
information.
2143-
2144-
:param app: the raw app data to start with.
2145-
:param config: the configuration data to use.
2146-
:return: the abbreviated app data dictionary.
2147-
"""
2148-
return {
2149-
"id": app["id"],
2150-
"name": app["name"],
2151-
"title": app["title"],
2152-
"app_mode": AppModes.get_by_ordinal(app["app_mode"]).name(),
2153-
"url": app["url"],
2154-
"config_url": config["config_url"],
2155-
}
2156-
2157-
def mapping_filter(client: RSConnectClient, app: ContentItemV0) -> AbbreviatedAppItem | None:
2158-
"""
2159-
Mapping/filter function for retrieving apps. We only keep apps
2160-
that have an app mode of static or Jupyter notebook. The data
2161-
for the apps we keep is an abbreviated subset.
2162-
2163-
:param client: the client object to use for Posit Connect calls.
2164-
:param app: the current app from Connect.
2165-
:return: the abbreviated data for the app or None.
2166-
"""
2167-
# Only keep apps that match our app modes.
2168-
app_mode = AppModes.get_by_ordinal(app["app_mode"])
2169-
if app_mode not in (AppModes.STATIC, AppModes.JUPYTER_NOTEBOOK):
2170-
return None
2171-
2172-
config = client.app_config(str(app["id"]))
2173-
config = connect_server.handle_bad_response(config)
2174-
2175-
return map_app(app, config)
2176-
2177-
apps = retrieve_matching_apps(
2178-
connect_server,
2179-
filters={"filter": "min_role:editor", "search": app_title},
2180-
mapping_function=mapping_filter,
2181-
limit=5,
2182-
)
2183-
2184-
if app_id:
2185-
found = next((app for app in apps if app["id"] == app_id), None)
2186-
2187-
if not found:
2188-
try:
2189-
app = get_app_info(connect_server, app_id)
2190-
mode = AppModes.get_by_ordinal(app["app_mode"])
2191-
if mode in (AppModes.STATIC, AppModes.JUPYTER_NOTEBOOK):
2192-
apps.append(map_app(app, get_app_config(connect_server, app_id)))
2193-
except RSConnectException:
2194-
logger.debug('Error getting info for previous app_id "%s", skipping.', app_id)
2195-
2196-
return apps
2197-
2198-
21992030
def find_unique_name(remote_server: TargetableServer, name: str):
22002031
"""
22012032
Poll through existing apps to see if anything with a similar name exists.
@@ -2206,24 +2037,36 @@ def find_unique_name(remote_server: TargetableServer, name: str):
22062037
:return: the name, potentially with a suffixed number to guarantee uniqueness.
22072038
"""
22082039
if isinstance(remote_server, (RSConnectServer, SPCSConnectServer)):
2209-
existing_names = retrieve_matching_apps(
2210-
remote_server,
2211-
filters={"search": name},
2212-
mapping_function=lambda client, app: app["name"],
2213-
)
2040+
# Use v1/content API with name query parameter
2041+
with RSConnectClient(remote_server) as client:
2042+
results = client.content_list(filters={"name": name})
2043+
2044+
# If name exists, append suffix and try again
2045+
if len(results) > 0:
2046+
suffix = 1
2047+
test_name = "%s%d" % (name, suffix)
2048+
while True:
2049+
results = client.content_list(filters={"name": test_name})
2050+
if len(results) == 0:
2051+
return test_name
2052+
suffix = suffix + 1
2053+
test_name = "%s%d" % (name, suffix)
2054+
2055+
return name
2056+
22142057
elif isinstance(remote_server, ShinyappsServer):
22152058
client = PositClient(remote_server)
22162059
existing_names = client.get_applications_like_name(name)
2217-
else:
2218-
# non-unique names are permitted in cloud
2219-
return name
22202060

2221-
if name in existing_names:
2222-
suffix = 1
2223-
test = "%s%d" % (name, suffix)
2224-
while test in existing_names:
2225-
suffix = suffix + 1
2061+
if name in existing_names:
2062+
suffix = 1
22262063
test = "%s%d" % (name, suffix)
2227-
name = test
2064+
while test in existing_names:
2065+
suffix = suffix + 1
2066+
test = "%s%d" % (name, suffix)
2067+
name = test
22282068

2229-
return name
2069+
return name
2070+
else:
2071+
# non-unique names are permitted in cloud
2072+
return name

0 commit comments

Comments
 (0)