ghost-api-cli 0.2.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.
Files changed (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,67 @@
1
+ """GraphQL: one URL, many operations. Each named operation becomes its own endpoint.
2
+
3
+ A site's GraphQL traffic all goes to `/graphql`, but `GetUser` and `ListOrders` are
4
+ different calls with different inputs and replies. The operation name is added to the
5
+ endpoint's template after a `#` (`POST /graphql#GetUser`), so every later stage (schemas,
6
+ testing, naming, the client) treats each operation as one endpoint. The `#...` part is
7
+ never sent: it is a URL fragment.
8
+
9
+ Mutations change data: they are never replayed or generated (probe/replay.py).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from typing import Any
17
+ from urllib.parse import parse_qs, urlsplit
18
+
19
+ _NAMED_OP = re.compile(r"^\s*(query|mutation|subscription)\s+([A-Za-z_]\w*)", re.M)
20
+ _OP_NAME = re.compile(r"^[A-Za-z_]\w{0,80}$")
21
+
22
+
23
+ def graphql_payload(method: str, url: str, body: str | None) -> dict[str, Any] | None:
24
+ """The GraphQL request (query, operationName, variables), or None if it isn't one.
25
+ A batch (a list of operations) is not split: it stays one endpoint."""
26
+ if method.upper() == "GET":
27
+ params = {k: v[0] for k, v in parse_qs(urlsplit(url).query).items()}
28
+ if "query" in params and ("{" in params["query"] or "operationName" in params):
29
+ variables = params.get("variables")
30
+ try:
31
+ parsed = json.loads(variables) if variables else {}
32
+ except ValueError:
33
+ parsed = {}
34
+ return {**params, "variables": parsed if isinstance(parsed, dict) else {}}
35
+ return None
36
+ try:
37
+ data = json.loads(body or "")
38
+ except ValueError:
39
+ return None
40
+ if not isinstance(data, dict):
41
+ return None
42
+ has_query = isinstance(data.get("query"), str) and "{" in data["query"]
43
+ extensions = data.get("extensions")
44
+ persisted = isinstance(extensions, dict) and "persistedQuery" in extensions
45
+ if not (has_query or persisted):
46
+ return None
47
+ return data
48
+
49
+
50
+ def operation_name(payload: dict[str, Any]) -> str | None:
51
+ name = payload.get("operationName")
52
+ if isinstance(name, str) and _OP_NAME.match(name):
53
+ return name
54
+ match = _NAMED_OP.search(str(payload.get("query") or ""))
55
+ return match.group(2) if match else None
56
+
57
+
58
+ def operation_of(method: str, url: str, body: str | None) -> str | None:
59
+ """'GetUser' for a GraphQL request with a named operation, else None."""
60
+ payload = graphql_payload(method, url, body)
61
+ return operation_name(payload) if payload is not None else None
62
+
63
+
64
+ def split_template(template: str) -> tuple[str, str | None]:
65
+ """'/graphql#GetUser' -> ('/graphql', 'GetUser'); other templates are unchanged."""
66
+ path, _, op = template.partition("#")
67
+ return path, op or None
@@ -0,0 +1,64 @@
1
+ """The endpoint map: what the analyzer learns from captured traffic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ ParamType = Literal["integer", "number", "boolean", "string"]
9
+
10
+
11
+ @dataclass
12
+ class QueryParam:
13
+ name: str
14
+ required: bool # present in every sample
15
+ constant: bool # same value in every sample (and seen more than once)
16
+ type: ParamType
17
+ examples: list[str] = field(default_factory=list)
18
+ sensitive: bool = False # value was redacted, so the real one is unknown
19
+
20
+ @property
21
+ def kind(self) -> str:
22
+ if self.constant:
23
+ return "constant"
24
+ return "required" if self.required else "optional"
25
+
26
+
27
+ @dataclass
28
+ class PathParam:
29
+ name: str
30
+ type: ParamType
31
+ examples: list[str] = field(default_factory=list)
32
+
33
+
34
+ @dataclass
35
+ class Endpoint:
36
+ method: str
37
+ host: str
38
+ path_template: str # e.g. /api/books/{id}
39
+ path_params: list[PathParam] = field(default_factory=list)
40
+ query_params: list[QueryParam] = field(default_factory=list)
41
+ request_schema: dict[str, Any] | None = None
42
+ response_schema: dict[str, Any] | None = None
43
+ sample_count: int = 0
44
+ request_samples: int = 0 # JSON request bodies that fed the request schema
45
+ response_samples: int = 0 # successful JSON responses that fed the response schema
46
+ statuses: list[int] = field(default_factory=list)
47
+ example_urls: list[str] = field(default_factory=list)
48
+ third_party: bool = False # served from a different domain than the target site
49
+ # Set by the prober (Stage 5):
50
+ replayable: bool | None = None # None = not tested
51
+ auth_type: str | None = None
52
+ pagination: dict[str, Any] | None = None
53
+ required_headers: list[str] = field(default_factory=list)
54
+ # Set by verification (Stage 7): discovered | verified | unverified | failed
55
+ status: str = "discovered"
56
+ status_note: str = ""
57
+
58
+ @property
59
+ def key(self) -> str:
60
+ return f"{self.method} {self.host}{self.path_template}"
61
+
62
+ @property
63
+ def label(self) -> str:
64
+ return f"{self.method} {self.path_template}"
@@ -0,0 +1,109 @@
1
+ """Infer JSON schemas from sample bodies (FR-19).
2
+
3
+ genson merges samples: a field missing from any sample is left out of "required"
4
+ (so it becomes optional), and a field seen as both null and a string becomes
5
+ ["null", "string"].
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from genson import SchemaBuilder
14
+
15
+
16
+ def parse_json(body: str | None) -> Any | None:
17
+ """Parse a body, returning None if it isn't valid JSON."""
18
+ if not body:
19
+ return None
20
+ try:
21
+ return json.loads(body)
22
+ except ValueError:
23
+ return None
24
+
25
+
26
+ def build_schema(samples: list[Any]) -> dict[str, Any] | None:
27
+ if not samples:
28
+ return None
29
+ builder = SchemaBuilder(schema_uri=None)
30
+ for sample in samples:
31
+ builder.add_object(sample)
32
+ schema: dict[str, Any] = builder.to_schema()
33
+ return schema
34
+
35
+
36
+ MAX_LABEL_DEPTH = 3
37
+ MAX_LABEL_CHARS = 60
38
+
39
+
40
+ def type_label(schema: dict[str, Any] | None, depth: int = 0) -> str:
41
+ """Short human-readable description: 'object', 'array[object]', 'string|null'.
42
+
43
+ Deeply nested arrays (Google Maps returns 9 levels) are cut at MAX_LABEL_DEPTH."""
44
+ if not schema:
45
+ return "?"
46
+ if depth >= MAX_LABEL_DEPTH:
47
+ return "…"
48
+ if "anyOf" in schema:
49
+ return "|".join(type_label(s, depth) for s in schema["anyOf"])
50
+ kind = schema.get("type", "any")
51
+ if isinstance(kind, list):
52
+ return "|".join(kind)
53
+ if kind == "array":
54
+ return f"array[{type_label(schema.get('items'), depth + 1)}]"
55
+ return str(kind)
56
+
57
+
58
+ def _shorten(text: str) -> str:
59
+ return text if len(text) <= MAX_LABEL_CHARS else text[: MAX_LABEL_CHARS - 1] + "…"
60
+
61
+
62
+ def top_level_fields(schema: dict[str, Any] | None, limit: int = 8) -> str:
63
+ """'has_next, page, quotes[] (+2 more)' — a one-line summary for tables."""
64
+ if not schema:
65
+ return "-"
66
+ target = schema
67
+ prefix = ""
68
+ if schema.get("type") == "array":
69
+ target = schema.get("items") or {}
70
+ prefix = "[] of "
71
+ props = target.get("properties")
72
+ if not props:
73
+ return _shorten(prefix + type_label(target))
74
+ names = [f"{k}[]" if v.get("type") == "array" else k for k, v in props.items()]
75
+ shown = ", ".join(names[:limit])
76
+ more = f" (+{len(names) - limit} more)" if len(names) > limit else ""
77
+ return prefix + shown + more
78
+
79
+
80
+ def field_paths(schema: dict[str, Any] | None, max_depth: int = 3, limit: int = 20) -> list[str]:
81
+ """Leaf field paths, e.g. ['page', 'quotes[].author.name', 'quotes[].tags[]'].
82
+
83
+ Lets the agent see what data an endpoint already returns, including nested fields.
84
+ """
85
+ paths: list[str] = []
86
+
87
+ def walk(node: dict[str, Any], prefix: str, depth: int) -> None:
88
+ if len(paths) >= limit:
89
+ return
90
+ kind = node.get("type")
91
+ if kind == "array" or (isinstance(kind, list) and "array" in kind):
92
+ items = node.get("items") or {}
93
+ if items.get("properties") and depth < max_depth:
94
+ walk(items, prefix + "[]", depth)
95
+ else:
96
+ paths.append(prefix + "[]")
97
+ return
98
+ props = node.get("properties")
99
+ if props and depth < max_depth:
100
+ for key, child in props.items():
101
+ walk(child, f"{prefix}.{key}" if prefix else key, depth + 1)
102
+ if len(paths) >= limit:
103
+ return
104
+ elif prefix:
105
+ paths.append(prefix)
106
+
107
+ if schema:
108
+ walk(schema, "", 0)
109
+ return paths
@@ -0,0 +1,217 @@
1
+ """Group concrete URLs into path templates (FR-17).
2
+
3
+ /api/books/1, /api/books/2 -> /api/books/{id}
4
+ /users/7/posts/19 -> /users/{user_id}/posts/{id}
5
+ /author/albert-einstein, /author/... -> /author/{slug}
6
+
7
+ Two rules decide that a path segment is a parameter:
8
+ 1. It looks like an identifier on its own (number, UUID, hex hash, date, long token).
9
+ 2. It varies across URLs that are identical everywhere else, and the values look
10
+ like slugs, or there are many distinct values. Plain words such as /api/quotes
11
+ vs /api/authors stay literal, because they are usually different endpoints.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from collections import defaultdict
18
+ from dataclasses import dataclass
19
+ from urllib.parse import unquote, urlsplit
20
+
21
+ from ghost_api.analysis.models import ParamType
22
+
23
+ _INT_RE = re.compile(r"^-?\d+$")
24
+ _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
25
+ _HEX_RE = re.compile(r"^(?=.*\d)[0-9a-f]{16,}$", re.I)
26
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
27
+ # Long mixed tokens like Mongo/Firebase/base64 IDs: letters AND digits, 20+ chars, no dashes.
28
+ _TOKEN_RE = re.compile(r"^(?=.*\d)(?=.*[A-Za-z])[A-Za-z0-9_]{20,}$")
29
+ _SLUG_RE = re.compile(r"^[a-z0-9]+([-_.][a-z0-9]+)+$", re.I)
30
+ _VERSION_RE = re.compile(r"^v\d+(\.\d+)*$", re.I)
31
+ # Encoded data rather than a name: "pb=!1m8!3m7..." (Google Maps tiles), "@12.9,77.6,15z",
32
+ # matrix parameters ("items;page=2"), or anything very long.
33
+ _OPAQUE_RE = re.compile(r"[!=;,@]")
34
+ OPAQUE_LENGTH = 48
35
+ _KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]{0,30})=")
36
+
37
+ # A varying segment with at least this many distinct values becomes a parameter even
38
+ # when the values are plain words.
39
+ HIGH_CARDINALITY = 6
40
+
41
+
42
+ def split_path(path: str) -> list[str]:
43
+ return [unquote(s) for s in path.split("/") if s]
44
+
45
+
46
+ def looks_like_id(segment: str) -> bool:
47
+ if _VERSION_RE.match(segment):
48
+ return False
49
+ return bool(
50
+ _INT_RE.match(segment)
51
+ or _UUID_RE.match(segment)
52
+ or _HEX_RE.match(segment)
53
+ or _DATE_RE.match(segment)
54
+ or _TOKEN_RE.match(segment)
55
+ or is_opaque(segment)
56
+ )
57
+
58
+
59
+ def is_opaque(segment: str) -> bool:
60
+ return len(segment) > OPAQUE_LENGTH or bool(_OPAQUE_RE.search(segment))
61
+
62
+
63
+ def _is_leading_version(pos: int, segment: str, length: int) -> bool:
64
+ return pos == 0 and length > 1 and segment.isdigit() and len(segment) <= 2
65
+
66
+
67
+ def looks_like_slug(segment: str) -> bool:
68
+ if _VERSION_RE.match(segment):
69
+ return False
70
+ return bool(_SLUG_RE.match(segment)) or any(c.isdigit() for c in segment)
71
+
72
+
73
+ def infer_type(values: list[str]) -> ParamType:
74
+ if values and all(_INT_RE.match(v) for v in values):
75
+ return "integer"
76
+ if values and all(re.match(r"^-?\d+\.\d+$", v) or _INT_RE.match(v) for v in values):
77
+ return "number"
78
+ if values and all(v.lower() in ("true", "false") for v in values):
79
+ return "boolean"
80
+ return "string"
81
+
82
+
83
+ _INVARIANT = {"news", "series", "species", "data", "media", "settings", "metrics"}
84
+
85
+
86
+ def singular(word: str) -> str:
87
+ word = re.sub(r"[^a-zA-Z0-9]+", "_", word).strip("_").lower()
88
+ if word.endswith("ies") and len(word) > 4:
89
+ return word[:-3] + "y"
90
+ if word.endswith(("xes", "ches", "shes", "sses", "zes")):
91
+ return word[:-2]
92
+ if word in _INVARIANT or word.endswith(("us", "is", "ss")):
93
+ return word # status, analysis, address, news
94
+ if word.endswith("s") and len(word) > 3:
95
+ return word[:-1]
96
+ return word
97
+
98
+
99
+ @dataclass
100
+ class _Shape:
101
+ """URLs sharing method, host, segment count and trailing slash."""
102
+
103
+ method: str
104
+ host: str
105
+ trailing_slash: bool
106
+ urls: list[str]
107
+ segments: list[list[str]]
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class TemplateMatch:
112
+ """Where one concrete URL landed: its template and the path-parameter values."""
113
+
114
+ method: str
115
+ host: str
116
+ template: str
117
+ param_names: tuple[str, ...]
118
+ param_values: tuple[str, ...]
119
+
120
+
121
+ def group_urls(requests: list[tuple[str, str]]) -> list[TemplateMatch]:
122
+ """Map each (method, url) to a template. Output order matches input order."""
123
+ shapes: dict[tuple[str, str, int, bool], _Shape] = {}
124
+ placement: list[tuple[tuple[str, str, int, bool], int]] = []
125
+ for method, url in requests:
126
+ parts = urlsplit(url)
127
+ segs = split_path(parts.path)
128
+ trailing = parts.path.endswith("/") and bool(segs)
129
+ key = (method.upper(), (parts.hostname or "").lower(), len(segs), trailing)
130
+ shape = shapes.setdefault(key, _Shape(key[0], key[1], trailing, [], []))
131
+ placement.append((key, len(shape.urls)))
132
+ shape.urls.append(url)
133
+ shape.segments.append(segs)
134
+
135
+ templated = {key: _template_shape(shape) for key, shape in shapes.items()}
136
+ return [templated[key][i] for key, i in placement]
137
+
138
+
139
+ def _template_shape(shape: _Shape) -> list[TemplateMatch]:
140
+ # Rule 1: each segment that looks like an ID is a parameter, URL by URL. A short number
141
+ # as the first of several segments is an API version (/1/indexes/...), not an ID.
142
+ masks = [
143
+ [
144
+ looks_like_id(seg) and not _is_leading_version(p, seg, len(segs))
145
+ for p, seg in enumerate(segs)
146
+ ]
147
+ for segs in shape.segments
148
+ ]
149
+ n = len(shape.segments[0]) if shape.segments else 0
150
+
151
+ # Rule 2: among URLs identical everywhere except `pos`, a varying slug-like or
152
+ # high-cardinality segment is a parameter.
153
+ for pos in range(n):
154
+ groups: dict[tuple[str | None, ...], list[int]] = defaultdict(list)
155
+ for i, segs in enumerate(shape.segments):
156
+ if masks[i][pos]:
157
+ continue
158
+ key = tuple(None if masks[i][p] else segs[p] for p in range(n) if p != pos)
159
+ groups[key].append(i)
160
+ for key, members in groups.items():
161
+ distinct = {shape.segments[i][pos] for i in members}
162
+ anchored = any(k is not None for k in key)
163
+ if len(distinct) > 1 and (
164
+ (anchored and all(looks_like_slug(v) for v in distinct))
165
+ or len(distinct) >= HIGH_CARDINALITY
166
+ ):
167
+ for i in members:
168
+ masks[i][pos] = True
169
+
170
+ # Name parameters per final template, using that template's own values.
171
+ by_skeleton: dict[tuple[str | None, ...], list[int]] = defaultdict(list)
172
+ for i, segs in enumerate(shape.segments):
173
+ by_skeleton[tuple(None if masks[i][p] else segs[p] for p in range(n))].append(i)
174
+
175
+ results: list[TemplateMatch | None] = [None] * len(shape.segments)
176
+ for skeleton, members in by_skeleton.items():
177
+ names = _param_names(list(skeleton), [shape.segments[i] for i in members])
178
+ out = [("{" + names[p] + "}") if seg is None else seg for p, seg in enumerate(skeleton)]
179
+ template = "/" + "/".join(out) + ("/" if shape.trailing_slash else "")
180
+ param_pos = [p for p, seg in enumerate(skeleton) if seg is None]
181
+ for i in members:
182
+ results[i] = TemplateMatch(
183
+ method=shape.method,
184
+ host=shape.host,
185
+ template=template,
186
+ param_names=tuple(names[p] for p in param_pos),
187
+ param_values=tuple(shape.segments[i][p] for p in param_pos),
188
+ )
189
+ return [r for r in results if r is not None]
190
+
191
+
192
+ def _param_names(skeleton: list[str | None], samples: list[list[str]]) -> list[str]:
193
+ """Name parameters after the literal segment before them: /users/{user_id}/posts/{id}."""
194
+ names = [""] * len(skeleton)
195
+ param_positions = [p for p, seg in enumerate(skeleton) if seg is None]
196
+ used: set[str] = set()
197
+ for idx, pos in enumerate(param_positions):
198
+ prev = skeleton[pos - 1] if pos > 0 else None
199
+ values = [segs[pos] for segs in samples]
200
+ keys = {m.group(1) for v in values if (m := _KEY_RE.match(v))}
201
+ if len(keys) == 1 and all(_KEY_RE.match(v) for v in values):
202
+ name = keys.pop().lower() # /maps/vt/{pb} for "pb=!1m8..."
203
+ elif idx == len(param_positions) - 1:
204
+ if all(looks_like_id(v) for v in values):
205
+ name = "id"
206
+ else: # /author/{author}, /indexes/{index}
207
+ name = singular(prev) if prev else "slug"
208
+ elif prev:
209
+ name = f"{singular(prev)}_id"
210
+ else:
211
+ name = f"param{idx + 1}"
212
+ base, k = name, 2
213
+ while name in used:
214
+ name, k = f"{base}{k}", k + 1
215
+ used.add(name)
216
+ names[pos] = name
217
+ return names