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 +3 -0
- api_qovrix/__main__.py +3 -0
- api_qovrix/cli/__init__.py +0 -0
- api_qovrix/cli/app.py +462 -0
- api_qovrix/config/__init__.py +0 -0
- api_qovrix/config/editing.py +73 -0
- api_qovrix/config/loader.py +304 -0
- api_qovrix/config/models.py +354 -0
- api_qovrix/config/templating.py +164 -0
- api_qovrix/drift/__init__.py +0 -0
- api_qovrix/drift/shape.py +201 -0
- api_qovrix/drift/store.py +76 -0
- api_qovrix/engine/__init__.py +0 -0
- api_qovrix/engine/assertions.py +270 -0
- api_qovrix/engine/auth.py +107 -0
- api_qovrix/engine/http.py +153 -0
- api_qovrix/engine/jsonpaths.py +41 -0
- api_qovrix/engine/results.py +106 -0
- api_qovrix/engine/runner.py +362 -0
- api_qovrix/errors.py +17 -0
- api_qovrix/importers/__init__.py +0 -0
- api_qovrix/importers/openapi.py +191 -0
- api_qovrix/openapi/__init__.py +0 -0
- api_qovrix/openapi/spec.py +210 -0
- api_qovrix/py.typed +0 -0
- api_qovrix/report/__init__.py +0 -0
- api_qovrix/report/output.py +253 -0
- api_qovrix/scaffold.py +125 -0
- api_qovrix-0.1.0.dist-info/METADATA +206 -0
- api_qovrix-0.1.0.dist-info/RECORD +34 -0
- api_qovrix-0.1.0.dist-info/WHEEL +4 -0
- api_qovrix-0.1.0.dist-info/entry_points.txt +2 -0
- api_qovrix-0.1.0.dist-info/licenses/LICENSE +202 -0
- api_qovrix-0.1.0.dist-info/licenses/NOTICE +4 -0
api_qovrix/__init__.py
ADDED
api_qovrix/__main__.py
ADDED
|
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
|