diff --git a/CHANGELOG.md b/CHANGELOG.md index 607c6d9..9e6c7d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [0.3.1] - 2026-05-21 + +### Added +- `pos3` console-script entry point with `ls`, `download`, `upload` subcommands + ([#10](https://github.com/Positronic-Robotics/pos3/issues/10)). + - `pos3 ls [-r] [--profile NAME]` lists objects, one full `s3://` URL + per line on stdout. + - `pos3 download [--local PATH] [--delete] [--exclude PATTERN]... [--profile NAME]` + prints only the resulting local path to stdout; progress and logs go to + stderr, so `data_dir=$(pos3 download s3://bucket/dataset/)` is safe. + - `pos3 upload [--local PATH] [--delete] [--exclude PATTERN]... [--profile NAME]` + is one-shot (no background loop or interval). Source defaults to the cache + path `pos3 download` would have produced; errors if the source doesn't + exist. + - `--delete` defaults OFF for both `download` and `upload` (the Python API + defaults to `True`; the CLI is more conservative for interactive use). + - `--profile` is supported alongside the URL form `s3://@bucket/...`; + the URL form wins on conflict, matching the Python precedence. + - `-n` / `--dry-run` on `download` and `upload` prints the planned per-file + actions to stdout (in `aws s3 sync --dryrun` style) and performs no + transfers, deletes, or directory creation. + ## [0.3.0] - 2026-05-19 ### Added diff --git a/README.md b/README.md index d207e67..07a341b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,47 @@ Lists files/objects in a directory or S3 prefix. **Returns**: List of full S3 URLs or local paths. +## CLI + +`pos3` ships a small command-line interface for the most common one-shot +operations. After `uv pip install pos3` (or `pip install pos3`), `pos3` is on +your `$PATH`: + +```bash +# List objects (one full s3:// URL per line on stdout) +pos3 ls s3://bucket/dataset/ +pos3 ls -r s3://bucket/dataset/ + +# Download an S3 prefix or object into the cache (or a custom --local path). +# Only the resulting local path is written to stdout — progress and logs go +# to stderr — so it's safe to capture in a shell variable: +data_dir=$(pos3 download s3://bucket/dataset/) + +# One-shot upload. Source defaults to the same cache path `pos3 download` +# would have produced; --local overrides. Errors if the source doesn't exist. +pos3 upload s3://bucket/results/ --local ./out/ + +# Preview what download/upload would do, without touching anything. +pos3 download -n s3://bucket/dataset/ --local ./data/ --delete +pos3 upload -n s3://bucket/results/ --local ./out/ +``` + +All three subcommands accept `--profile NAME`. The URL form +`s3://@bucket/...` takes precedence over `--profile` on conflict +(matching the Python API). + +`--delete` defaults to **OFF** for both `download` and `upload`, even though +the Python API defaults to `True`. CLI defaults are conservative for +interactive shell use; pass `--delete` explicitly to mirror file removals. + +`-n` / `--dry-run` is accepted on `download` and `upload` (not `ls`). It +prints per-file plan lines to stdout in `aws s3 sync --dryrun` style and +performs no transfers, deletes, or directory creation. + +The CLI is one-shot only — no background sync, no `pos3 sync` subcommand. +Use the Python `pos3.mirror()` context manager when you need an interval-based +loop or bi-directional sync over a job's lifetime. + ## Comparison with Libraries Why use `pos3` instead of other Python libraries? diff --git a/pos3/cli.py b/pos3/cli.py new file mode 100644 index 0000000..3b63d40 --- /dev/null +++ b/pos3/cli.py @@ -0,0 +1,229 @@ +"""Command-line interface for pos3. + +Three subcommands — ``ls``, ``download``, ``upload`` — each running inside a +short-lived ``pos3.mirror()`` context. ``upload`` is one-shot: no background +interval, no sync loop. + +CLI defaults intentionally differ from the Python API: ``--delete`` defaults +to OFF for both ``download`` and ``upload``, because the API's ``True`` +default is too destructive for interactive shell use. ``download`` writes +only the resulting local path to stdout so the output is safe to capture in +``$(pos3 download ...)``; progress bars and logs go to stderr. + +``--dry-run`` / ``-n`` is accepted only on ``download`` and ``upload``: it +prints the planned per-file actions to stdout in ``aws s3 sync --dryrun`` +style and performs no transfers, deletes, or directory creation. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from . import ( + _compute_sync_diff, + _filter_fileinfo, + _is_s3_path, + _make_s3_key, + _parse_s3_url, + _require_active_mirror, + _scan_local, + download, + ls, + mirror, + upload, +) +from .profiles import _resolve_profile, _url_profile + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="pos3", description="pos3 command-line interface.") + subparsers = parser.add_subparsers(dest="command", required=True) + + def add_profile(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--profile", + metavar="NAME", + help="Named pos3 profile. Overridden by the URL form s3://@bucket/key.", + ) + + def add_transfer_args(p: argparse.ArgumentParser, local_help: str) -> None: + p.add_argument("--local", metavar="PATH", help=local_help) + p.add_argument( + "--delete", + action="store_true", + help="Delete files not present at the other end. Defaults to OFF in the CLI.", + ) + p.add_argument( + "--exclude", + metavar="PATTERN", + action="append", + default=None, + help="Glob pattern to skip. May be passed multiple times.", + ) + p.add_argument( + "-n", + "--dry-run", + action="store_true", + help="Print the planned actions and exit without transferring or deleting anything.", + ) + add_profile(p) + + p_ls = subparsers.add_parser("ls", help="List objects under a prefix.") + p_ls.add_argument("prefix", help="S3 prefix (s3://bucket/key) or local path.") + p_ls.add_argument("-r", "--recursive", action="store_true", help="List subdirectories recursively.") + add_profile(p_ls) + + p_dl = subparsers.add_parser("download", help="Download an S3 prefix or object to a local path.") + p_dl.add_argument("url", help="Source S3 URL (s3://bucket/key).") + add_transfer_args(p_dl, local_help="Destination path. Defaults to the cache path.") + + p_up = subparsers.add_parser("upload", help="One-shot upload of a local path to S3.") + p_up.add_argument("url", help="Destination S3 URL (s3://bucket/key).") + add_transfer_args(p_up, local_help="Source path. Defaults to the cache path.") + + return parser + + +def _cmd_ls(args: argparse.Namespace) -> int: + with mirror(show_progress=False): + for item in ls(args.prefix, recursive=args.recursive, profile=args.profile): + print(item) + return 0 + + +def _cmd_download(args: argparse.Namespace) -> int: + if args.dry_run: + with mirror(show_progress=False): + _print_download_plan(args) + return 0 + with mirror(show_progress=True): + local_path = download( + args.url, + local=args.local, + delete=args.delete, + exclude=args.exclude, + profile=args.profile, + ) + print(str(local_path)) + return 0 + + +def _resolve_upload_source(args: argparse.Namespace) -> Path | None: + """Resolve the local source path, mirroring the precedence used by upload(). + + Prints an error and returns None if the source path does not exist. + """ + mirror_obj = _require_active_mirror() + if args.local: + source = Path(args.local).expanduser().resolve() + else: + # Resolve the same profile precedence (URL > --profile > context default) + # the upload() call will use, so the cache path we check matches the one + # upload() would target. + profile: str | None = args.profile + if _is_s3_path(args.url): + url_profile = _url_profile(args.url) + if url_profile is not None: + profile = url_profile + effective_profile = _resolve_profile(profile) or mirror_obj.options.default_profile + source = mirror_obj.options.cache_path_for(args.url, effective_profile) + if not source.exists(): + print(f"pos3 upload: source path does not exist: {source}", file=sys.stderr) + return None + return source + + +def _cmd_upload(args: argparse.Namespace) -> int: + with mirror(show_progress=not args.dry_run): + source = _resolve_upload_source(args) + if source is None: + return 1 + if args.dry_run: + _print_upload_plan(args, source) + return 0 + upload( + args.url, + local=source, + interval=None, + delete=args.delete, + exclude=args.exclude, + profile=args.profile, + ) + return 0 + + +def _print_download_plan(args: argparse.Namespace) -> None: + if not _is_s3_path(args.url): + return # download() on a local path is a no-op pass-through. + mirror_obj = _require_active_mirror() + profile = mirror_obj._effective_profile(args.profile, args.url) + local_path = ( + mirror_obj.options.cache_path_for(args.url, profile) + if args.local is None + else Path(args.local).expanduser().resolve() + ) + bucket, prefix = _parse_s3_url(args.url) + to_copy, to_delete = _compute_sync_diff( + _filter_fileinfo(mirror_obj._scan_s3(bucket, prefix, profile), args.exclude), + _filter_fileinfo(_scan_local(local_path), args.exclude), + ) + # Skip synthesized directory entries: only file-level actions matter for the user. + for info in to_copy: + if info.is_dir: + continue + s3_key = _make_s3_key(prefix, info) + dst = local_path / info.relative_path if info.relative_path else local_path + print(f"download: s3://{bucket}/{s3_key} to {dst}") + if args.delete: + for info in to_delete: + if info.is_dir: + continue + target = local_path / info.relative_path if info.relative_path else local_path + print(f"delete: {target}") + + +def _print_upload_plan(args: argparse.Namespace, source: Path) -> None: + if not _is_s3_path(args.url): + return # upload() on a local path is a no-op pass-through (it would mkdir). + mirror_obj = _require_active_mirror() + profile = mirror_obj._effective_profile(args.profile, args.url) + bucket, prefix = _parse_s3_url(args.url) + to_copy, to_delete = _compute_sync_diff( + _filter_fileinfo(_scan_local(source), args.exclude), + _filter_fileinfo(mirror_obj._scan_s3(bucket, prefix, profile), args.exclude), + ) + for info in to_copy: + if info.is_dir: + continue + s3_key = _make_s3_key(prefix, info) + local = source / info.relative_path if info.relative_path else source + print(f"upload: {local} to s3://{bucket}/{s3_key}") + if args.delete: + for info in to_delete: + if info.is_dir: + continue + s3_key = _make_s3_key(prefix, info) + print(f"delete: s3://{bucket}/{s3_key}") + + +_COMMANDS = { + "ls": _cmd_ls, + "download": _cmd_download, + "upload": _cmd_upload, +} + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + try: + return _COMMANDS[args.command](args) + except ValueError as exc: + print(f"pos3 {args.command}: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 9421ebf..4d6627e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pos3" -version = "0.3.0" +version = "0.3.1" description = "S3 Simple Sync - Make using S3 as simple as using local files" readme = "README.md" requires-python = ">=3.11" @@ -27,6 +27,9 @@ dependencies = [ Homepage = "https://github.com/Positronic-Robotics/pos3" Repository = "https://github.com/Positronic-Robotics/pos3" +[project.scripts] +pos3 = "pos3.cli:main" + [project.optional-dependencies] dev = [ "pytest>=7.0", diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f1fe453 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,370 @@ +import tempfile +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from botocore.exceptions import ClientError + +from pos3.cli import main + +BOTO3_PATCH_TARGET = "pos3.profiles.boto3.client" + + +def _make_404_error(*_args, **_kwargs): + raise ClientError({"Error": {"Code": "404"}}, "head_object") + + +def _setup_s3_mock(mock_boto_client, paginate_return_value=None): + mock_s3 = Mock() + mock_boto_client.return_value = mock_s3 + mock_s3.head_object.side_effect = _make_404_error + mock_paginator = Mock() + mock_s3.get_paginator.return_value = mock_paginator + mock_paginator.paginate.return_value = paginate_return_value or [{"Contents": []}] + return mock_s3 + + +class TestCliLs: + @patch(BOTO3_PATCH_TARGET) + def test_ls_prints_full_s3_urls(self, mock_boto_client, capsys): + paginate = [ + { + "Contents": [ + {"Key": "data/file.txt", "Size": 5}, + {"Key": "data/sub/nested.txt", "Size": 10}, + ] + } + ] + _setup_s3_mock(mock_boto_client, paginate) + + rc = main(["ls", "s3://bucket/data"]) + + captured = capsys.readouterr() + assert rc == 0 + lines = captured.out.strip().splitlines() + assert "s3://bucket/data/file.txt" in lines + # Non-recursive excludes nested items + assert "s3://bucket/data/sub/nested.txt" not in lines + + @patch(BOTO3_PATCH_TARGET) + def test_ls_recursive(self, mock_boto_client, capsys): + paginate = [{"Contents": [{"Key": "data/sub/nested.txt", "Size": 10}]}] + _setup_s3_mock(mock_boto_client, paginate) + + rc = main(["ls", "-r", "s3://bucket/data"]) + + captured = capsys.readouterr() + assert rc == 0 + assert "s3://bucket/data/sub/nested.txt" in captured.out + + def test_ls_local_path(self, capsys): + with tempfile.TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + (base / "file.txt").write_text("x") + + rc = main(["ls", str(base)]) + + captured = capsys.readouterr() + assert rc == 0 + assert str(base / "file.txt") in captured.out + + +class TestCliDownload: + @patch(BOTO3_PATCH_TARGET) + def test_download_prints_only_local_path_to_stdout(self, mock_boto_client, capsys): + paginate = [{"Contents": [{"Key": "data/file.txt", "Size": 5}]}] + _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + local_dir = Path(tmpdir) / "dst" + rc = main(["download", "s3://bucket/data", "--local", str(local_dir)]) + + captured = capsys.readouterr() + assert rc == 0 + # Exactly one line on stdout: the resulting local path. + lines = captured.out.strip().splitlines() + assert len(lines) == 1 + assert lines[0] == str(local_dir.resolve()) + + @patch(BOTO3_PATCH_TARGET) + def test_download_default_does_not_delete(self, mock_boto_client): + """CLI default for --delete is OFF; orphan local files survive.""" + paginate = [{"Contents": [{"Key": "data/file1.txt", "Size": 5}]}] + _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + local_dir = Path(tmpdir) / "data" + local_dir.mkdir() + (local_dir / "file1.txt").write_bytes(b"12345") + orphan = local_dir / "orphan.txt" + orphan.write_text("orphan") + + rc = main(["download", "s3://bucket/data", "--local", str(local_dir)]) + + assert rc == 0 + assert orphan.exists() + + @patch(BOTO3_PATCH_TARGET) + def test_download_delete_flag_removes_orphans(self, mock_boto_client): + paginate = [{"Contents": [{"Key": "data/file1.txt", "Size": 5}]}] + _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + local_dir = Path(tmpdir) / "data" + local_dir.mkdir() + (local_dir / "file1.txt").write_bytes(b"12345") + orphan = local_dir / "orphan.txt" + orphan.write_text("orphan") + + rc = main(["download", "s3://bucket/data", "--local", str(local_dir), "--delete"]) + + assert rc == 0 + assert not orphan.exists() + + @patch(BOTO3_PATCH_TARGET) + def test_download_exclude_multiple_patterns(self, mock_boto_client): + paginate = [ + { + "Contents": [ + {"Key": "data/file.txt", "Size": 5}, + {"Key": "data/file.log", "Size": 10}, + {"Key": "data/file.tmp", "Size": 10}, + ] + } + ] + mock_s3 = _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + local_dir = Path(tmpdir) / "dst" + rc = main( + [ + "download", + "s3://bucket/data", + "--local", + str(local_dir), + "--exclude", + "*.log", + "--exclude", + "*.tmp", + ] + ) + + assert rc == 0 + assert mock_s3.download_file.call_count == 1 + downloaded_key = mock_s3.download_file.call_args_list[0][0][1] + assert "file.txt" in downloaded_key + + @patch(BOTO3_PATCH_TARGET) + def test_download_unknown_profile_returns_error(self, mock_boto_client, capsys): + _setup_s3_mock(mock_boto_client) + + rc = main(["download", "s3://bucket/data", "--profile", "no-such-profile"]) + + captured = capsys.readouterr() + assert rc == 1 + assert "Unknown profile" in captured.err + + +class TestCliUpload: + @patch(BOTO3_PATCH_TARGET) + def test_upload_errors_when_source_missing(self, mock_boto_client, capsys): + _setup_s3_mock(mock_boto_client) + + with tempfile.TemporaryDirectory() as tmpdir: + missing = Path(tmpdir) / "does-not-exist" + rc = main(["upload", "s3://bucket/data", "--local", str(missing)]) + + captured = capsys.readouterr() + assert rc == 1 + assert "does not exist" in captured.err + # Nothing should have been written to S3. + mock_boto_client.return_value.upload_file.assert_not_called() + + @patch(BOTO3_PATCH_TARGET) + def test_upload_uploads_existing_local_source(self, mock_boto_client): + mock_s3 = _setup_s3_mock(mock_boto_client) + + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "src" + src.mkdir() + (src / "file.txt").write_text("content") + + rc = main(["upload", "s3://bucket/data", "--local", str(src)]) + + assert rc == 0 + assert mock_s3.upload_file.call_count >= 1 + + @patch(BOTO3_PATCH_TARGET) + def test_upload_default_source_is_cache_path(self, mock_boto_client): + """When --local is omitted, source defaults to the same cache path pos3 download + would have produced.""" + mock_s3 = _setup_s3_mock(mock_boto_client) + + with tempfile.TemporaryDirectory() as tmpdir: + cache_root = Path(tmpdir) + cache_path = cache_root / "_" / "bucket" / "data" + cache_path.mkdir(parents=True) + (cache_path / "file.txt").write_text("hi") + + # Override the CLI's mirror() to anchor cache_root at our tmpdir, so + # the no-`--local` path resolves under it. + import pos3 + + real_mirror = pos3.mirror + + def fixed_mirror(**_kwargs): + return real_mirror(cache_root=str(cache_root), show_progress=False) + + with patch("pos3.cli.mirror", side_effect=fixed_mirror): + rc = main(["upload", "s3://bucket/data"]) + + assert rc == 0 + assert mock_s3.upload_file.call_count >= 1 + + @patch(BOTO3_PATCH_TARGET) + def test_upload_default_does_not_delete(self, mock_boto_client): + """CLI default for --delete is OFF; remote orphans survive.""" + # S3 has remote_only.txt; local has file.txt. Without --delete, remote should stay. + paginate = [{"Contents": [{"Key": "data/remote_only.txt", "Size": 5}]}] + mock_s3 = _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "src" + src.mkdir() + (src / "file.txt").write_text("content") + + rc = main(["upload", "s3://bucket/data", "--local", str(src)]) + + assert rc == 0 + # No deletes should have been issued. + assert mock_s3.delete_object.call_count == 0 + + @patch(BOTO3_PATCH_TARGET) + def test_upload_delete_flag_removes_remote_orphans(self, mock_boto_client): + paginate = [{"Contents": [{"Key": "data/remote_only.txt", "Size": 5}]}] + mock_s3 = _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "src" + src.mkdir() + (src / "file.txt").write_text("content") + + rc = main(["upload", "s3://bucket/data", "--local", str(src), "--delete"]) + + assert rc == 0 + assert mock_s3.delete_object.call_count >= 1 + + +class TestCliDryRun: + @patch(BOTO3_PATCH_TARGET) + def test_download_dry_run_prints_plan_and_does_not_transfer(self, mock_boto_client, capsys): + paginate = [ + { + "Contents": [ + {"Key": "data/file.txt", "Size": 5}, + {"Key": "data/sub/nested.txt", "Size": 7}, + ] + } + ] + mock_s3 = _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + local_dir = Path(tmpdir) / "dst" + rc = main(["download", "-n", "s3://bucket/data", "--local", str(local_dir)]) + + captured = capsys.readouterr() + assert rc == 0 + # No actual download was performed. + mock_s3.download_file.assert_not_called() + out_lines = captured.out.strip().splitlines() + # Two files planned, no extra trailing local-path line. + copy_lines = [line for line in out_lines if line.startswith("download:")] + assert len(copy_lines) == 2 + assert any("s3://bucket/data/file.txt" in line for line in copy_lines) + assert any("s3://bucket/data/sub/nested.txt" in line for line in copy_lines) + assert all(" to " in line for line in copy_lines) + # No delete lines without --delete. + assert not any(line.startswith("delete:") for line in out_lines) + + @patch(BOTO3_PATCH_TARGET) + def test_download_dry_run_with_delete_emits_delete_lines(self, mock_boto_client, capsys): + paginate = [{"Contents": [{"Key": "data/keep.txt", "Size": 5}]}] + mock_s3 = _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + local_dir = Path(tmpdir) / "data" + local_dir.mkdir() + (local_dir / "keep.txt").write_bytes(b"12345") + orphan = local_dir / "orphan.txt" + orphan.write_text("orphan") + + rc = main( + ["download", "-n", "s3://bucket/data", "--local", str(local_dir), "--delete"] + ) + + assert rc == 0 + # Dry-run must not touch the filesystem. + assert orphan.exists() + + captured = capsys.readouterr() + delete_lines = [ + line for line in captured.out.splitlines() if line.startswith("delete:") + ] + assert any(str(orphan) in line for line in delete_lines) + mock_s3.download_file.assert_not_called() + + @patch(BOTO3_PATCH_TARGET) + def test_upload_dry_run_prints_plan_and_does_not_transfer(self, mock_boto_client, capsys): + mock_s3 = _setup_s3_mock(mock_boto_client) + + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "src" + src.mkdir() + (src / "file.txt").write_text("content") + + rc = main(["upload", "-n", "s3://bucket/data", "--local", str(src)]) + + captured = capsys.readouterr() + assert rc == 0 + mock_s3.upload_file.assert_not_called() + upload_lines = [ + line for line in captured.out.splitlines() if line.startswith("upload:") + ] + assert len(upload_lines) == 1 + assert "s3://bucket/data/file.txt" in upload_lines[0] + assert str(src / "file.txt") in upload_lines[0] + + @patch(BOTO3_PATCH_TARGET) + def test_upload_dry_run_with_delete_emits_remote_delete_lines(self, mock_boto_client, capsys): + paginate = [{"Contents": [{"Key": "data/remote_only.txt", "Size": 5}]}] + mock_s3 = _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "src" + src.mkdir() + (src / "file.txt").write_text("content") + + rc = main( + ["upload", "-n", "s3://bucket/data", "--local", str(src), "--delete"] + ) + + captured = capsys.readouterr() + assert rc == 0 + mock_s3.upload_file.assert_not_called() + mock_s3.delete_object.assert_not_called() + delete_lines = [ + line for line in captured.out.splitlines() if line.startswith("delete:") + ] + assert any("s3://bucket/data/remote_only.txt" in line for line in delete_lines) + + def test_dry_run_not_accepted_on_ls(self): + with pytest.raises(SystemExit) as exc: + main(["ls", "-n", "s3://bucket/data"]) + assert exc.value.code != 0 + + +class TestCliEntry: + def test_no_subcommand_exits_with_error(self, capsys): + with pytest.raises(SystemExit) as exc: + main([]) + assert exc.value.code != 0