From 81a1746cf29893842915a227a91530e6a9f0ba8a Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Tue, 23 Jun 2026 13:13:19 -0400 Subject: [PATCH 1/8] feat: add deploy bundle command for downloaded bundles Add `rsconnect deploy bundle ` to deploy a previously built content bundle (such as one downloaded from a Connect server) directly to a server. The bundle is uploaded as-is; its existing manifest.json determines the content type and dependencies, making it easy to copy content between servers. Rather than extract and re-bundle, this reuses the existing executor deploy chain: make_bundle simply opens the tarball and deploy_bundle uploads it unchanged. New bundle.py helpers read the app mode and default title from the tarball's manifest.json without full extraction. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/CHANGELOG.md | 5 +++ docs/deploying.md | 20 ++++++++++ rsconnect/bundle.py | 38 ++++++++++++++++++ rsconnect/main.py | 92 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_bundle.py | 41 ++++++++++++++++++++ 5 files changed, 196 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6e52bc83..37267490 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `rsconnect deploy bundle` command for deploying a previously built content + bundle (a `.tar.gz`, such as one downloaded from a Connect server) directly to + a server. The bundle is uploaded as-is and its existing `manifest.json` + determines the content type and dependencies, making it easy to copy content + from one server to another. - R dependency detection from an `renv.lock` file. When deploying Python content that also uses R (e.g. `rpy2`), rsconnect-python reads the lockfile and adds the R version and packages to the manifest so Posit Connect can restore the R diff --git a/docs/deploying.md b/docs/deploying.md index 0af64af9..cc0a085e 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -316,6 +316,26 @@ library(rsconnect) ?rsconnect::writeManifest ``` +### Deploying a Downloaded Bundle + +If you already have a content bundle as a `.tar.gz` file—for example, one +downloaded from a Posit Connect server—you can deploy it directly without +unpacking it. This is handy for copying content from one server to another. + +```bash +rsconnect deploy bundle /path/to/bundle.tar.gz +``` + +The bundle is uploaded as-is; its existing `manifest.json` determines the content +type and dependencies. The options are the same as for `deploy manifest`; see +`rsconnect deploy bundle --help` for details. + +> **Note** +> Bundles do not contain environment variables or secrets, so any that the +> content relied on must be set on the target server (you can pass them with +> `-E NAME=VALUE`). Dependencies are pinned in the manifest, so the target +> server needs a compatible Python or R version available. + ### Deploying from a pyproject.toml `rsconnect deploy pyproject` reads a `[tool.rsconnect]` table from a project's diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index ae7defdc..a5274d90 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -843,6 +843,44 @@ def read_manifest_file(manifest_path: str | Path) -> tuple[ManifestData, str]: return manifest, raw_manifest +def read_bundle_manifest(bundle_path: str | Path) -> tuple[ManifestData, str]: + """ + Read the manifest.json contained in a bundle tarball without extracting the + whole bundle. The content is provided as both a parsed dictionary and the + raw string. + + :param bundle_path: the path to a bundle .tar.gz file. + :return: the parsed manifest data and the raw manifest content as a string. + """ + with tarfile.open(name=str(bundle_path), mode="r:gz") as tar: + try: + extracted = tar.extractfile("manifest.json") + except KeyError: + extracted = None + if extracted is None: + raise RSConnectException('Bundle "%s" does not contain a manifest.json file.' % bundle_path) + raw_manifest = extracted.read().decode("utf-8") + + manifest = json.loads(raw_manifest) + return manifest, raw_manifest + + +def read_bundle_app_mode(bundle_path: str | Path) -> AppMode: + source_manifest, _ = read_bundle_manifest(bundle_path) + # noinspection SpellCheckingInspection + return AppModes.get_by_name(source_manifest["metadata"]["appmode"]) + + +def default_title_from_bundle(bundle_path: str | Path) -> str: + source_manifest, _ = read_bundle_manifest(bundle_path) + return _default_title_from_manifest(source_manifest, bundle_path) + + +def open_bundle(bundle_path: str | Path) -> typing.IO[bytes]: + """Open an existing bundle tarball so it can be uploaded as-is.""" + return open(bundle_path, "rb") + + def make_manifest_bundle(manifest_path: str | Path) -> typing.IO[bytes]: """Create a bundle, given a manifest. diff --git a/rsconnect/main.py b/rsconnect/main.py index e4679d40..a7508db7 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -89,6 +89,7 @@ server_supports_git_metadata, ) from .bundle import ( + default_title_from_bundle, default_title_from_manifest, make_api_bundle, make_html_bundle, @@ -99,6 +100,8 @@ make_notebook_source_bundle, make_tensorflow_bundle, make_voila_bundle, + open_bundle, + read_bundle_app_mode, read_manifest_app_mode, resolve_shiny_express_entrypoint, validate_entry_point, @@ -1794,6 +1797,95 @@ def deploy_manifest( ce.verify_deployment() +@deploy.command( + name="bundle", + short_help="Deploy a previously downloaded bundle to Posit Connect, Posit Cloud, or shinyapps.io.", + help=( + "Deploy a content bundle (a .tar.gz file, such as one downloaded from a Connect server) " + "directly to a server. The bundle is uploaded as-is; its existing manifest.json determines " + "the content type and dependencies. This is useful for copying content from one server to " + "another." + ), + no_args_is_help=True, +) +@server_args +@spcs_args +@content_args +@cloud_shinyapps_args +@click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) +@shinyapps_deploy_args +@cli_exception_handler +@click.pass_context +def deploy_bundle( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + account: Optional[str], + token: Optional[str], + secret: Optional[str], + new: bool, + app_id: Optional[str], + title: Optional[str], + verbose: int, + file: str, + env_vars: dict[str, str], + visibility: Optional[str], + no_verify: bool, + draft: bool, + metadata: tuple[str, ...] = tuple(), + no_metadata: bool = False, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + + app_mode = read_bundle_app_mode(file) + title = title or default_title_from_bundle(file) + + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + account=account, + token=token, + secret=secret, + path=file, + server=server, + new=new, + app_id=app_id, + title=title, + visibility=visibility, + env_vars=env_vars, + ) + + # Prepare metadata for upload + server_version = None + if isinstance(ce.client, RSConnectClient): + server_version = ce.client.server_settings().get("version", "") + deploy_metadata = prepare_deploy_metadata(dirname(file), metadata, no_metadata, server_version) + ce.metadata = deploy_metadata + + ( + ce.validate_server() + .validate_app_mode(app_mode=app_mode) + .make_bundle( + open_bundle, + file, + ) + .deploy_bundle(activate=not draft) + .save_deployed_info() + .emit_task_log() + ) + if not no_verify: + ce.verify_deployment() + + @deploy.command( name="pyproject", short_help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io by pyproject.", diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 332fae41..9111d022 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import io import json import os import sys @@ -35,6 +36,10 @@ make_tensorflow_bundle, make_tensorflow_manifest, make_voila_bundle, + default_title_from_bundle, + open_bundle, + read_bundle_app_mode, + read_bundle_manifest, resolve_shiny_express_entrypoint, to_bytes, validate_entry_point, @@ -783,6 +788,42 @@ def test_manifest_bundle(self): manifest_names = sorted(filter(keep_manifest_specified_file, manifest["files"].keys())) self.assertEqual(tar_names, manifest_names) + def test_read_bundle_manifest(self): + bundle_path = join(dirname(__file__), "testdata", "bundle.tar.gz") + manifest, raw_manifest = read_bundle_manifest(bundle_path) + + self.assertEqual(manifest["metadata"]["appmode"], "python-api") + # raw and parsed manifest agree + self.assertEqual(json.loads(raw_manifest), manifest) + + def test_read_bundle_app_mode(self): + bundle_path = join(dirname(__file__), "testdata", "bundle.tar.gz") + self.assertEqual(read_bundle_app_mode(bundle_path), AppModes.PYTHON_API) + + def test_default_title_from_bundle(self): + bundle_path = join(dirname(__file__), "testdata", "bundle.tar.gz") + # derived from the manifest's entrypoint (app.py) + self.assertEqual(default_title_from_bundle(bundle_path), "app") + + def test_open_bundle(self): + bundle_path = join(dirname(__file__), "testdata", "bundle.tar.gz") + with open_bundle(bundle_path) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + self.assertIn("manifest.json", tar.getnames()) + + def test_read_bundle_manifest_missing_manifest(self): + # A tarball without a manifest.json should raise a helpful error. + with tempfile.TemporaryDirectory() as tmp: + bundle_path = join(tmp, "no_manifest.tar.gz") + with tarfile.open(bundle_path, mode="w:gz") as tar: + data = b"hello" + info = tarfile.TarInfo("notes.txt") + info.size = len(data) + tar.addfile(info, fileobj=io.BytesIO(data)) + + with self.assertRaises(RSConnectException) as ctx: + read_bundle_manifest(bundle_path) + self.assertIn("manifest.json", str(ctx.exception)) + def test_make_source_manifest(self): # Verify the optional parameters # image=None, # type: str From 30c6f01a7da6a2aeb8e7857c9cdb08fbd2713255 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Tue, 23 Jun 2026 13:18:22 -0400 Subject: [PATCH 2/8] refactor: pull git metadata detection out of prepare_deploy_metadata prepare_deploy_metadata now receives already-detected metadata instead of a directory to inspect, so callers decide whether to auto-detect git metadata. The existing deploy commands pass detect_git_metadata(base_dir); deploy bundle passes an empty dict so no git metadata is auto-attached. A bundle's location on disk is unrelated to the content's source, so detecting git metadata from it would attach misleading provenance. Only explicit --metadata overrides are sent for bundle deployments. Co-Authored-By: Claude Opus 4.8 (1M context) --- rsconnect/main.py | 32 ++++++++++++++++---------------- tests/test_git_metadata.py | 26 +++++++++++++++++++++----- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index a7508db7..acac6fbe 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -307,7 +307,7 @@ def validate_env_vars(ctx: click.Context, param: click.Parameter, all_values: tu def prepare_deploy_metadata( - directory: str, + detected_metadata: dict[str, str], metadata_overrides: tuple[str, ...], no_metadata: bool, server_version: Optional[str] = None, @@ -315,7 +315,8 @@ def prepare_deploy_metadata( """ Prepare metadata for bundle upload. - :param directory: Directory to detect git metadata from + :param detected_metadata: Auto-detected metadata (e.g. git metadata). Pass an + empty dict to send only the CLI overrides. :param metadata_overrides: CLI metadata overrides (key=value pairs) :param no_metadata: Flag to disable all metadata :param server_version: Optional server version to check support @@ -337,9 +338,6 @@ def prepare_deploy_metadata( else: # Empty value clears the key cli_metadata[key] = "" - # Auto-detect git metadata - detected_metadata = detect_git_metadata(directory) - # Merge: CLI overrides take precedence, then remove empty values final_metadata = {**detected_metadata, **cli_metadata} final_metadata = {k: v for k, v in final_metadata.items() if v} @@ -1508,7 +1506,7 @@ def deploy_notebook( server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) ce.metadata = deploy_metadata ce.validate_server().validate_app_mode(app_mode=app_mode) @@ -1684,7 +1682,7 @@ def deploy_voila( if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") base_dir = path if isdir(path) else dirname(path) - deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) ce.metadata = deploy_metadata ce.validate_server().validate_app_mode(app_mode=app_mode) @@ -1779,7 +1777,7 @@ def deploy_manifest( if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") base_dir = dirname(file_name) - deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) ce.metadata = deploy_metadata ( @@ -1864,11 +1862,13 @@ def deploy_bundle( env_vars=env_vars, ) - # Prepare metadata for upload + # Prepare metadata for upload. Git metadata is not auto-detected for bundle + # deployments: the bundle's location on disk is unrelated to the content's + # source, so only explicit --metadata overrides are sent. server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - deploy_metadata = prepare_deploy_metadata(dirname(file), metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata({}, metadata, no_metadata, server_version) ce.metadata = deploy_metadata ( @@ -2080,7 +2080,7 @@ def quickstart_hint() -> str: server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - ce.metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) + ce.metadata = prepare_deploy_metadata(detect_git_metadata(directory), metadata, no_metadata, server_version) ( ce.validate_server() @@ -2255,7 +2255,7 @@ def deploy_quarto( server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) ce.metadata = deploy_metadata ( @@ -2367,7 +2367,7 @@ def deploy_tensorflow( server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(detect_git_metadata(directory), metadata, no_metadata, server_version) ce.metadata = deploy_metadata ( @@ -2491,7 +2491,7 @@ def deploy_html( if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") base_dir = path if isdir(path) else dirname(path) - deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) ce.metadata = deploy_metadata ( @@ -2717,7 +2717,7 @@ def deploy_app( ) # Prepare metadata for upload - deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(detect_git_metadata(directory), metadata, no_metadata, server_version) ce.metadata = deploy_metadata ce.validate_server() @@ -2880,7 +2880,7 @@ def deploy_nodejs( connect_version_string = ce.client.server_settings().get("version", "") server_version = connect_version_string - deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(detect_git_metadata(directory), metadata, no_metadata, server_version) ce.metadata = deploy_metadata ce.validate_server() diff --git a/tests/test_git_metadata.py b/tests/test_git_metadata.py index 811ea7a5..6f894d47 100644 --- a/tests/test_git_metadata.py +++ b/tests/test_git_metadata.py @@ -197,19 +197,19 @@ def temp_git_repo(self): def test_prepare_metadata_no_metadata_flag(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata - result = prepare_deploy_metadata(temp_git_repo, tuple(), True, "2025.12.0") + result = prepare_deploy_metadata(detect_git_metadata(temp_git_repo), tuple(), True, "2025.12.0") assert result is None def test_prepare_metadata_old_server_no_cli_overrides(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata - result = prepare_deploy_metadata(temp_git_repo, tuple(), False, "2024.01.0") + result = prepare_deploy_metadata(detect_git_metadata(temp_git_repo), tuple(), False, "2024.01.0") assert result is None def test_prepare_metadata_new_server(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata - result = prepare_deploy_metadata(temp_git_repo, tuple(), False, "2025.12.0") + result = prepare_deploy_metadata(detect_git_metadata(temp_git_repo), tuple(), False, "2025.12.0") assert result is not None assert result["source"] == "git" assert "source_commit" in result @@ -221,7 +221,7 @@ def test_prepare_metadata_cli_overrides(self, temp_git_repo): # CLI overrides force metadata even on old servers result = prepare_deploy_metadata( - temp_git_repo, ("source=custom", "custom_key=custom_value"), False, "2024.01.0" + detect_git_metadata(temp_git_repo), ("source=custom", "custom_key=custom_value"), False, "2024.01.0" ) assert result is not None assert result["source"] == "custom" @@ -231,12 +231,28 @@ def test_prepare_metadata_cli_clears_value(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata # Empty value should clear the key - result = prepare_deploy_metadata(temp_git_repo, ("source_repo=",), False, "2.0") + result = prepare_deploy_metadata(detect_git_metadata(temp_git_repo), ("source_repo=",), False, "2.0") assert result is not None assert "source_repo" not in result # Cleared by empty value assert "source" in result # Still detected assert "source_commit" in result # Still detected + def test_prepare_metadata_no_detection(self): + from rsconnect.main import prepare_deploy_metadata + + # When no metadata is detected and no CLI overrides are given, nothing is + # sent even on a new server. This is what `deploy bundle` relies on to + # avoid attaching unrelated git metadata. + result = prepare_deploy_metadata({}, tuple(), False, "2025.12.0") + assert result is None + + def test_prepare_metadata_no_detection_with_cli_overrides(self): + from rsconnect.main import prepare_deploy_metadata + + # CLI overrides are still sent even when nothing is auto-detected. + result = prepare_deploy_metadata({}, ("source=manual",), False, "2025.12.0") + assert result == {"source": "manual"} + class TestIntegration: """Integration tests for the full workflow.""" From e32bb3933d395e727a375eecc3262a4086828f1a Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Tue, 23 Jun 2026 13:25:40 -0400 Subject: [PATCH 3/8] fix: address review findings for deploy bundle - default_title_from_bundle now falls back to the bundle's own filename (e.g. "mycontent" from "mycontent.tar.gz") when the manifest has no usable entrypoint, instead of the directory the bundle happens to live in, which is unrelated to the content's identity (roborev #24, medium). - Add a CLI-level test (test_deploy_bundle) covering the full deploy flow and asserting the tarball is uploaded unchanged (roborev #24, low). - Add unit tests for the filename fallback and a .tar.gz/.tgz extension stripping helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- rsconnect/bundle.py | 30 +++++++++- tests/test_bundle.py | 24 ++++++++ tests/test_main.py | 136 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 184 insertions(+), 6 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index a5274d90..e1e3470c 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -873,7 +873,35 @@ def read_bundle_app_mode(bundle_path: str | Path) -> AppMode: def default_title_from_bundle(bundle_path: str | Path) -> str: source_manifest, _ = read_bundle_manifest(bundle_path) - return _default_title_from_manifest(source_manifest, bundle_path) + + # Prefer the manifest's entry point / primary file, mirroring how a manifest + # deployment derives its title. + filename = None + metadata = source_manifest.get("metadata") + if metadata: + # noinspection SpellCheckingInspection + filename = metadata.get("entrypoint") or metadata.get("primary_rmd") or metadata.get("primary_html") + # If the manifest is for a module-style API entry point, there is no + # useful filename to derive a title from. + if filename and _module_pattern.match(filename): + filename = None + + # When the manifest has no usable filename, fall back to the bundle's own + # file name (e.g. "mycontent" from "mycontent.tar.gz") rather than the + # directory the bundle happens to live in, which is unrelated to the content. + if not filename: + filename = _strip_bundle_extension(basename(str(bundle_path))) + + return _default_title(filename) + + +def _strip_bundle_extension(name: str) -> str: + """Strip a trailing bundle archive extension (.tar.gz, .tgz, .tar) from a name.""" + lowered = name.lower() + for suffix in (".tar.gz", ".tgz", ".tar"): + if lowered.endswith(suffix): + return name[: -len(suffix)] + return name def open_bundle(bundle_path: str | Path) -> typing.IO[bytes]: diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 9111d022..3ef4b48e 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -805,6 +805,30 @@ def test_default_title_from_bundle(self): # derived from the manifest's entrypoint (app.py) self.assertEqual(default_title_from_bundle(bundle_path), "app") + @staticmethod + def _write_bundle(bundle_path, manifest): + with tarfile.open(bundle_path, mode="w:gz") as tar: + raw = json.dumps(manifest).encode("utf-8") + info = tarfile.TarInfo("manifest.json") + info.size = len(raw) + tar.addfile(info, fileobj=io.BytesIO(raw)) + + def test_default_title_from_bundle_module_entrypoint(self): + # A module-style entrypoint (e.g. "app:app") has no usable filename, so + # the title should fall back to the bundle's own filename rather than the + # directory the bundle happens to live in. + with tempfile.TemporaryDirectory() as tmp: + bundle_path = join(tmp, "my-cool-api.tar.gz") + self._write_bundle(bundle_path, {"metadata": {"appmode": "python-api", "entrypoint": "app:app"}}) + self.assertEqual(default_title_from_bundle(bundle_path), "my-cool-api") + + def test_default_title_from_bundle_no_entrypoint(self): + # No entrypoint at all: fall back to the bundle filename. + with tempfile.TemporaryDirectory() as tmp: + bundle_path = join(tmp, "report.tgz") + self._write_bundle(bundle_path, {"metadata": {"appmode": "static"}}) + self.assertEqual(default_title_from_bundle(bundle_path), "report") + def test_open_bundle(self): bundle_path = join(dirname(__file__), "testdata", "bundle.tar.gz") with open_bundle(bundle_path) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: diff --git a/tests/test_main.py b/tests/test_main.py index 044a634f..62a3d0b3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -303,6 +303,124 @@ def post_application_deploy_callback(request, uri, response_headers): if original_server_value: os.environ["CONNECT_SERVER"] = original_server_value + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_bundle(self, caplog): + # Deploying a downloaded bundle should upload the tarball as-is (no + # re-bundling) and run the standard Connect deploy flow. + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + bundle_path = join("tests", "testdata", "bundle.tar.gz") + with open(bundle_path, "rb") as f: + original_bundle_bytes = f.read() + + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/server_settings", + body=json.dumps({"version": "9999.99.99"}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/user", + body=open("tests/testdata/connect-responses/me.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + # Unique-name check: the bundle's title defaults to "app", so the name + # derived from it is "app", which is available. + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/content?name=app", + body=json.dumps([]), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + content_body = json.dumps( + { + "id": "1234", + "guid": "1234-5678-9012-3456", + "title": "app", + "content_url": "http://fake_server/content/1234-5678-9012-3456", + "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + } + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content", + body=content_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.PATCH, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + 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", + body=content_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + # Capture the uploaded bundle to verify it is uploaded unchanged. + uploaded_bundle = [] + + def post_bundle_callback(request, uri, response_headers): + uploaded_bundle.append(request.body) + return [200, {"Content-Type": "application/json"}, json.dumps({"id": "FAKE_BUNDLE_ID"})] + + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", + body=post_bundle_callback, + ) + + deploy_api_invoked = [] + + def post_application_deploy_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + assert parsed_request == {"bundle_id": "FAKE_BUNDLE_ID"} + deploy_api_invoked.append(True) + return [201, {"Content-Type": "application/json"}, json.dumps({"task_id": "FAKE_TASK_ID"})] + + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + body=post_application_deploy_callback, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/tasks/FAKE_TASK_ID" "?wait=1", + body=json.dumps({"output": ["FAKE_OUTPUT"], "last": "FAKE_LAST", "finished": True, "code": 0}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + try: + runner = CliRunner() + args = apply_common_args(["deploy", "bundle", bundle_path], server="http://fake_server", key="FAKE_API_KEY") + args.append("--no-verify") + with caplog.at_level("INFO"): + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + assert deploy_api_invoked == [True] + assert "Deployment completed successfully." in caplog.text + # The bundle was uploaded as-is: the original tarball bytes appear + # unchanged within the (chunk-encoded) upload body. + assert len(uploaded_bundle) == 1 + assert original_bundle_bytes in uploaded_bundle[0] + 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 + # noinspection SpellCheckingInspection @pytest.mark.skip(reason="Skipping R manifest test (requires R 3.5, docker containers have moved on).") def test_deploy_manifest(self): @@ -1201,8 +1319,8 @@ def test_list_shows_default_marker(self, tmp_path): with mock.patch("rsconnect.main.server_store", store): result = runner.invoke(cli, ["list"]) assert result.exit_code == 0, result.output - assert '[default]' in result.output - assert 's1' in result.output + assert "[default]" in result.output + assert "s1" in result.output def test_list_no_default_marker(self, tmp_path): from rsconnect.metadata import ServerStore @@ -1214,7 +1332,7 @@ def test_list_no_default_marker(self, tmp_path): with mock.patch("rsconnect.main.server_store", store): result = runner.invoke(cli, ["list"]) assert result.exit_code == 0, result.output - assert '[default]' not in result.output + assert "[default]" not in result.output def test_remove_default_shows_note(self, tmp_path): from rsconnect.metadata import ServerStore @@ -1269,8 +1387,16 @@ def test_add_set_default(self, tmp_path): with mock.patch("rsconnect.main.server_store", store): result = runner.invoke( cli, - ["add", "--name", "myserver", "--server", "http://connect.local", - "--api-key", "fake-key", "--set-default"], + [ + "add", + "--name", + "myserver", + "--server", + "http://connect.local", + "--api-key", + "fake-key", + "--set-default", + ], ) assert result.exit_code == 0, result.output assert "is now the default" in result.output From 598310ef9813cab685e7060f6daea8b9e5e3b05a Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Tue, 23 Jun 2026 13:31:06 -0400 Subject: [PATCH 4/8] fix: preserve dots in bundle filename when deriving default title A bundle filename like my.cool.api.tar.gz was truncated to "my.cool": after stripping the archive extension, _default_title ran a second rsplit(".", 1) on the result. Extract length enforcement into _enforce_title_length and format the pre-stripped bundle name directly, so dotted stems are preserved (roborev #26, low). Co-Authored-By: Claude Opus 4.8 (1M context) --- rsconnect/bundle.py | 15 +++++++++++++-- tests/test_bundle.py | 8 ++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index e1e3470c..c84491f3 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -889,8 +889,11 @@ def default_title_from_bundle(bundle_path: str | Path) -> str: # When the manifest has no usable filename, fall back to the bundle's own # file name (e.g. "mycontent" from "mycontent.tar.gz") rather than the # directory the bundle happens to live in, which is unrelated to the content. + # The bundle name may legitimately contain dots (e.g. "my.cool.api"), so only + # the archive extension is stripped — formatting it directly avoids stripping + # a second "extension". if not filename: - filename = _strip_bundle_extension(basename(str(bundle_path))) + return _enforce_title_length(_strip_bundle_extension(basename(str(bundle_path)))) return _default_title(filename) @@ -1701,6 +1704,14 @@ def make_quarto_manifest( return manifest, relevant_files +def _enforce_title_length(title: str) -> str: + """ + Pad or truncate a title so it is between 3 and 1024 characters long, as + required by Posit Connect. + """ + return title[:1024].rjust(3, "0") + + def _default_title(file_name: str | Path) -> str: """ Produce a default content title from the given file path. The result is @@ -1713,7 +1724,7 @@ def _default_title(file_name: str | Path) -> str: # Make sure we have enough of a path to derive text from. file_name = abspath(file_name) # noinspection PyTypeChecker - return basename(file_name).rsplit(".", 1)[0][:1024].rjust(3, "0") + return _enforce_title_length(basename(file_name).rsplit(".", 1)[0]) def validate_file_is_notebook(file_name: str | Path) -> None: diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 3ef4b48e..8a01310a 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -829,6 +829,14 @@ def test_default_title_from_bundle_no_entrypoint(self): self._write_bundle(bundle_path, {"metadata": {"appmode": "static"}}) self.assertEqual(default_title_from_bundle(bundle_path), "report") + def test_default_title_from_bundle_dotted_filename(self): + # A bundle filename with dots in the stem should keep all of them: only + # the archive extension is stripped, not a second "extension". + with tempfile.TemporaryDirectory() as tmp: + bundle_path = join(tmp, "my.cool.api.tar.gz") + self._write_bundle(bundle_path, {"metadata": {"appmode": "python-api", "entrypoint": "app:app"}}) + self.assertEqual(default_title_from_bundle(bundle_path), "my.cool.api") + def test_open_bundle(self): bundle_path = join(dirname(__file__), "testdata", "bundle.tar.gz") with open_bundle(bundle_path) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: From ff79e1f2e7a07b3264dfab8218de334ed7547c3a Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Wed, 24 Jun 2026 08:07:44 -0400 Subject: [PATCH 5/8] fix: address PR review feedback for deploy bundle - Revert prepare_deploy_metadata to take directory: Optional[str] and detect git metadata internally; bundle deployment passes directory=None to skip auto-detection, rather than extracting detect_git_metadata to every call site. - Simplify bundle title fallback to only strip .tar.gz (Connect always produces .tar.gz bundles), dropping the .tgz/.tar affordance. - Collapse the metadata assignment in deploy bundle to a single line. - Move the deploy bundle CHANGELOG entry to the bottom of "Added". Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/CHANGELOG.md | 10 +++++----- rsconnect/bundle.py | 20 +++++++------------- rsconnect/main.py | 38 +++++++++++++++++++++----------------- tests/test_bundle.py | 2 +- tests/test_git_metadata.py | 14 +++++++------- 5 files changed, 41 insertions(+), 43 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 37267490..c98046fc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -23,11 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `rsconnect deploy bundle` command for deploying a previously built content - bundle (a `.tar.gz`, such as one downloaded from a Connect server) directly to - a server. The bundle is uploaded as-is and its existing `manifest.json` - determines the content type and dependencies, making it easy to copy content - from one server to another. - R dependency detection from an `renv.lock` file. When deploying Python content that also uses R (e.g. `rpy2`), rsconnect-python reads the lockfile and adds the R version and packages to the manifest so Posit Connect can restore the R @@ -45,6 +40,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 from a project's `pyproject.toml` with a `[tool.rsconnect]` table. Also writes the environment file the manifest references (e.g. `requirements.txt`), regenerating it on each run unless it is itself the requirements source. +- `rsconnect deploy bundle` command for deploying a previously built content + bundle (a `.tar.gz`, such as one downloaded from a Connect server) directly to + a server. The bundle is uploaded as-is and its existing `manifest.json` + determines the content type and dependencies, making it easy to copy content + from one server to another. ### Changed diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index c84491f3..207491ad 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -889,24 +889,18 @@ def default_title_from_bundle(bundle_path: str | Path) -> str: # When the manifest has no usable filename, fall back to the bundle's own # file name (e.g. "mycontent" from "mycontent.tar.gz") rather than the # directory the bundle happens to live in, which is unrelated to the content. - # The bundle name may legitimately contain dots (e.g. "my.cool.api"), so only - # the archive extension is stripped — formatting it directly avoids stripping - # a second "extension". + # Connect always produces .tar.gz bundles, and the name may legitimately + # contain dots (e.g. "my.cool.api"), so only the .tar.gz extension is stripped + # rather than splitting off a second "extension". if not filename: - return _enforce_title_length(_strip_bundle_extension(basename(str(bundle_path)))) + name = basename(str(bundle_path)) + if name.lower().endswith(".tar.gz"): + name = name[: -len(".tar.gz")] + return _enforce_title_length(name) return _default_title(filename) -def _strip_bundle_extension(name: str) -> str: - """Strip a trailing bundle archive extension (.tar.gz, .tgz, .tar) from a name.""" - lowered = name.lower() - for suffix in (".tar.gz", ".tgz", ".tar"): - if lowered.endswith(suffix): - return name[: -len(suffix)] - return name - - def open_bundle(bundle_path: str | Path) -> typing.IO[bytes]: """Open an existing bundle tarball so it can be uploaded as-is.""" return open(bundle_path, "rb") diff --git a/rsconnect/main.py b/rsconnect/main.py index acac6fbe..830aa406 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -307,7 +307,7 @@ def validate_env_vars(ctx: click.Context, param: click.Parameter, all_values: tu def prepare_deploy_metadata( - detected_metadata: dict[str, str], + directory: Optional[str], metadata_overrides: tuple[str, ...], no_metadata: bool, server_version: Optional[str] = None, @@ -315,8 +315,10 @@ def prepare_deploy_metadata( """ Prepare metadata for bundle upload. - :param detected_metadata: Auto-detected metadata (e.g. git metadata). Pass an - empty dict to send only the CLI overrides. + :param directory: Directory to auto-detect git metadata from. Pass None to + skip auto-detection and send only the CLI overrides (e.g. for bundle + deployments, where the bundle's location on disk is unrelated to the + content's source). :param metadata_overrides: CLI metadata overrides (key=value pairs) :param no_metadata: Flag to disable all metadata :param server_version: Optional server version to check support @@ -338,6 +340,9 @@ def prepare_deploy_metadata( else: # Empty value clears the key cli_metadata[key] = "" + # Auto-detect git metadata, unless the caller opted out by passing None. + detected_metadata = detect_git_metadata(directory) if directory is not None else {} + # Merge: CLI overrides take precedence, then remove empty values final_metadata = {**detected_metadata, **cli_metadata} final_metadata = {k: v for k, v in final_metadata.items() if v} @@ -1506,7 +1511,7 @@ def deploy_notebook( server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata ce.validate_server().validate_app_mode(app_mode=app_mode) @@ -1682,7 +1687,7 @@ def deploy_voila( if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") base_dir = path if isdir(path) else dirname(path) - deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata ce.validate_server().validate_app_mode(app_mode=app_mode) @@ -1777,7 +1782,7 @@ def deploy_manifest( if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") base_dir = dirname(file_name) - deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata ( @@ -1862,14 +1867,13 @@ def deploy_bundle( env_vars=env_vars, ) - # Prepare metadata for upload. Git metadata is not auto-detected for bundle - # deployments: the bundle's location on disk is unrelated to the content's - # source, so only explicit --metadata overrides are sent. + # Prepare metadata for upload. Passing directory=None skips git auto-detection: + # the bundle's location on disk is unrelated to the content's source, so only + # explicit --metadata overrides are sent. server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - deploy_metadata = prepare_deploy_metadata({}, metadata, no_metadata, server_version) - ce.metadata = deploy_metadata + ce.metadata = prepare_deploy_metadata(None, metadata, no_metadata, server_version) ( ce.validate_server() @@ -2080,7 +2084,7 @@ def quickstart_hint() -> str: server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - ce.metadata = prepare_deploy_metadata(detect_git_metadata(directory), metadata, no_metadata, server_version) + ce.metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) ( ce.validate_server() @@ -2255,7 +2259,7 @@ def deploy_quarto( server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata ( @@ -2367,7 +2371,7 @@ def deploy_tensorflow( server_version = None if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") - deploy_metadata = prepare_deploy_metadata(detect_git_metadata(directory), metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) ce.metadata = deploy_metadata ( @@ -2491,7 +2495,7 @@ def deploy_html( if isinstance(ce.client, RSConnectClient): server_version = ce.client.server_settings().get("version", "") base_dir = path if isdir(path) else dirname(path) - deploy_metadata = prepare_deploy_metadata(detect_git_metadata(base_dir), metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata ( @@ -2717,7 +2721,7 @@ def deploy_app( ) # Prepare metadata for upload - deploy_metadata = prepare_deploy_metadata(detect_git_metadata(directory), metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) ce.metadata = deploy_metadata ce.validate_server() @@ -2880,7 +2884,7 @@ def deploy_nodejs( connect_version_string = ce.client.server_settings().get("version", "") server_version = connect_version_string - deploy_metadata = prepare_deploy_metadata(detect_git_metadata(directory), metadata, no_metadata, server_version) + deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) ce.metadata = deploy_metadata ce.validate_server() diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 8a01310a..32d7f75c 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -825,7 +825,7 @@ def test_default_title_from_bundle_module_entrypoint(self): def test_default_title_from_bundle_no_entrypoint(self): # No entrypoint at all: fall back to the bundle filename. with tempfile.TemporaryDirectory() as tmp: - bundle_path = join(tmp, "report.tgz") + bundle_path = join(tmp, "report.tar.gz") self._write_bundle(bundle_path, {"metadata": {"appmode": "static"}}) self.assertEqual(default_title_from_bundle(bundle_path), "report") diff --git a/tests/test_git_metadata.py b/tests/test_git_metadata.py index 6f894d47..c15e19fb 100644 --- a/tests/test_git_metadata.py +++ b/tests/test_git_metadata.py @@ -197,19 +197,19 @@ def temp_git_repo(self): def test_prepare_metadata_no_metadata_flag(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata - result = prepare_deploy_metadata(detect_git_metadata(temp_git_repo), tuple(), True, "2025.12.0") + result = prepare_deploy_metadata(temp_git_repo, tuple(), True, "2025.12.0") assert result is None def test_prepare_metadata_old_server_no_cli_overrides(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata - result = prepare_deploy_metadata(detect_git_metadata(temp_git_repo), tuple(), False, "2024.01.0") + result = prepare_deploy_metadata(temp_git_repo, tuple(), False, "2024.01.0") assert result is None def test_prepare_metadata_new_server(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata - result = prepare_deploy_metadata(detect_git_metadata(temp_git_repo), tuple(), False, "2025.12.0") + result = prepare_deploy_metadata(temp_git_repo, tuple(), False, "2025.12.0") assert result is not None assert result["source"] == "git" assert "source_commit" in result @@ -221,7 +221,7 @@ def test_prepare_metadata_cli_overrides(self, temp_git_repo): # CLI overrides force metadata even on old servers result = prepare_deploy_metadata( - detect_git_metadata(temp_git_repo), ("source=custom", "custom_key=custom_value"), False, "2024.01.0" + temp_git_repo, ("source=custom", "custom_key=custom_value"), False, "2024.01.0" ) assert result is not None assert result["source"] == "custom" @@ -231,7 +231,7 @@ def test_prepare_metadata_cli_clears_value(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata # Empty value should clear the key - result = prepare_deploy_metadata(detect_git_metadata(temp_git_repo), ("source_repo=",), False, "2.0") + result = prepare_deploy_metadata(temp_git_repo, ("source_repo=",), False, "2.0") assert result is not None assert "source_repo" not in result # Cleared by empty value assert "source" in result # Still detected @@ -243,14 +243,14 @@ def test_prepare_metadata_no_detection(self): # When no metadata is detected and no CLI overrides are given, nothing is # sent even on a new server. This is what `deploy bundle` relies on to # avoid attaching unrelated git metadata. - result = prepare_deploy_metadata({}, tuple(), False, "2025.12.0") + result = prepare_deploy_metadata(None, tuple(), False, "2025.12.0") assert result is None def test_prepare_metadata_no_detection_with_cli_overrides(self): from rsconnect.main import prepare_deploy_metadata # CLI overrides are still sent even when nothing is auto-detected. - result = prepare_deploy_metadata({}, ("source=manual",), False, "2025.12.0") + result = prepare_deploy_metadata(None, ("source=manual",), False, "2025.12.0") assert result == {"source": "manual"} From d032ed162930675f1c19db364d612fb72b35874d Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Wed, 24 Jun 2026 08:16:10 -0400 Subject: [PATCH 6/8] docs: explain why open_bundle exists open_bundle is a no-op "builder" passed to RSConnectExecutor.make_bundle, which expects a callable returning a file-like bundle. Document that for deploy bundle the tarball already exists, so we just open() it and route through make_bundle to reuse the existing deployment-name and upload flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- rsconnect/bundle.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 207491ad..9c79e384 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -902,7 +902,15 @@ def default_title_from_bundle(bundle_path: str | Path) -> str: def open_bundle(bundle_path: str | Path) -> typing.IO[bytes]: - """Open an existing bundle tarball so it can be uploaded as-is.""" + """Open an existing bundle tarball so it can be uploaded as-is. + + This exists to plug into ``RSConnectExecutor.make_bundle``, which expects a + callable that returns the bundle as a file-like object (e.g. + ``make_manifest_bundle``, which builds a tarball). For ``deploy bundle`` we + already have a finished ``.tar.gz`` on disk, so the "builder" is just an + open() — no tarball is constructed. Routing through ``make_bundle`` keeps the + deployment-name setup and upload flow identical to the other deploy commands. + """ return open(bundle_path, "rb") From 04b14c8ab5b488cdda78dea179de936fe9e55db9 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Wed, 24 Jun 2026 08:18:35 -0400 Subject: [PATCH 7/8] refactor: return only parsed manifest from read_bundle_manifest The raw manifest string was modeled after read_manifest_file, where it is re-added to a freshly built tarball. deploy bundle uploads the bundle as-is and never rebuilds it, so every caller discarded the raw string. Return just the parsed ManifestData. Co-Authored-By: Claude Opus 4.8 (1M context) --- rsconnect/bundle.py | 16 +++++++--------- tests/test_bundle.py | 4 +--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 9c79e384..6ecc7d21 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -843,14 +843,13 @@ def read_manifest_file(manifest_path: str | Path) -> tuple[ManifestData, str]: return manifest, raw_manifest -def read_bundle_manifest(bundle_path: str | Path) -> tuple[ManifestData, str]: +def read_bundle_manifest(bundle_path: str | Path) -> ManifestData: """ - Read the manifest.json contained in a bundle tarball without extracting the - whole bundle. The content is provided as both a parsed dictionary and the - raw string. + Read and parse the manifest.json contained in a bundle tarball without + extracting the whole bundle. :param bundle_path: the path to a bundle .tar.gz file. - :return: the parsed manifest data and the raw manifest content as a string. + :return: the parsed manifest data. """ with tarfile.open(name=str(bundle_path), mode="r:gz") as tar: try: @@ -861,18 +860,17 @@ def read_bundle_manifest(bundle_path: str | Path) -> tuple[ManifestData, str]: raise RSConnectException('Bundle "%s" does not contain a manifest.json file.' % bundle_path) raw_manifest = extracted.read().decode("utf-8") - manifest = json.loads(raw_manifest) - return manifest, raw_manifest + return json.loads(raw_manifest) def read_bundle_app_mode(bundle_path: str | Path) -> AppMode: - source_manifest, _ = read_bundle_manifest(bundle_path) + source_manifest = read_bundle_manifest(bundle_path) # noinspection SpellCheckingInspection return AppModes.get_by_name(source_manifest["metadata"]["appmode"]) def default_title_from_bundle(bundle_path: str | Path) -> str: - source_manifest, _ = read_bundle_manifest(bundle_path) + source_manifest = read_bundle_manifest(bundle_path) # Prefer the manifest's entry point / primary file, mirroring how a manifest # deployment derives its title. diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 32d7f75c..8cec6131 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -790,11 +790,9 @@ def test_manifest_bundle(self): def test_read_bundle_manifest(self): bundle_path = join(dirname(__file__), "testdata", "bundle.tar.gz") - manifest, raw_manifest = read_bundle_manifest(bundle_path) + manifest = read_bundle_manifest(bundle_path) self.assertEqual(manifest["metadata"]["appmode"], "python-api") - # raw and parsed manifest agree - self.assertEqual(json.loads(raw_manifest), manifest) def test_read_bundle_app_mode(self): bundle_path = join(dirname(__file__), "testdata", "bundle.tar.gz") From 843eaacefa57956ee6fac52e500c8aef76dbcfb2 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Wed, 24 Jun 2026 14:03:24 -0400 Subject: [PATCH 8/8] fix: locate manifest in nested bundle tarballs Connect collapses a bundle whose extraction root holds a single subdirectory, moving its contents up a level and repeating. Downloaded bundles therefore commonly nest manifest.json under a top-level directory (e.g. "bundle/manifest.json"). read_bundle_manifest only looked at the tar root, so deploying such a bundle failed with "does not contain a manifest.json file" even though Connect would have accepted it. Mirror Connect's single-subdirectory collapse when locating the manifest, so app mode and default title are read correctly. The upload path is unchanged: the bundle is still sent as-is and Connect performs its own collapse. Reported by @marcosnav in PR review. Co-Authored-By: Claude Opus 4.8 (1M context) --- rsconnect/bundle.py | 40 ++++++++++++++++++++++--- tests/test_bundle.py | 69 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 6ecc7d21..00ef2bde 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -843,6 +843,40 @@ def read_manifest_file(manifest_path: str | Path) -> tuple[ManifestData, str]: return manifest, raw_manifest +def _find_manifest_member(tar: tarfile.TarFile) -> Optional[tarfile.TarInfo]: + """ + Locate the manifest.json member within a bundle tarball, mirroring the way + Connect collapses single-subdirectory bundles when it extracts them. + + Connect repeatedly descends while the extraction root contains exactly one + entry and that entry is a directory, moving its contents up a level. So a + downloaded bundle may legitimately store manifest.json under a nested + directory (e.g. "bundle/manifest.json") rather than at the top level. + + :return: the manifest.json member, or None if no manifest could be located. + """ + file_names = {(m.name[2:] if m.name.startswith("./") else m.name): m for m in tar.getmembers() if m.isfile()} + + prefix = "" + while True: + member = file_names.get(prefix + "manifest.json") + if member is not None: + return member + + # Look at the entries directly under the current prefix. + remaining = [name[len(prefix) :] for name in file_names if name.startswith(prefix)] + entries = {name.split("/", 1)[0] for name in remaining} + + # Only descend when this level holds exactly one entry and it is a + # directory (i.e. no file is named exactly that entry). + if len(entries) != 1: + return None + entry = next(iter(entries)) + if entry in remaining: + return None + prefix = prefix + entry + "/" + + def read_bundle_manifest(bundle_path: str | Path) -> ManifestData: """ Read and parse the manifest.json contained in a bundle tarball without @@ -852,10 +886,8 @@ def read_bundle_manifest(bundle_path: str | Path) -> ManifestData: :return: the parsed manifest data. """ with tarfile.open(name=str(bundle_path), mode="r:gz") as tar: - try: - extracted = tar.extractfile("manifest.json") - except KeyError: - extracted = None + member = _find_manifest_member(tar) + extracted = tar.extractfile(member) if member is not None else None if extracted is None: raise RSConnectException('Bundle "%s" does not contain a manifest.json file.' % bundle_path) raw_manifest = extracted.read().decode("utf-8") diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 8cec6131..d6aa5d4b 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -804,12 +804,17 @@ def test_default_title_from_bundle(self): self.assertEqual(default_title_from_bundle(bundle_path), "app") @staticmethod - def _write_bundle(bundle_path, manifest): + def _write_bundle_files(bundle_path, files): + # files: mapping of archive member name -> bytes content with tarfile.open(bundle_path, mode="w:gz") as tar: - raw = json.dumps(manifest).encode("utf-8") - info = tarfile.TarInfo("manifest.json") - info.size = len(raw) - tar.addfile(info, fileobj=io.BytesIO(raw)) + for name, data in files.items(): + info = tarfile.TarInfo(name) + info.size = len(data) + tar.addfile(info, fileobj=io.BytesIO(data)) + + @classmethod + def _write_bundle(cls, bundle_path, manifest, manifest_name="manifest.json"): + cls._write_bundle_files(bundle_path, {manifest_name: json.dumps(manifest).encode("utf-8")}) def test_default_title_from_bundle_module_entrypoint(self): # A module-style entrypoint (e.g. "app:app") has no usable filename, so @@ -854,6 +859,60 @@ def test_read_bundle_manifest_missing_manifest(self): read_bundle_manifest(bundle_path) self.assertIn("manifest.json", str(ctx.exception)) + def test_read_bundle_manifest_nested_single_dir(self): + # Connect collapses a bundle whose root is a single subdirectory, so a + # downloaded bundle may store everything under e.g. "bundle/". The + # manifest must still be located. + with tempfile.TemporaryDirectory() as tmp: + bundle_path = join(tmp, "nested.tar.gz") + self._write_bundle_files( + bundle_path, + { + "bundle/manifest.json": json.dumps({"metadata": {"appmode": "python-api"}}).encode("utf-8"), + "bundle/app.py": b"# app", + }, + ) + self.assertEqual(read_bundle_app_mode(bundle_path), AppModes.PYTHON_API) + + def test_read_bundle_manifest_nested_multiple_levels(self): + # Connect collapses repeatedly, so several layers of single-directory + # nesting should still resolve to the manifest. + with tempfile.TemporaryDirectory() as tmp: + bundle_path = join(tmp, "deep.tar.gz") + self._write_bundle_files( + bundle_path, + { + "outer/inner/manifest.json": json.dumps({"metadata": {"appmode": "static"}}).encode("utf-8"), + "outer/inner/index.html": b"", + }, + ) + self.assertEqual(read_bundle_app_mode(bundle_path), AppModes.STATIC) + + def test_read_bundle_manifest_with_leading_dot_slash(self): + # tar members are sometimes prefixed with "./"; the manifest should still + # be found. + with tempfile.TemporaryDirectory() as tmp: + bundle_path = join(tmp, "dotslash.tar.gz") + self._write_bundle(bundle_path, {"metadata": {"appmode": "static"}}, manifest_name="./manifest.json") + self.assertEqual(read_bundle_app_mode(bundle_path), AppModes.STATIC) + + def test_read_bundle_manifest_multiple_top_level_entries(self): + # When the root holds more than one entry, Connect does not collapse, so a + # nested manifest with a sibling at the top level is not deployable and we + # report the missing manifest rather than silently descending. + with tempfile.TemporaryDirectory() as tmp: + bundle_path = join(tmp, "ambiguous.tar.gz") + self._write_bundle_files( + bundle_path, + { + "bundle/manifest.json": json.dumps({"metadata": {"appmode": "static"}}).encode("utf-8"), + "README.md": b"# readme", + }, + ) + with self.assertRaises(RSConnectException) as ctx: + read_bundle_manifest(bundle_path) + self.assertIn("manifest.json", str(ctx.exception)) + def test_make_source_manifest(self): # Verify the optional parameters # image=None, # type: str