Skip to content

Commit 111de23

Browse files
committed
cli/_output(refactor[typing]): Add JSON payload TypedDicts
why: Make plan/output JSON structures explicit for safer indexing and clearer contracts. what: - Introduce JsonValue aliases plus typed payloads for plan entry/summary/result - Type OutputFormatter payload handling and cast summary/status payloads - Adjust plan output tests for TypedDict iteration
1 parent f59314b commit 111de23

4 files changed

Lines changed: 91 additions & 23 deletions

File tree

src/vcspull/cli/_output.py

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,64 @@
88
from dataclasses import dataclass, field
99
from enum import Enum
1010

11+
from typing_extensions import NotRequired
12+
13+
JsonPrimitive: t.TypeAlias = str | int | float | bool | None
14+
JsonValue: t.TypeAlias = JsonPrimitive | dict[str, "JsonValue"] | list["JsonValue"]
15+
JsonObject: t.TypeAlias = t.Mapping[str, JsonValue]
16+
OutputPayload: t.TypeAlias = JsonObject | "PlanEntryPayload" | "PlanSummaryPayload"
17+
18+
19+
class PlanEntryPayload(t.TypedDict):
20+
"""Typed JSON payload for a plan entry."""
21+
22+
format_version: str
23+
type: str
24+
name: str
25+
path: str
26+
workspace_root: str
27+
action: str
28+
detail: NotRequired[str]
29+
url: NotRequired[str]
30+
branch: NotRequired[str]
31+
remote_branch: NotRequired[str]
32+
current_rev: NotRequired[str]
33+
target_rev: NotRequired[str]
34+
ahead: NotRequired[int]
35+
behind: NotRequired[int]
36+
dirty: NotRequired[bool]
37+
error: NotRequired[str]
38+
diagnostics: NotRequired[list[str]]
39+
40+
41+
class PlanSummaryPayload(t.TypedDict):
42+
"""Typed JSON payload for a plan summary."""
43+
44+
format_version: str
45+
type: str
46+
clone: int
47+
update: int
48+
unchanged: int
49+
blocked: int
50+
errors: int
51+
total: int
52+
duration_ms: NotRequired[int]
53+
54+
55+
class PlanWorkspacePayload(t.TypedDict):
56+
"""Typed JSON payload for a workspace grouping."""
57+
58+
path: str
59+
operations: list[PlanEntryPayload]
60+
61+
62+
class PlanResultPayload(t.TypedDict):
63+
"""Typed JSON payload for a plan result."""
64+
65+
format_version: str
66+
workspaces: list[PlanWorkspacePayload]
67+
summary: PlanSummaryPayload
68+
1169

1270
class OutputMode(Enum):
1371
"""Output format modes."""
@@ -47,9 +105,9 @@ class PlanEntry:
47105
error: str | None = None
48106
diagnostics: list[str] = field(default_factory=list)
49107

50-
def to_payload(self) -> dict[str, t.Any]:
108+
def to_payload(self) -> PlanEntryPayload:
51109
"""Convert the plan entry into a serialisable payload."""
52-
payload: dict[str, t.Any] = {
110+
payload: PlanEntryPayload = {
53111
"format_version": "1",
54112
"type": "operation",
55113
"name": self.name,
@@ -97,9 +155,9 @@ def total(self) -> int:
97155
"""Return the total number of repositories accounted for."""
98156
return self.clone + self.update + self.unchanged + self.blocked + self.errors
99157

100-
def to_payload(self) -> dict[str, t.Any]:
158+
def to_payload(self) -> PlanSummaryPayload:
101159
"""Convert the summary to a serialisable payload."""
102-
payload: dict[str, t.Any] = {
160+
payload: PlanSummaryPayload = {
103161
"format_version": "1",
104162
"type": "summary",
105163
"clone": self.clone,
@@ -139,9 +197,9 @@ def to_workspace_mapping(self) -> dict[str, list[PlanEntry]]:
139197
grouped.setdefault(entry.workspace_root, []).append(entry)
140198
return grouped
141199

142-
def to_json_object(self) -> dict[str, t.Any]:
200+
def to_json_object(self) -> PlanResultPayload:
143201
"""Return the JSON structure for ``--json`` output."""
144-
workspaces: list[dict[str, t.Any]] = []
202+
workspaces: list[PlanWorkspacePayload] = []
145203
for workspace_root, entries in self.to_workspace_mapping().items():
146204
workspaces.append(
147205
{
@@ -168,17 +226,18 @@ def __init__(self, mode: OutputMode = OutputMode.HUMAN) -> None:
168226
The output mode to use (human, json, ndjson)
169227
"""
170228
self.mode = mode
171-
self._json_buffer: list[dict[str, t.Any]] = []
229+
self._json_buffer: list[OutputPayload] = []
172230

173-
def emit(self, data: dict[str, t.Any] | PlanEntry | PlanSummary) -> None:
231+
def emit(self, data: OutputPayload | PlanEntry | PlanSummary) -> None:
174232
"""Emit a data event.
175233
176234
Parameters
177235
----------
178-
data : dict | PlanEntry | PlanSummary
236+
data : OutputPayload | PlanEntry | PlanSummary
179237
Event data to emit. PlanEntry and PlanSummary instances are serialised
180238
automatically.
181239
"""
240+
payload: OutputPayload
182241
if isinstance(data, (PlanEntry, PlanSummary)):
183242
payload = data.to_payload()
184243
else:

src/vcspull/cli/status.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from vcspull.types import ConfigDict
2020

2121
from ._colors import Colors, get_color_mode
22-
from ._output import OutputFormatter, get_output_mode
22+
from ._output import JsonObject, OutputFormatter, get_output_mode
2323
from ._workspaces import filter_by_workspace
2424

2525
log = logging.getLogger(__name__)
@@ -451,25 +451,28 @@ def status_repos(
451451
summary["missing"] += 1
452452

453453
# Emit status
454-
formatter.emit(
454+
status_payload = t.cast(
455+
"JsonObject",
455456
{
456457
"reason": "status",
457458
**status,
458459
},
459460
)
461+
formatter.emit(status_payload)
460462

461463
# Human output
462464
_format_status_line(status, formatter, colors, detailed)
463465

464466
# Emit summary
465-
summary_data: dict[str, t.Any] = {
466-
"reason": "summary",
467-
**summary,
468-
}
469-
if duration_ms is not None:
470-
summary_data["duration_ms"] = duration_ms
471-
472-
formatter.emit(summary_data)
467+
summary_payload = t.cast(
468+
"JsonObject",
469+
{
470+
"reason": "summary",
471+
**summary,
472+
**({"duration_ms": duration_ms} if duration_ms is not None else {}),
473+
},
474+
)
475+
formatter.emit(summary_payload)
473476

474477
# Human summary
475478
formatter.emit_text(

src/vcspull/cli/sync.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
from ._colors import Colors, get_color_mode
3434
from ._output import (
35+
JsonObject,
3536
OutputFormatter,
3637
OutputMode,
3738
PlanAction,
@@ -763,12 +764,14 @@ def silent_progress(output: str, timestamp: datetime) -> None:
763764
f"{colors.error(str(e))}",
764765
)
765766
if exit_on_error:
766-
formatter.emit(
767+
summary_payload = t.cast(
768+
"JsonObject",
767769
{
768770
"reason": "summary",
769771
**summary,
770772
},
771773
)
774+
formatter.emit(summary_payload)
772775
formatter.finalize()
773776
if parser is not None:
774777
parser.exit(status=1, message=EXIT_ON_ERROR_MSG)
@@ -783,12 +786,14 @@ def silent_progress(output: str, timestamp: datetime) -> None:
783786
f"{colors.muted('→')} {display_repo_path}",
784787
)
785788

786-
formatter.emit(
789+
summary_payload = t.cast(
790+
"JsonObject",
787791
{
788792
"reason": "summary",
789793
**summary,
790794
},
791795
)
796+
formatter.emit(summary_payload)
792797

793798
if formatter.mode == OutputMode.HUMAN:
794799
formatter.emit_text(

tests/cli/test_plan_output_helpers.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,13 @@ def test_plan_entry_to_payload(
9393
"""Ensure PlanEntry serialises optional fields correctly."""
9494
entry = PlanEntry(**kwargs)
9595
payload = entry.to_payload()
96+
payload_map = t.cast(dict[str, t.Any], payload)
9697

9798
for key, value in expected_keys.items():
98-
assert payload[key] == value
99+
assert payload_map[key] == value
99100

100101
for key in unexpected_keys:
101-
assert key not in payload
102+
assert key not in payload_map
102103

103104
assert payload["format_version"] == "1"
104105
assert payload["type"] == "operation"

0 commit comments

Comments
 (0)