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 ADDED
@@ -0,0 +1,20 @@
1
+ """
2
+ specshift - a CLI tool that detects, classifies, and (optionally)
3
+ summarizes changes in OpenAPI and Swagger contracts using AI.
4
+ """
5
+
6
+ from specshift.differ import diff_specs
7
+ from specshift.models import Change, Severity, ChangeType, DiffResult
8
+ from specshift.spec_loader import load_spec
9
+
10
+ __version__ = "1.0.0"
11
+
12
+ __all__ = [
13
+ "diff_specs",
14
+ "load_spec",
15
+ "Change",
16
+ "Severity",
17
+ "ChangeType",
18
+ "DiffResult",
19
+ "__version__",
20
+ ]
@@ -0,0 +1,201 @@
1
+ """
2
+ Generates a natural-language summary from a DiffResult.
3
+
4
+ This module is entirely optional. If neither GROQ_API_KEY nor GEMINI_API_KEY
5
+ is set, a rule-based summary is produced without making any network calls.
6
+ This keeps the project fully functional without depending on any paid
7
+ service.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from typing import Optional
14
+
15
+ import requests
16
+
17
+ from specshift.models import DiffResult, Severity
18
+
19
+ GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
20
+ GROQ_DEFAULT_MODEL = "llama-3.3-70b-versatile"
21
+
22
+ GEMINI_API_URL_TEMPLATE = (
23
+ "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
24
+ )
25
+ GEMINI_DEFAULT_MODEL = "gemini-2.0-flash"
26
+
27
+ OPENAI_COMPATIBLE_URL_ENV = "SPECSHIFT_OPENAI_BASE_URL"
28
+
29
+
30
+ class AISummaryError(Exception):
31
+ """Raised when the AI summary could not be generated."""
32
+
33
+
34
+ def generate_summary(
35
+ result: DiffResult,
36
+ provider: Optional[str] = None,
37
+ model: Optional[str] = None,
38
+ timeout: int = 30,
39
+ ) -> str:
40
+ """
41
+ Returns a natural-language summary using a configured AI provider when
42
+ possible, otherwise falls back to a rule-based summary. Never raises an
43
+ exception that would halt execution; failures fall back silently.
44
+ """
45
+ prompt = _build_prompt(result)
46
+
47
+ chosen_provider = provider or _detect_provider()
48
+
49
+ if chosen_provider == "groq" and os.environ.get("GROQ_API_KEY"):
50
+ try:
51
+ return _call_groq(prompt, model or GROQ_DEFAULT_MODEL, timeout)
52
+ except AISummaryError:
53
+ pass
54
+
55
+ if chosen_provider == "gemini" and os.environ.get("GEMINI_API_KEY"):
56
+ try:
57
+ return _call_gemini(prompt, model or GEMINI_DEFAULT_MODEL, timeout)
58
+ except AISummaryError:
59
+ pass
60
+
61
+ if chosen_provider == "openai_compatible" and os.environ.get("SPECSHIFT_API_KEY"):
62
+ try:
63
+ return _call_openai_compatible(prompt, model, timeout)
64
+ except AISummaryError:
65
+ pass
66
+
67
+ return build_template_summary(result)
68
+
69
+
70
+ def _detect_provider() -> Optional[str]:
71
+ if os.environ.get("GROQ_API_KEY"):
72
+ return "groq"
73
+ if os.environ.get("GEMINI_API_KEY"):
74
+ return "gemini"
75
+ if os.environ.get("SPECSHIFT_API_KEY") and os.environ.get(OPENAI_COMPATIBLE_URL_ENV):
76
+ return "openai_compatible"
77
+ return None
78
+
79
+
80
+ def _build_prompt(result: DiffResult) -> str:
81
+ counts = result.summary_counts()
82
+ lines = [
83
+ "Below is a list of contract changes detected between two versions "
84
+ "of an API. Read this list and write a short, clear, professional "
85
+ "summary in English that a developer consuming this API would "
86
+ "understand. First highlight the most important breaking changes, "
87
+ "then anything worth paying attention to, and finally briefly "
88
+ "mention minor additions. Do not use lists or headings, write in "
89
+ "flowing paragraphs. Do not invent information, base the summary "
90
+ "only on the changes given.",
91
+ "",
92
+ f"Total: {counts['breaking']} breaking, {counts['warning']} warning, {counts['info']} informational changes.",
93
+ "",
94
+ ]
95
+ for change in result.sorted_changes()[:60]:
96
+ lines.append(f"- [{change.severity.value}] {change.location}: {change.message}")
97
+
98
+ return "\n".join(lines)
99
+
100
+
101
+ def _call_groq(prompt: str, model: str, timeout: int) -> str:
102
+ api_key = os.environ["GROQ_API_KEY"]
103
+ try:
104
+ response = requests.post(
105
+ GROQ_API_URL,
106
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
107
+ json={
108
+ "model": model,
109
+ "messages": [{"role": "user", "content": prompt}],
110
+ "temperature": 0.3,
111
+ "max_tokens": 700,
112
+ },
113
+ timeout=timeout,
114
+ )
115
+ response.raise_for_status()
116
+ data = response.json()
117
+ return data["choices"][0]["message"]["content"].strip()
118
+ except (requests.RequestException, KeyError, IndexError, ValueError) as exc:
119
+ raise AISummaryError(f"Groq request failed: {exc}") from exc
120
+
121
+
122
+ def _call_gemini(prompt: str, model: str, timeout: int) -> str:
123
+ api_key = os.environ["GEMINI_API_KEY"]
124
+ url = GEMINI_API_URL_TEMPLATE.format(model=model, key=api_key)
125
+ try:
126
+ response = requests.post(
127
+ url,
128
+ json={
129
+ "contents": [{"parts": [{"text": prompt}]}],
130
+ "generationConfig": {"temperature": 0.3, "maxOutputTokens": 700},
131
+ },
132
+ timeout=timeout,
133
+ )
134
+ response.raise_for_status()
135
+ data = response.json()
136
+ return data["candidates"][0]["content"]["parts"][0]["text"].strip()
137
+ except (requests.RequestException, KeyError, IndexError, ValueError) as exc:
138
+ raise AISummaryError(f"Gemini request failed: {exc}") from exc
139
+
140
+
141
+ def _call_openai_compatible(prompt: str, model: Optional[str], timeout: int) -> str:
142
+ api_key = os.environ["SPECSHIFT_API_KEY"]
143
+ base_url = os.environ[OPENAI_COMPATIBLE_URL_ENV].rstrip("/")
144
+ try:
145
+ response = requests.post(
146
+ f"{base_url}/chat/completions",
147
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
148
+ json={
149
+ "model": model or "default",
150
+ "messages": [{"role": "user", "content": prompt}],
151
+ "temperature": 0.3,
152
+ "max_tokens": 700,
153
+ },
154
+ timeout=timeout,
155
+ )
156
+ response.raise_for_status()
157
+ data = response.json()
158
+ return data["choices"][0]["message"]["content"].strip()
159
+ except (requests.RequestException, KeyError, IndexError, ValueError) as exc:
160
+ raise AISummaryError(f"Custom endpoint request failed: {exc}") from exc
161
+
162
+
163
+ def build_template_summary(result: DiffResult) -> str:
164
+ """A deterministic, rule-based summary that works without any API key."""
165
+ counts = result.summary_counts()
166
+
167
+ if result.is_empty:
168
+ return "No differences were detected between the two specifications."
169
+
170
+ parts: list[str] = []
171
+
172
+ if counts["breaking"] > 0:
173
+ top_breaking = result.breaking_changes[:5]
174
+ breaking_desc = "; ".join(f"{c.location} ({c.message.rstrip('.')})" for c in top_breaking)
175
+ extra = counts["breaking"] - len(top_breaking)
176
+ extra_note = f" and {extra} more breaking change(s)" if extra > 0 else ""
177
+ parts.append(
178
+ f"This update contains {counts['breaking']} breaking change(s) and may affect "
179
+ f"existing API consumers. Notably: {breaking_desc}{extra_note}. "
180
+ "Clients using this API should review these points before migrating."
181
+ )
182
+ else:
183
+ parts.append(
184
+ "This update does not contain any breaking changes, existing clients "
185
+ "can continue working without any additional action."
186
+ )
187
+
188
+ if counts["warning"] > 0:
189
+ parts.append(
190
+ f"There are also {counts['warning']} change(s) worth paying attention to; "
191
+ "these are not directly breaking but may cause behavioral differences "
192
+ "in some usage scenarios."
193
+ )
194
+
195
+ if counts["info"] > 0:
196
+ parts.append(
197
+ f"The remaining {counts['info']} change(s) are new features or improvements "
198
+ "and do not pose a risk to existing integrations."
199
+ )
200
+
201
+ return " ".join(parts)
specshift/cli.py ADDED
@@ -0,0 +1,256 @@
1
+ """SpecShift command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from specshift import __version__
14
+ from specshift.ai_summary import generate_summary
15
+ from specshift.config import SpecShiftConfig, write_default_config
16
+ from specshift.differ import diff_specs
17
+ from specshift.git_utils import GitError, load_spec_from_git
18
+ from specshift.models import DiffResult
19
+ from specshift.notifier import NotifyError, notify_discord, notify_slack
20
+ from specshift.reporter import print_console_report, render_json_report, render_markdown_report
21
+ from specshift.spec_loader import SpecLoadError, load_spec
22
+
23
+ CACHE_DIR = Path(".specshift_cache")
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="specshift",
29
+ description="Detects and classifies changes in OpenAPI/Swagger specifications.",
30
+ )
31
+ parser.add_argument("--version", action="version", version=f"specshift {__version__}")
32
+
33
+ subparsers = parser.add_subparsers(dest="command", required=True)
34
+
35
+ diff_parser = subparsers.add_parser("diff", help="Compare two specifications")
36
+ diff_parser.add_argument("old", help="Old specification: file path or URL")
37
+ diff_parser.add_argument("new", help="New specification: file path or URL")
38
+ _add_common_diff_args(diff_parser)
39
+
40
+ check_parser = subparsers.add_parser(
41
+ "check", help="For CI: compares the current specification against a git reference"
42
+ )
43
+ check_parser.add_argument("--spec", help="Path to the specification file (overrides config)")
44
+ check_parser.add_argument("--base-ref", help="Git reference to compare against (overrides config)")
45
+ check_parser.add_argument("--config", default=None, help="Path to the configuration file")
46
+ _add_common_diff_args(check_parser)
47
+
48
+ watch_parser = subparsers.add_parser(
49
+ "watch", help="Periodically watches a URL and sends a notification when it changes"
50
+ )
51
+ watch_parser.add_argument("url", help="URL of the OpenAPI specification to watch")
52
+ watch_parser.add_argument("--interval", type=int, default=300, help="Polling interval in seconds, default 300")
53
+ watch_parser.add_argument("--once", action="store_true", help="Check only once and exit")
54
+ watch_parser.add_argument("--slack-webhook", help="Slack webhook URL")
55
+ watch_parser.add_argument("--discord-webhook", help="Discord webhook URL")
56
+ watch_parser.add_argument("--ai", action="store_true", help="Add an AI summary to notifications")
57
+ watch_parser.add_argument("--ai-provider", choices=["groq", "gemini", "openai_compatible"], default=None)
58
+
59
+ init_parser = subparsers.add_parser("init", help="Creates a sample .specshift.yml configuration file")
60
+ init_parser.add_argument("--path", default=".specshift.yml", help="Path of the file to create")
61
+ init_parser.add_argument("--force", action="store_true", help="Overwrite an existing file")
62
+
63
+ return parser
64
+
65
+
66
+ def _add_common_diff_args(parser: argparse.ArgumentParser) -> None:
67
+ parser.add_argument(
68
+ "--format", choices=["console", "markdown", "json"], default="console", help="Output format"
69
+ )
70
+ parser.add_argument("--output", help="Write the output to a file (stdout if omitted)")
71
+ parser.add_argument("--ai", action="store_true", help="Generate a natural-language AI summary")
72
+ parser.add_argument(
73
+ "--ai-provider", choices=["groq", "gemini", "openai_compatible"], default=None, help="AI provider to use"
74
+ )
75
+ parser.add_argument("--ai-model", default=None, help="Name of the AI model to use")
76
+ parser.add_argument(
77
+ "--fail-on",
78
+ choices=["breaking", "warning", "none"],
79
+ default="breaking",
80
+ help="Determines at which severity level a non-zero exit code is returned",
81
+ )
82
+ parser.add_argument("--quiet", action="store_true", help="Only print the summary line")
83
+
84
+
85
+ def main(argv: Optional[list[str]] = None) -> int:
86
+ parser = build_parser()
87
+ args = parser.parse_args(argv)
88
+
89
+ try:
90
+ if args.command == "diff":
91
+ return _run_diff(args)
92
+ if args.command == "check":
93
+ return _run_check(args)
94
+ if args.command == "watch":
95
+ return _run_watch(args)
96
+ if args.command == "init":
97
+ return _run_init(args)
98
+ except (SpecLoadError, GitError) as exc:
99
+ print(f"Error: {exc}", file=sys.stderr)
100
+ return 2
101
+ except KeyboardInterrupt:
102
+ print("\nStopped.")
103
+ return 130
104
+
105
+ parser.print_help()
106
+ return 1
107
+
108
+
109
+ def _run_diff(args: argparse.Namespace) -> int:
110
+ old_spec = load_spec(args.old)
111
+ new_spec = load_spec(args.new)
112
+ result = diff_specs(old_spec, new_spec)
113
+ return _emit_result(result, args)
114
+
115
+
116
+ def _run_check(args: argparse.Namespace) -> int:
117
+ config = SpecShiftConfig.load(args.config)
118
+ spec_path = args.spec or config.spec_path
119
+ base_ref = args.base_ref or config.base_ref
120
+
121
+ old_spec = load_spec_from_git(spec_path, base_ref)
122
+ new_spec = load_spec(spec_path)
123
+
124
+ result = diff_specs(old_spec, new_spec)
125
+
126
+ if not args.ai_provider and config.ai_provider:
127
+ args.ai_provider = config.ai_provider
128
+ if not args.ai_model and config.ai_model:
129
+ args.ai_model = config.ai_model
130
+ if args.fail_on == "breaking" and config.fail_on != "breaking":
131
+ args.fail_on = config.fail_on
132
+
133
+ exit_code = _emit_result(result, args)
134
+
135
+ if config.slack_webhook or config.discord_webhook:
136
+ ai_summary = None
137
+ if args.ai:
138
+ ai_summary = generate_summary(result, provider=args.ai_provider, model=args.ai_model)
139
+ _send_notifications(result, config.slack_webhook, config.discord_webhook, ai_summary)
140
+
141
+ return exit_code
142
+
143
+
144
+ def _emit_result(result: DiffResult, args: argparse.Namespace) -> int:
145
+ ai_summary = None
146
+ if args.ai:
147
+ ai_summary = generate_summary(result, provider=args.ai_provider, model=args.ai_model)
148
+
149
+ output_text: str
150
+ if args.format == "json":
151
+ output_text = render_json_report(result, ai_summary=ai_summary)
152
+ elif args.format == "markdown":
153
+ output_text = render_markdown_report(result, ai_summary=ai_summary)
154
+ else:
155
+ output_text = None # console format is printed directly
156
+
157
+ if args.format == "console":
158
+ if args.output:
159
+ from specshift.reporter import render_plain_text_report
160
+
161
+ Path(args.output).write_text(render_plain_text_report(result), encoding="utf-8")
162
+ if not args.quiet:
163
+ print(f"Report written to: {args.output}")
164
+ else:
165
+ print_console_report(result, verbose=not args.quiet)
166
+ if ai_summary:
167
+ print(f"\nAI Summary:\n{ai_summary}")
168
+ else:
169
+ if args.output:
170
+ Path(args.output).write_text(output_text, encoding="utf-8")
171
+ print(f"Report written to: {args.output}")
172
+ else:
173
+ print(output_text)
174
+
175
+ return _exit_code_for(result, args.fail_on)
176
+
177
+
178
+ def _exit_code_for(result: DiffResult, fail_on: str) -> int:
179
+ if fail_on == "none":
180
+ return 0
181
+ if fail_on == "warning":
182
+ return 1 if (result.has_breaking_changes or result.warnings) else 0
183
+ return 1 if result.has_breaking_changes else 0
184
+
185
+
186
+ def _run_init(args: argparse.Namespace) -> int:
187
+ target = Path(args.path)
188
+ if target.exists() and not args.force:
189
+ print(f"'{target}' already exists. Use --force to overwrite it.", file=sys.stderr)
190
+ return 1
191
+
192
+ write_default_config(str(target))
193
+ print(f"Configuration file created: {target}")
194
+ return 0
195
+
196
+
197
+ def _run_watch(args: argparse.Namespace) -> int:
198
+ CACHE_DIR.mkdir(exist_ok=True)
199
+ cache_key = hashlib.sha256(args.url.encode("utf-8")).hexdigest()[:16]
200
+ cache_file = CACHE_DIR / f"{cache_key}.json"
201
+
202
+ while True:
203
+ try:
204
+ current_spec = load_spec(args.url)
205
+ except SpecLoadError as exc:
206
+ print(f"Warning: could not fetch specification ({exc}), will retry.", file=sys.stderr)
207
+ if args.once:
208
+ return 2
209
+ time.sleep(args.interval)
210
+ continue
211
+
212
+ if cache_file.exists():
213
+ previous_spec = json.loads(cache_file.read_text(encoding="utf-8"))
214
+ result = diff_specs(previous_spec, current_spec)
215
+
216
+ if not result.is_empty:
217
+ print_console_report(result)
218
+ ai_summary = None
219
+ if args.ai:
220
+ ai_summary = generate_summary(result, provider=args.ai_provider)
221
+
222
+ _send_notifications(result, args.slack_webhook, args.discord_webhook, ai_summary)
223
+ else:
224
+ print("No changes found.")
225
+ else:
226
+ print(f"Initial snapshot saved: {args.url}")
227
+
228
+ cache_file.write_text(json.dumps(current_spec), encoding="utf-8")
229
+
230
+ if args.once:
231
+ return 0
232
+
233
+ time.sleep(args.interval)
234
+
235
+
236
+ def _send_notifications(
237
+ result: DiffResult, slack_webhook: Optional[str], discord_webhook: Optional[str], ai_summary: Optional[str]
238
+ ) -> None:
239
+ if result.is_empty:
240
+ return
241
+
242
+ if slack_webhook:
243
+ try:
244
+ notify_slack(slack_webhook, result, ai_summary=ai_summary)
245
+ except NotifyError as exc:
246
+ print(f"Failed to send Slack notification: {exc}", file=sys.stderr)
247
+
248
+ if discord_webhook:
249
+ try:
250
+ notify_discord(discord_webhook, result, ai_summary=ai_summary)
251
+ except NotifyError as exc:
252
+ print(f"Failed to send Discord notification: {exc}", file=sys.stderr)
253
+
254
+
255
+ if __name__ == "__main__":
256
+ sys.exit(main())
specshift/config.py ADDED
@@ -0,0 +1,79 @@
1
+ """Reads the '.specshift.yml' configuration file and merges it with defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import yaml
10
+
11
+ DEFAULT_CONFIG_FILENAMES = (".specshift.yml", ".specshift.yaml")
12
+
13
+
14
+ @dataclass
15
+ class SpecShiftConfig:
16
+ spec_path: str = "openapi.yaml"
17
+ base_ref: str = "main"
18
+ fail_on: str = "breaking" # breaking | warning | none
19
+ ai_provider: Optional[str] = None
20
+ ai_model: Optional[str] = None
21
+ slack_webhook: Optional[str] = None
22
+ discord_webhook: Optional[str] = None
23
+ ignore_paths: list = field(default_factory=list)
24
+
25
+ @classmethod
26
+ def load(cls, path: Optional[str] = None) -> "SpecShiftConfig":
27
+ candidates = [path] if path else list(DEFAULT_CONFIG_FILENAMES)
28
+
29
+ for candidate in candidates:
30
+ if not candidate:
31
+ continue
32
+ file_path = Path(candidate)
33
+ if file_path.exists():
34
+ data = yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
35
+ return cls._from_dict(data)
36
+
37
+ return cls()
38
+
39
+ @classmethod
40
+ def _from_dict(cls, data: dict) -> "SpecShiftConfig":
41
+ return cls(
42
+ spec_path=data.get("spec_path", cls.spec_path),
43
+ base_ref=data.get("base_ref", cls.base_ref),
44
+ fail_on=data.get("fail_on", cls.fail_on),
45
+ ai_provider=data.get("ai_provider"),
46
+ ai_model=data.get("ai_model"),
47
+ slack_webhook=data.get("slack_webhook"),
48
+ discord_webhook=data.get("discord_webhook"),
49
+ ignore_paths=data.get("ignore_paths", []),
50
+ )
51
+
52
+
53
+ def write_default_config(path: str = ".specshift.yml") -> None:
54
+ content = """\
55
+ # specshift configuration file
56
+ # For detailed documentation, see: https://github.com/Lethe044/specshift
57
+
58
+ # Path to the OpenAPI/Swagger file to track (relative to the repo root)
59
+ spec_path: openapi.yaml
60
+
61
+ # The base git reference to compare against (branch, tag, or commit)
62
+ base_ref: main
63
+
64
+ # Determines at which severity level the CI build should fail: breaking | warning | none
65
+ fail_on: breaking
66
+
67
+ # Optional: AI provider for the natural-language summary (groq | gemini | openai_compatible)
68
+ # The API key must be provided as an environment variable: GROQ_API_KEY or GEMINI_API_KEY
69
+ # ai_provider: groq
70
+ # ai_model: llama-3.3-70b-versatile
71
+
72
+ # Optional: notification webhook URLs
73
+ # slack_webhook: https://hooks.slack.com/services/...
74
+ # discord_webhook: https://discord.com/api/webhooks/...
75
+
76
+ # Path patterns to ignore during the diff (optional)
77
+ ignore_paths: []
78
+ """
79
+ Path(path).write_text(content, encoding="utf-8")