Skip to content

Commit 22278f7

Browse files
authored
Merge pull request #47 from toddysm/feature/cli-formatting
Implement CLI output formatting enhancements
2 parents ede4c84 + 52dbe34 commit 22278f7

13 files changed

Lines changed: 685 additions & 308 deletions

File tree

specs/cli/formatting.md

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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.)

src/regshape/cli/auth.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@
1212
.. moduleauthor:: ToddySM <toddysm@gmail.com>
1313
"""
1414

15-
import sys
16-
1715
import click
1816
import requests
1917

18+
from regshape.cli.formatting import emit_error
2019
from regshape.libs.auth.credentials import erase_credentials, resolve_credentials, store_credentials
2120
from regshape.libs.decorators import telemetry_options
2221
from regshape.libs.decorators.scenario import track_scenario
@@ -102,11 +101,9 @@ def login(ctx, registry, username, password, password_stdin, docker_config):
102101
try:
103102
_verify_credentials(registry, resolved_username, resolved_password, insecure=insecure)
104103
except AuthError as e:
105-
_error(registry, str(e))
106-
sys.exit(1)
104+
emit_error(registry, str(e))
107105
except requests.exceptions.RequestException as e:
108-
_error(registry, str(e))
109-
sys.exit(1)
106+
emit_error(registry, str(e))
110107

111108
# --- Persist credentials ------------------------------------------------
112109
try:
@@ -117,8 +114,7 @@ def login(ctx, registry, username, password, password_stdin, docker_config):
117114
docker_config_path=docker_config,
118115
)
119116
except AuthError as e:
120-
_error(registry, f"Could not store credentials: {e}")
121-
sys.exit(1)
117+
emit_error(registry, f"Could not store credentials: {e}")
122118

123119
# --- Success output ------------------------------------------------------
124120
click.echo("Login succeeded.")
@@ -144,8 +140,7 @@ def logout(ctx, registry, docker_config):
144140
try:
145141
found = erase_credentials(registry, docker_config_path=docker_config)
146142
except AuthError as e:
147-
_error(registry, str(e))
148-
sys.exit(1)
143+
emit_error(registry, str(e))
149144

150145
if found:
151146
click.echo(f"Removing login credentials for {registry}.")
@@ -194,6 +189,3 @@ def _verify_credentials(registry: str, username: str, password: str, insecure: b
194189
)
195190

196191

197-
def _error(registry: str, reason: str) -> None:
198-
"""Print an error message to stderr."""
199-
click.echo(f"Error for {registry}: {reason}", err=True)

0 commit comments

Comments
 (0)