|
| 1 | +# CLI: Output Formatting Enhancements |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +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). |
| 6 | + |
| 7 | +## Problem Statement |
| 8 | + |
| 9 | +The current CLI modules exhibit several repeated patterns: |
| 10 | + |
| 11 | +1. **Duplicated `_write()` helper** — Identical implementations in `manifest.py`, `tag.py`, `catalog.py`, and `referrer.py`. |
| 12 | +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`. |
| 13 | +3. **Inconsistent JSON formatting** — Most modules use `json.dumps(obj, indent=2)`, but the pattern is manually applied everywhere. |
| 14 | +4. **No progress indicators** — Long-running operations (blob upload, layout push) provide no feedback. |
| 15 | +5. **No structured table output** — List commands (`tag list`, `catalog list`, `referrer list`) use raw `"\n".join()` or hand-crafted space-separated columns. |
| 16 | + |
| 17 | +## API Surface |
| 18 | + |
| 19 | +### Module: `src/regshape/cli/formatting.py` |
| 20 | + |
| 21 | +All output helpers live in a single module. No classes — just functions. |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +#### `emit_json(data: dict | list, output_path: str | None = None) -> None` |
| 26 | + |
| 27 | +Format and emit a JSON object. Always uses 2-space indentation. |
| 28 | + |
| 29 | +**Parameters:** |
| 30 | +- `data` — Serializable dict or list. |
| 31 | +- `output_path` — If provided, write to file instead of stdout. |
| 32 | + |
| 33 | +**Behavior:** |
| 34 | +- Calls `json.dumps(data, indent=2)`. |
| 35 | +- If `output_path` is set, writes to file (appending `\n` if missing), otherwise `click.echo()` to stdout. |
| 36 | + |
| 37 | +--- |
| 38 | + |
| 39 | +#### `emit_text(content: str, output_path: str | None = None) -> None` |
| 40 | + |
| 41 | +Emit plain text content to stdout or a file. |
| 42 | + |
| 43 | +**Parameters:** |
| 44 | +- `content` — Text string to output. |
| 45 | +- `output_path` — If provided, write to file instead of stdout. |
| 46 | + |
| 47 | +**Behavior:** |
| 48 | +- If `output_path` is set, writes to file (appending `\n` if missing), otherwise `click.echo()` to stdout. |
| 49 | + |
| 50 | +This replaces the four duplicated `_write()` functions. |
| 51 | + |
| 52 | +--- |
| 53 | + |
| 54 | +#### `emit_error(reference: str, reason: str, exit_code: int = 1) -> None` |
| 55 | + |
| 56 | +Print a standardized error message to stderr and exit. |
| 57 | + |
| 58 | +**Parameters:** |
| 59 | +- `reference` — Context identifier (image ref, registry, repo). Displayed in brackets. |
| 60 | +- `reason` — Human-readable error description. |
| 61 | +- `exit_code` — Process exit code (default: 1). |
| 62 | + |
| 63 | +**Output format:** |
| 64 | +``` |
| 65 | +Error [registry/repo:tag]: manifest not found |
| 66 | +``` |
| 67 | + |
| 68 | +**Behavior:** |
| 69 | +- `click.echo(f"Error [{reference}]: {reason}", err=True)` |
| 70 | +- `sys.exit(exit_code)` |
| 71 | + |
| 72 | +--- |
| 73 | + |
| 74 | +#### `emit_table(rows: list[list[str]], headers: list[str] | None = None) -> None` |
| 75 | + |
| 76 | +Print tabular data with aligned columns. |
| 77 | + |
| 78 | +**Parameters:** |
| 79 | +- `rows` — List of row data, each row a list of string values. |
| 80 | +- `headers` — Optional column headers. When provided, printed first as a header row. |
| 81 | + |
| 82 | +**Behavior:** |
| 83 | +- Calculates column widths from the maximum length in each column (including headers). |
| 84 | +- Left-aligns all columns with 2-space padding between them. |
| 85 | +- Writes to stdout via `click.echo()`. |
| 86 | + |
| 87 | +**Example output (with headers):** |
| 88 | +``` |
| 89 | +DIGEST ARTIFACT TYPE SIZE |
| 90 | +sha256:abc123... application/vnd.example+json 1024 |
| 91 | +sha256:def456... application/vnd.other+json 2048 |
| 92 | +``` |
| 93 | + |
| 94 | +**Example output (without headers):** |
| 95 | +``` |
| 96 | +sha256:abc123... application/vnd.example+json 1024 |
| 97 | +sha256:def456... application/vnd.other+json 2048 |
| 98 | +``` |
| 99 | + |
| 100 | +--- |
| 101 | + |
| 102 | +#### `emit_list(items: list[str], output_path: str | None = None) -> None` |
| 103 | + |
| 104 | +Print a simple one-item-per-line list. |
| 105 | + |
| 106 | +**Parameters:** |
| 107 | +- `items` — List of string items. |
| 108 | +- `output_path` — If provided, write to file instead of stdout. |
| 109 | + |
| 110 | +**Behavior:** |
| 111 | +- Joins items with `"\n"` and emits via `emit_text()`. |
| 112 | +- Replaces inline `"\n".join(...)` patterns in `tag list` and `catalog list`. |
| 113 | + |
| 114 | +--- |
| 115 | + |
| 116 | +#### `format_key_value(pairs: list[tuple[str, str]], separator: str = ":") -> str` |
| 117 | + |
| 118 | +Format aligned key-value pairs for display (used by `manifest info`). |
| 119 | + |
| 120 | +**Parameters:** |
| 121 | +- `pairs` — List of `(key, value)` tuples. |
| 122 | +- `separator` — Character between key and value (default: `":"`). |
| 123 | + |
| 124 | +**Returns:** Formatted multi-line string with left-aligned keys and a vertically aligned separator. |
| 125 | + |
| 126 | +**Example:** |
| 127 | +``` |
| 128 | +Digest : sha256:abc123... |
| 129 | +Media Type : application/vnd.oci.image.manifest.v1+json |
| 130 | +Size : 1234 |
| 131 | +``` |
| 132 | + |
| 133 | +--- |
| 134 | + |
| 135 | +#### `progress_status(message: str) -> None` |
| 136 | + |
| 137 | +Print a transient status message to stderr for long-running operations. |
| 138 | + |
| 139 | +**Parameters:** |
| 140 | +- `message` — Status message to display. |
| 141 | + |
| 142 | +**Behavior:** |
| 143 | +- Writes to stderr (`err=True`) so it doesn't interfere with piped stdout. |
| 144 | +- Uses `click.echo(message, err=True)`. |
| 145 | + |
| 146 | +**Usage in CLI commands:** |
| 147 | +```python |
| 148 | +from regshape.cli.formatting import progress_status |
| 149 | + |
| 150 | +progress_status("Uploading blob...") |
| 151 | +result = upload_blob(client, repo, file_path) |
| 152 | +progress_status("Upload complete.") |
| 153 | +``` |
| 154 | + |
| 155 | +--- |
| 156 | + |
| 157 | +## Migration Plan |
| 158 | + |
| 159 | +### Phase 1: Create `formatting.py` with all helpers |
| 160 | + |
| 161 | +Create the module with the functions defined above. Add unit tests. |
| 162 | + |
| 163 | +### Phase 2: Migrate existing CLI modules |
| 164 | + |
| 165 | +Replace duplicated code in each CLI module one at a time: |
| 166 | + |
| 167 | +| Module | Change | |
| 168 | +|--------|--------| |
| 169 | +| `manifest.py` | Replace `_write()` with `emit_text()` / `emit_json()`; replace `_emit_error()` with `emit_error()` | |
| 170 | +| `tag.py` | Replace `_write()` with `emit_text()` / `emit_list()`; replace `_emit_error()` with `emit_error()` | |
| 171 | +| `catalog.py` | Replace `_write()` with `emit_text()` / `emit_list()`; replace `_emit_error()` with `emit_error()` | |
| 172 | +| `referrer.py` | Replace `_write()` with `emit_text()`; replace `_emit_error()` with `emit_error()`; use `emit_table()` for list output | |
| 173 | +| `blob.py` | Replace `_emit_error()` with `emit_error()` | |
| 174 | +| `ping.py` | Replace inline error echoing with `emit_error()` | |
| 175 | +| `auth.py` | Replace `_emit_error()` with `emit_error()` | |
| 176 | +| `layout.py` | Replace inline progress messages with `progress_status()`; replace `_emit_error()` with `emit_error()` | |
| 177 | +| `docker.py` | Replace `_emit_error()` with `emit_error()` | |
| 178 | + |
| 179 | +### Phase 3: Add tests |
| 180 | + |
| 181 | +Create `src/regshape/tests/test_formatting.py` covering: |
| 182 | + |
| 183 | +- `emit_json` — Verifies correct JSON structure and file output. |
| 184 | +- `emit_text` — Verifies stdout and file output with newline handling. |
| 185 | +- `emit_error` — Verifies stderr output format and `sys.exit()` call. |
| 186 | +- `emit_table` — Verifies column alignment with and without headers. |
| 187 | +- `emit_list` — Verifies newline-joined output. |
| 188 | +- `format_key_value` — Verifies aligned key-value output. |
| 189 | +- `progress_status` — Verifies output goes to stderr. |
| 190 | + |
| 191 | +## Design Decisions |
| 192 | + |
| 193 | +### Why plain functions, not a class? |
| 194 | + |
| 195 | +The formatting helpers are stateless utilities. A class would add ceremony without benefit. Individual functions are easier to import selectively and test independently. |
| 196 | + |
| 197 | +### Why no third-party table library (e.g., `tabulate`, `rich`)? |
| 198 | + |
| 199 | +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. |
| 200 | + |
| 201 | +### Why `progress_status()` instead of spinners/progress bars? |
| 202 | + |
| 203 | +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. |
| 204 | + |
| 205 | +### Why `emit_error()` calls `sys.exit()`? |
| 206 | + |
| 207 | +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. |
| 208 | + |
| 209 | +## Dependencies |
| 210 | + |
| 211 | +- **Internal:** `click` (already a project dependency) |
| 212 | +- **External:** None (no new dependencies) |
| 213 | + |
| 214 | +## Open Questions |
| 215 | + |
| 216 | +- [ ] Should `emit_table()` support right-aligned numeric columns? (Current proposal: all left-aligned for simplicity.) |
| 217 | +- [ ] Should `progress_status()` use `\r` for overwriting the same line, or print sequential lines? (Current proposal: sequential lines.) |
| 218 | +- [ ] Should `emit_error()` accept an optional `--json` flag to output errors as JSON objects? (Some tools do this for machine-parseable error reporting.) |
0 commit comments