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 ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ diffenv — compare environment-level changes between git refs.
3
+
4
+ Public SDK surface:
5
+ from diffenv import compare
6
+ """
7
+
8
+ from diffenv.api import compare
9
+
10
+ __all__ = ["compare"]
11
+ __version__ = "0.1.0"
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()