gitinspect 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffenv/__init__.py +11 -0
- diffenv/api.py +72 -0
- diffenv/cli.py +155 -0
- diffenv/diff_layer.py +102 -0
- diffenv/exceptions.py +29 -0
- diffenv/formatter_layer.py +198 -0
- diffenv/git_layer.py +278 -0
- diffenv/logging_config.py +23 -0
- diffenv/models.py +107 -0
- diffenv/parser_layer.py +257 -0
- gitinspect-0.1.0.dist-info/METADATA +233 -0
- gitinspect-0.1.0.dist-info/RECORD +15 -0
- gitinspect-0.1.0.dist-info/WHEEL +5 -0
- gitinspect-0.1.0.dist-info/entry_points.txt +2 -0
- gitinspect-0.1.0.dist-info/top_level.txt +1 -0
diffenv/__init__.py
ADDED
diffenv/api.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public Python SDK for diffenv.
|
|
3
|
+
|
|
4
|
+
from diffenv import compare
|
|
5
|
+
result = compare("main", "feature/new-auth")
|
|
6
|
+
|
|
7
|
+
# Or get pre-rendered output directly:
|
|
8
|
+
text = compare("main", "feature/new-auth", output_format="json")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from diffenv.models import DiffResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compare(
|
|
17
|
+
ref_old: str,
|
|
18
|
+
ref_new: str,
|
|
19
|
+
*,
|
|
20
|
+
repo_path: str = ".",
|
|
21
|
+
auto_fetch: bool = True,
|
|
22
|
+
output_format: str | None = None,
|
|
23
|
+
) -> DiffResult | str:
|
|
24
|
+
"""
|
|
25
|
+
Compare environment-level changes between two git refs.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
ref_old: Base ref (branch name, tag, or commit SHA).
|
|
29
|
+
ref_new: Target ref to compare against.
|
|
30
|
+
repo_path: Path to the git repository root. Defaults to CWD.
|
|
31
|
+
auto_fetch: If True (default for SDK use), missing refs are
|
|
32
|
+
fetched from the remote automatically. If False,
|
|
33
|
+
a missing ref raises RefNotFoundError immediately.
|
|
34
|
+
The SDK never blocks on an interactive prompt
|
|
35
|
+
(that behavior is CLI-only); auto_fetch is the
|
|
36
|
+
only switch controlling this.
|
|
37
|
+
output_format: If given ("text", "json", or "color"), returns a
|
|
38
|
+
pre-rendered string instead of a DiffResult object.
|
|
39
|
+
Leave as None to get the structured DiffResult for
|
|
40
|
+
programmatic use.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A DiffResult by default, or a rendered str if output_format is set.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
NotAGitRepoError: ``repo_path`` is not inside a git repository.
|
|
47
|
+
RefNotFoundError: A ref could not be resolved locally or remotely.
|
|
48
|
+
ParseError: A tracked file exists but could not be parsed.
|
|
49
|
+
DiffEnvError: Any other diffenv-specific failure.
|
|
50
|
+
"""
|
|
51
|
+
# Imports are deferred so the module loads fast even before deps are ready.
|
|
52
|
+
from diffenv.git_layer import GitClient
|
|
53
|
+
from diffenv.parser_layer import parse_snapshot
|
|
54
|
+
from diffenv.diff_layer import compute_diff
|
|
55
|
+
from diffenv.formatter_layer import get_formatter
|
|
56
|
+
|
|
57
|
+
# The SDK is a non-interactive context: there is no terminal to prompt
|
|
58
|
+
# against, so a missing ref should fail fast (raise RefNotFoundError)
|
|
59
|
+
# rather than block on input(). auto_fetch governs the actual decision;
|
|
60
|
+
# this callback only exists to prevent GitClient from falling back to
|
|
61
|
+
# its interactive y/N console prompt.
|
|
62
|
+
client = GitClient(repo_path=repo_path, confirm_callback=lambda ref: False)
|
|
63
|
+
|
|
64
|
+
snapshot_old = parse_snapshot(client.fetch_files(ref_old, auto_fetch=auto_fetch), ref=ref_old)
|
|
65
|
+
snapshot_new = parse_snapshot(client.fetch_files(ref_new, auto_fetch=auto_fetch), ref=ref_new)
|
|
66
|
+
|
|
67
|
+
result = compute_diff(snapshot_old, snapshot_new)
|
|
68
|
+
|
|
69
|
+
if output_format is None:
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
return get_formatter(output_format).render(result)
|
diffenv/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for diffenv.
|
|
3
|
+
|
|
4
|
+
diffenv main feature/new-auth
|
|
5
|
+
diffenv main feature/new-auth --format json
|
|
6
|
+
diffenv main feature/new-auth --auto-fetch
|
|
7
|
+
diffenv main feature/new-auth --repo-path /path/to/repo
|
|
8
|
+
|
|
9
|
+
Design: this module is intentionally thin. All real logic lives in the
|
|
10
|
+
git/parser/diff/formatter layers; the CLI's only job is argument parsing,
|
|
11
|
+
wiring those layers together, and translating exceptions into clean,
|
|
12
|
+
user-facing messages with appropriate exit codes. No stack traces are ever
|
|
13
|
+
shown to the user — use --verbose to enable debug logging to stderr for
|
|
14
|
+
troubleshooting.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from diffenv.exceptions import DiffEnvError, GitError, NotAGitRepoError, ParseError, RefNotFoundError
|
|
25
|
+
from diffenv.logging_config import configure as configure_logging
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="diffenv",
|
|
29
|
+
help="Compare environment-level changes between two git branches or commits.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
pretty_exceptions_show_locals=False,
|
|
32
|
+
pretty_exceptions_enable=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_console_out = Console()
|
|
36
|
+
_console_err = Console(stderr=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def main(
|
|
41
|
+
ref_old: str = typer.Argument(..., help="Base branch, tag, or commit (e.g. 'main')"),
|
|
42
|
+
ref_new: str = typer.Argument(..., help="Target branch, tag, or commit (e.g. 'feature/new-auth')"),
|
|
43
|
+
output_format: str = typer.Option(
|
|
44
|
+
"color",
|
|
45
|
+
"--format",
|
|
46
|
+
"-f",
|
|
47
|
+
help="Output format: color, text, or json.",
|
|
48
|
+
),
|
|
49
|
+
repo_path: str = typer.Option(
|
|
50
|
+
".",
|
|
51
|
+
"--repo-path",
|
|
52
|
+
"-C",
|
|
53
|
+
help="Path to the git repository (defaults to current directory).",
|
|
54
|
+
),
|
|
55
|
+
auto_fetch: bool = typer.Option(
|
|
56
|
+
False,
|
|
57
|
+
"--auto-fetch",
|
|
58
|
+
help="Automatically fetch missing refs from the remote without prompting.",
|
|
59
|
+
),
|
|
60
|
+
verbose: bool = typer.Option(
|
|
61
|
+
False,
|
|
62
|
+
"--verbose",
|
|
63
|
+
"-v",
|
|
64
|
+
help="Show debug logging on stderr for troubleshooting.",
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Compare environment-level changes between REF_OLD and REF_NEW.
|
|
69
|
+
|
|
70
|
+
Both refs must exist locally, or be fetchable from the 'origin' remote.
|
|
71
|
+
If a ref is missing locally and --auto-fetch is not set, you'll be
|
|
72
|
+
prompted interactively to fetch it.
|
|
73
|
+
"""
|
|
74
|
+
configure_logging(verbose=verbose)
|
|
75
|
+
|
|
76
|
+
# Validate format early so a typo doesn't waste time doing git work first.
|
|
77
|
+
valid_formats = {"color", "text", "json"}
|
|
78
|
+
if output_format not in valid_formats:
|
|
79
|
+
_console_err.print(
|
|
80
|
+
f"[red]Error:[/red] Unknown output format '{output_format}'. "
|
|
81
|
+
f"Valid options: {', '.join(sorted(valid_formats))}."
|
|
82
|
+
)
|
|
83
|
+
raise typer.Exit(code=2)
|
|
84
|
+
|
|
85
|
+
from diffenv.git_layer import GitClient
|
|
86
|
+
from diffenv.parser_layer import parse_snapshot
|
|
87
|
+
from diffenv.diff_layer import compute_diff
|
|
88
|
+
from diffenv.formatter_layer import get_formatter
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
client = GitClient(repo_path=repo_path)
|
|
92
|
+
|
|
93
|
+
snapshot_old = parse_snapshot(client.fetch_files(ref_old, auto_fetch=auto_fetch), ref=ref_old)
|
|
94
|
+
snapshot_new = parse_snapshot(client.fetch_files(ref_new, auto_fetch=auto_fetch), ref=ref_new)
|
|
95
|
+
|
|
96
|
+
diff = compute_diff(snapshot_old, snapshot_new)
|
|
97
|
+
rendered = get_formatter(output_format).render(diff)
|
|
98
|
+
|
|
99
|
+
if output_format == "color":
|
|
100
|
+
_console_out.print(rendered)
|
|
101
|
+
else:
|
|
102
|
+
# text and json go to plain stdout — no rich markup, no ANSI
|
|
103
|
+
# codes — so output is safe to pipe/redirect/parse.
|
|
104
|
+
typer.echo(rendered)
|
|
105
|
+
|
|
106
|
+
# Exit code reflects whether changes were found — useful for CI
|
|
107
|
+
# gating (e.g. "fail the build if env changed without review").
|
|
108
|
+
raise typer.Exit(code=0 if diff.is_empty else 1)
|
|
109
|
+
|
|
110
|
+
except NotAGitRepoError as exc:
|
|
111
|
+
_console_err.print(f"[red]Error:[/red] {exc}")
|
|
112
|
+
raise typer.Exit(code=2)
|
|
113
|
+
|
|
114
|
+
except RefNotFoundError as exc:
|
|
115
|
+
_console_err.print(
|
|
116
|
+
f"[red]Error:[/red] Could not find ref '{exc.ref}'. "
|
|
117
|
+
"It doesn't exist locally and could not be resolved from the "
|
|
118
|
+
"remote. Try running 'git fetch' manually, or check the ref name."
|
|
119
|
+
)
|
|
120
|
+
raise typer.Exit(code=2)
|
|
121
|
+
|
|
122
|
+
except ParseError as exc:
|
|
123
|
+
_console_err.print(f"[red]Error:[/red] {exc}")
|
|
124
|
+
raise typer.Exit(code=2)
|
|
125
|
+
|
|
126
|
+
except GitError as exc:
|
|
127
|
+
_console_err.print(f"[red]Error:[/red] {exc}")
|
|
128
|
+
raise typer.Exit(code=2)
|
|
129
|
+
|
|
130
|
+
except DiffEnvError as exc:
|
|
131
|
+
_console_err.print(f"[red]Error:[/red] {exc}")
|
|
132
|
+
raise typer.Exit(code=2)
|
|
133
|
+
|
|
134
|
+
except typer.Exit:
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
except Exception as exc: # noqa: BLE001 — final safety net, never leak raw tracebacks
|
|
138
|
+
configure_logging.__module__ # no-op to keep import used
|
|
139
|
+
from diffenv.logging_config import get_logger
|
|
140
|
+
|
|
141
|
+
get_logger("cli").debug("Unexpected error", exc_info=True)
|
|
142
|
+
_console_err.print(
|
|
143
|
+
"[red]Error:[/red] An unexpected error occurred. "
|
|
144
|
+
"Run with --verbose for details."
|
|
145
|
+
)
|
|
146
|
+
raise typer.Exit(code=1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def app_entry() -> None:
|
|
150
|
+
"""Console-script entry point (referenced by pyproject.toml)."""
|
|
151
|
+
app()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
app()
|
diffenv/diff_layer.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diff layer — compares two ParsedSnapshot objects and produces a DiffResult.
|
|
3
|
+
|
|
4
|
+
This layer is pure data transformation: no file I/O, no git, no formatting.
|
|
5
|
+
It takes two already-parsed snapshots and figures out what changed.
|
|
6
|
+
Keeping it pure makes it trivially unit-testable and reusable from both
|
|
7
|
+
the CLI and the SDK.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from diffenv.logging_config import get_logger
|
|
13
|
+
from diffenv.models import DepChange, DiffResult, EnvVarChange, ParsedSnapshot
|
|
14
|
+
|
|
15
|
+
logger = get_logger("diff_layer")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _diff_dependencies(old: ParsedSnapshot, new: ParsedSnapshot) -> list[DepChange]:
|
|
19
|
+
"""
|
|
20
|
+
Compare dependency lists by name (case-insensitive — PyPI package names
|
|
21
|
+
are not case-sensitive) and report additions, removals, and version
|
|
22
|
+
changes. Unchanged dependencies are NOT included in the result.
|
|
23
|
+
|
|
24
|
+
Presence and version are tracked separately: a dependency can be
|
|
25
|
+
"present but unpinned" (version=None, present=True), which is distinct
|
|
26
|
+
from "absent" (present=False). This avoids conflating "no version
|
|
27
|
+
pinned" with "package not installed."
|
|
28
|
+
"""
|
|
29
|
+
old_by_name = {dep.name.lower(): dep for dep in old.dependencies}
|
|
30
|
+
new_by_name = {dep.name.lower(): dep for dep in new.dependencies}
|
|
31
|
+
|
|
32
|
+
all_names = sorted(set(old_by_name) | set(new_by_name))
|
|
33
|
+
changes: list[DepChange] = []
|
|
34
|
+
|
|
35
|
+
for name_key in all_names:
|
|
36
|
+
old_dep = old_by_name.get(name_key)
|
|
37
|
+
new_dep = new_by_name.get(name_key)
|
|
38
|
+
|
|
39
|
+
old_present = old_dep is not None
|
|
40
|
+
new_present = new_dep is not None
|
|
41
|
+
old_version = old_dep.version if old_dep else None
|
|
42
|
+
new_version = new_dep.version if new_dep else None
|
|
43
|
+
|
|
44
|
+
if old_present == new_present and old_version == new_version:
|
|
45
|
+
continue # no change
|
|
46
|
+
|
|
47
|
+
# Use the original-cased name from whichever side has it (prefer new)
|
|
48
|
+
display_name = (new_dep or old_dep).name
|
|
49
|
+
changes.append(
|
|
50
|
+
DepChange(
|
|
51
|
+
name=display_name,
|
|
52
|
+
old_present=old_present,
|
|
53
|
+
new_present=new_present,
|
|
54
|
+
old_version=old_version,
|
|
55
|
+
new_version=new_version,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return changes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _diff_env_vars(old: ParsedSnapshot, new: ParsedSnapshot) -> list[EnvVarChange]:
|
|
63
|
+
"""
|
|
64
|
+
Compare declared env var keys (case-sensitive — env var names ARE
|
|
65
|
+
case-sensitive at the OS level). Reports only additions and removals;
|
|
66
|
+
env vars present in both are unchanged by definition (we don't track
|
|
67
|
+
values, only key presence).
|
|
68
|
+
"""
|
|
69
|
+
old_keys = {ev.key for ev in old.env_vars}
|
|
70
|
+
new_keys = {ev.key for ev in new.env_vars}
|
|
71
|
+
|
|
72
|
+
added = sorted(new_keys - old_keys)
|
|
73
|
+
removed = sorted(old_keys - new_keys)
|
|
74
|
+
|
|
75
|
+
changes: list[EnvVarChange] = []
|
|
76
|
+
changes.extend(EnvVarChange(key=key, is_added=True) for key in added)
|
|
77
|
+
changes.extend(EnvVarChange(key=key, is_added=False) for key in removed)
|
|
78
|
+
return changes
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def compute_diff(old: ParsedSnapshot, new: ParsedSnapshot) -> DiffResult:
|
|
82
|
+
"""
|
|
83
|
+
Compute the full diff between two parsed snapshots.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
old: snapshot of the base ref (e.g. 'main').
|
|
87
|
+
new: snapshot of the target ref (e.g. 'feature/new-auth').
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
DiffResult containing only what changed. Use `.is_empty` to check
|
|
91
|
+
whether there were no environment-level differences at all.
|
|
92
|
+
"""
|
|
93
|
+
logger.debug("Computing diff: '%s' -> '%s'", old.ref, new.ref)
|
|
94
|
+
|
|
95
|
+
return DiffResult(
|
|
96
|
+
ref_old=old.ref,
|
|
97
|
+
ref_new=new.ref,
|
|
98
|
+
dep_changes=_diff_dependencies(old, new),
|
|
99
|
+
env_changes=_diff_env_vars(old, new),
|
|
100
|
+
python_old=old.python_version,
|
|
101
|
+
python_new=new.python_version,
|
|
102
|
+
)
|
diffenv/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Custom exceptions for diffenv. All user-facing errors derive from DiffEnvError."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DiffEnvError(Exception):
|
|
5
|
+
"""Base class for all diffenv errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitError(DiffEnvError):
|
|
9
|
+
"""Raised when a git operation fails (bad ref, not a repo, etc.)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RefNotFoundError(GitError):
|
|
13
|
+
"""Raised when a ref doesn't exist locally or remotely."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, ref: str) -> None:
|
|
16
|
+
self.ref = ref
|
|
17
|
+
super().__init__(f"Ref not found: '{ref}'")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotAGitRepoError(GitError):
|
|
21
|
+
"""Raised when the working directory is not inside a git repo."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ParseError(DiffEnvError):
|
|
25
|
+
"""Raised when a file cannot be parsed."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, filename: str, reason: str) -> None:
|
|
28
|
+
self.filename = filename
|
|
29
|
+
super().__init__(f"Could not parse '{filename}': {reason}")
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Formatter layer — renders a DiffResult as output text.
|
|
3
|
+
|
|
4
|
+
Three formatters ship today: plain text, JSON, and colored terminal output.
|
|
5
|
+
All implement the same minimal Formatter protocol, so the CLI/SDK can swap
|
|
6
|
+
between them via dependency injection without any conditional logic at the
|
|
7
|
+
call site — formatters are interchangeable by design.
|
|
8
|
+
|
|
9
|
+
To add a new output format later (e.g. Markdown for CI comments):
|
|
10
|
+
1. Write a class with a `.render(diff: DiffResult) -> str` method.
|
|
11
|
+
2. Register it in `FORMATTERS`.
|
|
12
|
+
That's the entire integration surface.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from diffenv.models import DiffResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Formatter(Protocol):
|
|
24
|
+
"""Minimal interface every formatter must implement."""
|
|
25
|
+
|
|
26
|
+
def render(self, diff: DiffResult) -> str:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Plain text formatter
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PlainTextFormatter:
|
|
36
|
+
"""Renders output exactly in the style shown in the project spec."""
|
|
37
|
+
|
|
38
|
+
def render(self, diff: DiffResult) -> str:
|
|
39
|
+
if diff.is_empty:
|
|
40
|
+
return f"No environment-level changes between '{diff.ref_old}' and '{diff.ref_new}'."
|
|
41
|
+
|
|
42
|
+
lines: list[str] = []
|
|
43
|
+
|
|
44
|
+
if diff.dep_changes:
|
|
45
|
+
lines.append("Dependencies")
|
|
46
|
+
for c in diff.dep_changes:
|
|
47
|
+
if c.is_added:
|
|
48
|
+
suffix = f"=={c.new_version}" if c.new_version else ""
|
|
49
|
+
lines.append(f"+ {c.name}{suffix}")
|
|
50
|
+
elif c.is_removed:
|
|
51
|
+
lines.append(f"- {c.name}")
|
|
52
|
+
elif c.is_upgraded:
|
|
53
|
+
new_v = c.new_version or "unpinned"
|
|
54
|
+
old_v = c.old_version or "unpinned"
|
|
55
|
+
lines.append(f"+ {c.name}=={new_v} (was {old_v})")
|
|
56
|
+
else:
|
|
57
|
+
# present both sides, version unchanged-but-flagged edge case
|
|
58
|
+
lines.append(f"~ {c.name}")
|
|
59
|
+
lines.append("")
|
|
60
|
+
|
|
61
|
+
if diff.env_changes:
|
|
62
|
+
lines.append("Environment Variables")
|
|
63
|
+
for c in diff.env_changes:
|
|
64
|
+
symbol = "+" if c.is_added else "-"
|
|
65
|
+
lines.append(f"{symbol} {c.key}")
|
|
66
|
+
lines.append("")
|
|
67
|
+
|
|
68
|
+
if diff.has_python_change:
|
|
69
|
+
lines.append("Python Runtime")
|
|
70
|
+
old_v = diff.python_old or "(none)"
|
|
71
|
+
new_v = diff.python_new or "(none)"
|
|
72
|
+
lines.append(f"{old_v} \u2192 {new_v}")
|
|
73
|
+
lines.append("")
|
|
74
|
+
|
|
75
|
+
return "\n".join(lines).rstrip("\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# JSON formatter
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class JSONFormatter:
|
|
84
|
+
"""Renders a machine-readable JSON representation, suitable for CI pipelines."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, indent: int = 2) -> None:
|
|
87
|
+
self.indent = indent
|
|
88
|
+
|
|
89
|
+
def render(self, diff: DiffResult) -> str:
|
|
90
|
+
payload = {
|
|
91
|
+
"ref_old": diff.ref_old,
|
|
92
|
+
"ref_new": diff.ref_new,
|
|
93
|
+
"is_empty": diff.is_empty,
|
|
94
|
+
"dependencies": [
|
|
95
|
+
{
|
|
96
|
+
"name": c.name,
|
|
97
|
+
"old_present": c.old_present,
|
|
98
|
+
"new_present": c.new_present,
|
|
99
|
+
"old_version": c.old_version,
|
|
100
|
+
"new_version": c.new_version,
|
|
101
|
+
"change_type": (
|
|
102
|
+
"added" if c.is_added else "removed" if c.is_removed else "upgraded" if c.is_upgraded else "changed"
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
for c in diff.dep_changes
|
|
106
|
+
],
|
|
107
|
+
"environment_variables": [
|
|
108
|
+
{"key": c.key, "change_type": "added" if c.is_added else "removed"}
|
|
109
|
+
for c in diff.env_changes
|
|
110
|
+
],
|
|
111
|
+
"python_runtime": {
|
|
112
|
+
"old": diff.python_old,
|
|
113
|
+
"new": diff.python_new,
|
|
114
|
+
"changed": diff.has_python_change,
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
return json.dumps(payload, indent=self.indent)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Colored terminal formatter (uses rich)
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ColorFormatter:
|
|
126
|
+
"""
|
|
127
|
+
Renders the same layout as PlainTextFormatter but with rich markup tags
|
|
128
|
+
for terminal coloring: green for additions, red for removals, yellow
|
|
129
|
+
for version upgrades and runtime changes.
|
|
130
|
+
|
|
131
|
+
Returns a string containing rich markup (e.g. "[green]+ foo[/green]"),
|
|
132
|
+
intended to be printed via a rich Console, not raw stdout — printing it
|
|
133
|
+
raw would show the literal markup tags.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def render(self, diff: DiffResult) -> str:
|
|
137
|
+
if diff.is_empty:
|
|
138
|
+
return f"No environment-level changes between '{diff.ref_old}' and '{diff.ref_new}'."
|
|
139
|
+
|
|
140
|
+
lines: list[str] = []
|
|
141
|
+
|
|
142
|
+
if diff.dep_changes:
|
|
143
|
+
lines.append("[bold]Dependencies[/bold]")
|
|
144
|
+
for c in diff.dep_changes:
|
|
145
|
+
if c.is_added:
|
|
146
|
+
suffix = f"=={c.new_version}" if c.new_version else ""
|
|
147
|
+
lines.append(f"[green]+ {c.name}{suffix}[/green]")
|
|
148
|
+
elif c.is_removed:
|
|
149
|
+
lines.append(f"[red]- {c.name}[/red]")
|
|
150
|
+
elif c.is_upgraded:
|
|
151
|
+
new_v = c.new_version or "unpinned"
|
|
152
|
+
old_v = c.old_version or "unpinned"
|
|
153
|
+
lines.append(f"[yellow]+ {c.name}=={new_v}[/yellow] [dim](was {old_v})[/dim]")
|
|
154
|
+
else:
|
|
155
|
+
lines.append(f"[yellow]~ {c.name}[/yellow]")
|
|
156
|
+
lines.append("")
|
|
157
|
+
|
|
158
|
+
if diff.env_changes:
|
|
159
|
+
lines.append("[bold]Environment Variables[/bold]")
|
|
160
|
+
for c in diff.env_changes:
|
|
161
|
+
if c.is_added:
|
|
162
|
+
lines.append(f"[green]+ {c.key}[/green]")
|
|
163
|
+
else:
|
|
164
|
+
lines.append(f"[red]- {c.key}[/red]")
|
|
165
|
+
lines.append("")
|
|
166
|
+
|
|
167
|
+
if diff.has_python_change:
|
|
168
|
+
lines.append("[bold]Python Runtime[/bold]")
|
|
169
|
+
old_v = diff.python_old or "(none)"
|
|
170
|
+
new_v = diff.python_new or "(none)"
|
|
171
|
+
lines.append(f"[yellow]{old_v} \u2192 {new_v}[/yellow]")
|
|
172
|
+
lines.append("")
|
|
173
|
+
|
|
174
|
+
return "\n".join(lines).rstrip("\n")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Registry
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
FORMATTERS: dict[str, type] = {
|
|
182
|
+
"text": PlainTextFormatter,
|
|
183
|
+
"json": JSONFormatter,
|
|
184
|
+
"color": ColorFormatter,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_formatter(name: str) -> Formatter:
|
|
189
|
+
"""
|
|
190
|
+
Look up a formatter by name. Raises ValueError with a clean message
|
|
191
|
+
(not a KeyError) listing valid options, since this is user-facing
|
|
192
|
+
(e.g. invalid --format flag value).
|
|
193
|
+
"""
|
|
194
|
+
formatter_cls = FORMATTERS.get(name)
|
|
195
|
+
if formatter_cls is None:
|
|
196
|
+
valid = ", ".join(sorted(FORMATTERS))
|
|
197
|
+
raise ValueError(f"Unknown output format '{name}'. Valid options: {valid}")
|
|
198
|
+
return formatter_cls()
|