Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ 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)
- 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
Expand Down
8 changes: 4 additions & 4 deletions docs/commands/preflight.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
18 changes: 9 additions & 9 deletions docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
63 changes: 28 additions & 35 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@
BootstrapOutputDTO,
BuildOutputDTO,
BundleMetadata,
ContentItemV0,
ContentItemV1,
DeleteInputDTO,
DeleteOutputDTO,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -948,11 +927,10 @@ 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
# 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"]

Expand Down Expand Up @@ -1148,8 +1126,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

Expand Down Expand Up @@ -1425,6 +1402,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(
Expand Down Expand Up @@ -1481,6 +1459,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(
Expand Down Expand Up @@ -2848,10 +2842,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(
Expand Down Expand Up @@ -4341,8 +4335,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)

Expand Down
41 changes: 37 additions & 4 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
AppMode,
AppModes,
BuildStatus,
ContentGuidParamType,
ContentGuidWithBundle,
ContentGuidWithBundleParamType,
KeyValueParamType,
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1365,6 +1396,7 @@ def preflight(
directory: str,
fix: bool,
new: bool,
guid: Optional[str],
app_id: Optional[str],
runtime: str,
node: Optional[str],
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading