publinker-cli 0.6.2__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.
@@ -0,0 +1,409 @@
1
+ """Route manifest loader for publinker-cli.
2
+
3
+ Loads ``routes.yaml`` (next to this module) into a list of typed ``Route``
4
+ records. The CLI dispatch builds a Typer command tree from these.
5
+
6
+ This module deliberately uses ``dataclasses`` + ``yaml`` rather than Pydantic
7
+ to keep runtime dependencies minimal (the CLI is a developer tool, every
8
+ megabyte of dependency cost is felt at ``pipx install`` time).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Optional
17
+ from urllib.parse import quote
18
+
19
+ import yaml
20
+
21
+ from publinker_cli import audience as audience_mod
22
+
23
+ DEFAULT_ROUTES_FILE = Path(__file__).parent / "routes.yaml"
24
+
25
+ # Allowed risk tiers. v0.3 only ships GET routes (risk "read" or "admin");
26
+ # mutating tiers ("write", "deploy-trigger") are reserved for v0.4+.
27
+ RISK_TIERS = {"read", "write", "deploy-trigger", "admin"}
28
+
29
+ # Allowed HTTP methods.
30
+ METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
31
+
32
+ # Matches `{name}` placeholders in API paths.
33
+ _PATH_PLACEHOLDER = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
34
+
35
+
36
+ class ManifestError(ValueError):
37
+ """Raised when the manifest is malformed."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CLISpec:
42
+ group: tuple[str, ...]
43
+ command: str
44
+
45
+ @classmethod
46
+ def from_dict(cls, route_id: str, raw: dict) -> "CLISpec":
47
+ if not isinstance(raw, dict):
48
+ raise ManifestError(f"{route_id}: cli must be a dict")
49
+ try:
50
+ group = raw["group"]
51
+ command = raw["command"]
52
+ except KeyError as exc:
53
+ raise ManifestError(f"{route_id}: cli missing required key {exc}") from exc
54
+ if not isinstance(group, list) or not all(isinstance(g, str) for g in group):
55
+ raise ManifestError(f"{route_id}: cli.group must be a list of strings")
56
+ if not isinstance(command, str):
57
+ raise ManifestError(f"{route_id}: cli.command must be a string")
58
+ return cls(group=tuple(group), command=command)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class MCPSpec:
63
+ expose: bool
64
+ tool_name: Optional[str]
65
+ description: str
66
+
67
+ @classmethod
68
+ def from_dict(cls, route_id: str, raw: dict) -> "MCPSpec":
69
+ if not isinstance(raw, dict):
70
+ raise ManifestError(f"{route_id}: mcp must be a dict")
71
+ expose = bool(raw.get("expose", False))
72
+ tool_name = raw.get("tool_name")
73
+ description = raw.get("description", "").strip()
74
+ if expose:
75
+ if not tool_name:
76
+ raise ManifestError(
77
+ f"{route_id}: mcp.tool_name required when mcp.expose is true"
78
+ )
79
+ if not description:
80
+ raise ManifestError(
81
+ f"{route_id}: mcp.description required when mcp.expose is true"
82
+ )
83
+ return cls(expose=expose, tool_name=tool_name, description=description)
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class PathArg:
88
+ """A positional argument required by the path (e.g., {quiz_id})."""
89
+
90
+ name: str
91
+ help: str
92
+
93
+ @classmethod
94
+ def from_path_placeholder(cls, name: str, helps: dict) -> "PathArg":
95
+ return cls(name=name, help=helps.get(name, f"The {name} value."))
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class QueryArg:
100
+ """An optional query-string argument (e.g., --limit)."""
101
+
102
+ name: str
103
+ help: str
104
+ default: Optional[str]
105
+
106
+ @classmethod
107
+ def from_dict(cls, route_id: str, raw: dict) -> "QueryArg":
108
+ if not isinstance(raw, dict) or "name" not in raw:
109
+ raise ManifestError(f"{route_id}: query_args entries need a 'name' key")
110
+ return cls(
111
+ name=raw["name"],
112
+ help=raw.get("help", ""),
113
+ default=raw.get("default"),
114
+ )
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class HeaderSpec:
119
+ """A request header sourced from an environment variable.
120
+
121
+ Used by routes whose server-side handler gates on a custom header instead
122
+ of the Auth0 bearer the CLI normally sends (e.g.
123
+ ``POST /site/{site}/wrapper/manual-deploy`` checks
124
+ ``x-manual-deploy-key`` against the ``MANUAL_DEPLOY_SECRET`` env on the
125
+ Lambda). The value never goes in routes.yaml; it's resolved from the
126
+ operator's environment at call time so secrets stay out of git.
127
+
128
+ If ``required`` is true and the env var is unset, the dispatcher fails
129
+ fast with a clear message before any network call.
130
+ """
131
+
132
+ name: str
133
+ env: str
134
+ help: str = ""
135
+ required: bool = False
136
+
137
+ @classmethod
138
+ def from_dict(cls, route_id: str, raw: dict) -> "HeaderSpec":
139
+ if not isinstance(raw, dict):
140
+ raise ManifestError(f"{route_id}: extra_headers entries must be dicts")
141
+ for required_field in ("name", "env"):
142
+ if required_field not in raw:
143
+ raise ManifestError(
144
+ f"{route_id}: extra_headers entries need a {required_field!r} key"
145
+ )
146
+ return cls(
147
+ name=raw["name"],
148
+ env=raw["env"],
149
+ help=raw.get("help", ""),
150
+ required=bool(raw.get("required", False)),
151
+ )
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class BodyArg:
156
+ """A JSON request-body field (for POST/PUT/PATCH).
157
+
158
+ Each ``BodyArg`` becomes a CLI option (``--<name>``); the dispatcher
159
+ assembles all provided options into a single JSON object posted to the
160
+ server. The ``required`` flag controls whether absence is a typer error
161
+ or just an omitted JSON key.
162
+
163
+ ``json`` opts into "value is a JSON-encoded string" — required for nested
164
+ objects / arrays in the body (e.g. a pricing payload that's a list of
165
+ floor records).
166
+ """
167
+
168
+ name: str
169
+ help: str
170
+ required: bool = False
171
+ json: bool = False
172
+ raw: bool = False
173
+ default: Optional[str] = None
174
+
175
+ @classmethod
176
+ def from_dict(cls, route_id: str, raw: dict) -> "BodyArg":
177
+ if not isinstance(raw, dict) or "name" not in raw:
178
+ raise ManifestError(f"{route_id}: body_args entries need a 'name' key")
179
+ return cls(
180
+ name=raw["name"],
181
+ help=raw.get("help", ""),
182
+ required=bool(raw.get("required", False)),
183
+ json=bool(raw.get("json", False)),
184
+ raw=bool(raw.get("raw", False)),
185
+ default=raw.get("default"),
186
+ )
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class Route:
191
+ id: str
192
+ path: str
193
+ method: str
194
+ cli: CLISpec
195
+ mcp: MCPSpec
196
+ risk: str
197
+ # Which build ships this route. Defaults to `internal` so a route added
198
+ # without the field never leaks into a customer wheel (DEV-1566).
199
+ audience: str = audience_mod.INTERNAL
200
+ path_args: tuple[PathArg, ...] = field(default_factory=tuple)
201
+ query_args: tuple[QueryArg, ...] = field(default_factory=tuple)
202
+ body_args: tuple[BodyArg, ...] = field(default_factory=tuple)
203
+ extra_headers: tuple[HeaderSpec, ...] = field(default_factory=tuple)
204
+
205
+ @property
206
+ def is_mutation(self) -> bool:
207
+ """True for any non-GET method (the methods the write-gate guards)."""
208
+ return self.method != "GET"
209
+
210
+ @property
211
+ def is_site_scoped(self) -> bool:
212
+ return "{site}" in self.path
213
+
214
+ @property
215
+ def extra_path_args(self) -> tuple[PathArg, ...]:
216
+ """Path args excluding ``site`` (which is handled by the resolver)."""
217
+ return tuple(a for a in self.path_args if a.name != "site")
218
+
219
+ def render_path(self, values: dict[str, str]) -> str:
220
+ """Substitute ``{var}`` placeholders, URL-encoding each value as a path
221
+ segment. Missing keys raise ManifestError.
222
+
223
+ Path segments are encoded with ``safe=""`` so reserved characters like
224
+ ``|`` in Auth0 user ids (``auth0|abc``) are percent-escaped. ``.`` and
225
+ ``-`` are unreserved and pass through unchanged, so site domains like
226
+ ``foo.com`` are not mangled.
227
+ """
228
+ try:
229
+ encoded = {k: quote(str(v), safe="") for k, v in values.items()}
230
+ return self.path.format(**encoded)
231
+ except KeyError as exc:
232
+ raise ManifestError(
233
+ f"{self.id}: missing value for path arg {exc} when rendering {self.path}"
234
+ ) from exc
235
+
236
+ @classmethod
237
+ def from_dict(cls, raw: dict) -> "Route":
238
+ if not isinstance(raw, dict):
239
+ raise ManifestError(f"route entry must be a dict, got {type(raw).__name__}")
240
+ try:
241
+ route_id = raw["id"]
242
+ path = raw["path"]
243
+ method = raw["method"].upper()
244
+ except KeyError as exc:
245
+ raise ManifestError(f"route missing required key {exc}") from exc
246
+ if method not in METHODS:
247
+ raise ManifestError(f"{route_id}: unknown HTTP method {method!r}")
248
+ risk = raw.get("risk", "read")
249
+ if risk not in RISK_TIERS:
250
+ raise ManifestError(
251
+ f"{route_id}: risk must be one of {sorted(RISK_TIERS)}, got {risk!r}"
252
+ )
253
+ # Absent means internal: forgetting the field hides the route from
254
+ # customers rather than exposing it to them.
255
+ route_audience = raw.get("audience", audience_mod.INTERNAL)
256
+ if route_audience not in audience_mod.VALID:
257
+ raise ManifestError(
258
+ f"{route_id}: audience must be one of "
259
+ f"{sorted(audience_mod.VALID)}, got {route_audience!r}"
260
+ )
261
+
262
+ # Extract path args from the path itself; per-arg help text overrides
263
+ # come from the optional `args:` block in the YAML.
264
+ path_arg_helps: dict[str, str] = {}
265
+ for entry in raw.get("args", []) or []:
266
+ if not isinstance(entry, dict) or "name" not in entry:
267
+ raise ManifestError(f"{route_id}: args entries need a 'name' key")
268
+ path_arg_helps[entry["name"]] = entry.get("help", "")
269
+
270
+ placeholders = _PATH_PLACEHOLDER.findall(path)
271
+ path_args = tuple(
272
+ PathArg.from_path_placeholder(name, path_arg_helps) for name in placeholders
273
+ )
274
+
275
+ query_args = tuple(
276
+ QueryArg.from_dict(route_id, q) for q in (raw.get("query_args") or [])
277
+ )
278
+ body_args = tuple(
279
+ BodyArg.from_dict(route_id, b) for b in (raw.get("body_args") or [])
280
+ )
281
+ if body_args and method == "GET":
282
+ raise ManifestError(f"{route_id}: GET routes cannot have body_args")
283
+ raw_args = [b for b in body_args if b.raw]
284
+ if raw_args and len(body_args) > 1:
285
+ raise ManifestError(
286
+ f"{route_id}: a body_arg with raw=true must be the only body_arg "
287
+ f"(found {[b.name for b in body_args]})"
288
+ )
289
+ if raw_args and not raw_args[0].json:
290
+ raise ManifestError(
291
+ f"{route_id}: body_arg {raw_args[0].name!r} uses raw=true and "
292
+ f"must also set json=true so the value is decoded as JSON"
293
+ )
294
+
295
+ extra_headers = tuple(
296
+ HeaderSpec.from_dict(route_id, h) for h in (raw.get("extra_headers") or [])
297
+ )
298
+
299
+ return cls(
300
+ id=route_id,
301
+ path=path,
302
+ method=method,
303
+ cli=CLISpec.from_dict(route_id, raw["cli"]),
304
+ mcp=MCPSpec.from_dict(route_id, raw["mcp"]),
305
+ risk=risk,
306
+ audience=route_audience,
307
+ path_args=path_args,
308
+ query_args=query_args,
309
+ body_args=body_args,
310
+ extra_headers=extra_headers,
311
+ )
312
+
313
+
314
+ def load_manifest(
315
+ path: Path = DEFAULT_ROUTES_FILE, audience: Optional[str] = None
316
+ ) -> list[Route]:
317
+ """Load + validate the manifest, filtered to this build's audience.
318
+
319
+ Validation runs over EVERY entry before filtering, so a malformed
320
+ internal-only route still fails a customer build's tests rather than
321
+ silently disappearing.
322
+
323
+ Pass `audience` explicitly in tests; production callers let it resolve
324
+ from the environment or the packaged build marker.
325
+ """
326
+ raw = yaml.safe_load(path.read_text())
327
+ if not isinstance(raw, list):
328
+ raise ManifestError(
329
+ f"{path}: top-level must be a YAML list, got {type(raw).__name__}"
330
+ )
331
+
332
+ routes = [Route.from_dict(entry) for entry in raw]
333
+
334
+ # Cross-route invariants.
335
+ ids: set[str] = set()
336
+ mcp_tool_names: set[str] = set()
337
+ cli_paths: dict[tuple[str, ...], str] = {}
338
+ cli_groups: dict[tuple[str, ...], str] = {}
339
+ for route in routes:
340
+ if route.id in ids:
341
+ raise ManifestError(f"duplicate route id: {route.id}")
342
+ ids.add(route.id)
343
+ if route.mcp.expose and route.mcp.tool_name in mcp_tool_names:
344
+ raise ManifestError(
345
+ f"{route.id}: duplicate MCP tool name {route.mcp.tool_name!r}"
346
+ )
347
+ if route.mcp.expose and route.mcp.tool_name:
348
+ mcp_tool_names.add(route.mcp.tool_name)
349
+ cli_path = (*route.cli.group, route.cli.command)
350
+ if cli_path in cli_paths:
351
+ raise ManifestError(f"{route.id}: duplicate CLI path {' '.join(cli_path)}")
352
+ cli_paths[cli_path] = route.id
353
+ for depth in range(1, len(route.cli.group) + 1):
354
+ cli_groups.setdefault(route.cli.group[:depth], route.id)
355
+
356
+ collisions = sorted(cli_paths.keys() & cli_groups.keys())
357
+ if collisions:
358
+ collision = collisions[0]
359
+ raise ManifestError(
360
+ f"{cli_paths[collision]}: CLI command path {' '.join(collision)!r} "
361
+ f"is also a command group for {cli_groups[collision]}"
362
+ )
363
+
364
+ resolved = audience or audience_mod.current_audience()
365
+ return [r for r in routes if audience_mod.route_is_visible(r.audience, resolved)]
366
+
367
+
368
+ def extract_app_py_paths(app_py: Path) -> set[tuple[str, str]]:
369
+ """Parse `app.py` and return the set of `(path, method)` tuples it declares.
370
+
371
+ Handles both single-line route decorators and the multi-line form Chalice
372
+ sometimes uses. Used by ``check_manifest_against_app_py`` for drift detection.
373
+ """
374
+ text = app_py.read_text()
375
+ pairs: set[tuple[str, str]] = set()
376
+
377
+ # Match @app.route("PATH", methods=[...]) — both single-line and multi-line.
378
+ pattern = re.compile(
379
+ r"@app\.route\(\s*\"([^\"]+)\"\s*,\s*methods=\[([^\]]+)\]",
380
+ re.MULTILINE,
381
+ )
382
+ for m in pattern.finditer(text):
383
+ path = m.group(1)
384
+ for method in re.findall(r"\"([A-Z]+)\"", m.group(2)):
385
+ pairs.add((path, method))
386
+ return pairs
387
+
388
+
389
+ def check_manifest_against_app_py(
390
+ routes: list[Route], app_py: Path
391
+ ) -> tuple[list[str], list[str]]:
392
+ """Return (manifest_only, app_py_only_routes).
393
+
394
+ ``manifest_only`` is the list of manifest entries whose (path, method) does
395
+ not appear in app.py — these are stale or typo'd entries.
396
+ ``app_py_only_routes`` is the list of (path, method) pairs in app.py that
397
+ no manifest entry covers. For v0.3 we expect this list to be long (we
398
+ haven't manifested every route yet); the check is informational only.
399
+ """
400
+ app_pairs = extract_app_py_paths(app_py)
401
+ manifest_pairs = {(r.path, r.method) for r in routes}
402
+
403
+ stale = [
404
+ f"{r.method} {r.path} (manifest id={r.id})"
405
+ for r in routes
406
+ if (r.path, r.method) not in app_pairs
407
+ ]
408
+ uncovered = [f"{m} {p}" for p, m in sorted(app_pairs - manifest_pairs)]
409
+ return stale, uncovered