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.
- publinker_cli/__init__.py +14 -0
- publinker_cli/__main__.py +83 -0
- publinker_cli/audience.py +73 -0
- publinker_cli/auth.py +152 -0
- publinker_cli/client.py +166 -0
- publinker_cli/commands/__init__.py +0 -0
- publinker_cli/commands/auth_cmds.py +69 -0
- publinker_cli/commands/report_options.py +100 -0
- publinker_cli/commands/skill.py +46 -0
- publinker_cli/commands/token_cmd.py +97 -0
- publinker_cli/commands/update.py +207 -0
- publinker_cli/commands/use.py +100 -0
- publinker_cli/commands/write.py +72 -0
- publinker_cli/config.py +64 -0
- publinker_cli/dispatch_cli.py +394 -0
- publinker_cli/keyring_store.py +109 -0
- publinker_cli/manifest.py +409 -0
- publinker_cli/routes.yaml +1648 -0
- publinker_cli/skill/SKILL.md +61 -0
- publinker_cli/state.py +138 -0
- publinker_cli/version.py +58 -0
- publinker_cli/write_gate.py +51 -0
- publinker_cli-0.6.2.dist-info/METADATA +81 -0
- publinker_cli-0.6.2.dist-info/RECORD +26 -0
- publinker_cli-0.6.2.dist-info/WHEEL +4 -0
- publinker_cli-0.6.2.dist-info/entry_points.txt +2 -0
|
@@ -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
|