ladex 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.
- ladex/__init__.py +8 -0
- ladex/cli/__init__.py +394 -0
- ladex/cli/report.py +306 -0
- ladex/engine/__init__.py +1 -0
- ladex/engine/attest/__init__.py +31 -0
- ladex/engine/attest/service.py +69 -0
- ladex/engine/attest/signer.py +107 -0
- ladex/engine/attest/statement.py +61 -0
- ladex/engine/attest/store.py +100 -0
- ladex/engine/bom/__init__.py +7 -0
- ladex/engine/bom/cyclonedx.py +215 -0
- ladex/engine/detect/__init__.py +8 -0
- ladex/engine/detect/ast_walker.py +263 -0
- ladex/engine/detect/iac.py +346 -0
- ladex/engine/detect/records.py +47 -0
- ladex/engine/enrich/__init__.py +34 -0
- ladex/engine/enrich/cache.py +98 -0
- ladex/engine/enrich/hfhub.py +74 -0
- ladex/engine/enrich/http.py +84 -0
- ladex/engine/enrich/models.py +104 -0
- ladex/engine/enrich/osv.py +53 -0
- ladex/engine/enrich/pypi.py +59 -0
- ladex/engine/enrich/service.py +73 -0
- ladex/engine/policy/__init__.py +41 -0
- ladex/engine/policy/context.py +71 -0
- ladex/engine/policy/evaluate.py +93 -0
- ladex/engine/policy/loader.py +91 -0
- ladex/engine/policy/models.py +108 -0
- ladex/engine/policy/report.py +62 -0
- ladex/engine/policy/service.py +16 -0
- ladex/engine/scan.py +116 -0
- ladex/engine/server/__init__.py +8 -0
- ladex/engine/server/diagnostics.py +55 -0
- ladex/engine/server/server.py +51 -0
- ladex/engine/taxonomy/__init__.py +41 -0
- ladex/engine/taxonomy/loader.py +128 -0
- ladex/engine/taxonomy/models.py +146 -0
- ladex/packs/__init__.py +1 -0
- ladex/packs/iac/iac.yaml +59 -0
- ladex/packs/policy/eu_ai_act/art50.yaml +42 -0
- ladex/packs/taxonomy/frameworks.yaml +46 -0
- ladex/packs/taxonomy/inference_apis.yaml +73 -0
- ladex/packs/taxonomy/model_loaders.yaml +66 -0
- ladex/packs/taxonomy/vector_stores.yaml +37 -0
- ladex/py.typed +0 -0
- ladex-0.1.0.dist-info/METADATA +164 -0
- ladex-0.1.0.dist-info/RECORD +50 -0
- ladex-0.1.0.dist-info/WHEEL +4 -0
- ladex-0.1.0.dist-info/entry_points.txt +2 -0
- ladex-0.1.0.dist-info/licenses/LICENSE +21 -0
ladex/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Ladex — a bill of lading for AI.
|
|
2
|
+
|
|
3
|
+
Ladex records what AI is aboard a codebase — models, datasets, agent frameworks,
|
|
4
|
+
vector stores, inference APIs — captured at the moment the code is written, and emits a
|
|
5
|
+
CycloneDX ML-BOM with signed human attestations for the fields no scanner can derive.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
ladex/cli/__init__.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Ladex command-line interface.
|
|
2
|
+
|
|
3
|
+
Kept intentionally dependency-free (stdlib ``argparse``) until Step 3, when ``ladex scan``
|
|
4
|
+
and a richer CLI (typer/rich) land. For now it exposes taxonomy inspection so Step 1 is
|
|
5
|
+
runnable and verifiable:
|
|
6
|
+
|
|
7
|
+
ladex --version
|
|
8
|
+
ladex scan [PATH] [--json]
|
|
9
|
+
ladex taxonomy validate [PACK.yaml ...]
|
|
10
|
+
ladex taxonomy list
|
|
11
|
+
ladex detect FILE.py
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ladex import __version__
|
|
22
|
+
from ladex.engine.detect import PythonDetector
|
|
23
|
+
from ladex.engine.scan import scan_path
|
|
24
|
+
from ladex.engine.taxonomy import (
|
|
25
|
+
AttributeMatch,
|
|
26
|
+
CallMatch,
|
|
27
|
+
ImportMatch,
|
|
28
|
+
Rule,
|
|
29
|
+
StringMatch,
|
|
30
|
+
TaxonomyError,
|
|
31
|
+
aggregate,
|
|
32
|
+
load_builtin_packs,
|
|
33
|
+
load_pack_file,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_VERSION_FLAGS = {"--version", "-V", "version"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
"""Entry point for the ``ladex`` console script."""
|
|
41
|
+
args = sys.argv[1:] if argv is None else argv
|
|
42
|
+
if not args:
|
|
43
|
+
_print_banner()
|
|
44
|
+
return 0
|
|
45
|
+
if args[0] in _VERSION_FLAGS:
|
|
46
|
+
print(f"ladex {__version__}")
|
|
47
|
+
return 0
|
|
48
|
+
if args[0] == "scan":
|
|
49
|
+
return _scan_command(args[1:])
|
|
50
|
+
if args[0] == "policy":
|
|
51
|
+
return _policy_command(args[1:])
|
|
52
|
+
if args[0] == "attest":
|
|
53
|
+
return _attest_command(args[1:])
|
|
54
|
+
if args[0] == "verify":
|
|
55
|
+
return _verify_command(args[1:])
|
|
56
|
+
if args[0] == "serve":
|
|
57
|
+
return _serve_command(args[1:])
|
|
58
|
+
if args[0] == "taxonomy":
|
|
59
|
+
return _taxonomy_command(args[1:])
|
|
60
|
+
if args[0] == "detect":
|
|
61
|
+
return _detect_command(args[1:])
|
|
62
|
+
print(f"ladex {__version__} - a bill of lading for AI")
|
|
63
|
+
print(f"unknown command: {args[0]!r}. Try 'ladex taxonomy list'.", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _print_banner() -> None:
|
|
68
|
+
print(f"ladex {__version__} - a bill of lading for AI")
|
|
69
|
+
print("Commands: `ladex scan [PATH]`, `ladex detect FILE`, `ladex taxonomy list`.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _scan_command(args: list[str]) -> int:
|
|
73
|
+
if args and args[0] in {"-h", "--help"}:
|
|
74
|
+
print(
|
|
75
|
+
"usage: ladex scan [PATH] [--json] [--enrich] [--offline] [--hf-token TOKEN]\n"
|
|
76
|
+
" [--write-bom [FILE]]",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
return 0
|
|
80
|
+
as_json = "--json" in args
|
|
81
|
+
write_bom = "--write-bom" in args
|
|
82
|
+
do_enrich = "--enrich" in args or "--offline" in args
|
|
83
|
+
offline = "--offline" in args
|
|
84
|
+
hf_token = _flag_value(args, "--hf-token") or os.environ.get("HF_TOKEN")
|
|
85
|
+
bom_path = Path(_flag_value(args, "--write-bom") or "aibom.cdx.json")
|
|
86
|
+
consumed = {v for v in (hf_token, str(bom_path)) if v}
|
|
87
|
+
positional = [a for a in args if not a.startswith("-") and a not in consumed]
|
|
88
|
+
root = Path(positional[0]) if positional else Path(".")
|
|
89
|
+
if not root.exists():
|
|
90
|
+
print(f"path does not exist: {root}", file=sys.stderr)
|
|
91
|
+
return 2
|
|
92
|
+
|
|
93
|
+
# Import rendering lazily so `--json` needs no rich formatting path.
|
|
94
|
+
from ladex.cli.report import (
|
|
95
|
+
enrichment_to_dict,
|
|
96
|
+
render_enrichment,
|
|
97
|
+
render_scan,
|
|
98
|
+
scan_to_dict,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
result = scan_path(root)
|
|
102
|
+
enrichment = None
|
|
103
|
+
if do_enrich:
|
|
104
|
+
from ladex.engine.enrich import enrich_scan
|
|
105
|
+
|
|
106
|
+
enrichment = enrich_scan(result, offline=offline, hf_token=hf_token)
|
|
107
|
+
|
|
108
|
+
if write_bom:
|
|
109
|
+
from ladex.engine.attest import AttestationStore
|
|
110
|
+
from ladex.engine.bom import build_bom, render_json
|
|
111
|
+
from ladex.engine.policy import check_scan
|
|
112
|
+
|
|
113
|
+
policy = check_scan(result)
|
|
114
|
+
attestations = AttestationStore.for_root(root).load()
|
|
115
|
+
bom = build_bom(result, enrichment=enrichment, policy=policy, attestations=attestations)
|
|
116
|
+
bom_path.write_text(render_json(bom), encoding="utf-8")
|
|
117
|
+
print(f"wrote {bom_path} ({len(result.detections)} detection(s))")
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
if as_json:
|
|
121
|
+
payload = scan_to_dict(result)
|
|
122
|
+
if enrichment is not None:
|
|
123
|
+
payload["enrichment"] = enrichment_to_dict(enrichment)
|
|
124
|
+
print(json.dumps(payload, indent=2))
|
|
125
|
+
else:
|
|
126
|
+
render_scan(result)
|
|
127
|
+
if enrichment is not None:
|
|
128
|
+
render_enrichment(enrichment)
|
|
129
|
+
# `scan` is informational: presence of AI is not a failure. Gating on obligations
|
|
130
|
+
# and gaps is the policy layer's job (Step 5).
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _flag_value(args: list[str], flag: str) -> str | None:
|
|
135
|
+
if flag in args:
|
|
136
|
+
idx = args.index(flag)
|
|
137
|
+
if idx + 1 < len(args):
|
|
138
|
+
return args[idx + 1]
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _tristate(args: list[str], yes: str, no: str) -> bool | None:
|
|
143
|
+
if yes in args:
|
|
144
|
+
return True
|
|
145
|
+
if no in args:
|
|
146
|
+
return False
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _policy_command(args: list[str]) -> int:
|
|
151
|
+
if not args or args[0] in {"-h", "--help"}:
|
|
152
|
+
print(
|
|
153
|
+
"usage: ladex policy {check|list} [PATH] [--json]\n"
|
|
154
|
+
" project facts: --user-facing/--not-user-facing, "
|
|
155
|
+
"--synthetic-content/--no-synthetic-content",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
return 0 if args else 2
|
|
159
|
+
sub, rest = args[0], args[1:]
|
|
160
|
+
if sub == "list":
|
|
161
|
+
return _policy_list()
|
|
162
|
+
if sub == "check":
|
|
163
|
+
return _policy_check(rest)
|
|
164
|
+
print(f"unknown policy subcommand: {sub!r}", file=sys.stderr)
|
|
165
|
+
return 2
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _policy_list() -> int:
|
|
169
|
+
from ladex.engine.policy import PolicyError, load_builtin_bundles
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
bundles = load_builtin_bundles()
|
|
173
|
+
except PolicyError as exc:
|
|
174
|
+
print(f"INVALID: {exc}", file=sys.stderr)
|
|
175
|
+
return 1
|
|
176
|
+
for bundle in bundles:
|
|
177
|
+
print(f"{bundle.id} ({bundle.regulation}) v{bundle.version}: {len(bundle.rules)} rule(s)")
|
|
178
|
+
for rule in bundle.rules:
|
|
179
|
+
print(f" - {rule.citation:<10} {rule.id} [{rule.verification.value}]")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _policy_check(args: list[str]) -> int:
|
|
184
|
+
from ladex.cli.report import policy_to_dict, render_policy
|
|
185
|
+
from ladex.engine.policy import PolicyError, ProjectContext, check_scan
|
|
186
|
+
|
|
187
|
+
as_json = "--json" in args
|
|
188
|
+
project = ProjectContext(
|
|
189
|
+
user_facing=_tristate(args, "--user-facing", "--not-user-facing"),
|
|
190
|
+
generates_synthetic_content=_tristate(
|
|
191
|
+
args, "--synthetic-content", "--no-synthetic-content"
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
positional = [a for a in args if not a.startswith("-")]
|
|
195
|
+
root = Path(positional[0]) if positional else Path(".")
|
|
196
|
+
if not root.exists():
|
|
197
|
+
print(f"path does not exist: {root}", file=sys.stderr)
|
|
198
|
+
return 2
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
result = scan_path(root)
|
|
202
|
+
report = check_scan(result, project)
|
|
203
|
+
except PolicyError as exc:
|
|
204
|
+
print(f"INVALID: {exc}", file=sys.stderr)
|
|
205
|
+
return 1
|
|
206
|
+
|
|
207
|
+
if as_json:
|
|
208
|
+
print(json.dumps(policy_to_dict(report), indent=2))
|
|
209
|
+
else:
|
|
210
|
+
render_policy(report)
|
|
211
|
+
# Informational for now; the CI gate on open gaps turns on with attestation (Step 7).
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _attest_command(args: list[str]) -> int:
|
|
216
|
+
if not args or args[0] in {"-h", "--help"}:
|
|
217
|
+
print(
|
|
218
|
+
"usage: ladex attest SUBJECT --claim provenance --value TEXT [--attester WHO]\n"
|
|
219
|
+
" [--path DIR]\n"
|
|
220
|
+
" SUBJECT is a model id (e.g. sentence-transformers/all-MiniLM-L6-v2).",
|
|
221
|
+
file=sys.stderr,
|
|
222
|
+
)
|
|
223
|
+
return 0 if args else 2
|
|
224
|
+
|
|
225
|
+
from ladex.engine.attest import (
|
|
226
|
+
ATTESTABLE_CLAIMS,
|
|
227
|
+
AttestationStore,
|
|
228
|
+
create_attestation,
|
|
229
|
+
get_signer,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
positional = [a for a in args if not a.startswith("-")]
|
|
233
|
+
consumed = {
|
|
234
|
+
v
|
|
235
|
+
for v in (
|
|
236
|
+
_flag_value(args, "--claim"),
|
|
237
|
+
_flag_value(args, "--value"),
|
|
238
|
+
_flag_value(args, "--attester"),
|
|
239
|
+
_flag_value(args, "--path"),
|
|
240
|
+
)
|
|
241
|
+
if v
|
|
242
|
+
}
|
|
243
|
+
positional = [p for p in positional if p not in consumed]
|
|
244
|
+
if not positional:
|
|
245
|
+
print("attest requires a SUBJECT (model id)", file=sys.stderr)
|
|
246
|
+
return 2
|
|
247
|
+
subject = positional[0]
|
|
248
|
+
claim = _flag_value(args, "--claim") or "provenance"
|
|
249
|
+
if claim not in ATTESTABLE_CLAIMS:
|
|
250
|
+
print(f"unknown claim {claim!r}; choose from {list(ATTESTABLE_CLAIMS)}", file=sys.stderr)
|
|
251
|
+
return 2
|
|
252
|
+
value = _flag_value(args, "--value")
|
|
253
|
+
if value is None:
|
|
254
|
+
print("attest requires --value (the declaration text)", file=sys.stderr)
|
|
255
|
+
return 2
|
|
256
|
+
attester = _flag_value(args, "--attester") or _git_email() or "unknown"
|
|
257
|
+
root = Path(_flag_value(args, "--path") or ".")
|
|
258
|
+
|
|
259
|
+
signer = get_signer("local")
|
|
260
|
+
attestation = create_attestation(subject, claim, value, attester, signer)
|
|
261
|
+
AttestationStore.for_root(root).add(attestation)
|
|
262
|
+
print(
|
|
263
|
+
f"attested {claim} for {subject}\n"
|
|
264
|
+
f" signed by {attester} (keyid {attestation.keyid}, {signer.__class__.__name__})\n"
|
|
265
|
+
f" stored in {root / '.ladex' / 'attestations.json'}"
|
|
266
|
+
)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _verify_command(args: list[str]) -> int:
|
|
271
|
+
if args and args[0] in {"-h", "--help"}:
|
|
272
|
+
print("usage: ladex verify [PATH]", file=sys.stderr)
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
from ladex.engine.attest import AttestationStore, verify_attestation
|
|
276
|
+
|
|
277
|
+
positional = [a for a in args if not a.startswith("-")]
|
|
278
|
+
root = Path(positional[0]) if positional else Path(".")
|
|
279
|
+
attestations = AttestationStore.for_root(root).load()
|
|
280
|
+
if not attestations:
|
|
281
|
+
print("no attestations found.")
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
ok = 0
|
|
285
|
+
for att in attestations:
|
|
286
|
+
valid = verify_attestation(att)
|
|
287
|
+
ok += valid
|
|
288
|
+
mark = "OK " if valid else "FAIL"
|
|
289
|
+
print(f" [{mark}] {att.subject} {att.claim} <- {att.attester}")
|
|
290
|
+
bad = len(attestations) - ok
|
|
291
|
+
print(f"\n{ok} valid, {bad} invalid attestation(s).")
|
|
292
|
+
return 0 if bad == 0 else 1
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _serve_command(args: list[str]) -> int:
|
|
296
|
+
if args and args[0] in {"-h", "--help"}:
|
|
297
|
+
print("usage: ladex serve (runs the LSP server over stdio)", file=sys.stderr)
|
|
298
|
+
return 0
|
|
299
|
+
from ladex.engine.server import start_stdio
|
|
300
|
+
|
|
301
|
+
start_stdio()
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _git_email() -> str | None:
|
|
306
|
+
import subprocess
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
out = subprocess.run(
|
|
310
|
+
["git", "config", "user.email"],
|
|
311
|
+
capture_output=True,
|
|
312
|
+
text=True,
|
|
313
|
+
timeout=5,
|
|
314
|
+
check=False,
|
|
315
|
+
)
|
|
316
|
+
except (OSError, subprocess.SubprocessError):
|
|
317
|
+
return None
|
|
318
|
+
email = out.stdout.strip()
|
|
319
|
+
return email or None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _detect_command(args: list[str]) -> int:
|
|
323
|
+
if not args or args[0] in {"-h", "--help"}:
|
|
324
|
+
print("usage: ladex detect FILE.py", file=sys.stderr)
|
|
325
|
+
return 0 if args else 2
|
|
326
|
+
target = Path(args[0])
|
|
327
|
+
if not target.is_file():
|
|
328
|
+
print(f"not a file: {target}", file=sys.stderr)
|
|
329
|
+
return 2
|
|
330
|
+
detections = PythonDetector().detect_file(target)
|
|
331
|
+
if not detections:
|
|
332
|
+
return 0 # ruthless silence: nothing AI-relevant, say nothing
|
|
333
|
+
for d in detections:
|
|
334
|
+
print(f"{d.location():<28} {d.component_type.value:<15} {d.rule_id:<28} {d.evidence}")
|
|
335
|
+
print(f"\n{len(detections)} detection(s).")
|
|
336
|
+
return 0
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _taxonomy_command(args: list[str]) -> int:
|
|
340
|
+
if not args or args[0] in {"-h", "--help"}:
|
|
341
|
+
print("usage: ladex taxonomy {validate|list} [PACK.yaml ...]", file=sys.stderr)
|
|
342
|
+
return 0 if args else 2
|
|
343
|
+
sub, rest = args[0], args[1:]
|
|
344
|
+
if sub == "validate":
|
|
345
|
+
return _taxonomy_validate([Path(p) for p in rest])
|
|
346
|
+
if sub == "list":
|
|
347
|
+
return _taxonomy_list()
|
|
348
|
+
print(f"unknown taxonomy subcommand: {sub!r}", file=sys.stderr)
|
|
349
|
+
return 2
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _taxonomy_validate(extra_paths: list[Path]) -> int:
|
|
353
|
+
try:
|
|
354
|
+
packs = load_builtin_packs()
|
|
355
|
+
packs.extend(load_pack_file(p) for p in extra_paths)
|
|
356
|
+
taxonomy = aggregate(packs)
|
|
357
|
+
except TaxonomyError as exc:
|
|
358
|
+
print(f"INVALID: {exc}", file=sys.stderr)
|
|
359
|
+
return 1
|
|
360
|
+
print(f"OK: {len(taxonomy.packs)} pack(s), {len(taxonomy)} rule(s) validated.")
|
|
361
|
+
for pack in taxonomy.packs:
|
|
362
|
+
print(f" - {pack.name} v{pack.version}: {len(pack.rules)} rule(s)")
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _taxonomy_list() -> int:
|
|
367
|
+
try:
|
|
368
|
+
taxonomy = aggregate(load_builtin_packs())
|
|
369
|
+
except TaxonomyError as exc:
|
|
370
|
+
print(f"INVALID: {exc}", file=sys.stderr)
|
|
371
|
+
return 1
|
|
372
|
+
rules = sorted(taxonomy.rules, key=lambda r: (r.component_type.value, r.id))
|
|
373
|
+
width = max((len(r.id) for r in rules), default=3)
|
|
374
|
+
for rule in rules:
|
|
375
|
+
print(f"{rule.id:<{width}} {rule.component_type.value:<15} {_match_summary(rule)}")
|
|
376
|
+
print(f"\n{len(rules)} rule(s).")
|
|
377
|
+
return 0
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _match_summary(rule: Rule) -> str:
|
|
381
|
+
m = rule.match
|
|
382
|
+
if isinstance(m, ImportMatch):
|
|
383
|
+
return f"import {m.module}" + (f".{m.symbol}" if m.symbol else "")
|
|
384
|
+
if isinstance(m, CallMatch):
|
|
385
|
+
return f"call {m.target}()"
|
|
386
|
+
if isinstance(m, AttributeMatch):
|
|
387
|
+
return f"attr {m.target}"
|
|
388
|
+
if isinstance(m, StringMatch):
|
|
389
|
+
return f"string /{m.pattern}/"
|
|
390
|
+
return "?" # pragma: no cover - exhaustive above
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
if __name__ == "__main__":
|
|
394
|
+
raise SystemExit(main())
|