specshift 1.0.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.
specshift/git_utils.py ADDED
@@ -0,0 +1,82 @@
1
+ """Helper functions for reading file content across git revisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from specshift.spec_loader import SpecLoadError, parse_spec_text
9
+
10
+
11
+ class GitError(Exception):
12
+ """Raised when a git command fails."""
13
+
14
+
15
+ def _run_git(args: list[str], cwd: str | None = None) -> str:
16
+ try:
17
+ result = subprocess.run(
18
+ ["git", *args],
19
+ cwd=cwd,
20
+ check=True,
21
+ capture_output=True,
22
+ text=True,
23
+ )
24
+ except FileNotFoundError as exc:
25
+ raise GitError("git command not found. Make sure git is installed.") from exc
26
+ except subprocess.CalledProcessError as exc:
27
+ raise GitError(f"git command failed: {exc.stderr.strip()}") from exc
28
+
29
+ return result.stdout
30
+
31
+
32
+ def is_git_repo(path: str = ".") -> bool:
33
+ try:
34
+ _run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
35
+ return True
36
+ except GitError:
37
+ return False
38
+
39
+
40
+ def load_spec_from_git(file_path: str, ref: str, repo_path: str = ".") -> dict:
41
+ """
42
+ Reads the content of a file at a given git reference (e.g. 'main',
43
+ 'HEAD~1', a tag, or a commit hash) and parses it as a specification.
44
+ """
45
+ relative_path = Path(file_path)
46
+ try:
47
+ # The command may be invoked from outside the repo root, so
48
+ # normalize the path relative to the repository root.
49
+ toplevel = _run_git(["rev-parse", "--show-toplevel"], cwd=repo_path).strip()
50
+ repo_root = Path(toplevel)
51
+ abs_target = (Path(repo_path).resolve() / relative_path).resolve()
52
+ rel_to_root = abs_target.relative_to(repo_root)
53
+ git_path = str(rel_to_root).replace("\\", "/")
54
+ except (GitError, ValueError):
55
+ git_path = str(relative_path).replace("\\", "/")
56
+
57
+ try:
58
+ content = _run_git(["show", f"{ref}:{git_path}"], cwd=repo_path)
59
+ except GitError as exc:
60
+ raise GitError(
61
+ f"Could not read '{ref}:{git_path}'. Make sure the file path and "
62
+ f"reference are correct. Details: {exc}"
63
+ ) from exc
64
+
65
+ try:
66
+ return parse_spec_text(content, hint=f"{ref}:{git_path}")
67
+ except SpecLoadError as exc:
68
+ raise GitError(str(exc)) from exc
69
+
70
+
71
+ def current_branch(repo_path: str = ".") -> str:
72
+ try:
73
+ return _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path).strip()
74
+ except GitError:
75
+ return "unknown"
76
+
77
+
78
+ def short_sha(ref: str = "HEAD", repo_path: str = ".") -> str:
79
+ try:
80
+ return _run_git(["rev-parse", "--short", ref], cwd=repo_path).strip()
81
+ except GitError:
82
+ return ref
specshift/models.py ADDED
@@ -0,0 +1,110 @@
1
+ """Data models representing diff results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any, Optional
8
+
9
+
10
+ class Severity(str, Enum):
11
+ """Severity level of a detected change."""
12
+
13
+ BREAKING = "breaking"
14
+ WARNING = "warning"
15
+ INFO = "info"
16
+
17
+ @property
18
+ def rank(self) -> int:
19
+ return {"breaking": 2, "warning": 1, "info": 0}[self.value]
20
+
21
+ @property
22
+ def icon(self) -> str:
23
+ return {"breaking": "[BREAKING]", "warning": "[WARNING]", "info": "[INFO]"}[self.value]
24
+
25
+
26
+ class ChangeType(str, Enum):
27
+ """The type of change that occurred."""
28
+
29
+ ADDED = "added"
30
+ REMOVED = "removed"
31
+ MODIFIED = "modified"
32
+
33
+
34
+ @dataclass
35
+ class Change:
36
+ """Represents a single change between the old and new specification."""
37
+
38
+ severity: Severity
39
+ change_type: ChangeType
40
+ location: str
41
+ message: str
42
+ old_value: Optional[Any] = None
43
+ new_value: Optional[Any] = None
44
+ category: str = "general"
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "severity": self.severity.value,
49
+ "change_type": self.change_type.value,
50
+ "location": self.location,
51
+ "message": self.message,
52
+ "old_value": _safe_repr(self.old_value),
53
+ "new_value": _safe_repr(self.new_value),
54
+ "category": self.category,
55
+ }
56
+
57
+
58
+ def _safe_repr(value: Any) -> Any:
59
+ if value is None:
60
+ return None
61
+ if isinstance(value, (str, int, float, bool)):
62
+ return value
63
+ if isinstance(value, (list, tuple, set)):
64
+ return [str(v) for v in value]
65
+ return str(value)
66
+
67
+
68
+ @dataclass
69
+ class DiffResult:
70
+ """The aggregated result of all changes between two specifications."""
71
+
72
+ changes: list[Change] = field(default_factory=list)
73
+ old_title: str = ""
74
+ new_title: str = ""
75
+ old_version: str = ""
76
+ new_version: str = ""
77
+
78
+ def add(self, change: Change) -> None:
79
+ self.changes.append(change)
80
+
81
+ @property
82
+ def breaking_changes(self) -> list[Change]:
83
+ return [c for c in self.changes if c.severity == Severity.BREAKING]
84
+
85
+ @property
86
+ def warnings(self) -> list[Change]:
87
+ return [c for c in self.changes if c.severity == Severity.WARNING]
88
+
89
+ @property
90
+ def info_changes(self) -> list[Change]:
91
+ return [c for c in self.changes if c.severity == Severity.INFO]
92
+
93
+ @property
94
+ def has_breaking_changes(self) -> bool:
95
+ return len(self.breaking_changes) > 0
96
+
97
+ @property
98
+ def is_empty(self) -> bool:
99
+ return len(self.changes) == 0
100
+
101
+ def sorted_changes(self) -> list[Change]:
102
+ return sorted(self.changes, key=lambda c: (-c.severity.rank, c.location))
103
+
104
+ def summary_counts(self) -> dict:
105
+ return {
106
+ "breaking": len(self.breaking_changes),
107
+ "warning": len(self.warnings),
108
+ "info": len(self.info_changes),
109
+ "total": len(self.changes),
110
+ }
specshift/notifier.py ADDED
@@ -0,0 +1,66 @@
1
+ """Sends diff results via Slack or Discord webhooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import requests
6
+
7
+ from specshift.models import DiffResult
8
+
9
+
10
+ class NotifyError(Exception):
11
+ """Raised when a notification could not be sent."""
12
+
13
+
14
+ def notify_slack(webhook_url: str, result: DiffResult, ai_summary: str | None = None, timeout: int = 15) -> None:
15
+ counts = result.summary_counts()
16
+ color = "#e01e5a" if result.has_breaking_changes else ("#ecb22e" if counts["warning"] else "#2eb67d")
17
+
18
+ title = f"{result.old_title or 'API'} contract change: {result.old_version} -> {result.new_version}"
19
+ summary_line = f"{counts['breaking']} breaking, {counts['warning']} warning, {counts['info']} info changes"
20
+
21
+ fields = []
22
+ for change in result.breaking_changes[:10]:
23
+ fields.append({"title": change.location, "value": change.message, "short": False})
24
+
25
+ payload = {
26
+ "attachments": [
27
+ {
28
+ "color": color,
29
+ "title": title,
30
+ "text": summary_line + ("\n\n" + ai_summary if ai_summary else ""),
31
+ "fields": fields,
32
+ }
33
+ ]
34
+ }
35
+
36
+ _post(webhook_url, payload, timeout)
37
+
38
+
39
+ def notify_discord(webhook_url: str, result: DiffResult, ai_summary: str | None = None, timeout: int = 15) -> None:
40
+ counts = result.summary_counts()
41
+ color = 0xE01E5A if result.has_breaking_changes else (0xECB22E if counts["warning"] else 0x2EB67D)
42
+
43
+ title = f"{result.old_title or 'API'} contract change"
44
+ description = (
45
+ f"**{result.old_version} -> {result.new_version}**\n"
46
+ f"{counts['breaking']} breaking, {counts['warning']} warning, {counts['info']} info changes found."
47
+ )
48
+ if ai_summary:
49
+ description += f"\n\n{ai_summary[:1500]}"
50
+
51
+ fields = [
52
+ {"name": change.location[:256], "value": change.message[:1024], "inline": False}
53
+ for change in result.breaking_changes[:10]
54
+ ]
55
+
56
+ payload = {"embeds": [{"title": title, "description": description[:4000], "color": color, "fields": fields}]}
57
+
58
+ _post(webhook_url, payload, timeout)
59
+
60
+
61
+ def _post(webhook_url: str, payload: dict, timeout: int) -> None:
62
+ try:
63
+ response = requests.post(webhook_url, json=payload, timeout=timeout)
64
+ response.raise_for_status()
65
+ except requests.RequestException as exc:
66
+ raise NotifyError(f"Failed to send webhook: {exc}") from exc
specshift/reporter.py ADDED
@@ -0,0 +1,167 @@
1
+ """Renders a DiffResult into console, Markdown, and JSON reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+
8
+ from specshift.models import DiffResult, Severity
9
+
10
+ try:
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ _HAS_RICH = True
16
+ except ImportError: # fall back to plain text if rich is not installed
17
+ _HAS_RICH = False
18
+
19
+ _SEVERITY_COLORS = {
20
+ Severity.BREAKING: "bold red",
21
+ Severity.WARNING: "bold yellow",
22
+ Severity.INFO: "cyan",
23
+ }
24
+
25
+
26
+ def print_console_report(result: DiffResult, verbose: bool = True) -> None:
27
+ """Prints the result to the terminal with colors. Falls back to plain text without rich."""
28
+ if _HAS_RICH:
29
+ _print_rich_report(result, verbose=verbose)
30
+ else:
31
+ print(render_plain_text_report(result, verbose=verbose))
32
+
33
+
34
+ def _print_rich_report(result: DiffResult, verbose: bool) -> None:
35
+ console = Console()
36
+ counts = result.summary_counts()
37
+
38
+ header = f"{result.old_title or 'Specification'} : {result.old_version} -> {result.new_version}"
39
+ console.print(f"\n[bold]{header}[/bold]")
40
+ console.print(
41
+ f"[bold red]{counts['breaking']} breaking[/bold red], "
42
+ f"[bold yellow]{counts['warning']} warning[/bold yellow], "
43
+ f"[cyan]{counts['info']} info[/cyan] changes found.\n"
44
+ )
45
+
46
+ if result.is_empty:
47
+ console.print("[green]No differences found between the two specifications.[/green]")
48
+ return
49
+
50
+ changes = result.sorted_changes() if verbose else result.breaking_changes + result.warnings
51
+
52
+ table = Table(show_lines=False, expand=True)
53
+ table.add_column("Severity", width=10)
54
+ table.add_column("Location", ratio=1)
55
+ table.add_column("Description", ratio=2)
56
+
57
+ for change in changes:
58
+ color = _SEVERITY_COLORS.get(change.severity, "white")
59
+ table.add_row(
60
+ Text(change.severity.value.upper(), style=color),
61
+ change.location,
62
+ change.message,
63
+ )
64
+
65
+ console.print(table)
66
+
67
+ if result.has_breaking_changes:
68
+ console.print(
69
+ f"\n[bold red]Result: {counts['breaking']} breaking change(s) make this "
70
+ "specification update risky for consumers.[/bold red]"
71
+ )
72
+ else:
73
+ console.print("\n[bold green]Result: no breaking changes found.[/bold green]")
74
+
75
+
76
+ def render_plain_text_report(result: DiffResult, verbose: bool = True) -> str:
77
+ lines: list[str] = []
78
+ counts = result.summary_counts()
79
+
80
+ lines.append(f"{result.old_title or 'Specification'} : {result.old_version} -> {result.new_version}")
81
+ lines.append(
82
+ f"{counts['breaking']} breaking, {counts['warning']} warning, {counts['info']} info changes found."
83
+ )
84
+ lines.append("")
85
+
86
+ if result.is_empty:
87
+ lines.append("No differences found between the two specifications.")
88
+ return "\n".join(lines)
89
+
90
+ changes = result.sorted_changes() if verbose else result.breaking_changes + result.warnings
91
+
92
+ for change in changes:
93
+ lines.append(f"{change.severity.icon} {change.location} :: {change.message}")
94
+
95
+ lines.append("")
96
+ if result.has_breaking_changes:
97
+ lines.append(
98
+ f"Result: {counts['breaking']} breaking change(s) make this update risky."
99
+ )
100
+ else:
101
+ lines.append("Result: no breaking changes found.")
102
+
103
+ return "\n".join(lines)
104
+
105
+
106
+ def render_markdown_report(result: DiffResult, ai_summary: str | None = None) -> str:
107
+ counts = result.summary_counts()
108
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
109
+
110
+ lines: list[str] = []
111
+ lines.append(f"# API Contract Change Report")
112
+ lines.append("")
113
+ lines.append(f"**Source:** {result.old_title or 'API'} ({result.old_version} -> {result.new_version})")
114
+ lines.append(f"**Generated at:** {timestamp}")
115
+ lines.append("")
116
+
117
+ if result.has_breaking_changes:
118
+ lines.append(f"## Summary: {counts['breaking']} breaking change(s) found")
119
+ else:
120
+ lines.append("## Summary: no breaking changes found")
121
+
122
+ lines.append("")
123
+ lines.append(f"| Severity | Count |")
124
+ lines.append(f"|---|---|")
125
+ lines.append(f"| Breaking | {counts['breaking']} |")
126
+ lines.append(f"| Warning | {counts['warning']} |")
127
+ lines.append(f"| Info | {counts['info']} |")
128
+ lines.append("")
129
+
130
+ if ai_summary:
131
+ lines.append("## AI Summary")
132
+ lines.append("")
133
+ lines.append(ai_summary)
134
+ lines.append("")
135
+
136
+ for severity, title in (
137
+ (Severity.BREAKING, "Breaking Changes"),
138
+ (Severity.WARNING, "Warnings"),
139
+ (Severity.INFO, "Informational Changes"),
140
+ ):
141
+ changes = [c for c in result.sorted_changes() if c.severity == severity]
142
+ if not changes:
143
+ continue
144
+ lines.append(f"## {title}")
145
+ lines.append("")
146
+ for change in changes:
147
+ lines.append(f"- **{change.location}**: {change.message}")
148
+ lines.append("")
149
+
150
+ if result.is_empty:
151
+ lines.append("No differences were found between the two specifications.")
152
+
153
+ return "\n".join(lines)
154
+
155
+
156
+ def render_json_report(result: DiffResult, ai_summary: str | None = None) -> str:
157
+ payload = {
158
+ "old_title": result.old_title,
159
+ "new_title": result.new_title,
160
+ "old_version": result.old_version,
161
+ "new_version": result.new_version,
162
+ "summary": result.summary_counts(),
163
+ "has_breaking_changes": result.has_breaking_changes,
164
+ "ai_summary": ai_summary,
165
+ "changes": [c.to_dict() for c in result.sorted_changes()],
166
+ }
167
+ return json.dumps(payload, indent=2, ensure_ascii=False)
@@ -0,0 +1,122 @@
1
+ """Loads an OpenAPI/Swagger specification from a file, URL, or raw text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import requests
11
+ import yaml
12
+
13
+
14
+ class SpecLoadError(Exception):
15
+ """Raised when a specification cannot be loaded."""
16
+
17
+
18
+ def _parse_text(text: str, hint: str = "") -> dict:
19
+ """Tries to parse text as JSON first, then falls back to YAML."""
20
+ stripped = text.strip()
21
+ if not stripped:
22
+ raise SpecLoadError(f"Empty content: {hint}")
23
+
24
+ if stripped.startswith("{") or stripped.startswith("["):
25
+ try:
26
+ return json.loads(stripped)
27
+ except json.JSONDecodeError:
28
+ pass
29
+
30
+ try:
31
+ data = yaml.safe_load(stripped)
32
+ except yaml.YAMLError as exc:
33
+ raise SpecLoadError(f"YAML/JSON parsing error ({hint}): {exc}") from exc
34
+
35
+ if not isinstance(data, dict):
36
+ raise SpecLoadError(f"Specification is not an object/mapping: {hint}")
37
+
38
+ return data
39
+
40
+
41
+ def parse_spec_text(text: str, hint: str = "") -> dict:
42
+ """Public entry point for parsing raw specification text."""
43
+ return _parse_text(text, hint=hint)
44
+
45
+
46
+ def is_url(source: str) -> bool:
47
+ return bool(re.match(r"^https?://", source.strip(), re.IGNORECASE))
48
+
49
+
50
+ def load_spec(source: str, timeout: int = 15) -> dict:
51
+ """
52
+ Loads an OpenAPI/Swagger specification.
53
+
54
+ source can be a URL, a file path, or raw JSON/YAML text.
55
+ """
56
+ source = source.strip()
57
+
58
+ if is_url(source):
59
+ return _load_from_url(source, timeout=timeout)
60
+
61
+ path = Path(source)
62
+ if path.exists() and path.is_file():
63
+ return _load_from_file(path)
64
+
65
+ if source.startswith("{") or "openapi:" in source or "swagger:" in source:
66
+ return _parse_text(source, hint="inline text")
67
+
68
+ raise SpecLoadError(
69
+ f"Source not found or not recognized: '{source}'. "
70
+ "Expected a file path, an http(s) URL, or raw JSON/YAML text."
71
+ )
72
+
73
+
74
+ def _load_from_url(url: str, timeout: int = 15) -> dict:
75
+ try:
76
+ response = requests.get(url, timeout=timeout, headers={"Accept": "application/json, text/yaml, */*"})
77
+ response.raise_for_status()
78
+ except requests.RequestException as exc:
79
+ raise SpecLoadError(f"Could not fetch URL '{url}': {exc}") from exc
80
+
81
+ return _parse_text(response.text, hint=url)
82
+
83
+
84
+ def _load_from_file(path: Path) -> dict:
85
+ try:
86
+ text = path.read_text(encoding="utf-8")
87
+ except OSError as exc:
88
+ raise SpecLoadError(f"Could not read file '{path}': {exc}") from exc
89
+
90
+ return _parse_text(text, hint=str(path))
91
+
92
+
93
+ def resolve_ref(ref: str, root_spec: dict) -> dict:
94
+ """
95
+ Resolves a JSON pointer reference such as '#/components/schemas/User'.
96
+ Returns an empty dict if it cannot be resolved.
97
+ """
98
+ if not ref.startswith("#/"):
99
+ return {}
100
+
101
+ parts = ref[2:].split("/")
102
+ node: Any = root_spec
103
+ for part in parts:
104
+ part = part.replace("~1", "/").replace("~0", "~")
105
+ if isinstance(node, dict) and part in node:
106
+ node = node[part]
107
+ else:
108
+ return {}
109
+
110
+ return node if isinstance(node, dict) else {}
111
+
112
+
113
+ def resolve_schema(schema: dict, root_spec: dict, _depth: int = 0) -> dict:
114
+ """Follows a schema's $ref field (if present) and returns the resolved schema."""
115
+ if _depth > 20 or not isinstance(schema, dict):
116
+ return schema if isinstance(schema, dict) else {}
117
+
118
+ if "$ref" in schema:
119
+ resolved = resolve_ref(schema["$ref"], root_spec)
120
+ return resolve_schema(resolved, root_spec, _depth + 1)
121
+
122
+ return schema