diff --git a/specs/cli/formatting.md b/specs/cli/formatting.md new file mode 100644 index 0000000..9d0f9ee --- /dev/null +++ b/specs/cli/formatting.md @@ -0,0 +1,218 @@ +# CLI: Output Formatting Enhancements + +## Overview + +Create a centralized `cli/formatting.py` module with reusable output helpers that replace the duplicated patterns currently scattered across CLI modules. This improves consistency, reduces code duplication, and prepares for richer output (progress indicators, table formatting). + +## Problem Statement + +The current CLI modules exhibit several repeated patterns: + +1. **Duplicated `_write()` helper** — Identical implementations in `manifest.py`, `tag.py`, `catalog.py`, and `referrer.py`. +2. **Duplicated `_emit_error()` helper** — Nearly identical `click.echo(f"Error [{ref}]: {reason}", err=True)` + `sys.exit(code)` across all modules, with minor format variations in `auth.py` and `ping.py`. +3. **Inconsistent JSON formatting** — Most modules use `json.dumps(obj, indent=2)`, but the pattern is manually applied everywhere. +4. **No progress indicators** — Long-running operations (blob upload, layout push) provide no feedback. +5. **No structured table output** — List commands (`tag list`, `catalog list`, `referrer list`) use raw `"\n".join()` or hand-crafted space-separated columns. + +## API Surface + +### Module: `src/regshape/cli/formatting.py` + +All output helpers live in a single module. No classes — just functions. + +--- + +#### `emit_json(data: dict | list, output_path: str | None = None) -> None` + +Format and emit a JSON object. Always uses 2-space indentation. + +**Parameters:** +- `data` — Serializable dict or list. +- `output_path` — If provided, write to file instead of stdout. + +**Behavior:** +- Calls `json.dumps(data, indent=2)`. +- If `output_path` is set, writes to file (appending `\n` if missing), otherwise `click.echo()` to stdout. + +--- + +#### `emit_text(content: str, output_path: str | None = None) -> None` + +Emit plain text content to stdout or a file. + +**Parameters:** +- `content` — Text string to output. +- `output_path` — If provided, write to file instead of stdout. + +**Behavior:** +- If `output_path` is set, writes to file (appending `\n` if missing), otherwise `click.echo()` to stdout. + +This replaces the four duplicated `_write()` functions. + +--- + +#### `emit_error(reference: str, reason: str, exit_code: int = 1) -> None` + +Print a standardized error message to stderr and exit. + +**Parameters:** +- `reference` — Context identifier (image ref, registry, repo). Displayed in brackets. +- `reason` — Human-readable error description. +- `exit_code` — Process exit code (default: 1). + +**Output format:** +``` +Error [registry/repo:tag]: manifest not found +``` + +**Behavior:** +- `click.echo(f"Error [{reference}]: {reason}", err=True)` +- `sys.exit(exit_code)` + +--- + +#### `emit_table(rows: list[list[str]], headers: list[str] | None = None) -> None` + +Print tabular data with aligned columns. + +**Parameters:** +- `rows` — List of row data, each row a list of string values. +- `headers` — Optional column headers. When provided, printed first as a header row. + +**Behavior:** +- Calculates column widths from the maximum length in each column (including headers). +- Left-aligns all columns with 2-space padding between them. +- Writes to stdout via `click.echo()`. + +**Example output (with headers):** +``` +DIGEST ARTIFACT TYPE SIZE +sha256:abc123... application/vnd.example+json 1024 +sha256:def456... application/vnd.other+json 2048 +``` + +**Example output (without headers):** +``` +sha256:abc123... application/vnd.example+json 1024 +sha256:def456... application/vnd.other+json 2048 +``` + +--- + +#### `emit_list(items: list[str], output_path: str | None = None) -> None` + +Print a simple one-item-per-line list. + +**Parameters:** +- `items` — List of string items. +- `output_path` — If provided, write to file instead of stdout. + +**Behavior:** +- Joins items with `"\n"` and emits via `emit_text()`. +- Replaces inline `"\n".join(...)` patterns in `tag list` and `catalog list`. + +--- + +#### `format_key_value(pairs: list[tuple[str, str]], separator: str = ":") -> str` + +Format aligned key-value pairs for display (used by `manifest info`). + +**Parameters:** +- `pairs` — List of `(key, value)` tuples. +- `separator` — Character between key and value (default: `":"`). + +**Returns:** Formatted multi-line string with left-aligned keys and a vertically aligned separator. + +**Example:** +``` +Digest : sha256:abc123... +Media Type : application/vnd.oci.image.manifest.v1+json +Size : 1234 +``` + +--- + +#### `progress_status(message: str) -> None` + +Print a transient status message to stderr for long-running operations. + +**Parameters:** +- `message` — Status message to display. + +**Behavior:** +- Writes to stderr (`err=True`) so it doesn't interfere with piped stdout. +- Uses `click.echo(message, err=True)`. + +**Usage in CLI commands:** +```python +from regshape.cli.formatting import progress_status + +progress_status("Uploading blob...") +result = upload_blob(client, repo, file_path) +progress_status("Upload complete.") +``` + +--- + +## Migration Plan + +### Phase 1: Create `formatting.py` with all helpers + +Create the module with the functions defined above. Add unit tests. + +### Phase 2: Migrate existing CLI modules + +Replace duplicated code in each CLI module one at a time: + +| Module | Change | +|--------|--------| +| `manifest.py` | Replace `_write()` with `emit_text()` / `emit_json()`; replace `_emit_error()` with `emit_error()` | +| `tag.py` | Replace `_write()` with `emit_text()` / `emit_list()`; replace `_emit_error()` with `emit_error()` | +| `catalog.py` | Replace `_write()` with `emit_text()` / `emit_list()`; replace `_emit_error()` with `emit_error()` | +| `referrer.py` | Replace `_write()` with `emit_text()`; replace `_emit_error()` with `emit_error()`; use `emit_table()` for list output | +| `blob.py` | Replace `_emit_error()` with `emit_error()` | +| `ping.py` | Replace inline error echoing with `emit_error()` | +| `auth.py` | Replace `_emit_error()` with `emit_error()` | +| `layout.py` | Replace inline progress messages with `progress_status()`; replace `_emit_error()` with `emit_error()` | +| `docker.py` | Replace `_emit_error()` with `emit_error()` | + +### Phase 3: Add tests + +Create `src/regshape/tests/test_formatting.py` covering: + +- `emit_json` — Verifies correct JSON structure and file output. +- `emit_text` — Verifies stdout and file output with newline handling. +- `emit_error` — Verifies stderr output format and `sys.exit()` call. +- `emit_table` — Verifies column alignment with and without headers. +- `emit_list` — Verifies newline-joined output. +- `format_key_value` — Verifies aligned key-value output. +- `progress_status` — Verifies output goes to stderr. + +## Design Decisions + +### Why plain functions, not a class? + +The formatting helpers are stateless utilities. A class would add ceremony without benefit. Individual functions are easier to import selectively and test independently. + +### Why no third-party table library (e.g., `tabulate`, `rich`)? + +The project convention is to keep dependencies minimal (`click`, `requests`, `pytest` only). The table formatting needed is simple enough to implement with basic string operations. A third-party library can be considered later if requirements grow. + +### Why `progress_status()` instead of spinners/progress bars? + +Click does provide `click.progressbar()`, but the current blob upload and layout push operations don't expose a byte-level progress callback. Simple status messages to stderr are sufficient for now and don't require refactoring the operation layer. This can be enhanced later when streaming upload progress is available. + +### Why `emit_error()` calls `sys.exit()`? + +This matches the existing pattern where `_emit_error()` always exits. Keeping the exit in the helper reduces the chance of forgetting to exit after an error. Commands that need to handle errors without exiting can use `click.echo(..., err=True)` directly. + +## Dependencies + +- **Internal:** `click` (already a project dependency) +- **External:** None (no new dependencies) + +## Open Questions + +- [ ] Should `emit_table()` support right-aligned numeric columns? (Current proposal: all left-aligned for simplicity.) +- [ ] Should `progress_status()` use `\r` for overwriting the same line, or print sequential lines? (Current proposal: sequential lines.) +- [ ] Should `emit_error()` accept an optional `--json` flag to output errors as JSON objects? (Some tools do this for machine-parseable error reporting.) diff --git a/src/regshape/cli/auth.py b/src/regshape/cli/auth.py index 3e00009..9022cf5 100644 --- a/src/regshape/cli/auth.py +++ b/src/regshape/cli/auth.py @@ -12,11 +12,10 @@ .. moduleauthor:: ToddySM """ -import sys - import click import requests +from regshape.cli.formatting import emit_error from regshape.libs.auth.credentials import erase_credentials, resolve_credentials, store_credentials from regshape.libs.decorators import telemetry_options from regshape.libs.decorators.scenario import track_scenario @@ -102,11 +101,9 @@ def login(ctx, registry, username, password, password_stdin, docker_config): try: _verify_credentials(registry, resolved_username, resolved_password, insecure=insecure) except AuthError as e: - _error(registry, str(e)) - sys.exit(1) + emit_error(registry, str(e)) except requests.exceptions.RequestException as e: - _error(registry, str(e)) - sys.exit(1) + emit_error(registry, str(e)) # --- Persist credentials ------------------------------------------------ try: @@ -117,8 +114,7 @@ def login(ctx, registry, username, password, password_stdin, docker_config): docker_config_path=docker_config, ) except AuthError as e: - _error(registry, f"Could not store credentials: {e}") - sys.exit(1) + emit_error(registry, f"Could not store credentials: {e}") # --- Success output ------------------------------------------------------ click.echo("Login succeeded.") @@ -144,8 +140,7 @@ def logout(ctx, registry, docker_config): try: found = erase_credentials(registry, docker_config_path=docker_config) except AuthError as e: - _error(registry, str(e)) - sys.exit(1) + emit_error(registry, str(e)) if found: click.echo(f"Removing login credentials for {registry}.") @@ -194,6 +189,3 @@ def _verify_credentials(registry: str, username: str, password: str, insecure: b ) -def _error(registry: str, reason: str) -> None: - """Print an error message to stderr.""" - click.echo(f"Error for {registry}: {reason}", err=True) diff --git a/src/regshape/cli/blob.py b/src/regshape/cli/blob.py index 5182975..7c536c2 100644 --- a/src/regshape/cli/blob.py +++ b/src/regshape/cli/blob.py @@ -12,13 +12,10 @@ .. moduleauthor:: ToddySM """ -import json -import sys -from typing import Optional - import click import requests +from regshape.cli.formatting import emit_error, emit_json from regshape.libs.blobs import ( delete_blob, get_blob, @@ -80,26 +77,23 @@ def blob_head(ctx, repo, digest): try: registry, repo_name, _ = parse_image_ref(repo) except ValueError as exc: - _error(repo, str(exc)) - sys.exit(1) + emit_error(repo, str(exc)) if repo.rstrip("/") != f"{registry}/{repo_name}": - _error( + emit_error( repo, "--repo must be a plain 'registry/repository' without tag or digest " "(e.g. ':tag' or '@sha256:...')", ) - sys.exit(1) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) try: info = head_blob(client=client, repo=repo_name, digest=digest) except (AuthError, BlobError, requests.exceptions.RequestException) as exc: - _error(f"{repo}@{digest}", str(exc)) - sys.exit(1) + emit_error(f"{repo}@{digest}", str(exc)) - click.echo(json.dumps(info.to_dict(), indent=2)) + emit_json(info.to_dict()) # =========================================================================== @@ -161,15 +155,13 @@ def blob_get(ctx, repo, digest, output, chunk_size): # Compare the reconstructed plain form against the raw input: any qualifier # (including ":latest") will cause a mismatch. if repo.rstrip("/") != f"{registry}/{repo_name}": - _error( + emit_error( repo, "--repo must be a plain 'registry/repository' without tag or digest " "(e.g. ':tag' or '@sha256:...')", ) - sys.exit(1) except ValueError as exc: - _error(repo, str(exc)) - sys.exit(1) + emit_error(repo, str(exc)) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) @@ -182,10 +174,9 @@ def blob_get(ctx, repo, digest, output, chunk_size): chunk_size=chunk_size, ) except (AuthError, BlobError, requests.exceptions.RequestException) as exc: - _error(f"{repo}@{digest}", str(exc)) - sys.exit(1) + emit_error(f"{repo}@{digest}", str(exc)) - click.echo(json.dumps(info.to_dict(), indent=2)) + emit_json(info.to_dict()) # =========================================================================== @@ -225,23 +216,20 @@ def blob_delete(ctx, repo, digest): try: registry, repo_name, _ = parse_image_ref(repo) except ValueError as exc: - _error(repo, str(exc)) - sys.exit(1) + emit_error(repo, str(exc)) if repo.rstrip("/") != f"{registry}/{repo_name}": - _error(repo, "--repo must be a plain 'registry/repository' without tag or digest " + emit_error(repo, "--repo must be a plain 'registry/repository' without tag or digest " "(e.g. ':tag' or '@sha256:...')") - sys.exit(1) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) try: delete_blob(client=client, repo=repo_name, digest=digest) except (AuthError, BlobError, requests.exceptions.RequestException) as exc: - _error(f"{repo}@{digest}", str(exc)) - sys.exit(1) + emit_error(f"{repo}@{digest}", str(exc)) - click.echo(json.dumps({"digest": digest, "status": "deleted"}, indent=2)) + emit_json({"digest": digest, "status": "deleted"}) # =========================================================================== @@ -312,12 +300,10 @@ def blob_upload(ctx, repo, source_file, digest, media_type, chunked, chunk_size) try: registry, repo_name, _ = parse_image_ref(repo) except ValueError as exc: - _error(repo, str(exc)) - sys.exit(1) + emit_error(repo, str(exc)) if repo.rstrip("/") != f"{registry}/{repo_name}": - _error(repo, "Repository must be a plain 'registry/repository' without a tag or digest") - sys.exit(1) + emit_error(repo, "Repository must be a plain 'registry/repository' without a tag or digest") client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) try: @@ -342,11 +328,9 @@ def blob_upload(ctx, repo, source_file, digest, media_type, chunked, chunk_size) content_type=media_type, ) except OSError as exc: - _error(source_file, str(exc)) - sys.exit(1) + emit_error(source_file, str(exc)) except (AuthError, BlobError, requests.exceptions.RequestException) as exc: - _error(f"{repo}@{digest}", str(exc)) - sys.exit(1) + emit_error(f"{repo}@{digest}", str(exc)) # Derive a canonical blob location from the confirmed digest. location = f"/v2/{repo_name}/blobs/{confirmed}" @@ -355,11 +339,8 @@ def blob_upload(ctx, repo, source_file, digest, media_type, chunked, chunk_size) except OSError: size = 0 - click.echo( - json.dumps( - {"digest": confirmed, "size": size, "location": location}, - indent=2, - ) + emit_json( + {"digest": confirmed, "size": size, "location": location} ) @@ -405,12 +386,10 @@ def blob_mount(ctx, repo, digest, from_repo): try: registry, repo_name, _ = parse_image_ref(repo) except ValueError as exc: - _error(repo, str(exc)) - sys.exit(1) + emit_error(repo, str(exc)) if repo.rstrip("/") != f"{registry}/{repo_name}": - _error(repo, "repository must be a plain 'registry/repo' without tag or digest") - sys.exit(1) + emit_error(repo, "repository must be a plain 'registry/repo' without tag or digest") client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) try: @@ -421,15 +400,11 @@ def blob_mount(ctx, repo, digest, from_repo): from_repo=from_repo, ) except (AuthError, BlobError, requests.exceptions.RequestException) as exc: - _error(f"{repo}@{digest}", str(exc)) - sys.exit(1) + emit_error(f"{repo}@{digest}", str(exc)) location = f"/v2/{repo_name}/blobs/{confirmed}" - click.echo( - json.dumps( - {"digest": confirmed, "status": "mounted", "location": location}, - indent=2, - ) + emit_json( + {"digest": confirmed, "status": "mounted", "location": location} ) @@ -438,10 +413,6 @@ def blob_mount(ctx, repo, digest, from_repo): # =========================================================================== -def _error(reference: str, reason: str) -> None: - """Print an error message to stderr, prefixed with the reference.""" - click.echo(f"Error [{reference}]: {reason}", err=True) - def _file_size(path: str) -> int: """Return the byte size of *path*.""" diff --git a/src/regshape/cli/catalog.py b/src/regshape/cli/catalog.py index a64a254..a10dff1 100644 --- a/src/regshape/cli/catalog.py +++ b/src/regshape/cli/catalog.py @@ -12,13 +12,10 @@ .. moduleauthor:: ToddySM """ -import json -import sys -from typing import Optional - import click import requests +from regshape.cli.formatting import emit_error, emit_json, emit_list from regshape.libs.catalog import list_catalog, list_catalog_all from regshape.libs.decorators import telemetry_options from regshape.libs.decorators.scenario import track_scenario @@ -107,8 +104,7 @@ def catalog_list(ctx, registry, page_size, last, fetch_all, as_json, output): 3=registry does not support the catalog API. """ if fetch_all and last: - _error(registry, "--all and --last are mutually exclusive") - sys.exit(2) + emit_error(registry, "--all and --last are mutually exclusive", exit_code=2) insecure = ctx.obj.get("insecure", False) if ctx.obj else False client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) @@ -119,37 +115,18 @@ def catalog_list(ctx, registry, page_size, last, fetch_all, as_json, output): else: result = list_catalog(client, page_size=page_size, last=last) except CatalogNotSupportedError as exc: - _error(registry, str(exc)) - sys.exit(3) + emit_error(registry, str(exc), exit_code=3) except (AuthError, CatalogError, requests.exceptions.RequestException) as exc: - _error(registry, str(exc)) - sys.exit(1) + emit_error(registry, str(exc)) if as_json: - _write(output, json.dumps(result.to_dict(), indent=2)) + emit_json(result.to_dict(), output) else: - _write(output, "\n".join(result.repositories)) + emit_list(result.repositories, output) # =========================================================================== # Internal helpers — output and error # =========================================================================== -def _write(output_path: Optional[str], content: str) -> None: - """Write *content* to a file or stdout. - - :param output_path: File path, or ``None`` to write to stdout. - :param content: Text to write. - """ - if output_path: - with open(output_path, "w", encoding="utf-8") as fh: - fh.write(content) - if not content.endswith("\n"): - fh.write("\n") - else: - click.echo(content) - -def _error(reference: str, reason: str) -> None: - """Print an error message to stderr, prefixed with the reference.""" - click.echo(f"Error [{reference}]: {reason}", err=True) diff --git a/src/regshape/cli/docker.py b/src/regshape/cli/docker.py index a61adf9..71797be 100644 --- a/src/regshape/cli/docker.py +++ b/src/regshape/cli/docker.py @@ -12,11 +12,9 @@ .. moduleauthor:: ToddySM """ -import json -import sys - import click +from regshape.cli.formatting import emit_error, emit_json from regshape.libs.docker import export_image, list_images, push_image from regshape.libs.errors import AuthError, BlobError, DockerError, LayoutError, ManifestError @@ -37,10 +35,6 @@ def _format_size(size_bytes: int) -> str: return f"{size_bytes}B" -def _error(context: str, reason: str) -> None: - """Print an error message to stderr.""" - click.echo(f"Error [{context}]: {reason}", err=True) - # =========================================================================== # Public Click group @@ -72,8 +66,7 @@ def list_cmd(ctx, name_filter, as_json): try: images = list_images(name_filter=name_filter) except DockerError as exc: - _error("docker list", str(exc)) - sys.exit(1) + emit_error("docker list", str(exc)) if as_json: output = [ @@ -88,7 +81,7 @@ def list_cmd(ctx, name_filter, as_json): } for img in images ] - click.echo(json.dumps(output, indent=2)) + emit_json(output) else: if not images: click.echo("No images found.") @@ -154,8 +147,7 @@ def export_cmd(ctx, image, output, platform, as_json): try: export_image(image, output, platform=platform) except (DockerError, LayoutError) as exc: - _error("docker export", str(exc)) - sys.exit(1) + emit_error("docker export", str(exc)) if as_json: result = { @@ -164,7 +156,7 @@ def export_cmd(ctx, image, output, platform, as_json): } if platform: result["platform"] = platform - click.echo(json.dumps(result, indent=2)) + emit_json(result) else: msg = f"Exported {image} to OCI layout at {output}" if platform: @@ -232,8 +224,7 @@ def push_cmd(ctx, image, dest, platform, force, chunked, chunk_size, as_json): chunk_size=chunk_size, ) except (DockerError, LayoutError, AuthError, BlobError, ManifestError) as exc: - _error("docker push", str(exc)) - sys.exit(1) + emit_error("docker push", str(exc)) if as_json: output = { @@ -249,7 +240,7 @@ def push_cmd(ctx, image, dest, platform, force, chunked, chunk_size, as_json): } if platform: output["platform"] = platform - click.echo(json.dumps(output, indent=2)) + emit_json(output) else: total_manifests = len(result.manifests) total_uploaded = sum( diff --git a/src/regshape/cli/formatting.py b/src/regshape/cli/formatting.py new file mode 100644 index 0000000..8924d67 --- /dev/null +++ b/src/regshape/cli/formatting.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 + +""" +:mod:`regshape.cli.formatting` - Reusable CLI output formatting helpers +======================================================================== + +.. module:: regshape.cli.formatting + :platform: Unix, Windows + :synopsis: Centralized output helpers for JSON, text, error messages, + tables, lists, key-value pairs, and progress status. + +.. moduleauthor:: ToddySM +""" + +import json +import sys +from typing import Optional + +import click + + +def emit_json(data: dict | list, output_path: Optional[str] = None, err: bool = False) -> None: + """Format and emit a JSON object with 2-space indentation. + + :param data: Serializable dict or list. + :param output_path: If provided, write to file instead of stdout. + :param err: If ``True``, write to stderr instead of stdout. + """ + content = json.dumps(data, indent=2) + emit_text(content, output_path, err=err) + + +def emit_text(content: str, output_path: Optional[str] = None, err: bool = False) -> None: + """Emit plain text content to stdout or a file. + + :param content: Text string to output. + :param output_path: If provided, write to file instead of stdout. + :param err: If ``True``, write to stderr instead of stdout (ignored + when *output_path* is set). + """ + if output_path: + with open(output_path, "w", encoding="utf-8") as fh: + fh.write(content) + if not content.endswith("\n"): + fh.write("\n") + else: + click.echo(content, err=err) + + +def emit_error(reference: str, reason: str, exit_code: int = 1) -> None: + """Print a standardized error message to stderr and exit. + + :param reference: Context identifier displayed in brackets. + :param reason: Human-readable error description. + :param exit_code: Process exit code (default: 1). + """ + click.echo(f"Error [{reference}]: {reason}", err=True) + sys.exit(exit_code) + + +def emit_table(rows: list[list[str]], headers: Optional[list[str]] = None) -> None: + """Print tabular data with aligned columns. + + :param rows: List of row data, each row a list of string values. + :param headers: Optional column headers. + """ + all_rows = ([headers] + rows) if headers else rows + if not all_rows: + return + + num_cols = max(len(row) for row in all_rows) + col_widths = [0] * num_cols + for row in all_rows: + for i, cell in enumerate(row): + col_widths[i] = max(col_widths[i], len(cell)) + + def _format_row(row: list[str]) -> str: + parts = [] + for i, cell in enumerate(row): + if i < len(col_widths): + parts.append(cell.ljust(col_widths[i])) + else: + parts.append(cell) + return " ".join(parts).rstrip() + + if headers: + click.echo(_format_row(headers)) + + for row in rows: + click.echo(_format_row(row)) + + +def emit_list(items: list[str], output_path: Optional[str] = None) -> None: + """Print a simple one-item-per-line list. + + :param items: List of string items. + :param output_path: If provided, write to file instead of stdout. + """ + emit_text("\n".join(items), output_path) + + +def format_key_value(pairs: list[tuple[str, str]], separator: str = ":") -> str: + """Format aligned key-value pairs for display. + + :param pairs: List of (key, value) tuples. + :param separator: Character between key and value (default: ``":"``). + :returns: Formatted multi-line string with aligned values. + """ + if not pairs: + return "" + max_key_len = max(len(k) for k, _ in pairs) + lines = [] + for key, value in pairs: + padded_key = key.ljust(max_key_len) + lines.append(f"{padded_key}{separator} {value}") + return "\n".join(lines) + + +def progress_status(message: str) -> None: + """Print a transient status message to stderr. + + :param message: Status message to display. + """ + click.echo(message, err=True) diff --git a/src/regshape/cli/layout.py b/src/regshape/cli/layout.py index eab96d4..7c0fedf 100644 --- a/src/regshape/cli/layout.py +++ b/src/regshape/cli/layout.py @@ -22,6 +22,7 @@ import click +from regshape.cli.formatting import emit_error, emit_json, progress_status from regshape.libs.decorators import telemetry_options from regshape.libs.decorators.scenario import track_scenario from regshape.libs.errors import AuthError, BlobError, LayoutError, ManifestError @@ -105,9 +106,6 @@ def _media_type_for_compression(compression: str) -> str: return OCI_IMAGE_LAYER_TAR_GZIP -def _error(context: str, reason: str) -> None: - """Print an error message to stderr.""" - click.echo(f"Error [{context}]: {reason}", err=True) # =========================================================================== @@ -174,11 +172,10 @@ def init_cmd(ctx, layout_path, as_json): try: init_layout(layout_path) except (LayoutError, OSError) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) if as_json: - click.echo(json.dumps({"layout_path": str(layout_path)}, indent=2)) + emit_json({"layout_path": str(layout_path)}) else: click.echo(f"Initialised OCI Image Layout at {layout_path}") @@ -232,8 +229,7 @@ def add_layer(ctx, layout_path, layer_file, compress_format, media_type, raw_ann with open(layer_file, "rb") as fh: data = fh.read() except OSError as exc: - _error(layer_file, str(exc)) - sys.exit(1) + emit_error(layer_file, str(exc)) detected = _detect_compression(data) @@ -259,8 +255,7 @@ def add_layer(ctx, layout_path, layer_file, compress_format, media_type, raw_ann annotations = _parse_annotations(raw_annotations) if raw_annotations else None descriptor = stage_layer(layout_path, data, media_type, annotations=annotations) except (LayoutError, OSError, click.BadParameter) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) if as_json: out: dict = { @@ -270,7 +265,7 @@ def add_layer(ctx, layout_path, layer_file, compress_format, media_type, raw_ann } if descriptor.annotations: out["annotations"] = descriptor.annotations - click.echo(json.dumps(out, indent=2)) + emit_json(out) else: click.echo(f"Staged layer {descriptor.digest} ({descriptor.size} bytes)") @@ -314,8 +309,7 @@ def annotate_layer(ctx, layout_path, layer_index, raw_annotations, replace, as_j layout_path, layer_index, annotations, replace=replace ) except (LayoutError, OSError, click.BadParameter) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) if as_json: out: dict = { @@ -326,7 +320,7 @@ def annotate_layer(ctx, layout_path, layer_index, raw_annotations, replace, as_j } if descriptor.annotations: out["annotations"] = descriptor.annotations - click.echo(json.dumps(out, indent=2)) + emit_json(out) else: click.echo(f"Updated layer {layer_index}: {descriptor.digest}") @@ -361,14 +355,13 @@ def annotate_manifest_cmd(ctx, layout_path, raw_annotations, replace, as_json): annotations = _parse_annotations(raw_annotations) descriptor = update_manifest_annotations(layout_path, annotations, replace=replace) except (LayoutError, OSError, click.BadParameter) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) if as_json: out: dict = {"digest": descriptor.digest, "size": descriptor.size} if descriptor.annotations: out["annotations"] = descriptor.annotations - click.echo(json.dumps(out, indent=2)) + emit_json(out) else: click.echo(f"Updated manifest annotations: {descriptor.digest}") @@ -434,8 +427,7 @@ def generate_config_cmd( annotations=annotations, ) except (LayoutError, OSError, click.BadParameter) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) if as_json: out: dict = { @@ -445,7 +437,7 @@ def generate_config_cmd( } if descriptor.annotations: out["annotations"] = descriptor.annotations - click.echo(json.dumps(out, indent=2)) + emit_json(out) else: click.echo(f"Generated config {descriptor.digest} ({descriptor.size} bytes)") @@ -503,8 +495,7 @@ def generate_manifest_cmd( annotations=annotations, ) except (LayoutError, OSError, click.BadParameter) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) if as_json: out: dict = { @@ -516,7 +507,7 @@ def generate_manifest_cmd( out["ref_name"] = ref_name if descriptor.annotations: out["annotations"] = descriptor.annotations - click.echo(json.dumps(out, indent=2)) + emit_json(out) else: ref_str = f" [{ref_name}]" if ref_name else "" click.echo( @@ -578,8 +569,7 @@ def update_config_cmd( replace_annotations=replace_annotations, ) except (LayoutError, OSError, click.BadParameter) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) # Warn when a manifest already exists (it will reference the old config digest) try: @@ -595,7 +585,7 @@ def update_config_cmd( "Manifest exists and references the old config digest; " "regenerate or update it." ) - click.echo(json.dumps(out, indent=2)) + emit_json(out) else: click.echo(f"Updated config {descriptor.digest} ({descriptor.size} bytes)") if manifest_stale: @@ -626,11 +616,10 @@ def status(ctx, layout_path, as_json): try: stage = read_stage(layout_path) except (LayoutError, OSError) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) if as_json: - click.echo(json.dumps(stage, indent=2)) + emit_json(stage) else: layers = stage.get("layers", []) config = stage.get("config") @@ -674,10 +663,9 @@ def show(ctx, layout_path): try: index = read_index(layout_path) except (LayoutError, OSError) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) - click.echo(json.dumps(json.loads(index.to_json()), indent=2)) + emit_json(json.loads(index.to_json())) # =========================================================================== @@ -707,8 +695,7 @@ def validate(ctx, layout_path): try: validate_layout(layout_path) except (LayoutError, OSError) as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) click.echo(f"Layout at {layout_path} is valid.") @@ -794,8 +781,7 @@ def push_cmd(ctx, layout_path, dest, force, chunked, chunk_size, dry_run, as_jso try: registry, repo, reference = parse_image_ref(dest) except ValueError as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) tag_override = reference if reference != "latest" or _has_explicit_ref(dest) else None @@ -827,8 +813,7 @@ def progress_callback(event, **kwargs): # Immediately complete — we don't have per-byte callback from # the library upload, so show as complete once done. else: - click.echo(f" Uploading {_short_digest(digest)} ({_format_size(size)})...", - err=True) + progress_status(f" Uploading {_short_digest(digest)} ({_format_size(size)})...") elif event == "blob_done": if use_progress and current_bar[0] is not None: bar = current_bar[0] @@ -836,14 +821,12 @@ def progress_callback(event, **kwargs): bar.__exit__(None, None, None) current_bar[0] = None else: - click.echo(f" Uploaded {_short_digest(digest)}", err=True) + progress_status(f" Uploaded {_short_digest(digest)}") elif event == "blob_skip": - click.echo(f" {_short_digest(digest)} ({_format_size(size)}) exists, skipping", - err=True) + progress_status(f" {_short_digest(digest)} ({_format_size(size)}) exists, skipping") elif event == "manifest_done": ref = kwargs.get("reference", "") - click.echo(f" Manifest {_short_digest(digest)} -> {ref} pushed", - err=True) + progress_status(f" Manifest {_short_digest(digest)} -> {ref} pushed") # --- Execute push --- dest_display = f"{registry}/{repo}" @@ -851,7 +834,7 @@ def progress_callback(event, **kwargs): dest_display += f":{tag_override}" if not as_json: - click.echo(f"Pushing layout {layout_path} -> {dest_display}\n", err=True) + progress_status(f"Pushing layout {layout_path} -> {dest_display}\n") try: result = push_layout( @@ -865,11 +848,9 @@ def progress_callback(event, **kwargs): progress_callback=progress_callback, ) except LayoutError as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) except (AuthError, BlobError, ManifestError) as exc: - _error(dest_display, str(exc)) - sys.exit(1) + emit_error(dest_display, str(exc)) finally: if current_bar[0] is not None: current_bar[0].__exit__(None, None, None) @@ -905,13 +886,12 @@ def progress_callback(event, **kwargs): "bytes_uploaded": result.bytes_uploaded, }, } - click.echo(json.dumps(output, indent=2)) + emit_json(output) else: - click.echo( + progress_status( f"\nPush complete: {result.manifests_pushed} manifest(s), " f"{result.blobs_uploaded} blob(s) uploaded, " - f"{result.blobs_skipped} blob(s) skipped.", - err=True, + f"{result.blobs_skipped} blob(s) skipped." ) @@ -924,20 +904,17 @@ def _push_dry_run(layout_path, registry, repo, tag_override, as_json): validate_layout(layout_path) index = read_index(layout_path) except LayoutError as exc: - _error(layout_path, str(exc)) - sys.exit(1) + emit_error(layout_path, str(exc)) if not index.manifests: - _error(layout_path, "index.json contains no manifests") - sys.exit(1) + emit_error(layout_path, "index.json contains no manifests") if tag_override and len(index.manifests) > 1: - _error( + emit_error( layout_path, f"tag override supplied but index.json has {len(index.manifests)} manifests; " "omit the tag or push a single-manifest layout", ) - sys.exit(1) from regshape.libs.layout import read_blob as _rb @@ -978,7 +955,7 @@ def _push_dry_run(layout_path, registry, repo, tag_override, as_json): for entry, ref, blobs in results ], } - click.echo(json.dumps(output, indent=2)) + emit_json(output) else: click.echo(f"[dry-run] Layout {layout_path} -> {registry}/{repo}\n") for entry, ref, blobs in results: diff --git a/src/regshape/cli/manifest.py b/src/regshape/cli/manifest.py index a1ac849..377c88c 100644 --- a/src/regshape/cli/manifest.py +++ b/src/regshape/cli/manifest.py @@ -15,18 +15,18 @@ import json import sys -from typing import Optional import click import requests +from regshape.cli.formatting import emit_error, emit_json, emit_text, format_key_value from regshape.libs.decorators import telemetry_options from regshape.libs.decorators.scenario import track_scenario from regshape.libs.errors import AuthError, ManifestError from regshape.libs.manifests import delete_manifest, get_manifest, head_manifest, push_manifest from regshape.libs.models.manifest import ImageIndex, ImageManifest, parse_manifest from regshape.libs.models.mediatype import ALL_MANIFEST_MEDIA_TYPES, OCI_IMAGE_MANIFEST -from regshape.libs.refs import format_ref, parse_image_ref +from regshape.libs.refs import parse_image_ref from regshape.libs.transport import RegistryClient, TransportConfig # --------------------------------------------------------------------------- @@ -119,8 +119,7 @@ def get(ctx, image_ref, accept, part, output, raw): try: registry, repo, reference = parse_image_ref(image_ref) except ValueError as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) @@ -132,31 +131,27 @@ def get(ctx, image_ref, accept, part, output, raw): accept=accept or _DEFAULT_ACCEPT, ) except (AuthError, ManifestError, requests.exceptions.RequestException) as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) if raw: - _write(output, body) + emit_text(body, output) return # Parse and optionally extract a specific field try: parsed = parse_manifest(body) except ManifestError as exc: - _error(image_ref, f"Failed to parse manifest: {exc}") - sys.exit(1) + emit_error(image_ref, f"Failed to parse manifest: {exc}") if part: exit_code, result = _extract_part(parsed, part) if exit_code != 0: - _error(image_ref, result) - sys.exit(exit_code) - _write(output, result) + emit_error(image_ref, result, exit_code) + emit_text(result, output) return # Full manifest output - manifest_dict = json.loads(parsed.to_json()) - _write(output, json.dumps(manifest_dict, indent=2)) + emit_json(json.loads(parsed.to_json()), output) # =========================================================================== @@ -192,8 +187,7 @@ def info(ctx, image_ref, accept): try: registry, repo, reference = parse_image_ref(image_ref) except ValueError as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) @@ -205,12 +199,13 @@ def info(ctx, image_ref, accept): accept=accept or _DEFAULT_ACCEPT, ) except (AuthError, ManifestError, requests.exceptions.RequestException) as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) - click.echo(f"Digest: {digest}") - click.echo(f"Media Type: {media_type}") - click.echo(f"Size: {size}") + click.echo(format_key_value([ + ("Digest", digest), + ("Media Type", media_type), + ("Size", str(size)), + ])) # =========================================================================== @@ -248,8 +243,7 @@ def descriptor(ctx, image_ref, accept): try: registry, repo, reference = parse_image_ref(image_ref) except ValueError as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) @@ -261,14 +255,13 @@ def descriptor(ctx, image_ref, accept): accept=accept or _DEFAULT_ACCEPT, ) except (AuthError, ManifestError, requests.exceptions.RequestException) as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) - click.echo(json.dumps({ + emit_json({ "mediaType": media_type, "digest": digest, "size": size, - }, indent=2)) + }) # =========================================================================== @@ -328,8 +321,7 @@ def put(ctx, image_ref, manifest_file, from_stdin, content_type): try: registry, repo, reference = parse_image_ref(image_ref) except ValueError as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) # Read manifest body if manifest_file: @@ -357,8 +349,7 @@ def put(ctx, image_ref, manifest_file, from_stdin, content_type): content_type=content_type, ) except (AuthError, ManifestError, requests.exceptions.RequestException) as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) click.echo(f"Pushed: {digest}") @@ -391,16 +382,15 @@ def delete(ctx, image_ref): try: registry, repo, reference = parse_image_ref(image_ref) except ValueError as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) if not reference.startswith("sha256:") and not reference.startswith("sha512:"): - _error( + emit_error( image_ref, "manifest delete requires a digest reference (@sha256:...); " "tag references are not supported by the OCI spec for delete operations", + exit_code=2, ) - sys.exit(2) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) @@ -411,8 +401,7 @@ def delete(ctx, image_ref): digest=reference, ) except (AuthError, ManifestError, requests.exceptions.RequestException) as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) click.echo(f"Deleted: {reference}") @@ -453,21 +442,4 @@ def _extract_part( return 1, f"unknown part: {part!r}" -def _write(output_path: Optional[str], content: str) -> None: - """Write *content* to a file or stdout. - :param output_path: File path, or ``None`` to write to stdout. - :param content: Text to write. - """ - if output_path: - with open(output_path, "w", encoding="utf-8") as fh: - fh.write(content) - if not content.endswith("\n"): - fh.write("\n") - else: - click.echo(content) - - -def _error(reference: str, reason: str) -> None: - """Print an error message to stderr, prefixed with the image reference.""" - click.echo(f"Error [{reference}]: {reason}", err=True) diff --git a/src/regshape/cli/ping.py b/src/regshape/cli/ping.py index 4e558a4..c6e8c87 100644 --- a/src/regshape/cli/ping.py +++ b/src/regshape/cli/ping.py @@ -12,12 +12,12 @@ .. moduleauthor:: ToddySM """ -import json import sys import click import requests +from regshape.cli.formatting import emit_json from regshape.libs.decorators import telemetry_options from regshape.libs.decorators.scenario import track_scenario from regshape.libs.errors import AuthError, PingError @@ -65,14 +65,14 @@ def ping(ctx, registry, as_json): # A 401/403 during token negotiation means the registry *is* # reachable but requires credentials. Report success with a hint. if as_json: - click.echo(json.dumps({ + emit_json({ "registry": registry, "reachable": True, "api_version": None, "latency_ms": None, "note": "Registry requires authentication", "error": str(exc), - }, indent=2)) + }) else: click.echo(f"Registry {registry} is reachable") click.echo(" Note: Registry requires authentication. " @@ -86,25 +86,14 @@ def ping(ctx, registry, as_json): _error(registry, str(exc), as_json) sys.exit(1) - if as_json: - output = { - "registry": registry, - "reachable": False, - "status_code": result.status_code, - "api_version": result.api_version, - "latency_ms": result.latency_ms, - "error": f"HTTP {result.status_code}", - } - click.echo(json.dumps(output, indent=2), err=True) - else: - _error(registry, f"HTTP {result.status_code}", as_json) + if not result.reachable: _error(registry, f"HTTP {result.status_code}", as_json) sys.exit(1) if as_json: output = result.to_dict() output["registry"] = registry - click.echo(json.dumps(output, indent=2)) + emit_json(output) else: click.echo(f"Registry {registry} is reachable") if result.api_version: @@ -120,10 +109,10 @@ def ping(ctx, registry, as_json): def _error(registry: str, detail: str, as_json: bool = False) -> None: """Print an error message to stderr.""" if as_json: - click.echo(json.dumps({ + emit_json({ "registry": registry, "reachable": False, "error": detail, - }, indent=2), err=True) + }, err=True) else: click.echo(f"Error: Registry {registry} is not reachable: {detail}", err=True) diff --git a/src/regshape/cli/referrer.py b/src/regshape/cli/referrer.py index 033ac92..d4f5b09 100644 --- a/src/regshape/cli/referrer.py +++ b/src/regshape/cli/referrer.py @@ -12,13 +12,10 @@ .. moduleauthor:: ToddySM """ -import json -import sys -from typing import Optional - import click import requests +from regshape.cli.formatting import emit_error, emit_json, emit_table, emit_text from regshape.libs.decorators import telemetry_options from regshape.libs.decorators.scenario import track_scenario from regshape.libs.errors import AuthError, ReferrerError @@ -103,18 +100,17 @@ def referrer_list(ctx, image_ref, artifact_type, fetch_all, as_json, output): try: registry, repo, reference = parse_image_ref(image_ref) except ValueError as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) # The referrers API requires a digest reference. if not reference.startswith("sha256:") and not reference.startswith("sha512:"): - _error( + emit_error( image_ref, "referrer list requires a digest reference " "(registry/repo@sha256:...); " "use 'manifest get' to resolve a tag to a digest", + exit_code=2, ) - sys.exit(2) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) @@ -134,38 +130,30 @@ def referrer_list(ctx, image_ref, artifact_type, fetch_all, as_json, output): artifact_type=artifact_type, ) except (AuthError, ReferrerError, requests.exceptions.RequestException) as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) if as_json: - _write(output, json.dumps(result.to_dict(), indent=2)) + emit_json(result.to_dict(), output) else: - lines = [ - f"{d.digest} {d.artifact_type or ''} {d.size}" + if not result.manifests: + if output: + # Ensure the output file is created even when there are no referrers + emit_text("", output) + return + rows = [ + [d.digest, d.artifact_type or "", str(d.size)] for d in result.manifests ] - _write(output, "\n".join(lines)) + if output: + # For file output, use space-separated columns + lines = [f"{r[0]} {r[1]} {r[2]}" for r in rows] + emit_text("\n".join(lines), output) + else: + emit_table(rows, headers=["DIGEST", "ARTIFACT TYPE", "SIZE"]) # =========================================================================== # Internal helpers — output and error # =========================================================================== -def _write(output_path: Optional[str], content: str) -> None: - """Write *content* to a file or stdout. - - :param output_path: File path, or ``None`` to write to stdout. - :param content: Text to write. - """ - if output_path: - with open(output_path, "w", encoding="utf-8") as fh: - fh.write(content) - if not content.endswith("\n"): - fh.write("\n") - else: - click.echo(content) - -def _error(reference: str, reason: str) -> None: - """Print an error message to stderr, prefixed with the reference.""" - click.echo(f"Error [{reference}]: {reason}", err=True) diff --git a/src/regshape/cli/tag.py b/src/regshape/cli/tag.py index 3643e9d..2b5f6c7 100644 --- a/src/regshape/cli/tag.py +++ b/src/regshape/cli/tag.py @@ -12,13 +12,10 @@ .. moduleauthor:: ToddySM """ -import json -import sys -from typing import Optional - import click import requests +from regshape.cli.formatting import emit_error, emit_json, emit_list, emit_text from regshape.libs.decorators import telemetry_options from regshape.libs.decorators.scenario import track_scenario from regshape.libs.errors import AuthError, TagError @@ -104,8 +101,7 @@ def tag_list(ctx, image_ref, page_size, last, as_json, output): try: registry, repo, _ = parse_image_ref(image_ref) except ValueError as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) @@ -117,13 +113,12 @@ def tag_list(ctx, image_ref, page_size, last, as_json, output): last=last, ) except (AuthError, TagError, requests.exceptions.RequestException) as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) if as_json: - _write(output, json.dumps(tag_list.to_dict(), indent=2)) + emit_json(tag_list.to_dict(), output) else: - _write(output, "\n".join(tag_list.tags)) + emit_list(tag_list.tags, output) # =========================================================================== @@ -159,16 +154,15 @@ def delete(ctx, image_ref): try: registry, repo, reference = parse_image_ref(image_ref) except ValueError as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) if reference.startswith("sha256:") or reference.startswith("sha512:"): - _error( + emit_error( image_ref, "tag delete requires a tag reference; " "use 'manifest delete' for digest references", + exit_code=2, ) - sys.exit(2) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) @@ -179,8 +173,7 @@ def delete(ctx, image_ref): tag=reference, ) except (AuthError, TagError, requests.exceptions.RequestException) as exc: - _error(image_ref, str(exc)) - sys.exit(1) + emit_error(image_ref, str(exc)) click.echo(f"Deleted tag: {format_ref(registry, repo, reference)}") @@ -189,21 +182,4 @@ def delete(ctx, image_ref): # Internal helpers — output and error # =========================================================================== -def _write(output_path: Optional[str], content: str) -> None: - """Write *content* to a file or stdout. - - :param output_path: File path, or ``None`` to write to stdout. - :param content: Text to write. - """ - if output_path: - with open(output_path, "w", encoding="utf-8") as fh: - fh.write(content) - if not content.endswith("\n"): - fh.write("\n") - else: - click.echo(content) - -def _error(reference: str, reason: str) -> None: - """Print an error message to stderr, prefixed with the reference.""" - click.echo(f"Error [{reference}]: {reason}", err=True) diff --git a/src/regshape/tests/test_formatting.py b/src/regshape/tests/test_formatting.py new file mode 100644 index 0000000..838bc2f --- /dev/null +++ b/src/regshape/tests/test_formatting.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 + +"""Tests for :mod:`regshape.cli.formatting`.""" + +import json + +import pytest + +from regshape.cli.formatting import ( + emit_error, + emit_json, + emit_list, + emit_table, + emit_text, + format_key_value, + progress_status, +) + + +# =========================================================================== +# emit_json +# =========================================================================== + + +class TestEmitJson: + def test_stdout(self, capsys): + emit_json({"key": "value", "num": 42}) + captured = capsys.readouterr() + assert json.loads(captured.out) == {"key": "value", "num": 42} + # Verify 2-space indentation + assert ' "key"' in captured.out + + def test_list(self, capsys): + emit_json([1, 2, 3]) + captured = capsys.readouterr() + assert json.loads(captured.out) == [1, 2, 3] + + def test_file_output(self, tmp_path): + out_file = str(tmp_path / "out.json") + emit_json({"a": 1}, output_path=out_file) + content = open(out_file).read() + assert json.loads(content) == {"a": 1} + assert content.endswith("\n") + + def test_err_goes_to_stderr(self, capsys): + emit_json({"error": "fail"}, err=True) + captured = capsys.readouterr() + assert captured.out == "" + assert json.loads(captured.err) == {"error": "fail"} + + +# =========================================================================== +# emit_text +# =========================================================================== + + +class TestEmitText: + def test_stdout(self, capsys): + emit_text("hello world") + assert capsys.readouterr().out == "hello world\n" + + def test_file_output(self, tmp_path): + out_file = str(tmp_path / "out.txt") + emit_text("hello", output_path=out_file) + assert open(out_file).read() == "hello\n" + + def test_file_output_preserves_trailing_newline(self, tmp_path): + out_file = str(tmp_path / "out.txt") + emit_text("hello\n", output_path=out_file) + assert open(out_file).read() == "hello\n" + + def test_err_goes_to_stderr(self, capsys): + emit_text("oops", err=True) + captured = capsys.readouterr() + assert captured.out == "" + assert "oops" in captured.err + + +# =========================================================================== +# emit_error +# =========================================================================== + + +class TestEmitError: + def test_format_and_exit(self, capsys): + with pytest.raises(SystemExit) as exc_info: + emit_error("registry/repo:tag", "manifest not found") + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert captured.err == "Error [registry/repo:tag]: manifest not found\n" + assert captured.out == "" + + def test_custom_exit_code(self, capsys): + with pytest.raises(SystemExit) as exc_info: + emit_error("ref", "bad input", exit_code=2) + assert exc_info.value.code == 2 + + def test_output_goes_to_stderr(self, capsys): + with pytest.raises(SystemExit): + emit_error("ctx", "reason") + captured = capsys.readouterr() + assert "Error [ctx]: reason" in captured.err + assert captured.out == "" + + +# =========================================================================== +# emit_table +# =========================================================================== + + +class TestEmitTable: + def test_with_headers(self, capsys): + emit_table( + [["sha256:abc", "sbom", "1024"], ["sha256:def", "sig", "2048"]], + headers=["DIGEST", "TYPE", "SIZE"], + ) + out = capsys.readouterr().out + lines = out.strip().split("\n") + assert len(lines) == 3 + assert "DIGEST" in lines[0] + assert "sha256:abc" in lines[1] + assert "sha256:def" in lines[2] + + def test_without_headers(self, capsys): + emit_table([["a", "bb"], ["ccc", "d"]]) + out = capsys.readouterr().out + lines = out.strip().split("\n") + assert len(lines) == 2 + + def test_column_alignment(self, capsys): + emit_table( + [["short", "x"], ["longer_value", "y"]], + headers=["COL1", "COL2"], + ) + out = capsys.readouterr().out + lines = out.strip().split("\n") + # All values in col2 should start at the same column + second_col_values = ["COL2", "x", "y"] + col2_positions = [ + line.index(value) for line, value in zip(lines, second_col_values) + ] + assert len(set(col2_positions)) == 1 + assert len(lines) == 3 + + def test_empty_rows(self, capsys): + emit_table([]) + assert capsys.readouterr().out == "" + + +# =========================================================================== +# emit_list +# =========================================================================== + + +class TestEmitList: + def test_stdout(self, capsys): + emit_list(["tag1", "tag2", "tag3"]) + assert capsys.readouterr().out == "tag1\ntag2\ntag3\n" + + def test_file_output(self, tmp_path): + out_file = str(tmp_path / "tags.txt") + emit_list(["a", "b"], output_path=out_file) + assert open(out_file).read() == "a\nb\n" + + +# =========================================================================== +# format_key_value +# =========================================================================== + + +class TestFormatKeyValue: + def test_basic_alignment(self): + result = format_key_value([ + ("Digest", "sha256:abc"), + ("Media Type", "application/vnd.oci.image.manifest.v1+json"), + ("Size", "1234"), + ]) + lines = result.split("\n") + assert len(lines) == 3 + assert lines[0].startswith("Digest :") + assert lines[1].startswith("Media Type:") + assert lines[2].startswith("Size :") + + def test_custom_separator(self): + result = format_key_value([("Key", "val")], separator="=") + assert result == "Key= val" + + def test_empty(self): + assert format_key_value([]) == "" + + +# =========================================================================== +# progress_status +# =========================================================================== + + +class TestProgressStatus: + def test_output_to_stderr(self, capsys): + progress_status("Uploading blob...") + captured = capsys.readouterr() + assert "Uploading blob..." in captured.err + assert captured.out == "" diff --git a/src/regshape/tests/test_manifest_cli.py b/src/regshape/tests/test_manifest_cli.py index f1ff57e..6773bbb 100644 --- a/src/regshape/tests/test_manifest_cli.py +++ b/src/regshape/tests/test_manifest_cli.py @@ -269,9 +269,9 @@ def test_info_success_plain(self): ["manifest", "info", "-i", f"{REGISTRY}/{REPO}:{TAG}"], ) assert result.exit_code == 0, result.output - assert "Digest:" in result.output + assert "Digest" in result.output assert DIGEST in result.output - assert "Media Type:" in result.output + assert "Media Type" in result.output def test_info_404_exits_1(self): with patch("regshape.cli.manifest.head_manifest",