api-qovrix 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.
api_qovrix/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """api-qovrix: contract-aware API monitoring."""
2
+
3
+ __version__ = "0.1.0"
api_qovrix/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from api_qovrix.cli.app import main
2
+
3
+ main()
File without changes
api_qovrix/cli/app.py ADDED
@@ -0,0 +1,462 @@
1
+ """The `api-qovrix` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Annotated, Any
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.markup import escape
14
+ from rich.table import Table
15
+
16
+ from api_qovrix import __version__
17
+ from api_qovrix.config.editing import add_secrets, dump_yaml, set_openapi_source
18
+ from api_qovrix.config.loader import Project, find_config, load_project
19
+ from api_qovrix.config.templating import references
20
+ from api_qovrix.drift.store import BaselineStore
21
+ from api_qovrix.engine.runner import BaselineMode, RunOptions, run_monitors, select_monitors
22
+ from api_qovrix.errors import QovrixError
23
+ from api_qovrix.report.output import print_summary, to_json, to_junit
24
+
25
+ app = typer.Typer(
26
+ name="api-qovrix",
27
+ help="Contract-aware API monitoring: catches breaking changes, not just downtime.",
28
+ no_args_is_help=True,
29
+ add_completion=False,
30
+ rich_markup_mode="rich",
31
+ pretty_exceptions_enable=False,
32
+ )
33
+ import_app = typer.Typer(help="Generate monitors from other formats.", no_args_is_help=True)
34
+ drift_app = typer.Typer(help="Inspect, learn and accept response-shape baselines.", no_args_is_help=True)
35
+ app.add_typer(import_app, name="import")
36
+ app.add_typer(drift_app, name="drift")
37
+
38
+ console = Console(highlight=False, soft_wrap=True)
39
+ err = Console(stderr=True, highlight=False, soft_wrap=True)
40
+
41
+ ConfigOpt = Annotated[
42
+ Path | None, typer.Option("--config", "-c", help="Path to api-qovrix.yaml (default: search upwards).")
43
+ ]
44
+ EnvOpt = Annotated[str | None, typer.Option("--env", "-e", help="Environment from api-qovrix.yaml.")]
45
+ EnvFileOpt = Annotated[
46
+ Path | None, typer.Option("--env-file", help="Load environment variables from a KEY=VALUE file.")
47
+ ]
48
+
49
+
50
+ def _fail(message: str, code: int = 2) -> typer.Exit:
51
+ err.print(f"[red]error:[/red] {escape(message)}")
52
+ return typer.Exit(code)
53
+
54
+
55
+ def _load(config: Path | None, env: str | None, env_file: Path | None) -> Project:
56
+ try:
57
+ return load_project(config, env, env_file)
58
+ except QovrixError as exc:
59
+ raise _fail(str(exc)) from None
60
+
61
+
62
+ def _version_callback(value: bool) -> None:
63
+ if value:
64
+ console.print(f"api-qovrix {__version__}")
65
+ raise typer.Exit()
66
+
67
+
68
+ @app.callback()
69
+ def _root(
70
+ version: Annotated[
71
+ bool,
72
+ typer.Option("--version", "-V", callback=_version_callback, is_eager=True, help="Show the version."),
73
+ ] = False,
74
+ ) -> None:
75
+ pass
76
+
77
+
78
+ # --------------------------------------------------------------------------- init / validate
79
+
80
+
81
+ @app.command()
82
+ def init(
83
+ directory: Annotated[Path, typer.Argument(help="Where to create the project.")] = Path("."),
84
+ project: Annotated[str | None, typer.Option(help="Project name.")] = None,
85
+ force: Annotated[bool, typer.Option(help="Overwrite existing files.")] = False,
86
+ ) -> None:
87
+ """Create api-qovrix.yaml and an example monitor."""
88
+ from api_qovrix.scaffold import init_project
89
+
90
+ try:
91
+ created = init_project(directory, project, force)
92
+ except QovrixError as exc:
93
+ raise _fail(str(exc)) from None
94
+ for p in created:
95
+ console.print(f"[green]created[/green] {escape(str(p))}")
96
+ console.print("\nNext: [bold]api-qovrix run[/bold]")
97
+
98
+
99
+ @app.command()
100
+ def validate(
101
+ config: ConfigOpt = None,
102
+ env: EnvOpt = None,
103
+ env_file: EnvFileOpt = None,
104
+ strict: Annotated[bool, typer.Option(help="Treat missing secrets as errors.")] = False,
105
+ ) -> None:
106
+ """Check config and monitor files: schema, references and secrets."""
107
+ project = _load(config, env, env_file)
108
+ missing = project.secrets.missing()
109
+ used: set[str] = set()
110
+ for m in project.monitors:
111
+ used |= references(m.monitor.model_dump(by_alias=True), "secrets")
112
+ if m.auth:
113
+ used |= references(m.auth.model_dump(by_alias=True), "secrets")
114
+ undeclared = sorted(used - set(project.secrets))
115
+ if undeclared:
116
+ raise _fail(f"secret(s) used but not declared under 'secrets:': {', '.join(undeclared)}")
117
+
118
+ if project.config.openapi:
119
+ from api_qovrix.openapi.spec import OpenAPISpec
120
+
121
+ try:
122
+ OpenAPISpec.load(project.config.openapi.source, project.root)
123
+ except QovrixError as exc:
124
+ raise _fail(str(exc)) from None
125
+
126
+ services = sorted({m.service for m in project.monitors})
127
+ console.print(
128
+ f"[green]✔[/green] {len(project.monitors)} monitors in {len(services)} services"
129
+ + (f" · env {escape(project.environment)}" if project.environment else "")
130
+ )
131
+ problems = 0
132
+ for name, source in sorted(missing.items()):
133
+ if name not in used:
134
+ continue
135
+ hint = f" (set {source})" if source else ""
136
+ style = "red" if strict else "yellow"
137
+ console.print(f"[{style}]![/{style}] secret {name} has no value{escape(hint)}")
138
+ problems += 1
139
+ if strict and problems:
140
+ raise typer.Exit(1)
141
+
142
+
143
+ # --------------------------------------------------------------------------- run
144
+
145
+
146
+ def _execute(
147
+ project: Project,
148
+ names: list[str],
149
+ services: list[str],
150
+ tags: list[str],
151
+ concurrency: int,
152
+ mode: BaselineMode,
153
+ ) -> Any:
154
+ try:
155
+ monitors = select_monitors(project, names, services, tags)
156
+ except QovrixError as exc:
157
+ raise _fail(str(exc)) from None
158
+ if not monitors:
159
+ raise _fail("no monitors selected", 1)
160
+ try:
161
+ return asyncio.run(
162
+ run_monitors(project, monitors, RunOptions(concurrency=concurrency, baseline_mode=mode))
163
+ )
164
+ except QovrixError as exc:
165
+ raise _fail(str(exc)) from None
166
+
167
+
168
+ @app.command()
169
+ def run(
170
+ monitors: Annotated[list[str] | None, typer.Argument(help="Only these monitors.")] = None,
171
+ service: Annotated[
172
+ list[str] | None, typer.Option("--service", "-s", help="Only this service (repeatable).")
173
+ ] = None,
174
+ tag: Annotated[
175
+ list[str] | None, typer.Option("--tag", "-t", help="Only monitors with this tag (repeatable).")
176
+ ] = None,
177
+ config: ConfigOpt = None,
178
+ env: EnvOpt = None,
179
+ env_file: EnvFileOpt = None,
180
+ junit: Annotated[Path | None, typer.Option(help="Write a JUnit XML report.")] = None,
181
+ json_out: Annotated[
182
+ Path | None, typer.Option("--json-out", help="Write a JSON report to this file ('-' for stdout).")
183
+ ] = None,
184
+ update_baselines: Annotated[
185
+ bool,
186
+ typer.Option(
187
+ "--update-baselines", help="Record responses into .api-qovrix/baselines/ (accepts changes)."
188
+ ),
189
+ ] = False,
190
+ concurrency: Annotated[int, typer.Option(min=1, max=200, help="Monitors run in parallel.")] = 10,
191
+ verbose: Annotated[
192
+ bool, typer.Option("--verbose", "-v", help="Show every assertion and timings.")
193
+ ] = False,
194
+ ) -> None:
195
+ """Run monitors once. Exit code 1 when any monitor fails (for CI)."""
196
+ project = _load(config, env, env_file)
197
+ summary = _execute(
198
+ project,
199
+ monitors or [],
200
+ service or [],
201
+ tag or [],
202
+ concurrency,
203
+ "update" if update_baselines else "check",
204
+ )
205
+ to_stdout = json_out is not None and str(json_out) == "-"
206
+ if not to_stdout:
207
+ print_summary(console, summary, verbose)
208
+ if json_out is not None:
209
+ if to_stdout:
210
+ sys.stdout.write(to_json(summary) + "\n")
211
+ else:
212
+ json_out.write_text(to_json(summary) + "\n", encoding="utf-8")
213
+ if junit is not None:
214
+ junit.parent.mkdir(parents=True, exist_ok=True)
215
+ junit.write_text(to_junit(summary), encoding="utf-8")
216
+ if update_baselines and not to_stdout:
217
+ console.print("[dim]Baselines updated in .api-qovrix/baselines/ — review and commit them.[/dim]")
218
+ raise typer.Exit(0 if summary.ok else 1)
219
+
220
+
221
+ # --------------------------------------------------------------------------- drift
222
+
223
+
224
+ @drift_app.command("show")
225
+ def drift_show(
226
+ monitor: Annotated[str, typer.Argument(help="Monitor name.")],
227
+ live: Annotated[
228
+ bool, typer.Option(help="Call the API now and show changes against the baseline.")
229
+ ] = False,
230
+ config: ConfigOpt = None,
231
+ env: EnvOpt = None,
232
+ env_file: EnvFileOpt = None,
233
+ ) -> None:
234
+ """Show a monitor's learned response shape (and, with --live, current drift)."""
235
+ project = _load(config, env, env_file)
236
+ try:
237
+ project.monitor(monitor)
238
+ except QovrixError as exc:
239
+ raise _fail(str(exc)) from None
240
+ data = BaselineStore(project.baselines_dir).load(monitor)
241
+ if not data:
242
+ console.print(
243
+ f"No baseline for {monitor} yet. Create one: api-qovrix run {monitor} --update-baselines"
244
+ )
245
+ raise typer.Exit(0)
246
+ for step_name, entry in data.get("steps", {}).items():
247
+ status = "accepted" if entry.get("accepted") else "learning"
248
+ console.print(
249
+ f"[bold]{escape(monitor)}[/bold] step [bold]{escape(step_name)}[/bold]"
250
+ f" · {entry['samples']} samples · "
251
+ f"{status} · updated {entry.get('updated_at', '?')}"
252
+ )
253
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
254
+ table.add_column("path")
255
+ table.add_column("type")
256
+ table.add_column("presence")
257
+ for path, types, presence in _flatten(entry["shape"]):
258
+ table.add_row(escape(path), types, presence)
259
+ console.print(table)
260
+ if live:
261
+ summary = _execute(project, [monitor], [], [], 1, "check")
262
+ console.print()
263
+ print_summary(console, summary, verbose=False)
264
+
265
+
266
+ def _flatten(node: dict[str, Any], path: str = "$") -> list[tuple[str, str, str]]:
267
+ rows = [(path, "|".join(sorted(node.get("types", {}))), "")]
268
+ objects = node.get("objects", 0)
269
+ for name, prop in sorted(node.get("props", {}).items()):
270
+ child = f"{path}.{name}"
271
+ sub = _flatten(prop["node"], child)
272
+ presence = "always" if objects and prop["n"] == objects else f"{prop['n']}/{objects}"
273
+ sub[0] = (sub[0][0], sub[0][1], presence)
274
+ rows.extend(sub)
275
+ if "items" in node and node["items"].get("types"):
276
+ rows.extend(_flatten(node["items"], path + "[*]"))
277
+ values = node.get("values")
278
+ if isinstance(values, list) and values and node.get("strings", 0) >= 10:
279
+ p, t, pres = rows[0]
280
+ rows[0] = (p, f"{t} (enum, {len(values)} values)", pres)
281
+ return rows
282
+
283
+
284
+ @drift_app.command("accept")
285
+ def drift_accept(
286
+ monitors: Annotated[list[str], typer.Argument(help="Monitor name(s).")],
287
+ config: ConfigOpt = None,
288
+ env: EnvOpt = None,
289
+ env_file: EnvFileOpt = None,
290
+ ) -> None:
291
+ """Call the API now and make the current response shape the new baseline."""
292
+ project = _load(config, env, env_file)
293
+ summary = _execute(project, monitors, [], [], 4, "replace")
294
+ print_summary(console, summary)
295
+ console.print("[green]Baseline replaced.[/green] Review and commit .api-qovrix/baselines/")
296
+ raise typer.Exit(0 if all(r.status != "error" for r in summary.results) else 1)
297
+
298
+
299
+ @drift_app.command("learn")
300
+ def drift_learn(
301
+ monitors: Annotated[
302
+ list[str] | None, typer.Argument(help="Monitor name(s) (default: all with drift).")
303
+ ] = None,
304
+ samples: Annotated[int, typer.Option(min=1, max=100, help="How many times to call each monitor.")] = 5,
305
+ delay: Annotated[float, typer.Option(min=0, help="Seconds between rounds.")] = 1.0,
306
+ config: ConfigOpt = None,
307
+ env: EnvOpt = None,
308
+ env_file: EnvFileOpt = None,
309
+ ) -> None:
310
+ """Collect several samples per monitor so optional fields are recognised (safe GET/HEAD monitors only)."""
311
+ project = _load(config, env, env_file)
312
+ try:
313
+ selected = select_monitors(project, monitors or [])
314
+ except QovrixError as exc:
315
+ raise _fail(str(exc)) from None
316
+ selected = [m for m in selected if m.drift is not None or any(s.drift for s in m.steps)]
317
+ unsafe = [m.name for m in selected if any(s.request.method not in ("GET", "HEAD") for s in m.steps)]
318
+ if unsafe:
319
+ console.print(f"[yellow]skipping monitors with non-GET steps:[/yellow] {', '.join(unsafe)}")
320
+ selected = [m for m in selected if m.name not in unsafe]
321
+ if not selected:
322
+ raise _fail("no drift-enabled GET monitors selected", 1)
323
+
324
+ async def rounds() -> None:
325
+ for i in range(samples):
326
+ summary = await run_monitors(project, selected, RunOptions(baseline_mode="learn"))
327
+ errors = [r.name for r in summary.results if r.status == "error"]
328
+ console.print(
329
+ f"round {i + 1}/{samples}" + (f" [red](errors: {', '.join(errors)})[/red]" if errors else "")
330
+ )
331
+ if i + 1 < samples:
332
+ await asyncio.sleep(delay)
333
+
334
+ try:
335
+ asyncio.run(rounds())
336
+ except QovrixError as exc:
337
+ raise _fail(str(exc)) from None
338
+ console.print("[green]Done.[/green] Review and commit .api-qovrix/baselines/")
339
+
340
+
341
+ # --------------------------------------------------------------------------- import
342
+
343
+
344
+ @import_app.command("openapi")
345
+ def import_openapi(
346
+ source: Annotated[str, typer.Argument(help="OpenAPI file or URL.")],
347
+ service: Annotated[str | None, typer.Option(help="Service name (default: the spec title).")] = None,
348
+ output: Annotated[
349
+ Path | None, typer.Option(help="Monitor file to write (default: monitors/<service>.yaml).")
350
+ ] = None,
351
+ base_url: Annotated[str | None, typer.Option(help="Override the server URL from the spec.")] = None,
352
+ force: Annotated[bool, typer.Option(help="Overwrite the monitor file if it exists.")] = False,
353
+ config: ConfigOpt = None,
354
+ ) -> None:
355
+ """Generate monitors for every safe (GET/HEAD) operation and enable response validation."""
356
+ from api_qovrix.importers.openapi import generate
357
+ from api_qovrix.openapi.spec import OpenAPISpec
358
+
359
+ try:
360
+ config_path = find_config(config)
361
+ root = config_path.parent
362
+ local = (
363
+ (root / source)
364
+ if not source.startswith(("http://", "https://")) and not Path(source).is_absolute()
365
+ else None
366
+ )
367
+ if local is not None and not local.is_file() and Path(source).is_file():
368
+ local = Path(source).resolve()
369
+ spec = OpenAPISpec.load(str(local) if local else source)
370
+ result = generate(spec, service, base_url)
371
+ except QovrixError as exc:
372
+ raise _fail(str(exc)) from None
373
+
374
+ out = output or root / "monitors" / f"{result.service}.yaml"
375
+ content: dict[str, Any] = {}
376
+ if result.service_block:
377
+ content["service"] = result.service_block
378
+ content["monitors"] = result.monitors
379
+
380
+ if out.exists() and not force:
381
+ import yaml
382
+
383
+ existing = yaml.safe_load(out.read_text(encoding="utf-8")) or {}
384
+ old = {str(m["name"]) for m in existing.get("monitors", []) if isinstance(m, dict) and m.get("name")}
385
+ new = {m["name"] for m in result.monitors}
386
+ console.print(
387
+ f"[yellow]{escape(str(out))} exists.[/yellow] Proposed changes (use --force to overwrite):"
388
+ )
389
+ for name in sorted(new - old):
390
+ console.print(f" [green]+ {escape(name)}[/green] new operation")
391
+ for name in sorted(old - new):
392
+ console.print(f" [red]- {escape(name)}[/red] no longer in the spec")
393
+ if new == old:
394
+ console.print(" no added or removed operations")
395
+ raise typer.Exit(0)
396
+
397
+ out.parent.mkdir(parents=True, exist_ok=True)
398
+ header = (
399
+ f"Generated by api-qovrix from {source} ({spec.title}).\n"
400
+ "Safe methods only (GET/HEAD). Edit freely; re-run the import to see new or removed operations."
401
+ )
402
+ out.write_text(dump_yaml(content, header), encoding="utf-8")
403
+ console.print(f"[green]wrote[/green] {escape(str(out))}: {len(result.monitors)} monitors")
404
+ if result.skipped_unsafe:
405
+ console.print(f"[dim]skipped {result.skipped_unsafe} unsafe operations (POST/PUT/PATCH/DELETE)[/dim]")
406
+ rel = source
407
+ if local is not None:
408
+ try:
409
+ rel = str(Path(local).resolve().relative_to(root.resolve()))
410
+ except ValueError:
411
+ rel = str(local)
412
+ try:
413
+ added = add_secrets(config_path, result.secrets)
414
+ enabled = set_openapi_source(config_path, rel)
415
+ except (QovrixError, TypeError, ValueError) as exc:
416
+ raise _fail(f"wrote {out}, but could not update {config_path.name}: {exc}") from None
417
+ for name in added:
418
+ console.print(
419
+ f"[green]added secret[/green] {name} to {config_path.name}: set the environment variable {name}"
420
+ )
421
+ if enabled:
422
+ console.print(f"[green]enabled[/green] response validation against {escape(rel)}")
423
+ for note in result.notes:
424
+ console.print(f"[yellow]![/yellow] {escape(note)}")
425
+
426
+
427
+ # --------------------------------------------------------------------------- schema
428
+
429
+
430
+ @app.command(hidden=True)
431
+ def schema(
432
+ output: Annotated[Path | None, typer.Option(help="Write schema files to this folder.")] = None,
433
+ ) -> None:
434
+ """Print or write the JSON Schema for api-qovrix.yaml and monitor files."""
435
+ from api_qovrix.scaffold import json_schemas, write_schemas
436
+
437
+ if output:
438
+ for p in write_schemas(output):
439
+ console.print(f"[green]wrote[/green] {escape(str(p))}")
440
+ else:
441
+ sys.stdout.write(json.dumps(json_schemas(), indent=2, sort_keys=True) + "\n")
442
+
443
+
444
+ def _utf8_output() -> None:
445
+ """Windows uses a legacy code page (e.g. cp1252) for piped output, which can't encode ✔ or →."""
446
+ for stream in (sys.stdout, sys.stderr):
447
+ encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "")
448
+ if encoding != "utf8" and hasattr(stream, "reconfigure"):
449
+ stream.reconfigure(encoding="utf-8", errors="replace")
450
+
451
+
452
+ def main() -> None:
453
+ _utf8_output()
454
+ try:
455
+ app()
456
+ except KeyboardInterrupt:
457
+ err.print("interrupted")
458
+ sys.exit(130)
459
+
460
+
461
+ if __name__ == "__main__":
462
+ main()
File without changes
@@ -0,0 +1,73 @@
1
+ """Small, comment-preserving edits to api-qovrix.yaml and writing generated monitor files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ruamel.yaml import YAML
10
+ from ruamel.yaml.comments import CommentedMap
11
+
12
+ from api_qovrix.errors import ConfigError
13
+
14
+
15
+ def _yaml() -> YAML:
16
+ y = YAML(typ="rt")
17
+ y.preserve_quotes = True
18
+ y.indent(mapping=2, sequence=4, offset=2)
19
+ y.width = 120
20
+ return y
21
+
22
+
23
+ def _load(path: Path) -> CommentedMap:
24
+ data = _yaml().load(path.read_text(encoding="utf-8")) or CommentedMap()
25
+ if not isinstance(data, CommentedMap):
26
+ raise ConfigError(f"{path}: expected a mapping at the top level")
27
+ return data
28
+
29
+
30
+ def _save(path: Path, data: CommentedMap) -> None:
31
+ buf = io.StringIO()
32
+ _yaml().dump(data, buf)
33
+ path.write_text(buf.getvalue(), encoding="utf-8")
34
+
35
+
36
+ def add_secrets(config_path: Path, secrets: dict[str, str]) -> list[str]:
37
+ """Add `NAME: ${env:NAME}` entries that are missing. Returns the names added."""
38
+ data = _load(config_path)
39
+ section = data.get("secrets")
40
+ if section is None:
41
+ section = CommentedMap()
42
+ data["secrets"] = section
43
+ elif not isinstance(section, CommentedMap):
44
+ raise ConfigError(f"{config_path}: 'secrets' must be a mapping of NAME: ${{env:VARIABLE}}")
45
+ added = []
46
+ for name, description in secrets.items():
47
+ if name in section:
48
+ continue
49
+ section[name] = f"${{env:{name}}}"
50
+ if description:
51
+ section.yaml_add_eol_comment(description, name)
52
+ added.append(name)
53
+ if added:
54
+ _save(config_path, data)
55
+ return added
56
+
57
+
58
+ def set_openapi_source(config_path: Path, source: str) -> bool:
59
+ data = _load(config_path)
60
+ if data.get("openapi"):
61
+ return False
62
+ data["openapi"] = CommentedMap(source=source, validate_responses=True)
63
+ _save(config_path, data)
64
+ return True
65
+
66
+
67
+ def dump_yaml(data: Any, header: str = "") -> str:
68
+ buf = io.StringIO()
69
+ _yaml().dump(data, buf)
70
+ text = buf.getvalue()
71
+ if header:
72
+ text = "".join(f"# {line}\n" if line else "#\n" for line in header.splitlines()) + "\n" + text
73
+ return text