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/__init__.py +20 -0
- specshift/ai_summary.py +201 -0
- specshift/cli.py +256 -0
- specshift/config.py +79 -0
- specshift/differ.py +721 -0
- specshift/git_utils.py +82 -0
- specshift/models.py +110 -0
- specshift/notifier.py +66 -0
- specshift/reporter.py +167 -0
- specshift/spec_loader.py +122 -0
- specshift-1.0.0.dist-info/METADATA +340 -0
- specshift-1.0.0.dist-info/RECORD +16 -0
- specshift-1.0.0.dist-info/WHEEL +5 -0
- specshift-1.0.0.dist-info/entry_points.txt +2 -0
- specshift-1.0.0.dist-info/licenses/LICENSE +21 -0
- specshift-1.0.0.dist-info/top_level.txt +1 -0
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)
|
specshift/spec_loader.py
ADDED
|
@@ -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
|