From 51b618d083ca0a9de47355cdaa52d604075bd8b4 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Thu, 29 Oct 2026 15:41:08 -0400 Subject: [PATCH 1/5] Require GUIDs for Connect content; add --guid to deploy and preflight Remove the legacy numeric content ID path (app_get, get_content_by_id, ContentItemV0) and use content_get directly. An explicit --app-id for a Connect target must be a content GUID. Add --guid to the deploy commands and preflight as the Connect-specific way to target existing content. It conflicts with --app-id and --new, and is rejected for Connect Cloud and shinyapps.io targets. Fixes #881 Fixes #885 Co-Authored-By: Claude Opus 5.5 --- docs/CHANGELOG.md | 9 ++ docs/commands/preflight.md | 8 +- docs/deploying.md | 18 +-- rsconnect/api.py | 62 +++++------ rsconnect/main.py | 41 ++++++- rsconnect/models.py | 76 +++---------- rsconnect/preflight.py | 6 +- rsconnect/preflight_node.py | 4 +- tests/test_agent_cli.py | 26 +++++ tests/test_agent_cli_e2e.py | 2 +- tests/test_api.py | 2 +- tests/test_main.py | 206 ++++++++++++++++++++++------------- tests/test_preflight.py | 6 +- tests/test_preflight_node.py | 6 +- 14 files changed, 272 insertions(+), 200 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 672d52b8..43f45357 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- **Breaking:** Posit Connect content can be targeted only by GUID. `--app-id` with + a numeric Connect content ID fails with an error explaining how to find the + content's GUID, and rsconnect-python no longer calls the legacy + `/__api__/applications/{id}` endpoint. Connect Cloud and shinyapps.io application + IDs are unaffected. (#881) +- The `deploy` commands and `preflight` accept `--guid` to target an existing + Posit Connect content item, matching the `--guid` option on the `content` + commands. `--app-id` continues to work, and is the option to use for Connect + Cloud and shinyapps.io. (#885) - Generating requirements from an environment now requires pip 20.3 or later. Older environments can upgrade pip or provide a requirements file. - Reports editable dependencies outside the deployment directory, which Posit Connect diff --git a/docs/commands/preflight.md b/docs/commands/preflight.md index ae712bd6..c9bf9584 100644 --- a/docs/commands/preflight.md +++ b/docs/commands/preflight.md @@ -61,11 +61,11 @@ Operational errors exit with status 1. Their JSON contains `status: error`, `run Invalid command syntax exits with status 2 and prints diagnostics to stderr. Use `-v` or `-vv` for diagnostics on stderr; stdout remains reserved for the JSON result. -Use `--new` to check a new content item when the project has a deployment record. Use `--app-id` to check a content item by its identifier (ID) or globally unique identifier (GUID). Do not combine these options. +Use `--new` to check a new content item when the project has a deployment record. Use `--guid` to check a content item by its globally unique identifier (GUID). Do not combine these options. ```{.bash filename="Terminal"} rsconnect preflight --name myserver ./project --new -rsconnect preflight --name myserver ./project --app-id 00000000-0000-0000-0000-000000000000 +rsconnect preflight --name myserver ./project --guid 00000000-0000-0000-0000-000000000000 ``` For content deployed from a notebook, manifest, bundle, or Quarto file, pass that file to select its deployment record: @@ -76,10 +76,10 @@ rsconnect preflight --name myserver ./project/report.ipynb Preflight reads project metadata and local prerequisites from the file's parent directory. An unreadable content item or ambiguous deployment record returns `unknown`; inspect its actions before deploying. -If a directory contains file deployment history, pass the exact deployed file or `--app-id` to resolve the target. +If a directory contains file deployment history, pass the exact deployed file or `--guid` to resolve the target. An unresolved target prevents `--fix` from creating a Python pin. Symlinked, non-regular, or oversized deployment records also return `unknown`. -Inspect the suggested actions and repair the record, or pass `--app-id` to select the existing content directly. +Inspect the suggested actions and repair the record, or pass `--guid` to select the existing content directly. ## Add a Python version pin diff --git a/docs/deploying.md b/docs/deploying.md index d8bc88f3..1c0ef83c 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -547,12 +547,15 @@ rsconnect deploy dash --new my-app/ #### Updating a Different Deployment If you want to update an existing deployment but don't have the saved deployment data, -you can provide the app's numeric ID or GUID on the command line: +you can provide the content's GUID on the command line: ```bash -rsconnect deploy notebook --app-id 123456 my-notebook.ipynb +rsconnect deploy notebook --guid 00000000-0000-0000-0000-000000000000 my-notebook.ipynb ``` +To update existing content on Posit Connect Cloud or shinyapps.io, pass the +application's ID with `--app-id` instead. + You must be the owner of the target deployment, or a collaborator with permission to change the content. The type of content (static notebook, notebook with source code, API, or application) must match the existing deployment. @@ -562,18 +565,15 @@ API, or application) must match the existing deployment. > accidentally, use the "Source Versions" dialog in the Posit Connect dashboard to > activate the previous version and remove the erroneous one. -##### Finding the App ID +##### Finding the GUID -The App ID associated with a piece of content you have previously deployed from the +The GUID associated with a piece of content you have previously deployed from the `rsconnect` command line interface can be found easily by querying the deployment information using the `info` command. For more information, see the [Showing the Deployment Information](#showing-the-deployment-information) section. -If the content was deployed elsewhere or `info` does not return the correct App ID, -but you can open the content on Posit Connect, find the content and open it in a -browser. The URL in your browser's location bar will contain `#/apps/NNN` where `NNN` -is your App ID. The GUID identifier for the app may be found on the **Info** tab for -the content in the Posit Connect UI. +If the content was deployed elsewhere, find it with `rsconnect content search`, or +open it in the Posit Connect UI and look on its **Info** tab. #### Showing the Deployment Information diff --git a/rsconnect/api.py b/rsconnect/api.py index 3453c981..ae72219c 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -86,7 +86,6 @@ BootstrapOutputDTO, BuildOutputDTO, BundleMetadata, - ContentItemV0, ContentItemV1, DeleteInputDTO, DeleteOutputDTO, @@ -106,6 +105,7 @@ ServerSettings, TaskStatusV1, UserRecord, + is_content_guid, ) from .snowflake import generate_jwt, get_parameters from .timeouts import get_task_timeout, get_task_timeout_help_message @@ -645,11 +645,6 @@ def nodejs_settings(self) -> dict[str, Any]: response = self.get("v1/server_settings/nodejs") return cast(typing.Dict[str, Any], self._server.handle_bad_response(response)) - def app_get(self, app_id: str) -> ContentItemV0: - response = cast(Union[ContentItemV0, HTTPResponse], self.get(f"applications/{app_id}")) - response = self._server.handle_bad_response(response) - return response - def add_environment_vars(self, content_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(f"v1/content/{content_guid}/environment", body=env_body) @@ -704,22 +699,6 @@ def content_get(self, content_guid: str) -> ContentItemV1: response = self._server.handle_bad_response(response) return response - def get_content_by_id(self, 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(id): - return self.content_get(id) - else: - # Legacy numeric ID - get v0 content first to get GUID - app_v0 = self.app_get(id) - # TODO: deprecation warning here - 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) @@ -731,10 +710,10 @@ def upload_bundle( """ Upload a bundle to the server. - :param app_id: Application ID + :param content_guid: Content GUID :param tarball: Bundle tarball file object :param metadata: Optional metadata dictionary (e.g., git metadata) - :return: ContentItemV0 with bundle information + :return: BundleMetadata for the uploaded bundle """ if metadata: # Use multipart form upload when metadata is provided @@ -932,7 +911,7 @@ def deploy_git( Creates or updates a git-backed content item in Posit Connect. Connect will clone the repository and regularly poll it for updates. - :param app_id: Existing content ID/GUID to update, or None to create new content + :param app_id: Existing content GUID to update, or None to create new content :param name: Name for the content item (used if creating new) :param repository: URL of the git repository (https:// only) :param branch: Branch to deploy from @@ -948,11 +927,9 @@ def deploy_git( app = self.content_create(name) else: try: - app = self.get_content_by_id(app_id) + app = self.content_get(app_id) except RSConnectException as e: - raise RSConnectException( - f"{e} Try setting the --new flag or omit --app-id to create new content." - ) from e + raise RSConnectException(f"{e} Try setting the --new flag or omit --guid to create new content.") from e app_guid = app["guid"] @@ -1148,8 +1125,7 @@ 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 - app = self.get_content_by_id(app_id) + app = self.content_get(app_id) except RSConnectException as e: raise RSConnectException(f"{e} Try setting the --new flag to overwrite the previous deployment.") from e @@ -1425,6 +1401,7 @@ def __init__( infer_target=(path is not None) if infer_target is None else infer_target, ) self.setup_client(cookies) + self.validate_explicit_app_id() @classmethod def fromConnectServer( @@ -1481,6 +1458,22 @@ def fromConnectServer( polling=polling, ) + def validate_explicit_app_id(self) -> None: + """Check an explicit --guid or --app-id against the kind of server it targets.""" + if self.app_id is None: + return + if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)): + if not is_content_guid(str(self.app_id)): + raise RSConnectException( + "--app-id must be a content GUID; numeric content IDs are no longer supported. " + "Find the GUID in the content's Info panel in Connect or with `rsconnect content search`." + ) + elif self.ctx is not None and self.ctx.params.get("guid"): + raise RSConnectException( + "--guid applies only to Posit Connect. " + "Use --app-id to target existing Connect Cloud or shinyapps.io content." + ) + def output_overlap_header(self, previous: bool) -> bool: if self.logger and not previous: self.logger.warning( @@ -2848,10 +2841,10 @@ def validate_app_mode(self, app_mode: AppMode): elif app_id is not None: # Don't read app metadata if app-id is specified. Instead, we need # to get this from the remote. - if isinstance(self.remote_server, RSConnectServer): + if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)): try: with RSConnectClient(self.remote_server) as client: - content = client.get_content_by_id(app_id) + content = client.content_get(app_id) existing_app_mode = AppModes.get_by_ordinal(content["app_mode"], True) except RSConnectException as e: raise RSConnectException( @@ -4341,8 +4334,7 @@ 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) - # Get content (handles both numeric IDs and GUIDs) - content = client.get_content_by_id(app_id) + content = client.content_get(app_id) app_url = content["dashboard_url"] return (app_url, *result) diff --git a/rsconnect/main.py b/rsconnect/main.py index 013cbcf1..fda97767 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -138,6 +138,7 @@ AppMode, AppModes, BuildStatus, + ContentGuidParamType, ContentGuidWithBundle, ContentGuidWithBundleParamType, KeyValueParamType, @@ -292,10 +293,22 @@ def wrapper(*args: P.args, **kwargs: P.kwargs): return wrapper +def _resolve_guid_option(new: Optional[bool], app_id: Optional[str], guid: Optional[str]) -> Optional[str]: + """Fold --guid into the app_id that the deploy plumbing understands.""" + if guid is None: + return app_id + if app_id: + raise click.UsageError("Specify only one of --guid or --app-id.") + if new: + raise click.UsageError("Specify only one of --new or --guid.") + return guid + + def _validate_preflight_options(ctx: click.Context) -> None: params = ctx.params if params["new"] and params["app_id"]: raise click.UsageError("Specify only one of --new or --app-id.") + _resolve_guid_option(params["new"], params["app_id"], params["guid"]) if params["runtime"] == "nodejs": if params["fix"]: raise click.UsageError("--fix applies only to Python projects; Node.js metadata is left unchanged.") @@ -711,13 +724,22 @@ def publish_args(func: Callable[P, T]) -> Callable[P, T]: is_flag=True, help=( "Force a new deployment, even if there is saved metadata from a " - "previous deployment. Cannot be used with --app-id." + "previous deployment. Cannot be used with --guid or --app-id." ), ) + @click.option( + "--guid", + type=ContentGuidParamType(), + help="GUID of an existing Connect content item to replace. Cannot be used with --new or --app-id.", + ) @click.option( "--app-id", "-a", - help="Existing app ID or GUID to replace. Cannot be used with --new.", + help=( + "ID of an existing Connect Cloud or shinyapps.io application to replace. " + "For Posit Connect, use --guid (--app-id also accepts a Connect content GUID). " + "Cannot be used with --new." + ), ) @click.option("--title", "-t", help="Title of the content (default is the same as the filename).") @click.option( @@ -750,6 +772,10 @@ def publish_args(func: Callable[P, T]) -> Callable[P, T]: ) @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs): + guid = cast(Optional[str], kwargs.pop("guid")) + kwargs["app_id"] = _resolve_guid_option( + cast(Optional[bool], kwargs.get("new")), cast(Optional[str], kwargs.get("app_id")), guid + ) return func(*args, **kwargs) return wrapper @@ -1354,7 +1380,12 @@ def details( @click.argument("directory", default=".", type=click.Path(exists=True)) @click.option("--fix", is_flag=True, help="For new content without a constraint, create a suggested .python-version.") @click.option("--new", is_flag=True, help="Check a new content item even when a deployment record exists.") -@click.option("--app-id", "-a", help="Check an existing content item by ID or GUID.") +@click.option( + "--guid", + type=ContentGuidParamType(), + help="Check an existing content item by GUID. Cannot be used with --new or --app-id.", +) +@click.option("--app-id", "-a", help="Check an existing content item by GUID. Prefer --guid.") @click.option("--runtime", type=click.Choice(["python", "nodejs"]), default="python", show_default=True) @click.option("--node", type=click.Path(exists=True, dir_okay=False), help="Node.js executable for --runtime nodejs.") @cli_exception_handler @@ -1365,6 +1396,7 @@ def preflight( directory: str, fix: bool, new: bool, + guid: Optional[str], app_id: Optional[str], runtime: str, node: Optional[str], @@ -1382,6 +1414,7 @@ def preflight( validation.require_posix("Deployment preflight") logger.log(VERBOSE, "Checking %s runtime availability.", runtime) _validate_preflight_options(ctx) + app_id = _resolve_guid_option(new, app_id, guid) ce = RSConnectExecutor( ctx=ctx, name=name, @@ -2868,7 +2901,7 @@ def quickstart_hint() -> str: "Connect will regularly poll the repository for updates." "\n\n" "This command creates a new git-backed content item. To update an existing git-backed " - "content item, use the --app-id option with the content's GUID." + "content item, use the --guid option with the content's GUID." ), ) @server_args diff --git a/rsconnect/models.py b/rsconnect/models.py index 85be2386..2b982a8d 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -25,6 +25,12 @@ _version_search_pattern = r"(^[=><]{0,2})(.*)" _content_guid_pattern = r"([^,]*),?(.*)" +_content_guid_re = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) + + +def is_content_guid(value: str) -> bool: + """Whether a value is formatted like a Connect content GUID.""" + return _content_guid_re.match(value) is not None class BuildStatus: @@ -413,6 +419,16 @@ def convert(self, value: str, param: Optional[click.Parameter], ctx: Optional[cl return value.strip("\"'") +class ContentGuidParamType(StrippedStringParamType): + name = "GUID" + + def convert(self, value: str, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str: + value = super(ContentGuidParamType, self).convert(value, param, ctx) + if not is_content_guid(value): + self.fail("%r is not a content GUID." % value, param, ctx) + return value + + class ContentGuidWithBundle(object): def __init__(self, guid: str, bundle_id: Optional[str] = None): self.guid = guid @@ -453,66 +469,6 @@ def convert( # pyright: ignore[reportIncompatibleMethodOverride] AppRole = Literal["owner", "editor", "viewer", "none"] -# Also known as AppRecord in Connect. -class ContentItemV0(TypedDict): - id: int - guid: str - access_type: Literal["all", "logged_in", "acl"] - connection_timeout: int | None - read_timeout: int | None - init_timeout: int | None - idle_timeout: int | None - max_processes: int | None - min_processes: int | None - max_conns_per_process: int | None - load_factor: float | None - memory_request: float | None - memory_limit: int | None - cpu_request: float | None - cpu_limit: int | None - amd_gpu_limit: int | None - nvidia_gpu_limit: int | None - url: str - vanity_url: bool - name: str - title: str | None - bundle_id: int | None - app_mode: int - content_category: str - has_parameters: bool - created_time: str - last_deployed_time: str - build_status: int - cluster_name: str | None - image_name: str | None - default_image_name: str | None - service_account_name: str | None - r_version: str | None - py_version: str | None - quarto_version: str | None - r_environment_management: bool | None - default_r_environment_management: bool | None - py_environment_management: bool | None - default_py_environment_management: bool | None - run_as: str | None - run_as_current_user: bool - description: str - EnvironmentJson: str | None - app_role: AppRole - owner_first_name: str - owner_last_name: str - owner_username: str - owner_guid: str - owner_email: str - owner_locked: bool - is_scheduled: bool - # Not sure how the following 4 fields are structured, so just use object for now. - git: object | None - users: object | None - groups: object | None - vanities: object | None - - # Also known as V1 ContentOutputDTO in Connect (note: this is not V1 experimental). class ContentItemV1(TypedDict): guid: str diff --git a/rsconnect/preflight.py b/rsconnect/preflight.py index 5ce4e47b..87ab9f28 100644 --- a/rsconnect/preflight.py +++ b/rsconnect/preflight.py @@ -264,7 +264,7 @@ def _saved_deployment_target(executor: RSConnectExecutor, target: str) -> tuple[ if issue: return True, None, issue if records: - return True, None, "This directory has a single-file deployment; pass its exact file path or --app-id." + return True, None, "This directory has a single-file deployment; pass its exact file path or --guid." return _target_from_records(records) @@ -280,7 +280,7 @@ def _current_python( client: RSConnectClient, app_id: str, warnings: list[str], actions: list[str] ) -> tuple[Optional[str], bool]: try: - content = client.get_content_by_id(app_id) + content = client.content_get(app_id) except RSConnectException as err: warnings.append(f"Could not read existing content's current Python version: {err}") if err.status == 404: @@ -556,7 +556,7 @@ def run_preflight(executor: RSConnectExecutor, project: str, fix: bool = False) if target_issue: warnings.append(target_issue) actions.append( - "Pass the exact file path for single-file content, specify --app-id, " + "Pass the exact file path for single-file content, specify --guid, " "or use --new for a separate content item." ) try: diff --git a/rsconnect/preflight_node.py b/rsconnect/preflight_node.py index aecb2057..c3eed119 100644 --- a/rsconnect/preflight_node.py +++ b/rsconnect/preflight_node.py @@ -311,7 +311,7 @@ def _existing_content_version( client: RSConnectClient, app_id: str, warnings: List[str], actions: List[str] ) -> Tuple[Optional[str], bool]: try: - content = client.get_content_by_id(app_id) + content = client.content_get(app_id) except RSConnectException as err: warnings.append(f"Could not read existing content's Node.js version: {err}") if err.status == 404: @@ -483,7 +483,7 @@ def run_node_preflight(executor: RSConnectExecutor, project: str, node: Optional exists, app_id, target_issue = _deployment_target(executor, target) if target_issue: warnings.append(target_issue) - actions.append("Resolve the local deployment record with --app-id or use --new for a separate content item.") + actions.append("Resolve the local deployment record with --guid or use --new for a separate content item.") compatibility = "unknown" if target_issue else _server_compatibility(info, not exists, evaluation) existing_content, content_readable = _existing_content( client, exists, app_id, compatibility, warnings, actions, target_issue diff --git a/tests/test_agent_cli.py b/tests/test_agent_cli.py index 1d345edf..0eb8eefa 100644 --- a/tests/test_agent_cli.py +++ b/tests/test_agent_cli.py @@ -209,6 +209,32 @@ def test_preflight_new_and_explicit_content_are_exclusive(): assert "only one" in result.stderr +def test_preflight_guid_targets_existing_content(tmp_path): + guid = "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b" + executor = Mock() + executor.validate_server.return_value = executor + with patch("rsconnect.main.RSConnectExecutor", return_value=executor) as create: + with patch("rsconnect.preflight.run_preflight", return_value={"status": "ok", "changed_files": []}): + result = cli_runner().invoke(cli, ["preflight", "-n", "target", str(tmp_path), "--guid", guid]) + assert result.exit_code == 0, result.output + assert create.call_args.kwargs["app_id"] == guid + + +@pytest.mark.parametrize( + "arguments,message", + [ + (["--guid", "1234"], "is not a content GUID"), + (["--new", "--guid", "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b"], "--new or --guid"), + (["--guid", "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "--app-id", "other"], "--guid or --app-id"), + ], +) +def test_preflight_guid_usage_errors(tmp_path, arguments, message): + result = cli_runner().invoke(cli, ["preflight", str(tmp_path)] + arguments) + assert result.exit_code == 2 + assert result.stdout == "" + assert message in result.stderr + + def test_node_preflight_dispatch_and_incompatible_exit(tmp_path): expected = {"status": "incompatible", "runtime": "nodejs", "changed_files": []} executor = Mock() diff --git a/tests/test_agent_cli_e2e.py b/tests/test_agent_cli_e2e.py index f24b5695..739a0f18 100644 --- a/tests/test_agent_cli_e2e.py +++ b/tests/test_agent_cli_e2e.py @@ -920,7 +920,7 @@ def test_incomplete_appstore_record_returns_actionable_unknown_without_fixing( assert report["status"] == "unknown" assert report["changed_files"] == [] assert report["warnings"] - assert any("--new" in action or "--app-id" in action for action in report["actions"]) # type: ignore[union-attr] + assert any("--new" in action or "--guid" in action for action in report["actions"]) # type: ignore[union-attr] assert not (project / ".python-version").exists() diff --git a/tests/test_api.py b/tests/test_api.py index 5e3c343f..00a607dd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -402,7 +402,7 @@ class RSConnectClientTestCase(TestCase): def test_deploy_existing_application_with_failure(self): with patch.object(RSConnectClient, "__init__", lambda _, server, cookies, timeout: None): client = RSConnectClient(Mock(), Mock(), Mock()) - client.app_get = Mock(side_effect=RSConnectException("")) + client.content_get = Mock(side_effect=RSConnectException("")) client._server = Mock(spec=RSConnectServer) app_id = Mock() with self.assertRaises(RSConnectException): diff --git a/tests/test_main.py b/tests/test_main.py index 54e94905..4aa0e958 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -183,10 +183,10 @@ def test_deploy_draft(self, command, target, expected_activate, caplog): body=json.dumps( { "id": "1234", - "guid": "1234-5678-9012-3456", + "guid": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "title": "app5", - "content_url": "http://fake_server/content/1234-5678-9012-3456", - "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + "content_url": "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", + "dashboard_url": "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", } ), adding_headers={"Content-Type": "application/json"}, @@ -194,14 +194,14 @@ def test_deploy_draft(self, command, target, expected_activate, caplog): ) httpretty.register_uri( httpretty.PATCH, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", body=json.dumps( { "id": "1234", - "guid": "1234-5678-9012-3456", + "guid": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "title": "app5", - "content_url": "http://fake_server/content/1234-5678-9012-3456", - "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + "content_url": "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", + "dashboard_url": "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", } ), adding_headers={"Content-Type": "application/json"}, @@ -209,28 +209,14 @@ def test_deploy_draft(self, command, target, expected_activate, caplog): ) 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.GET, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", body=json.dumps( { "id": "1234", - "guid": "1234-5678-9012-3456", + "guid": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "title": "app5", - "content_url": "http://fake_server/content/1234-5678-9012-3456", - "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + "content_url": "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", + "dashboard_url": "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", } ), adding_headers={"Content-Type": "application/json"}, @@ -239,7 +225,7 @@ def test_deploy_draft(self, command, target, expected_activate, caplog): httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/bundles", body=json.dumps( { "id": "FAKE_BUNDLE_ID", @@ -264,7 +250,7 @@ def post_application_deploy_callback(request, uri, response_headers): httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/deploy", body=post_application_deploy_callback, ) @@ -279,26 +265,20 @@ def post_application_deploy_callback(request, uri, response_headers): 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, - ) - - httpretty.register_uri( - httpretty.GET, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", body=json.dumps( { - "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + "dashboard_url": "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", } ), adding_headers={"Content-Type": "application/json"}, status=200, ) - expected_content_url = "http://fake_server/content/1234-5678-9012-3456" - expected_draft_url = "http://fake_server/connect/#/apps/1234-5678-9012-3456/draft/FAKE_BUNDLE_ID" + expected_content_url = "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b" + expected_draft_url = ( + "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/draft/FAKE_BUNDLE_ID" + ) try: runner = CliRunner() args = apply_common_args(["deploy", command, target], server="http://fake_server", key="FAKE_API_KEY") @@ -441,10 +421,10 @@ def test_deploy_verify_before_activate(self, caplog): content_body = json.dumps( { "id": "1234", - "guid": "1234-5678-9012-3456", + "guid": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "title": "app5", - "content_url": "http://fake_server/content/1234-5678-9012-3456", - "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + "content_url": "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", + "dashboard_url": "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", } ) httpretty.register_uri( @@ -456,14 +436,14 @@ def test_deploy_verify_before_activate(self, caplog): ) httpretty.register_uri( httpretty.GET, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", body=content_body, adding_headers={"Content-Type": "application/json"}, status=200, ) httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/bundles", body=json.dumps({"id": "FAKE_BUNDLE_ID"}), adding_headers={"Content-Type": "application/json"}, status=200, @@ -479,7 +459,7 @@ def post_application_deploy_callback(request, uri, response_headers): httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/deploy", body=post_application_deploy_callback, ) httpretty.register_uri( @@ -499,7 +479,7 @@ def get_bundle_preview_callback(request, uri, response_headers): httpretty.register_uri( httpretty.GET, - "http://fake_server/content/1234-5678-9012-3456/_bundleFAKE_BUNDLE_ID/", + "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/_bundleFAKE_BUNDLE_ID/", body=get_bundle_preview_callback, ) @@ -627,10 +607,10 @@ def test_deploy_verify_before_activate_unsupported_server(self, caplog): content_body = json.dumps( { "id": "1234", - "guid": "1234-5678-9012-3456", + "guid": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "title": "app5", - "content_url": "http://fake_server/content/1234-5678-9012-3456", - "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + "content_url": "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", + "dashboard_url": "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", } ) httpretty.register_uri( @@ -642,14 +622,14 @@ def test_deploy_verify_before_activate_unsupported_server(self, caplog): ) httpretty.register_uri( httpretty.GET, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", body=content_body, adding_headers={"Content-Type": "application/json"}, status=200, ) httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/bundles", body=json.dumps({"id": "FAKE_BUNDLE_ID"}), adding_headers={"Content-Type": "application/json"}, status=200, @@ -663,7 +643,7 @@ def post_application_deploy_callback(request, uri, response_headers): httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/deploy", body=post_application_deploy_callback, ) httpretty.register_uri( @@ -683,7 +663,7 @@ def get_active_content_callback(request, uri, response_headers): httpretty.register_uri( httpretty.GET, - "http://fake_server/content/1234-5678-9012-3456/", + "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/", body=get_active_content_callback, ) @@ -752,7 +732,7 @@ def post_application_deploy_callback(request, uri, response_headers): httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/deploy", body=post_application_deploy_callback, ) @@ -786,10 +766,10 @@ def _register_quiet_deploy_routes(self, task_body): content_body = json.dumps( { "id": "1234", - "guid": "1234-5678-9012-3456", + "guid": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "title": "app5", - "content_url": "http://fake_server/content/1234-5678-9012-3456", - "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + "content_url": "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", + "dashboard_url": "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", } ) httpretty.register_uri( @@ -822,28 +802,28 @@ def _register_quiet_deploy_routes(self, task_body): ) httpretty.register_uri( httpretty.PATCH, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", body=content_body, adding_headers={"Content-Type": "application/json"}, status=200, ) httpretty.register_uri( httpretty.GET, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", body=content_body, adding_headers={"Content-Type": "application/json"}, status=200, ) httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/bundles", body=json.dumps({"id": "FAKE_BUNDLE_ID"}), adding_headers={"Content-Type": "application/json"}, status=200, ) httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/deploy", body=json.dumps({"task_id": "FAKE_TASK_ID"}), adding_headers={"Content-Type": "application/json"}, status=200, @@ -867,9 +847,9 @@ def test_deploy_quiet(self, draft): ) target = get_dir(join("pip1", "dummy.ipynb")) expected_url = ( - "http://fake_server/connect/#/apps/1234-5678-9012-3456/draft/FAKE_BUNDLE_ID" + "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/draft/FAKE_BUNDLE_ID" if draft - else "http://fake_server/content/1234-5678-9012-3456" + else "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b" ) try: runner = CliRunner() @@ -934,7 +914,7 @@ def test_deploy_quiet_verify_failure_keeps_stdout_clean(self): ) httpretty.register_uri( httpretty.GET, - re.compile(r"http://fake_server/content/1234-5678-9012-3456/"), + re.compile(r"http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/"), body="", status=502, ) @@ -1023,10 +1003,10 @@ def test_deploy_bundle(self, caplog): content_body = json.dumps( { "id": "1234", - "guid": "1234-5678-9012-3456", + "guid": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "title": "app", - "content_url": "http://fake_server/content/1234-5678-9012-3456", - "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + "content_url": "http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", + "dashboard_url": "http://fake_server/connect/#/apps/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", } ) httpretty.register_uri( @@ -1038,14 +1018,14 @@ def test_deploy_bundle(self, caplog): ) httpretty.register_uri( httpretty.PATCH, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", body=content_body, adding_headers={"Content-Type": "application/json"}, status=200, ) httpretty.register_uri( httpretty.GET, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", body=content_body, adding_headers={"Content-Type": "application/json"}, status=200, @@ -1060,7 +1040,7 @@ def post_bundle_callback(request, uri, response_headers): httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/bundles", body=post_bundle_callback, ) @@ -1074,7 +1054,7 @@ def post_application_deploy_callback(request, uri, response_headers): httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/deploy", body=post_application_deploy_callback, ) httpretty.register_uri( @@ -1211,7 +1191,7 @@ def test_deploy_manifest_redeploy_preserves_title(self, caplog): # deploy; under an API key it would silently rename the content. original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) original_server_value = os.environ.pop("CONNECT_SERVER", None) - guid = "1234-5678-9012-3456" + guid = "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b" patch_calls = [] try: @@ -1241,7 +1221,7 @@ def test_deploy_manifest_redeploy_with_explicit_title_still_updates(self, caplog # title should still be updated (the pre-#835-fix behavior, preserved). original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) original_server_value = os.environ.pop("CONNECT_SERVER", None) - guid = "1234-5678-9012-3456" + guid = "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b" patch_calls = [] try: @@ -1271,7 +1251,7 @@ def test_deploy_bundle_redeploy_preserves_title(self, caplog): # for `deploy bundle --app-id`. original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) original_server_value = os.environ.pop("CONNECT_SERVER", None) - guid = "1234-5678-9012-3456" + guid = "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b" patch_calls = [] bundle_path = join("tests", "testdata", "bundle.tar.gz") @@ -1301,7 +1281,7 @@ def test_deploy_manifest_new_content_uses_manifest_derived_title(self, caplog): # generic path-based fallback ("manifest"). original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) original_server_value = os.environ.pop("CONNECT_SERVER", None) - guid = "1234-5678-9012-3456" + guid = "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b" patch_calls = [] try: @@ -1325,6 +1305,82 @@ def test_deploy_manifest_new_content_uses_manifest_derived_title(self, caplog): if original_server_value: os.environ["CONNECT_SERVER"] = original_server_value + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_manifest_redeploy_with_guid(self, caplog): + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + guid = "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b" + patch_calls = [] + + try: + deploy_api_invoked = self._register_deploy_endpoints(guid, "My Curated Title", patch_calls) + + runner = CliRunner() + args = apply_common_args( + ["deploy", "manifest", get_manifest_path("pyshiny_with_manifest", "")], + server="http://fake_server", + key="FAKE_API_KEY", + ) + args += ["--guid", guid, "--no-verify"] + with caplog.at_level("INFO"): + result = runner.invoke(cli, args) + 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 + if original_server_value: + os.environ["CONNECT_SERVER"] = original_server_value + + @pytest.mark.parametrize( + ("extra_args", "message"), + [ + (["--guid", "1234"], "is not a content GUID"), + (["--guid", "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "--app-id", "other"], "--guid or --app-id"), + (["--guid", "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", "--new"], "--new or --guid"), + ], + ) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_guid_usage_errors(self, extra_args, message): + args = apply_common_args( + ["deploy", "manifest", get_manifest_path("pyshiny_with_manifest", "")], + server="http://fake_server", + key="FAKE_API_KEY", + ) + result = CliRunner().invoke(cli, args + extra_args) + assert result.exit_code == 2, result.output + assert message in result.output + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_rejects_numeric_app_id_for_connect(self): + args = apply_common_args( + ["deploy", "manifest", get_manifest_path("pyshiny_with_manifest", "")], + server="http://fake_server", + key="FAKE_API_KEY", + ) + result = CliRunner().invoke(cli, args + ["--app-id", "1234"]) + assert result.exit_code == 1, result.output + assert "numeric content IDs are no longer supported" in result.output + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_rejects_guid_for_shinyapps(self): + args = [ + "deploy", + "manifest", + get_manifest_path("shinyapp"), + "--account", + "some-account", + "--token", + "someToken", + "--secret", + "c29tZVNlY3JldAo=", + "--guid", + "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", + ] + result = CliRunner().invoke(cli, args) + assert result.exit_code == 1, result.output + assert "--guid applies only to Posit Connect" in result.output + # noinspection SpellCheckingInspection @pytest.mark.skip(reason="Skipping R manifest test (requires R 3.5, docker containers have moved on).") def test_deploy_manifest(self): diff --git a/tests/test_preflight.py b/tests/test_preflight.py index e1a1d482..2c337ee4 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -35,7 +35,7 @@ def __init__(self, settings: Any, content: dict[str, dict[str, Any]] | None = No def python_settings(self) -> Any: return self.settings - def get_content_by_id(self, id: str) -> Any: + def content_get(self, id: str) -> Any: self.content_requests.append(id) return self.content.get(id, {}) @@ -196,7 +196,7 @@ def test_incomplete_deployment_record_is_unknown_and_does_not_fix(tmp_path): assert result["status"] == "unknown" assert result["existing_content"]["exists"] is True assert any("no content ID" in warning for warning in result["warnings"]) - assert any("--app-id" in action and "--new" in action for action in result["actions"]) + assert any("--guid" in action and "--new" in action for action in result["actions"]) assert not (tmp_path / ".python-version").exists() executor.new = True @@ -548,7 +548,7 @@ def test_unreadable_redeploy_content_is_unknown_with_recovery_action(tmp_path, m def fail_read(_app_id: str): raise RSConnectException("content lookup failed", status=status_code) - monkeypatch.setattr(client, "get_content_by_id", fail_read) + monkeypatch.setattr(client, "content_get", fail_read) result = run_preflight(executor, str(tmp_path)) assert result["status"] == "unknown" diff --git a/tests/test_preflight_node.py b/tests/test_preflight_node.py index 92dd16ab..3a0bde47 100644 --- a/tests/test_preflight_node.py +++ b/tests/test_preflight_node.py @@ -64,7 +64,7 @@ def nodejs_settings(self): raise self.settings_error return self.settings - def get_content_by_id(self, app_id: str): + def content_get(self, app_id: str): self.content_requests.append(app_id) return self.content.get(app_id, {}) @@ -255,7 +255,7 @@ def test_unreadable_existing_content_is_unknown(tmp_path, monkeypatch, content_s def inaccessible_content(app_id): raise RSConnectException("Content lookup failed.", status=content_status) - monkeypatch.setattr(client, "get_content_by_id", inaccessible_content) + monkeypatch.setattr(client, "content_get", inaccessible_content) result = run_node_preflight(executor, str(project)) assert result["status"] == "unknown" @@ -336,7 +336,7 @@ def test_unresolved_target_overrides_empty_server_candidate_set(tmp_path, monkey assert result["existing_content"]["exists"] is True assert result["existing_content"]["app_id"] is None assert any("no content ID" in warning for warning in result["warnings"]) - assert any("--app-id" in action for action in result["actions"]) + assert any("--guid" in action for action in result["actions"]) def test_missing_publishable_flag_is_unknown_for_new_content(tmp_path, monkeypatch): From d7e52193706bf03ec4f9af816724731e72d88fe6 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Wed, 4 Nov 2026 18:52:35 -0500 Subject: [PATCH 2/5] fix: isolate shinyapps --guid test from CONNECT_API_KEY env Co-Authored-By: Claude Opus 5.5 --- 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 4aa0e958..3496002a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1377,7 +1377,9 @@ def test_deploy_rejects_guid_for_shinyapps(self): "--guid", "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b", ] - result = CliRunner().invoke(cli, args) + # Integration CI exports CONNECT_API_KEY/CONNECT_SERVER, which would trip the + # Connect-vs-shinyapps option conflict check before the --guid check. + result = CliRunner().invoke(cli, args, env={"CONNECT_API_KEY": None, "CONNECT_SERVER": None}) assert result.exit_code == 1, result.output assert "--guid applies only to Posit Connect" in result.output From 97811229f45d54a1cb956f484a916fb5f9743ba4 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Wed, 4 Nov 2026 18:55:09 -0500 Subject: [PATCH 3/5] review: clarify deploy git error when the given content GUID is not found The GUID may come from --guid or --app-id, and --new cannot be combined with either, so suggest omitting the ID instead. Co-Authored-By: Claude Opus 5.5 --- rsconnect/api.py | 3 ++- tests/test_api.py | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index ae72219c..29bd5b1e 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -929,7 +929,8 @@ def deploy_git( try: app = self.content_get(app_id) except RSConnectException as e: - raise RSConnectException(f"{e} Try setting the --new flag or omit --guid to create new content.") from e + # app_id may have come from either --guid or --app-id (publish_args folds them together). + raise RSConnectException(f"{e} Omit --guid/--app-id to create new content instead.") from e app_guid = app["guid"] diff --git a/tests/test_api.py b/tests/test_api.py index 00a607dd..8d5ca53c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -791,6 +791,33 @@ def test_deploy_git_reports_unsupported_server(self): self.assertIn("not enabled", str(ctx.exception)) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_git_missing_content_suggests_omitting_guid(self): + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/content/abc-123", + body=json.dumps({"code": 4, "error": "Requested object does not exist."}), + status=404, + forcing_headers={"Content-Type": "application/json"}, + ) + + with self.assertRaises(RSConnectException) as ctx: + client.deploy_git( + app_id="abc-123", + name="my-app", + repository="https://github.com/user/repo", + branch="main", + subdirectory="", + title="My App", + env_vars=None, + ) + + message = str(ctx.exception) + self.assertIn("Omit --guid/--app-id to create new content", message) + self.assertNotIn("--new", message) + @httpretty.activate(verbose=True, allow_net_connect=False) def test_deploy_git_updates_existing_content(self): """Test that deploy_git updates existing git-managed content when app_id is provided.""" From 2655443d8bf529241275b65a58c67e1df0e53f01 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Thu, 5 Nov 2026 17:06:17 -0500 Subject: [PATCH 4/5] review: cover and document the SPCS --app-id/--guid fix Deploying to Connect in SPCS with an explicit content GUID failed on main with "Unable to infer Connect client." because validate_app_mode() did not treat SPCSConnectServer as Connect. Add tests for the GUID lookup, the app-mode mismatch check, and numeric-ID rejection on SPCS, plus a CHANGELOG entry. Co-Authored-By: Claude Opus 5.5 --- docs/CHANGELOG.md | 3 +++ tests/test_api.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 43f45357..9d850cb9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Posit Connect content item, matching the `--guid` option on the `content` commands. `--app-id` continues to work, and is the option to use for Connect Cloud and shinyapps.io. (#885) +- Deploying to Posit Connect in Snowflake (SPCS) with `--app-id` or `--guid` + updates the existing content item instead of failing with "Unable to infer + Connect client." - Generating requirements from an environment now requires pip 20.3 or later. Older environments can upgrade pip or provide a requirements file. - Reports editable dependencies outside the deployment directory, which Posit Connect diff --git a/tests/test_api.py b/tests/test_api.py index 8d5ca53c..5f338312 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,6 +2,7 @@ import json import os import sys +import tempfile from unittest import TestCase from unittest.mock import Mock, patch @@ -19,6 +20,8 @@ verify_api_key, ) from rsconnect.exception import DeploymentFailedException, RSConnectException +from rsconnect.metadata import AppStore +from rsconnect.models import AppModes from .utils import require_api_key, require_connect @@ -650,6 +653,57 @@ def test_exchange_token_empty_response(self, mock_fmt_payload, mock_token_endpoi server.exchange_token() +class SPCSExplicitAppIdTestCase(TestCase): + """An explicit --app-id/--guid against Connect in SPCS is resolved like any Connect server.""" + + GUID = "11111111-2222-3333-4444-555555555555" + + def _executor(self, app_id): + tempdir = tempfile.TemporaryDirectory() + self.addCleanup(tempdir.cleanup) + app_path = os.path.join(tempdir.name, "app.py") + + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.logger = None + executor.ctx = None + executor.remote_server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection") + executor.app_store = AppStore(app_path) + executor.app_store_version = None + executor.path = app_path + executor.new = False + executor.app_id = app_id + executor.app_mode = None + return executor + + @patch("rsconnect.api.SPCSConnectServer.exchange_token", return_value="snowflake-token") + def test_validate_app_mode_looks_up_existing_content_by_guid(self, _exchange_token): + executor = self._executor(self.GUID) + content = {"guid": self.GUID, "app_mode": AppModes.PYTHON_SHINY.ordinal()} + with patch.object(RSConnectClient, "content_get", return_value=content) as content_get: + executor.validate_app_mode(AppModes.PYTHON_SHINY) + + content_get.assert_called_once_with(self.GUID) + self.assertEqual(executor.app_id, self.GUID) + self.assertEqual(executor.app_mode, AppModes.PYTHON_SHINY) + + @patch("rsconnect.api.SPCSConnectServer.exchange_token", return_value="snowflake-token") + def test_validate_app_mode_rejects_mismatched_existing_mode(self, _exchange_token): + executor = self._executor(self.GUID) + content = {"guid": self.GUID, "app_mode": AppModes.STREAMLIT_APP.ordinal()} + with patch.object(RSConnectClient, "content_get", return_value=content): + with self.assertRaises(RSConnectException) as ctx: + executor.validate_app_mode(AppModes.PYTHON_SHINY) + + self.assertIn("existing deployment has mode", str(ctx.exception)) + + def test_validate_explicit_app_id_rejects_numeric_id(self): + executor = self._executor("1234") + with self.assertRaises(RSConnectException) as ctx: + executor.validate_explicit_app_id() + + self.assertIn("must be a content GUID", str(ctx.exception)) + + class RSConnectClientDeployGitTestCase(TestCase): """Tests for RSConnectClient.deploy_git() method.""" From 34071e122b0ff820f078267f99a6b17718d79317 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Thu, 5 Nov 2026 17:14:07 -0500 Subject: [PATCH 5/5] test: use the content GUID from the shared deploy routes in #883's test Co-Authored-By: Claude Opus 5.5 --- tests/test_main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 3496002a..f66d7766 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -531,7 +531,7 @@ def post_application_deploy_callback(request, uri, response_headers): httpretty.register_uri( httpretty.POST, - "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + "http://fake_server/__api__/v1/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/deploy", body=post_application_deploy_callback, ) verify_invoked = [] @@ -542,7 +542,7 @@ def get_content_callback(request, uri, response_headers): httpretty.register_uri( httpretty.GET, - re.compile(r"http://fake_server/content/1234-5678-9012-3456/"), + re.compile(r"http://fake_server/content/f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b/"), body=get_content_callback, )