steppy-dev 0.3.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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Deterministic coverage substrate: record -> merge -> map store -> gap report.
|
|
2
|
+
|
|
3
|
+
Recording happens inside the *generated* conftest (env-gated on
|
|
4
|
+
``STEPPY_COVERAGE=1``); everything in this package runs steppy-side and is
|
|
5
|
+
lazy-imported from the CLI only — ``steppy run`` without ``--coverage`` never
|
|
6
|
+
imports it (guarded by ``tests/test_no_coverage_import_on_default_path.py``).
|
|
7
|
+
|
|
8
|
+
No LLM calls anywhere: recording, merge, and gap compute are deterministic.
|
|
9
|
+
"""
|
steppy/coverage/gaps.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Gap computation: pure bucket math over the coverage map.
|
|
2
|
+
|
|
3
|
+
Consumes ``store.read_map()`` output, produces the bucket structure the
|
|
4
|
+
report renders. No IO, no clock — same map in, same buckets out.
|
|
5
|
+
|
|
6
|
+
Buckets per kind (endpoints are the primary surface, routes when a sitemap
|
|
7
|
+
is configured):
|
|
8
|
+
|
|
9
|
+
- ``tested`` — declared AND exercised by at least one scenario, with a
|
|
10
|
+
``strength``: *strong* when some covering scenario both ran with asserts
|
|
11
|
+
enabled and contains at least one assert step; *weak* otherwise.
|
|
12
|
+
- ``declared_unreached`` — in the manifest, never hit by any scenario. The
|
|
13
|
+
key bucket. Each entry carries its triage flag so non-UI endpoints
|
|
14
|
+
(webhooks, cron callbacks) can be discounted, plus ``triage_source``
|
|
15
|
+
saying whether that came from ``config.yaml`` or the store's own table.
|
|
16
|
+
- ``discovered_uncovered`` — seen in a trail but only weakly covered.
|
|
17
|
+
- ``discovered_tested`` — exercised + strongly asserted but absent from the
|
|
18
|
+
manifest (stale spec, or a base-URL misconfiguration).
|
|
19
|
+
- ``unclassified`` (endpoints only) — requests that matched no configured
|
|
20
|
+
API base URL.
|
|
21
|
+
|
|
22
|
+
Modules have no declared source in v1 — they are reported as a covered
|
|
23
|
+
inventory only.
|
|
24
|
+
|
|
25
|
+
``e2e_surface`` sits beside ``counts`` and answers the badge's question for each
|
|
26
|
+
surface (see :func:`surface_ratio`). It is derived, not another bucket: nothing
|
|
27
|
+
here decides *which* nodes are non-UI — that is the read-time overlay in
|
|
28
|
+
:mod:`steppy.coverage.triage`, applied to the map before it arrives.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
#: Surfaces the e2e ratio can be computed over. ``all`` is the default because
|
|
34
|
+
#: the declared surface is "routes and API endpoints" — either alone silently
|
|
35
|
+
#: drops half of what a project declared.
|
|
36
|
+
SURFACES = ("all", "endpoints", "routes")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def surface_ratio(counts: dict, surface: str = "all") -> dict:
|
|
40
|
+
"""``tested / declared`` over one surface, with non_ui removed from both.
|
|
41
|
+
|
|
42
|
+
The formula as written in the docs — ``tested / (declared - non_ui)`` — can
|
|
43
|
+
exceed 1, because a *tested* endpoint flagged ``non_ui`` stays in the
|
|
44
|
+
numerator while leaving the denominator. Subtracting from both keeps the ratio
|
|
45
|
+
in range and keeps the question honest: *of the surface a UI test could reach,
|
|
46
|
+
how much does the suite reach?*
|
|
47
|
+
|
|
48
|
+
``total == 0`` returns ``ratio``/``percent`` of ``None`` rather than 0, so a
|
|
49
|
+
project with nothing declared reads as "not measured" instead of "0% tested".
|
|
50
|
+
"""
|
|
51
|
+
kinds = ("endpoints", "routes") if surface == "all" else (surface,)
|
|
52
|
+
tested = 0
|
|
53
|
+
total = 0
|
|
54
|
+
for kind in kinds:
|
|
55
|
+
section = counts.get(kind) or {}
|
|
56
|
+
tested += section.get("tested", 0) - section.get("tested_non_ui", 0)
|
|
57
|
+
total += section.get("declared", 0) - section.get("declared_non_ui", 0)
|
|
58
|
+
if total <= 0:
|
|
59
|
+
return {"surface": surface, "tested": max(tested, 0), "declared": max(total, 0), "ratio": None, "percent": None}
|
|
60
|
+
ratio = tested / total
|
|
61
|
+
return {
|
|
62
|
+
"surface": surface,
|
|
63
|
+
"tested": tested,
|
|
64
|
+
"declared": total,
|
|
65
|
+
"ratio": round(ratio, 4),
|
|
66
|
+
"percent": int(round(ratio * 100)),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _strength(node: dict, scenarios_meta: dict) -> str:
|
|
71
|
+
"""Strong iff some covering scenario has asserts enabled AND assert steps."""
|
|
72
|
+
for cov in node["scenarios"]:
|
|
73
|
+
meta = scenarios_meta.get(cov["scenario_id"], {})
|
|
74
|
+
if cov["asserts_enabled"] and meta.get("asserts_count", 0) > 0:
|
|
75
|
+
return "strong"
|
|
76
|
+
return "weak"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _covered_entry(node: dict, scenarios_meta: dict) -> dict:
|
|
80
|
+
# ``triage`` rides on a *covered* entry too, which it historically did not.
|
|
81
|
+
# Without it the e2e-surface ratio cannot subtract a non_ui endpoint that
|
|
82
|
+
# happens to be tested, and the number could exceed 100%.
|
|
83
|
+
triage = node.get("triage") or {}
|
|
84
|
+
return {
|
|
85
|
+
"api": node["api"],
|
|
86
|
+
"method": node["method"],
|
|
87
|
+
"key": node["key"],
|
|
88
|
+
"scenarios": [c["scenario_id"] for c in node["scenarios"]],
|
|
89
|
+
"strength": _strength(node, scenarios_meta),
|
|
90
|
+
"triage": triage.get("flag", "unknown"),
|
|
91
|
+
"triage_source": triage.get("source", ""),
|
|
92
|
+
"note": triage.get("note", ""),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _unreached_entry(node: dict) -> dict:
|
|
97
|
+
triage = node.get("triage") or {}
|
|
98
|
+
return {
|
|
99
|
+
"api": node["api"],
|
|
100
|
+
"method": node["method"],
|
|
101
|
+
"key": node["key"],
|
|
102
|
+
"triage": triage.get("flag", "unknown"),
|
|
103
|
+
# Which rule discounted this endpoint: "config" (a coverage.non_ui
|
|
104
|
+
# pattern), "store" (a triage row), or "" (neither). A number nobody can
|
|
105
|
+
# trace is a number nobody trusts.
|
|
106
|
+
"triage_source": triage.get("source", ""),
|
|
107
|
+
"note": triage.get("note", ""),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def compute_gaps(map_view: dict) -> dict:
|
|
112
|
+
"""Bucket every node of the map. ``map_view`` comes from ``store.read_map``."""
|
|
113
|
+
scenarios_meta = map_view["scenarios"]
|
|
114
|
+
buckets: dict = {
|
|
115
|
+
"endpoints": {
|
|
116
|
+
"tested": [],
|
|
117
|
+
"declared_unreached": [],
|
|
118
|
+
"discovered_uncovered": [],
|
|
119
|
+
"discovered_tested": [],
|
|
120
|
+
"unclassified": [],
|
|
121
|
+
},
|
|
122
|
+
"routes": {
|
|
123
|
+
"tested": [],
|
|
124
|
+
"declared_unreached": [],
|
|
125
|
+
"discovered_uncovered": [],
|
|
126
|
+
"discovered_tested": [],
|
|
127
|
+
},
|
|
128
|
+
"modules": {"covered": []},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for node in map_view["nodes"]:
|
|
132
|
+
declared = bool(node["declared"])
|
|
133
|
+
covered = bool(node["scenarios"])
|
|
134
|
+
if not declared and not covered:
|
|
135
|
+
continue # placeholder left by a re-link, or triage-only node
|
|
136
|
+
|
|
137
|
+
if node["kind"] == "module":
|
|
138
|
+
if covered:
|
|
139
|
+
buckets["modules"]["covered"].append(
|
|
140
|
+
{
|
|
141
|
+
"key": node["key"],
|
|
142
|
+
"scenarios": [c["scenario_id"] for c in node["scenarios"]],
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
kind_bucket = buckets["endpoints" if node["kind"] == "endpoint" else "routes"]
|
|
148
|
+
|
|
149
|
+
if node["kind"] == "endpoint" and covered and not declared and node["api"] == "":
|
|
150
|
+
kind_bucket["unclassified"].append(_covered_entry(node, scenarios_meta))
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
if declared and covered:
|
|
154
|
+
kind_bucket["tested"].append(_covered_entry(node, scenarios_meta))
|
|
155
|
+
elif declared:
|
|
156
|
+
kind_bucket["declared_unreached"].append(_unreached_entry(node))
|
|
157
|
+
else:
|
|
158
|
+
entry = _covered_entry(node, scenarios_meta)
|
|
159
|
+
if entry["strength"] == "weak":
|
|
160
|
+
kind_bucket["discovered_uncovered"].append(entry)
|
|
161
|
+
else:
|
|
162
|
+
kind_bucket["discovered_tested"].append(entry)
|
|
163
|
+
|
|
164
|
+
counts: dict = {"scenarios": len(scenarios_meta)}
|
|
165
|
+
for kind in ("endpoints", "routes"):
|
|
166
|
+
section = buckets[kind]
|
|
167
|
+
tested = section["tested"]
|
|
168
|
+
unreached = section["declared_unreached"]
|
|
169
|
+
tested_non_ui = sum(1 for e in tested if e["triage"] == "non_ui")
|
|
170
|
+
unreached_non_ui = sum(1 for e in unreached if e["triage"] == "non_ui")
|
|
171
|
+
counts[kind] = {
|
|
172
|
+
"declared": len(tested) + len(unreached),
|
|
173
|
+
"tested": len(tested),
|
|
174
|
+
"tested_strong": sum(1 for e in tested if e["strength"] == "strong"),
|
|
175
|
+
"declared_unreached": len(unreached),
|
|
176
|
+
"declared_unreached_actionable": len(unreached) - unreached_non_ui,
|
|
177
|
+
"discovered_uncovered": len(section["discovered_uncovered"]),
|
|
178
|
+
"discovered_tested": len(section["discovered_tested"]),
|
|
179
|
+
# non_ui across tested UNION unreached. Counting it only among the
|
|
180
|
+
# unreached (which ``declared_unreached_actionable`` does, and which
|
|
181
|
+
# is right for its own purpose) would leave a tested-but-non_ui
|
|
182
|
+
# endpoint in the numerator and out of the denominator.
|
|
183
|
+
"tested_non_ui": tested_non_ui,
|
|
184
|
+
"declared_non_ui": tested_non_ui + unreached_non_ui,
|
|
185
|
+
}
|
|
186
|
+
counts["endpoints"]["unclassified"] = len(buckets["endpoints"]["unclassified"])
|
|
187
|
+
counts["modules"] = {"covered": len(buckets["modules"]["covered"])}
|
|
188
|
+
buckets["counts"] = counts
|
|
189
|
+
buckets["e2e_surface"] = {surface: surface_ratio(counts, surface) for surface in SURFACES}
|
|
190
|
+
return buckets
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Declared-surface ingest: OpenAPI endpoints + optional sitemap routes.
|
|
2
|
+
|
|
3
|
+
``refresh_declared`` is the one entry point: it loads each configured
|
|
4
|
+
manifest (http(s) URL via httpx, or a local file path — local paths keep
|
|
5
|
+
tests offline and support repos that commit their spec), parses it as
|
|
6
|
+
**JSON or YAML** (a served ``/openapi.json`` is the first, a spec committed
|
|
7
|
+
to a repo is usually the second), replaces the
|
|
8
|
+
per-source declared rows in the store, then runs the **re-link pass**:
|
|
9
|
+
observed endpoint nodes (coverage but no declared row) whose evidence
|
|
10
|
+
samples now match a declared template get their coverage re-pointed onto
|
|
11
|
+
the declared node. That makes ``merge -> refresh`` and ``refresh -> merge``
|
|
12
|
+
converge to the same map regardless of order.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import xml.etree.ElementTree as ET
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
from .normalize import compile_extra_patterns, match_template, normalize_route
|
|
24
|
+
from .store import (
|
|
25
|
+
DeclaredEntry,
|
|
26
|
+
observed_endpoint_nodes,
|
|
27
|
+
replace_declared_source,
|
|
28
|
+
repoint_node_coverage,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ManifestError(RuntimeError):
|
|
35
|
+
"""Raised when a manifest cannot be loaded or parsed."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_text(location: str) -> str:
|
|
39
|
+
"""Load manifest text from an http(s) URL or a local file path."""
|
|
40
|
+
if location.startswith(("http://", "https://")):
|
|
41
|
+
import httpx # local import: keeps merge/report paths network-free
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
response = httpx.get(location, timeout=30.0, follow_redirects=True)
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
except httpx.HTTPError as exc:
|
|
47
|
+
raise ManifestError(f"failed to fetch {location}: {exc}") from exc
|
|
48
|
+
return response.text
|
|
49
|
+
path = Path(location)
|
|
50
|
+
try:
|
|
51
|
+
return path.read_text(encoding="utf-8")
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
raise ManifestError(f"failed to read {location}: {exc}") from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_manifest_document(text: str, api_name: str):
|
|
57
|
+
"""Parse an OpenAPI document from either JSON or YAML.
|
|
58
|
+
|
|
59
|
+
JSON is tried first: it is what an application serves at ``/openapi.json``,
|
|
60
|
+
it is the fast path on a large spec, and its error names the exact offset.
|
|
61
|
+
YAML is the fallback because a spec **committed to a repo** is usually
|
|
62
|
+
``.yaml`` — and ``load_text`` has always accepted a local path, so refusing
|
|
63
|
+
to parse the file it fetched was a gap rather than a policy.
|
|
64
|
+
|
|
65
|
+
Not YAML-only, even though YAML is a JSON superset: parsing a multi-megabyte
|
|
66
|
+
generated spec through the YAML loader is markedly slower, for a document
|
|
67
|
+
that is already known to be JSON in the common case.
|
|
68
|
+
|
|
69
|
+
The return type is deliberately unconstrained — shape validation belongs to
|
|
70
|
+
:func:`parse_openapi_endpoints`, which is where a non-mapping document has to
|
|
71
|
+
be rejected anyway, and duplicating the check would let the two disagree.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
return json.loads(text)
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass
|
|
77
|
+
try:
|
|
78
|
+
return yaml.safe_load(text)
|
|
79
|
+
except yaml.YAMLError as exc:
|
|
80
|
+
raise ManifestError(f"OpenAPI for {api_name!r} is not valid JSON or YAML: {exc}") from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_openapi_endpoints(doc: dict, api_name: str) -> list[DeclaredEntry]:
|
|
84
|
+
"""Extract ``paths`` x methods from an OpenAPI document.
|
|
85
|
+
|
|
86
|
+
Non-operation keys on path items (``parameters``, ``servers``, ``$ref``,
|
|
87
|
+
``x-*`` extensions, ``summary``/``description``) are skipped.
|
|
88
|
+
"""
|
|
89
|
+
if not isinstance(doc, dict):
|
|
90
|
+
# Format-neutral: the document may have arrived as YAML, and a YAML file
|
|
91
|
+
# that parses to a list would otherwise be reported as bad JSON.
|
|
92
|
+
raise ManifestError(f"OpenAPI document for {api_name!r} is not a mapping")
|
|
93
|
+
paths = doc.get("paths") or {}
|
|
94
|
+
entries: list[DeclaredEntry] = []
|
|
95
|
+
for path in sorted(paths):
|
|
96
|
+
item = paths[path]
|
|
97
|
+
if not isinstance(item, dict):
|
|
98
|
+
continue
|
|
99
|
+
for method in _HTTP_METHODS:
|
|
100
|
+
operation = item.get(method)
|
|
101
|
+
if not isinstance(operation, dict):
|
|
102
|
+
continue
|
|
103
|
+
details = {k: operation[k] for k in ("operationId", "summary", "tags") if k in operation}
|
|
104
|
+
entries.append(
|
|
105
|
+
DeclaredEntry(
|
|
106
|
+
kind="endpoint",
|
|
107
|
+
key=path,
|
|
108
|
+
api=api_name,
|
|
109
|
+
method=method.upper(),
|
|
110
|
+
details=details,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return entries
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_sitemap_routes(xml_text: str, route_param_patterns: list[str] | None = None) -> list[DeclaredEntry]:
|
|
117
|
+
"""Extract ``<loc>`` URLs from a sitemap, normalized to route patterns.
|
|
118
|
+
|
|
119
|
+
Namespace-agnostic (matches any ``*}loc`` tag). Concrete URLs are run
|
|
120
|
+
through the same route normalization as observed navigations so declared
|
|
121
|
+
and observed route nodes share identities.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
root = ET.fromstring(xml_text)
|
|
125
|
+
except ET.ParseError as exc:
|
|
126
|
+
raise ManifestError(f"invalid sitemap XML: {exc}") from exc
|
|
127
|
+
extra = compile_extra_patterns(route_param_patterns or [])
|
|
128
|
+
keys = sorted(
|
|
129
|
+
{
|
|
130
|
+
normalize_route(el.text.strip(), extra)
|
|
131
|
+
for el in root.iter()
|
|
132
|
+
if el.tag.rsplit("}", 1)[-1] == "loc" and el.text and el.text.strip()
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
return [DeclaredEntry(kind="route", key=key) for key in keys]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def relink_observed_endpoints(conn) -> int:
|
|
139
|
+
"""Re-point coverage from observed endpoint nodes onto declared templates.
|
|
140
|
+
|
|
141
|
+
For each endpoint node with coverage but no declared row, retry its
|
|
142
|
+
evidence samples (raw relative paths) against the declared templates of
|
|
143
|
+
the same (api, method). First matching sample wins (samples are sorted,
|
|
144
|
+
so this is deterministic). The emptied observed node is kept — nodes are
|
|
145
|
+
never deleted implicitly.
|
|
146
|
+
"""
|
|
147
|
+
templates: dict[tuple[str, str], dict[str, int]] = {}
|
|
148
|
+
for row in conn.execute(
|
|
149
|
+
"SELECT DISTINCT n.id, n.api, n.method, n.key FROM nodes n"
|
|
150
|
+
" JOIN declared d ON d.node_id = n.id WHERE n.kind = 'endpoint'"
|
|
151
|
+
):
|
|
152
|
+
templates.setdefault((row["api"], row["method"]), {})[row["key"]] = int(row["id"])
|
|
153
|
+
|
|
154
|
+
moved = 0
|
|
155
|
+
for candidate in observed_endpoint_nodes(conn):
|
|
156
|
+
candidates = templates.get((candidate["api"], candidate["method"]))
|
|
157
|
+
if not candidates:
|
|
158
|
+
continue
|
|
159
|
+
target_key = None
|
|
160
|
+
for sample in candidate["samples"]:
|
|
161
|
+
target_key = match_template(sample, list(candidates))
|
|
162
|
+
if target_key:
|
|
163
|
+
break
|
|
164
|
+
if target_key:
|
|
165
|
+
moved += repoint_node_coverage(conn, candidate["id"], candidates[target_key])
|
|
166
|
+
return moved
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def refresh_declared(
|
|
170
|
+
conn,
|
|
171
|
+
apis: list[dict],
|
|
172
|
+
sitemap: str | None = None,
|
|
173
|
+
route_param_patterns: list[str] | None = None,
|
|
174
|
+
*,
|
|
175
|
+
updated_at: str = "",
|
|
176
|
+
) -> dict:
|
|
177
|
+
"""Refresh the whole declared surface and run the re-link pass.
|
|
178
|
+
|
|
179
|
+
``apis`` entries are ``{"name": str, "openapi": <url-or-path>}``. Each
|
|
180
|
+
manifest source is replaced independently (``openapi:<name>`` /
|
|
181
|
+
``sitemap``); triage flags and nodes are never touched.
|
|
182
|
+
"""
|
|
183
|
+
summary: dict = {"apis": [], "sitemap": None, "relinked": 0}
|
|
184
|
+
for api in apis:
|
|
185
|
+
doc = parse_manifest_document(load_text(api["openapi"]), api["name"])
|
|
186
|
+
entries = parse_openapi_endpoints(doc, api["name"])
|
|
187
|
+
result = replace_declared_source(conn, f"openapi:{api['name']}", entries, updated_at=updated_at)
|
|
188
|
+
summary["apis"].append(result)
|
|
189
|
+
if sitemap:
|
|
190
|
+
entries = parse_sitemap_routes(load_text(sitemap), route_param_patterns)
|
|
191
|
+
summary["sitemap"] = replace_declared_source(conn, "sitemap", entries, updated_at=updated_at)
|
|
192
|
+
summary["relinked"] = relink_observed_endpoints(conn)
|
|
193
|
+
return summary
|