diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6e52bc83..c98046fc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -40,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/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..00ef2bde 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -843,6 +843,107 @@ 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 + extracting the whole bundle. + + :param bundle_path: the path to a bundle .tar.gz file. + :return: the parsed manifest data. + """ + with tarfile.open(name=str(bundle_path), mode="r:gz") as tar: + 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") + + return json.loads(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) + + # 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. + # 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: + 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 open_bundle(bundle_path: str | Path) -> typing.IO[bytes]: + """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") + + def make_manifest_bundle(manifest_path: str | Path) -> typing.IO[bytes]: """Create a bundle, given a manifest. @@ -1635,6 +1736,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 @@ -1647,7 +1756,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/rsconnect/main.py b/rsconnect/main.py index e4679d40..830aa406 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, @@ -304,7 +307,7 @@ def validate_env_vars(ctx: click.Context, param: click.Parameter, all_values: tu def prepare_deploy_metadata( - directory: str, + directory: Optional[str], metadata_overrides: tuple[str, ...], no_metadata: bool, server_version: Optional[str] = None, @@ -312,7 +315,10 @@ def prepare_deploy_metadata( """ Prepare metadata for bundle upload. - :param directory: Directory to detect git metadata from + :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 @@ -334,8 +340,8 @@ def prepare_deploy_metadata( else: # Empty value clears the key cli_metadata[key] = "" - # Auto-detect git metadata - detected_metadata = detect_git_metadata(directory) + # 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} @@ -1794,6 +1800,96 @@ 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. 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", "") + ce.metadata = prepare_deploy_metadata(None, metadata, no_metadata, server_version) + + ( + 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..d6aa5d4b 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,131 @@ 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 = read_bundle_manifest(bundle_path) + + self.assertEqual(manifest["metadata"]["appmode"], "python-api") + + 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") + + @staticmethod + 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: + 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 + # 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.tar.gz") + 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: + 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_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 diff --git a/tests/test_git_metadata.py b/tests/test_git_metadata.py index 811ea7a5..c15e19fb 100644 --- a/tests/test_git_metadata.py +++ b/tests/test_git_metadata.py @@ -237,6 +237,22 @@ def test_prepare_metadata_cli_clears_value(self, temp_git_repo): 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(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(None, ("source=manual",), False, "2025.12.0") + assert result == {"source": "manual"} + class TestIntegration: """Integration tests for the full workflow.""" 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