reqctl 0.1.38__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.
reqctl/corpus.py ADDED
@@ -0,0 +1,1283 @@
1
+ import base64
2
+ import contextlib
3
+ import functools
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import secrets
9
+ import shutil
10
+ from pathlib import Path
11
+ from urllib.parse import urldefrag, urljoin, urlsplit
12
+
13
+ import yaml
14
+ from jsonschema import Draft202012Validator
15
+ from jsonschema.exceptions import SchemaError
16
+ from jsonschema.validators import validator_for
17
+ from referencing import Registry
18
+ from referencing.jsonschema import DRAFT202012, specification_with
19
+
20
+ Loader = yaml.CSafeLoader if yaml.__with_libyaml__ else yaml.SafeLoader
21
+ NEST_LIMIT = 64
22
+
23
+ CONFLICTED = re.compile(r"^<{7}", re.M)
24
+
25
+ PREFIXES = {"REQ-": "requirement", "GUARD-": "guard",
26
+ "PARAM-": "parameter", "TERM-": "term", "DATA-": "data"}
27
+ FOLDERS = {"requirement": "reqs", "guard": "guards", "parameter": "params",
28
+ "term": "terms", "data": "data"}
29
+ KINDS = "|".join(prefix.rstrip("-") for prefix in PREFIXES)
30
+ NAMED = ", ".join(prefix.rstrip("-") for prefix in PREFIXES)
31
+ KINDS_NAMED = ", ".join(FOLDERS)
32
+
33
+ NAME = r"[a-z][a-z0-9_]*"
34
+ UID = re.compile(rf"^({KINDS})-\d{{8}}$")
35
+ UID_IN_PROSE = re.compile(rf"\b(?:{KINDS})-\d{{8}}\b")
36
+ PARAM_REF = re.compile(rf"\$\{{({NAME})((?:\.[^}}\s]+)?)\}}")
37
+ PARAM_NEAR = re.compile(r"\$\{?(?:param|data)[-_]\d+(?:\.[\w.-]+)?\}?", re.IGNORECASE)
38
+ NAME_FORM = "${name}"
39
+ # @req> REQ-11930677@dH7Pu7k4K4YI lzcna6
40
+ CONCEPT_TARGET = re.compile(rf"{NAME}(?:\.{NAME}){{0,2}}")
41
+ CONCEPT_LINK = re.compile(rf"\[([^\]\n]+)\]\(({CONCEPT_TARGET.pattern})\)")
42
+ TERM_NEAR = re.compile(r"\bterm[-_]\d+\b", re.IGNORECASE)
43
+ LINK_FORM = "[the words](handle)"
44
+ CONCEPT_NEAR = re.compile(r"\]\(((?:TERM|PARAM|DATA)-\d+[^)]*)\)")
45
+ TAG_STAMP = 12
46
+ TEST_PATH = re.compile(r"(^|/)tests?/|(^|/)test_[^/]+$|[._](test|spec)\.[A-Za-z]+$")
47
+
48
+ STATEMENT_STAMPED = ("text", "acceptance_criteria")
49
+ ITEM_STAMPED = ("text", "value_type", "unit", "default")
50
+ DATA_KEY = re.compile(r"^[a-z][a-z0-9_]*$")
51
+ STATED_SKIP = ("aliases",)
52
+ # @req> REQ-76787560@yx4uV-ApjJfe sfuabl
53
+ POLICED_SKIP = ("aliases", "word", "name", "carried")
54
+ # @req> REQ-68407569@9WdKt66SbMuQ bzk766
55
+ POLICED_ITEM_SKIP = POLICED_SKIP + ("default", "unit")
56
+ # @req> REQ-83702299@kBGzIwAyKDHL zgqncd
57
+ ENUMERATED = ("kind", "type", "verification", "priority", "value_type")
58
+ CITATION_LIST = "citations"
59
+ WHOLE_FILES = "whole_file_citations"
60
+ # @req> REQ-88203622@wlZ9orsr9c7q otmh6w
61
+ CITABLE = ("requirement", "guard", "parameter", "data")
62
+ # @req> REQ-22682482@F_OIMxlkifKy utfemd
63
+ ITEM_SKIP = ("entries", "acceptance_criteria", "text", "fields",
64
+ "assessed", "relations", "status", CITATION_LIST, WHOLE_FILES)
65
+ MEMBERS = "*"
66
+ ADDRESS = re.compile(rf"^((?:{KINDS})-\d{{8}}|{NAME})(?:\.(.+))?$")
67
+
68
+ # @req> REQ-10966072@RC1_0V_R-34U zikuq2
69
+ # @req+ skipped_folders@EwHTRPcwGsLx p2lhya
70
+ # @req> REQ-87066486@AtzPEAcAbJ1v m5r2gd
71
+ SCAN_SKIP = {
72
+ ".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__",
73
+ ".mypy_cache", ".ruff_cache", ".pytest_cache", ".elucidate", ".reqctl",
74
+ }
75
+
76
+
77
+ def skipped(root, folder):
78
+ return (folder.name in SCAN_SKIP or folder.name.endswith(".egg-info")
79
+ or folder == Path(root) / "requirements")
80
+ # @req- p2lhya
81
+
82
+
83
+ class ReqctlError(Exception):
84
+ pass
85
+
86
+
87
+ class Item:
88
+ def __init__(self, uid, path, data):
89
+ self.uid = uid
90
+ self.path = path
91
+ self.data = data
92
+
93
+
94
+ class Store:
95
+ def __init__(self, root):
96
+ self.root = root
97
+ self._items = None
98
+ self._index = None
99
+
100
+
101
+ def find_root(start=None):
102
+ here = Path(start or Path.cwd()).resolve()
103
+ for candidate in [here, *here.parents]:
104
+ if (candidate / "requirements").is_dir() and (candidate / ".git").exists():
105
+ return candidate
106
+ # @req> REQ-61212158@EISsRx_ntdvz pdpnj6
107
+ for candidate in [here, *here.parents]:
108
+ if (candidate / ".git").exists():
109
+ return candidate
110
+ # @req> REQ-30898926@Vu28eWaG_1mE nq47sk
111
+ raise ReqctlError("not inside a git repository (no .git here or above) -- "
112
+ "run reqctl inside the repository")
113
+
114
+
115
+ def load(root=None):
116
+ root = Path(root) if root else find_root()
117
+ return Store(root), root
118
+
119
+
120
+ def _for_prefix(uid, table):
121
+ for prefix, value in table.items():
122
+ if uid.startswith(prefix):
123
+ return value
124
+ # @req> REQ-30898926@Vu28eWaG_1mE vtwjpf
125
+ raise ReqctlError(f"{uid}: not a {NAMED} uid -- use a uid of one of those "
126
+ "kinds; `reqctl list` names them")
127
+
128
+
129
+ # @req> REQ-14917237@a9P7pldigUrg 7vs2op
130
+ def kind_of(uid, data):
131
+ return kind_for_uid(uid) or (data or {}).get("kind")
132
+
133
+
134
+ def folder_for(root, kind):
135
+ if kind not in FOLDERS:
136
+ # @req> REQ-30898926@Vu28eWaG_1mE 5vz2qe
137
+ raise ReqctlError(f"{kind}: not a {KINDS_NAMED} kind -- use one of those kinds")
138
+ return Path(root) / "requirements" / FOLDERS[kind]
139
+
140
+
141
+ def item_files(root):
142
+ found = []
143
+ for folder in FOLDERS.values():
144
+ directory = Path(root) / "requirements" / folder
145
+ found += sorted(
146
+ path
147
+ for pattern in ("*.yml", "*.yaml")
148
+ for path in directory.glob(pattern)
149
+ if not path.name.startswith(".")
150
+ )
151
+ seen = {}
152
+ # @req> REQ-17446443@86KE0VfkVuKG 2mb54k
153
+ for path in found:
154
+ first = seen.setdefault(path.stem, path)
155
+ if first is not path:
156
+ # @req> REQ-30898926@Vu28eWaG_1mE k6zhqw
157
+ raise ReqctlError(
158
+ f"{path.stem}: two files claim this uid: "
159
+ f"{first.relative_to(root)} and {path.relative_to(root)} -- "
160
+ "remove one of the two files, or rename it to a different uid"
161
+ )
162
+ return found
163
+
164
+
165
+ def _repeated(node):
166
+ if not isinstance(node, yaml.MappingNode):
167
+ return []
168
+ stated = [key.value for key, _ in node.value
169
+ if isinstance(key, yaml.ScalarNode)]
170
+ return sorted(set(key for key in stated if stated.count(key) > 1))
171
+
172
+
173
+ def _folded(loader, node):
174
+ walked, stack = set(), [node]
175
+ while stack:
176
+ held = stack.pop()
177
+ if held in walked:
178
+ continue
179
+ walked.add(held)
180
+ if isinstance(held, yaml.SequenceNode):
181
+ stack.extend(held.value)
182
+ if isinstance(held, yaml.MappingNode):
183
+ keys = {}
184
+ for key, value in held.value:
185
+ stack.append(value)
186
+ if isinstance(key, yaml.ScalarNode):
187
+ read = loader.construct_object(key)
188
+ if read in keys:
189
+ return [keys[read]]
190
+ keys[read] = read
191
+ return []
192
+
193
+
194
+ def loads(text, where, aliases=True, reading=Loader):
195
+ loader = reading(text)
196
+ try:
197
+ # @req+ REQ-49870454@KKNV-vdY6oM1 ts6cfc
198
+ named = [] if aliases else [
199
+ f"*{event.anchor} on line {event.start_mark.line + 1}"
200
+ for event in yaml.parse(text, Loader=Loader)
201
+ if isinstance(event, yaml.AliasEvent)]
202
+ if named:
203
+ # @req> REQ-30898926@Vu28eWaG_1mE danz6r
204
+ raise ReqctlError(f"{where} holds the YAML alias {', '.join(named)} "
205
+ "-- write each value out in full in place of its alias")
206
+ # @req- ts6cfc
207
+ node = loader.get_single_node()
208
+ twice = _repeated(node)
209
+ # @req> REQ-84465433@iXBRKIfkf18a pkvikc
210
+ if twice:
211
+ # @req> REQ-30898926@Vu28eWaG_1mE e2tsff
212
+ raise ReqctlError(
213
+ f"{where} states {', '.join(twice)} more than once -- YAML "
214
+ "keeps the last, so drop the one that does not govern"
215
+ )
216
+ # @req+ REQ-19913588@hBNJrpn4vtZe 3222m4
217
+ try:
218
+ document = loader.construct_document(node) if node is not None else None
219
+ except (yaml.YAMLError, RecursionError):
220
+ raise
221
+ except Exception as error:
222
+ # @req> REQ-30898926@Vu28eWaG_1mE obrq2j
223
+ raise ReqctlError(f"{where} holds a value YAML cannot build, "
224
+ f"{type(error).__name__}: {error} -- replace the "
225
+ "value with plain YAML") from error
226
+ # @req- 3222m4
227
+ # @req+ REQ-48650108@-jXFakmxSYbB i4wv7k
228
+ # @req+ REQ-83085784@mB9K6Zw3dGnC lxmmuv
229
+ folded = _folded(loader, node)
230
+ if folded:
231
+ # @req> REQ-30898926@Vu28eWaG_1mE q232n2
232
+ raise ReqctlError(
233
+ f"{where} states the key {folded[0]} twice in one mapping -- YAML "
234
+ "keeps the last, so drop the one that does not govern"
235
+ )
236
+ # @req- lxmmuv
237
+ # @req- i4wv7k
238
+ # @req> REQ-39368456@LvbuCW5-SvUW bpdfcl
239
+ for field, value in (document.items() if isinstance(document, dict) else ()):
240
+ fault = _unreadable(value)
241
+ looped = _looped(text) if fault else None
242
+ if looped:
243
+ # @req> REQ-30898926@Vu28eWaG_1mE 5l5zyz
244
+ raise ReqctlError(
245
+ f"{where}: the YAML alias {looped} forms a cycle, so it "
246
+ "cannot be read as text -- state it without an alias to itself")
247
+ if fault:
248
+ # @req> REQ-30898926@Vu28eWaG_1mE xnluqf
249
+ raise ReqctlError(f"{where}: {field} {fault}")
250
+ return document
251
+ except yaml.YAMLError as error:
252
+ # @req> REQ-30898926@Vu28eWaG_1mE 5ek57f
253
+ raise ReqctlError(f"{where} is not YAML: {error} -- correct the YAML where "
254
+ "the parser points; a settings file is corrected on "
255
+ "the default branch") from error
256
+ except RecursionError as error:
257
+ # @req> REQ-30898926@Vu28eWaG_1mE aykasy
258
+ raise ReqctlError(f"{where} nests too deeply to read -- flatten the "
259
+ "nesting") from error
260
+ finally:
261
+ loader.dispose()
262
+
263
+
264
+ def _looped(text):
265
+ open_ = []
266
+ for event in yaml.parse(text, Loader=Loader):
267
+ if isinstance(event, yaml.CollectionStartEvent):
268
+ open_.append(event.anchor)
269
+ elif isinstance(event, yaml.CollectionEndEvent):
270
+ open_.pop()
271
+ elif isinstance(event, yaml.AliasEvent) and event.anchor in open_:
272
+ return f"*{event.anchor} on line {event.start_mark.line + 1}"
273
+ return None
274
+
275
+
276
+ def _unreadable(value):
277
+ height, open_ = {}, set()
278
+ stack = [(value, False)]
279
+ while stack:
280
+ node, closing = stack.pop()
281
+ if not isinstance(node, (dict, list, tuple)):
282
+ continue
283
+ children = list(node.values() if isinstance(node, dict) else node)
284
+ if closing:
285
+ open_.discard(id(node))
286
+ height[id(node)] = 1 + max((height.get(id(child), 0) for child in children),
287
+ default=0)
288
+ if height[id(node)] > NEST_LIMIT:
289
+ return (f"nests deeper than {NEST_LIMIT} levels, so it cannot be read "
290
+ "as text -- state it flatter")
291
+ elif id(node) in open_:
292
+ return ("forms a cycle, so it cannot be read as text -- state it "
293
+ "without an alias to itself")
294
+ elif id(node) not in height:
295
+ open_.add(id(node))
296
+ stack.append((node, True))
297
+ stack.extend((child, False) for child in children)
298
+ return None
299
+
300
+
301
+ def read_text(path):
302
+ try:
303
+ return Path(path).read_text()
304
+ except UnicodeDecodeError as error:
305
+ # @req> REQ-30898926@Vu28eWaG_1mE 6yjen4
306
+ raise ReqctlError(f"cannot read {path}: not valid UTF-8 -- save the file "
307
+ "as UTF-8") from error
308
+ except OSError as error:
309
+ # @req> REQ-30898926@Vu28eWaG_1mE acn4ae
310
+ raise ReqctlError(f"cannot read {path}: {error.strerror} -- make the file "
311
+ "readable, then retry") from error
312
+ except ValueError as error:
313
+ # @req> REQ-30898926@Vu28eWaG_1mE epjjtu
314
+ raise ReqctlError(f"cannot read {path}: {error} -- name a path that can "
315
+ "be read, then retry") from error
316
+
317
+
318
+ def read(path, reading=Loader):
319
+ return loads(read_text(path), path, reading=reading)
320
+
321
+
322
+ def sides(text):
323
+ ours, theirs, taking = [], [], "both"
324
+ for line in text.splitlines(keepends=True):
325
+ if line.startswith("<<<<<<<"):
326
+ taking = "ours"
327
+ elif line.startswith("|||||||"):
328
+ taking = "base"
329
+ elif line.startswith("======="):
330
+ taking = "theirs"
331
+ elif line.startswith(">>>>>>>"):
332
+ taking = "both"
333
+ else:
334
+ if taking in ("both", "ours"):
335
+ ours.append(line)
336
+ if taking in ("both", "theirs"):
337
+ theirs.append(line)
338
+ return "".join(ours), "".join(theirs)
339
+
340
+
341
+ def same(value, prior):
342
+ if type(value) is not type(prior):
343
+ return False
344
+ if isinstance(value, list):
345
+ return len(value) == len(prior) and all(
346
+ same(v, p) for v, p in zip(value, prior)
347
+ )
348
+ if isinstance(value, dict):
349
+ return value.keys() == prior.keys() and all(
350
+ same(v, prior[k]) for k, v in value.items()
351
+ )
352
+ return value == prior
353
+
354
+
355
+ def path_for(root, uid):
356
+ for path in item_files(root):
357
+ if path.stem == uid:
358
+ return path
359
+ # @req> REQ-30898926@Vu28eWaG_1mE vdl325
360
+ raise ReqctlError(f"no such item: {uid} -- `reqctl list` names the uids that "
361
+ "exist")
362
+
363
+
364
+ def items(store):
365
+ if store._items is None:
366
+ loaded = []
367
+ for path in item_files(store.root):
368
+ text = read_text(path)
369
+ # @req> REQ-38498011@eJXtv0pwMf-A q7wu74
370
+ if CONFLICTED.search(text):
371
+ from .resolve import merged
372
+ # @req> REQ-30898926@Vu28eWaG_1mE tts4hh
373
+ raise ReqctlError(merged(path.relative_to(store.root), text))
374
+ # @req> REQ-49870454@KKNV-vdY6oM1 4sqskc
375
+ data = loads(text, path, aliases=False)
376
+ if not isinstance(data, dict):
377
+ # @req> REQ-30898926@Vu28eWaG_1mE kytafn
378
+ raise ReqctlError(f"{path.stem}: item file is not a mapping -- edit the "
379
+ "file by hand so it holds a YAML mapping of fields")
380
+ refuse_misshapen(path.relative_to(store.root), data)
381
+ # @req> REQ-56223366@zS6FN-Id9V3H wvbhnl
382
+ loaded.append(Item(path.stem, path, data))
383
+ store._items = loaded
384
+ return store._items
385
+
386
+
387
+ # @req> REQ-19913588@hBNJrpn4vtZe 5es3j4
388
+ def refuse_misshapen(where, data):
389
+ for field in ("kind", "status"):
390
+ if not isinstance(data.get(field, ""), str):
391
+ # @req> REQ-30898926@Vu28eWaG_1mE 3grind
392
+ raise ReqctlError(f"{where}: {field} holds {data[field]!r}, not one name -- "
393
+ f"state one {field}")
394
+ held = data.get("entries")
395
+ for key, fields in (held.items() if isinstance(held, dict) else ()):
396
+ if not isinstance(fields, dict):
397
+ # @req> REQ-30898926@Vu28eWaG_1mE mmffva
398
+ raise ReqctlError(f"{where}: entry {key} holds {fields!r}, not fields -- "
399
+ "state its fields under it")
400
+ if found := _untexted(data):
401
+ # @req> REQ-30898926@Vu28eWaG_1mE d5vilj
402
+ raise ReqctlError(f"{where}: the key {found[-1]!r} under "
403
+ f"{'.'.join(map(str, found[:-1])) or 'the item'} is not text -- "
404
+ "quote it or rename it")
405
+
406
+
407
+ def _untexted(value, at=()):
408
+ pairs = (value.items() if isinstance(value, dict)
409
+ else enumerate(value) if isinstance(value, list) else ())
410
+ for key, held in pairs:
411
+ if isinstance(value, dict) and not isinstance(key, str):
412
+ return (*at, key)
413
+ if found := _untexted(held, (*at, key)):
414
+ return found
415
+ return None
416
+
417
+
418
+ def invalidate(store):
419
+ store._items = None
420
+ store._index = None
421
+
422
+
423
+ def find(store, uid):
424
+ if store._index is None:
425
+ index = {item.uid: item for item in items(store)}
426
+ for item in items(store):
427
+ name = name_of(item.uid, item.data)
428
+ if name is not None:
429
+ index.setdefault(name, item)
430
+ store._index = index
431
+ try:
432
+ return store._index[uid]
433
+ except KeyError:
434
+ # @req> REQ-30898926@Vu28eWaG_1mE j6yens
435
+ raise ReqctlError(f"no such item: {uid} -- `reqctl list` names the uids that "
436
+ "exist") from None
437
+
438
+
439
+ def raw(item):
440
+ return item.data
441
+
442
+
443
+ # @req> REQ-79557189@9se-A9aoBjZx np3ttn
444
+ def atomic_write(path, content):
445
+ target = Path(path).resolve()
446
+ temp = target.with_name(f"{target.name}.{secrets.token_hex(8)}.tmp")
447
+ staged = False
448
+ try:
449
+ # @req> REQ-24406170@ffuKefeAdU7p ig234p
450
+ _hold(target)
451
+ # @req> REQ-81667762@82D0B66GuW3u lkbxat
452
+ make_folder(target.parent)
453
+ with open(temp, "xb" if isinstance(content, bytes) else "x") as sink:
454
+ staged = True
455
+ sink.write(content)
456
+ sink.flush()
457
+ os.fsync(sink.fileno())
458
+ if target.exists():
459
+ shutil.copymode(target, temp)
460
+ os.replace(temp, target)
461
+ staged = False
462
+ except OSError as error:
463
+ # @req> REQ-30898926@Vu28eWaG_1mE azwvi6
464
+ raise ReqctlError(
465
+ f"cannot write {path}: {error.strerror or error} -- make the path "
466
+ "writable, then retry"
467
+ ) from error
468
+ finally:
469
+ if staged:
470
+ with contextlib.suppress(OSError):
471
+ temp.unlink()
472
+
473
+
474
+ # @req> REQ-65668011@WuPZe9UcmiaP l5i6id
475
+ def write_all(changes):
476
+ with all_or_nothing():
477
+ for path, content in changes:
478
+ atomic_write(path, content)
479
+
480
+
481
+ # @req+ REQ-24406170@ffuKefeAdU7p u5qcba
482
+ _held = None
483
+ _made = None
484
+
485
+
486
+ @contextlib.contextmanager
487
+ def all_or_nothing():
488
+ global _held, _made
489
+ if _held is not None:
490
+ yield
491
+ return
492
+ _held, _made = {}, []
493
+ try:
494
+ yield
495
+ except BaseException as error:
496
+ if isinstance(error, SystemExit) and error.code in (None, 0):
497
+ raise
498
+ held, _held = _held, None
499
+ kept = [str(path) for path, before in reversed(held.items())
500
+ if not _put_back(path, before)]
501
+ # @req> REQ-81667762@82D0B66GuW3u cdcyzh
502
+ for folder in reversed(_made):
503
+ with contextlib.suppress(OSError):
504
+ folder.rmdir()
505
+ # @req+ REQ-83236530@f_SrFVNH8QSC v2zljr
506
+ kept += [f"{folder}{os.sep}" for folder in reversed(_made)
507
+ if _stands(folder)]
508
+ if kept:
509
+ # @req> REQ-30898926@Vu28eWaG_1mE vtahuy
510
+ left = (f"not put back: {', '.join(kept)} -- restore each file by hand "
511
+ "(`git restore PATH`) and "
512
+ "remove each folder")
513
+ if isinstance(error, SystemExit):
514
+ # @req> REQ-30898926@Vu28eWaG_1mE qdhgis
515
+ raise SystemExit(f"{error}; {left}") from error
516
+ if isinstance(error, (ReqctlError, OSError, UnicodeError)):
517
+ # @req> REQ-30898926@Vu28eWaG_1mE exlmdp
518
+ raise ReqctlError(f"{error}; {left}") from error
519
+ error.add_note(left)
520
+ # @req- v2zljr
521
+ raise
522
+ finally:
523
+ _held = None
524
+
525
+
526
+ def atomically(command):
527
+ with all_or_nothing():
528
+ return command()
529
+
530
+
531
+ def _hold(path):
532
+ if _held is not None and path not in _held:
533
+ _held[path] = path.read_bytes() if path.exists() else None
534
+
535
+
536
+ def _put_back(path, before):
537
+ try:
538
+ if before is None:
539
+ if path.exists():
540
+ path.unlink()
541
+ elif not path.is_file() or path.read_bytes() != before:
542
+ atomic_write(path, before)
543
+ except (OSError, ReqctlError):
544
+ return False
545
+ return True
546
+
547
+
548
+ def remove(path, missing_ok=False):
549
+ held = Path(path).absolute()
550
+ _hold(held)
551
+ held.unlink(missing_ok=missing_ok)
552
+ # @req- u5qcba
553
+
554
+
555
+ # @req> REQ-81667762@82D0B66GuW3u 3rsgq3
556
+ def make_folder(path):
557
+ folder = Path(path).absolute()
558
+ if _held is not None:
559
+ _made.extend(one for one in (*reversed(folder.parents), folder)
560
+ if not one.exists())
561
+ folder.mkdir(parents=True, exist_ok=True)
562
+
563
+
564
+ # @req> REQ-83236530@f_SrFVNH8QSC 3j7lib
565
+ def _stands(folder):
566
+ try:
567
+ return folder.is_dir()
568
+ except OSError:
569
+ return True
570
+
571
+
572
+ # @req+ REQ-41148688@SFtnFhcEeNvS xm3u4i
573
+ class _Unaliased(yaml.SafeDumper):
574
+ def ignore_aliases(self, data):
575
+ return True
576
+
577
+
578
+ def dump(data):
579
+ return yaml.dump(data, Dumper=_Unaliased, default_flow_style=False,
580
+ sort_keys=True, allow_unicode=True)
581
+ # @req- xm3u4i
582
+
583
+
584
+ def save(store, item):
585
+ atomic_write(item.path, dump(item.data))
586
+ invalidate(store)
587
+
588
+
589
+ def _lexical(value):
590
+ return f"!{type(value).__name__}:{value}"
591
+
592
+
593
+ def digest(payload):
594
+ content = json.dumps(payload, sort_keys=True, separators=(",", ":"),
595
+ default=_lexical)
596
+ hashed = hashlib.sha256(content.encode()).digest()
597
+ return base64.urlsafe_b64encode(hashed).decode().rstrip("=")
598
+
599
+
600
+ def entries(data):
601
+ held = data.get("entries")
602
+ return held if isinstance(held, dict) else None
603
+
604
+
605
+ def split_address(address):
606
+ found = ADDRESS.match(str(address))
607
+ if not found:
608
+ # @req> REQ-30898926@Vu28eWaG_1mE ibipo5
609
+ raise ReqctlError(f"{address}: not a UID or UID.entry address -- write UID, "
610
+ "or UID.entry")
611
+ return found.group(1), found.group(2)
612
+
613
+
614
+ def kind_for_uid(uid):
615
+ try:
616
+ return _for_prefix(uid, PREFIXES)
617
+ except ReqctlError:
618
+ return None
619
+
620
+
621
+ def name_of(uid, data):
622
+ kind = kind_of(uid, data)
623
+ if kind == "term":
624
+ return next(iter(entries(data) or {}), None)
625
+ if kind in ("parameter", "data"):
626
+ name = data.get("name")
627
+ return name if isinstance(name, str) and name else None
628
+ return None
629
+
630
+
631
+ def carried_of(uid, data):
632
+ held = data.get("carried")
633
+ if held is None:
634
+ return []
635
+ if not isinstance(held, list) or not all(
636
+ isinstance(one, str) for one in held):
637
+ # @req> REQ-30898926@Vu28eWaG_1mE pgm2dk
638
+ raise ReqctlError(
639
+ f"{uid}: carried is not a list of names, so what this item has "
640
+ "answered to cannot be read -- `reqctl validate` names the fault; edit "
641
+ "the item by hand so carried is a list of names, or remove carried"
642
+ )
643
+ return held
644
+
645
+
646
+ def carriers(tree):
647
+ held = {}
648
+ for item in items(tree):
649
+ for name in set(carried_of(item.uid, raw(item))):
650
+ held.setdefault(name, []).append(item.uid)
651
+ return held
652
+
653
+
654
+ def reachable(records):
655
+ held = {uid: uid for uid in records}
656
+ for uid, data in records.items():
657
+ name = name_of(uid, data)
658
+ if name is not None:
659
+ held.setdefault(name, uid)
660
+ return held
661
+
662
+
663
+ def addressable(records):
664
+ return set(reachable(records))
665
+
666
+
667
+ def stamp_of(uid, data):
668
+ return digest([uid, covered(uid, data)])
669
+
670
+
671
+ def _term_digest(uid, fields):
672
+ return digest([uid, {"definition": fields.get("definition")}])
673
+
674
+
675
+ def flagged(data, flag):
676
+ held = entries(data) or {}
677
+ if not isinstance(flag, str) or flag in held:
678
+ return None
679
+ carried = [fields.get(flag) for fields in held.values()
680
+ if isinstance(fields, dict) and flag in fields]
681
+ if not carried:
682
+ return None
683
+ # @req> REQ-79222815@smBL1zf2tP96 3wyszr
684
+ return {key: fields for key, fields in held.items()
685
+ if isinstance(fields, dict) and fields.get(flag) is True}
686
+
687
+
688
+ def held_block(data, name):
689
+ if name in (entries(data) or {}) or name in ITEM_SKIP:
690
+ return None
691
+ held = data.get(name)
692
+ return held if isinstance(held, dict) else None
693
+
694
+
695
+ def entry_at(data, key):
696
+ held = entries(data) or {}
697
+ if key in held:
698
+ fields = held[key]
699
+ return fields if isinstance(fields, dict) else None
700
+ if not isinstance(key, str):
701
+ return None
702
+ steps = key.split(".")
703
+ # @req+ REQ-11930677@dH7Pu7k4K4YI 2nario
704
+ block = held_block(data, steps[0])
705
+ if block is not None and len(steps) > 1:
706
+ held, steps = block, steps[1:]
707
+ # @req- 2nario
708
+ while steps:
709
+ name = steps.pop(0)
710
+ if not isinstance(held, dict) or name not in held:
711
+ return None
712
+ fields = held[name]
713
+ if not isinstance(fields, dict):
714
+ return None
715
+ if not steps:
716
+ return fields
717
+ field = steps.pop(0)
718
+ if not isinstance(fields.get(field), dict):
719
+ return None
720
+ held = fields[field]
721
+ return None
722
+
723
+
724
+ def entry_stamp_of(uid, data, key):
725
+ # @req+ REQ-23898341@oXCyxMhvY0ka nvrect
726
+ fields = entry_at(data, key)
727
+ if fields is None:
728
+ return None
729
+ if kind_of(uid, data) == "term":
730
+ # @req> REQ-23281207@-EoSDW-v6e5y 5spkdj
731
+ return _term_digest(uid, fields)
732
+ # @req> REQ-80106209@1V8R51ejNtrH hydltf
733
+ return digest([uid, str(key), dict(fields)])
734
+ # @req- nvrect
735
+
736
+
737
+ def address_stamp_of(uid, data, entry_key):
738
+ if entry_key is None:
739
+ return stamp_of(uid, data)
740
+ if entry_key == MEMBERS:
741
+ return digest([uid, MEMBERS, entries(data) or {}])
742
+ # @req+ REQ-63712597@Lvnsc2CjyqjN okjacr
743
+ held = flagged(data, entry_key)
744
+ if held is not None:
745
+ return digest([uid, entry_key, held])
746
+ # @req- okjacr
747
+ return entry_stamp_of(uid, data, entry_key)
748
+
749
+
750
+ def stamp_at(item, entry_key=None):
751
+ return address_stamp_of(item.uid, item.data, entry_key)
752
+
753
+
754
+ # @req> REQ-24608716@rrTjNN2Ay0D0 kzig7g
755
+ def covered(uid, data, entry_key=None):
756
+ if entry_key is None:
757
+ # @req> REQ-54851939@n6YOz94UUTn7 njypn2
758
+ if uid.startswith(("REQ-", "GUARD-")):
759
+ # @req> REQ-38833983@N77mQ5lbSN4A ekeubm
760
+ return {key: data.get(key) for key in STATEMENT_STAMPED}
761
+ # @req+ REQ-56581795@ZHEasHzzTZLC ixanwu
762
+ if kind_of(uid, data) == "term":
763
+ # @req> REQ-23281207@-EoSDW-v6e5y vnctjd
764
+ return {"definition": term_fields(data).get("definition")}
765
+ # @req> REQ-15984677@POiZGBTxrqb6 ltlklp
766
+ return {key: data.get(key) for key in ITEM_STAMPED}
767
+ # @req- ixanwu
768
+ if entry_key == MEMBERS:
769
+ return {"entries": entries(data) or {}}
770
+ held = flagged(data, entry_key)
771
+ if held is not None:
772
+ return {"entries": held}
773
+ fields = entry_at(data, entry_key)
774
+ if kind_of(uid, data) == "term":
775
+ return {"definition": (fields or {}).get("definition")}
776
+ # @req+ REQ-11930677@dH7Pu7k4K4YI a7a4eb
777
+ block, _, member = str(entry_key).partition(".")
778
+ if member and held_block(data, block) is not None:
779
+ return {block: {member: fields}}
780
+ # @req- a7a4eb
781
+ return {"entries": {entry_key: fields}}
782
+
783
+
784
+ def stamp(item):
785
+ return stamp_of(item.uid, item.data)
786
+
787
+
788
+ # @req> REQ-88203622@wlZ9orsr9c7q zi6xxa
789
+ def citable(item):
790
+ return kind_of(item.uid, item.data) in CITABLE
791
+
792
+
793
+ # @req> REQ-18176935@A6AhLhUZ7MdW r63o2r
794
+ # @req> REQ-34330878@E2ViDxqgA25l ymaadg
795
+ def cited_stamp(item, entry=None):
796
+ held = stamp(item)
797
+ if entry or kind_of(item.uid, item.data) in ("parameter", "data"):
798
+ held = digest([held, stamp_at(item, entry or MEMBERS)])
799
+ return tag_stamp(held)
800
+
801
+
802
+ # @req> REQ-24608716@rrTjNN2Ay0D0 ayt44q
803
+ def cited_fields(uid, data, entry):
804
+ held = covered(uid, data)
805
+ if entry or kind_of(uid, data) in ("parameter", "data"):
806
+ held |= covered(uid, data, entry or MEMBERS)
807
+ return held
808
+
809
+
810
+ def cited_entries(item):
811
+ if kind_of(item.uid, item.data) != "data":
812
+ return {}
813
+ return entries(item.data) or {}
814
+
815
+
816
+ # @req> REQ-22755763@lFaaseccSg81 ez4hkv
817
+ def cited_item(store, address):
818
+ named, entry = split_address(address)
819
+ item = find(store, named)
820
+ # @req> REQ-11930677@dH7Pu7k4K4YI bwqnk4
821
+ if entry is not None and entry not in cited_entries(item):
822
+ # @req> REQ-30898926@Vu28eWaG_1mE lxj56m
823
+ raise ReqctlError(f"{address}: {named} is not a data item holding "
824
+ f"{entry} -- only an entry of a data item is cited "
825
+ "by its name; name a data item that holds it "
826
+ "(`reqctl list data`), or cite the item without an entry")
827
+ return item, entry
828
+
829
+
830
+ def tag_stamp(held):
831
+ return held[:TAG_STAMP]
832
+
833
+
834
+ def mapping(data, key):
835
+ value = data.get(key)
836
+ if not isinstance(value, dict):
837
+ return {}
838
+ return {name: held for name, held in value.items()
839
+ if isinstance(name, str)}
840
+
841
+
842
+ # @req> REQ-73719888@SQkvdemRTgC1 ww7kzv
843
+ def plain(value):
844
+ if isinstance(value, dict):
845
+ return ", ".join(f"{name} ({plain(held)})" if isinstance(held, dict)
846
+ else f"{name}: {plain(held)}"
847
+ for name, held in value.items())
848
+ if isinstance(value, list):
849
+ return ", ".join(f"({plain(member)})" if isinstance(member, dict)
850
+ else plain(member) for member in value)
851
+ if isinstance(value, bool):
852
+ return "true" if value else "false"
853
+ return "null" if value is None else str(value)
854
+
855
+
856
+ def _stated(fields, skip, top=()):
857
+ held = []
858
+ if not isinstance(fields, dict):
859
+ return held
860
+ for key, value in fields.items():
861
+ if key in skip or key in top:
862
+ continue
863
+ if isinstance(value, dict):
864
+ held += _stated(value, skip)
865
+ elif isinstance(value, list):
866
+ held += [str(one) for one in value]
867
+ else:
868
+ held.append(str(value))
869
+ return held
870
+
871
+
872
+ def prose(data, skip=STATED_SKIP, item=True):
873
+ pieces = [str(data.get("text") or data.get("definition") or "")]
874
+ if item:
875
+ pieces += _stated(data, skip, ITEM_SKIP)
876
+ for fields in (entries(data) or {}).values():
877
+ pieces += _stated(fields, skip)
878
+ criteria = data.get("acceptance_criteria")
879
+ for criterion in criteria if isinstance(criteria, list) else []:
880
+ if isinstance(criterion, dict):
881
+ pieces += [str(value) for value in criterion.values()]
882
+ else:
883
+ pieces.append(str(criterion))
884
+ return "\n".join(pieces)
885
+
886
+
887
+ def prosed(data, paths, resolve, at=(), under=False):
888
+ under = under or at in paths
889
+ if isinstance(data, dict):
890
+ return {key: prosed(value, paths, resolve, at + (str(key),), under)
891
+ for key, value in data.items()}
892
+ if isinstance(data, list):
893
+ return [prosed(value, paths, resolve, at + (str(index),), under)
894
+ for index, value in enumerate(data)]
895
+ return resolve(data) if under and isinstance(data, str) else data
896
+
897
+
898
+ def references(data):
899
+ return sorted({uid + rest for uid, rest in PARAM_REF.findall(prose(data))})
900
+
901
+
902
+ def concept_references(data):
903
+ return sorted({address for _, address in CONCEPT_LINK.findall(prose(data))})
904
+
905
+
906
+ def unlinked_prose(data):
907
+ # @req> REQ-96763217@3jJWb99oL6GQ vintgg
908
+ if data.get("kind") == "data":
909
+ data = {**data, "entries": {
910
+ key: {name: value for name, value in fields.items()
911
+ if name != "unit" or isinstance(value, dict)}
912
+ if isinstance(fields, dict) else fields
913
+ for key, fields in (entries(data) or {}).items()}}
914
+ # @req> REQ-83702299@kBGzIwAyKDHL et7k4t
915
+ return PARAM_REF.sub(
916
+ " ", CONCEPT_LINK.sub(" ", "\n".join(
917
+ [prose(data, POLICED_SKIP + ("value_type",), item=False)]
918
+ + _stated(data, POLICED_SKIP + ("value_type", "unit"),
919
+ ITEM_SKIP + POLICED_ITEM_SKIP + ENUMERATED))))
920
+
921
+
922
+ def dependencies(data):
923
+ return sorted(
924
+ set(mapping(data, "relations"))
925
+ | set(references(data))
926
+ | set(concept_references(data))
927
+ )
928
+
929
+
930
+ def expand_address(lookup, address):
931
+ uid, rest = split_address(address)
932
+ wanted = {uid}
933
+ target = lookup(uid)
934
+ held = entries(target) if target is not None else None
935
+ if held is None or kind_of(uid, target) == "term":
936
+ return wanted
937
+ if rest is None:
938
+ wanted.add(f"{uid}.{MEMBERS}")
939
+ elif rest == "default":
940
+ member = target.get("default")
941
+ if member is not None:
942
+ wanted.add(f"{uid}.{member}")
943
+ else:
944
+ # @req+ REQ-11930677@dH7Pu7k4K4YI ujoctz
945
+ head, _, tail = rest.partition(".")
946
+ member = tail.partition(".")[0]
947
+ wanted.add(f"{uid}.{head}.{member}" if member and held_block(target, head) is not None
948
+ else f"{uid}.{head}")
949
+ # @req- ujoctz
950
+ return wanted
951
+
952
+
953
+ def pin_addresses(lookup, data):
954
+ wanted = set()
955
+ for address in dependencies(data):
956
+ wanted |= expand_address(lookup, address)
957
+ return sorted(wanted)
958
+
959
+
960
+ def data_lookup(store):
961
+ def lookup(uid):
962
+ try:
963
+ return find(store, uid).data
964
+ except ReqctlError:
965
+ return None
966
+ return lookup
967
+
968
+
969
+ def is_suspect(store, item, address):
970
+ pinned = mapping(item.data, "assessed").get(address)
971
+ if not isinstance(pinned, str):
972
+ return True
973
+ uid, rest = split_address(address)
974
+ return pinned != stamp_at(find(store, uid), rest)
975
+
976
+
977
+ def term_word(data):
978
+ stated = term_fields(data).get("word")
979
+ return stated.strip() if isinstance(stated, str) and stated.strip() else None
980
+
981
+
982
+ def handle_for(word):
983
+ return re.sub(r"[^a-z0-9]+", "_", str(word).lower()).strip("_")
984
+
985
+
986
+ def term_fields(data):
987
+ sole = next(iter((entries(data) or {}).values()), {})
988
+ return sole if isinstance(sole, dict) else {}
989
+
990
+
991
+ def unclaimed(data):
992
+ held = term_fields(data).get("unclaimed")
993
+ return ({str(phrase): str(reason) for phrase, reason in held.items()}
994
+ if isinstance(held, dict) else {})
995
+
996
+
997
+ def concept_at(root, uid, data, rest):
998
+ if data is None:
999
+ return None
1000
+ if rest is not None:
1001
+ # @req+ REQ-11930677@dH7Pu7k4K4YI krnbz5
1002
+ head, dot, _ = rest.partition(".")
1003
+ held = None if dot and head not in held_blocks(root, uid, data) else entry_at(data, rest)
1004
+ # @req- krnbz5
1005
+ return None if held is None else (
1006
+ held.get("name"), str(held.get("definition") or "").strip())
1007
+ if kind_of(uid, data) == "term":
1008
+ return (term_word(data),
1009
+ str(term_fields(data).get("definition") or "").strip())
1010
+ return data.get("name"), str(data.get("text") or "").strip()
1011
+
1012
+
1013
+ def schema_path(root, name):
1014
+ return Path(root) / "requirements" / "schemas" / f"{name}.schema.yaml"
1015
+
1016
+
1017
+ _VALIDATORS: dict[tuple[str, int], Draft202012Validator] = {}
1018
+ _HELD: dict[tuple[int, int], tuple[object, Draft202012Validator]] = {}
1019
+
1020
+
1021
+ # @req> REQ-21229437@Hp3-AwgxgHMK bzxs2e
1022
+ def validator(schema, format_checker=None):
1023
+ seen = (id(schema), id(format_checker))
1024
+ held = _HELD.get(seen)
1025
+ if held is not None and held[0] is schema:
1026
+ return held[1]
1027
+ key = (digest(schema), id(format_checker))
1028
+ if key not in _VALIDATORS:
1029
+ _VALIDATORS[key] = Draft202012Validator(schema, format_checker=format_checker)
1030
+ if len(_HELD) >= 256:
1031
+ _HELD.clear()
1032
+ _HELD[seen] = (schema, _VALIDATORS[key])
1033
+ return _VALIDATORS[key]
1034
+
1035
+
1036
+ @functools.lru_cache(maxsize=None)
1037
+ def _parsed_schema(path, stamped):
1038
+ # @req+ REQ-19913588@hBNJrpn4vtZe rnou7b
1039
+ try:
1040
+ loader = Loader(path.read_text())
1041
+ try:
1042
+ node = loader.get_single_node()
1043
+ declared = loader.construct_document(node) if node is not None else None
1044
+ # @req> REQ-81761490@4QFne8WaXttt 45ks43
1045
+ folded = _folded(loader, node)
1046
+ finally:
1047
+ loader.dispose()
1048
+ except UnicodeDecodeError as error:
1049
+ # @req> REQ-30898926@Vu28eWaG_1mE hcnzso
1050
+ raise ReqctlError(f"unreadable schema {path}: not valid UTF-8 -- save the "
1051
+ "schema as UTF-8") from error
1052
+ except (OSError, yaml.YAMLError) as error:
1053
+ # @req> REQ-30898926@Vu28eWaG_1mE phwexs
1054
+ raise ReqctlError(f"unreadable schema {path}: {error} -- correct the schema "
1055
+ "where the parser points") from error
1056
+ except Exception as error:
1057
+ # @req> REQ-30898926@Vu28eWaG_1mE ytojsb
1058
+ raise ReqctlError(f"unreadable schema {path}: a value YAML cannot build, "
1059
+ f"{type(error).__name__}: {error} -- replace the value "
1060
+ "with plain YAML") from error
1061
+ # @req- rnou7b
1062
+ # @req> REQ-81761490@4QFne8WaXttt 35mtk4
1063
+ if folded:
1064
+ # @req> REQ-30898926@Vu28eWaG_1mE 5jzbmw
1065
+ raise ReqctlError(f"{path.name} states the key {folded[0]} twice in one mapping "
1066
+ "-- YAML keeps the last, so drop the one that does not govern")
1067
+ # @req+ REQ-19913588@hBNJrpn4vtZe mszvfu
1068
+ try:
1069
+ for fault in validator(Draft202012Validator.META_SCHEMA,
1070
+ Draft202012Validator.FORMAT_CHECKER).iter_errors(declared):
1071
+ raise SchemaError.create_from(fault)
1072
+ _refuse_unfollowable(path, DRAFT202012.create_resource(declared))
1073
+ except SchemaError as error:
1074
+ where = ".".join(str(p) for p in error.absolute_path) or "(schema)"
1075
+ # @req> REQ-30898926@Vu28eWaG_1mE 63jzmw
1076
+ raise ReqctlError(f"{path.name}: {where}: {error.message} -- correct the "
1077
+ "schema at that place") from error
1078
+ except RecursionError as error:
1079
+ # @req> REQ-30898926@Vu28eWaG_1mE aml3cg
1080
+ raise ReqctlError(f"{path.name}: nests too deeply to check -- flatten the "
1081
+ "schema's nesting") from error
1082
+ # @req- mszvfu
1083
+ return declared
1084
+
1085
+
1086
+ # @req> REQ-19913588@hBNJrpn4vtZe 4sbgsj
1087
+ def _refuse_unfollowable(path, resource):
1088
+ try:
1089
+ walked = list(_walked(resource, ""))
1090
+ if any(specification_with(schema.get("$schema", ""), default=DRAFT202012)
1091
+ is not DRAFT202012
1092
+ or validator_for(schema, Draft202012Validator) is not Draft202012Validator
1093
+ for _, schema in walked[1:]):
1094
+ return
1095
+ except ValueError as error:
1096
+ # @req> REQ-30898926@Vu28eWaG_1mE hme6mh
1097
+ raise ReqctlError(f"{path.name}: an $id or $schema is not an address, "
1098
+ f"{error} -- make it an address") from error
1099
+ root = resource.id() or ""
1100
+ registry = Registry().with_resource(root, resource)
1101
+ with contextlib.suppress(ValueError):
1102
+ registry = registry.crawl()
1103
+ if registry[root] is not resource:
1104
+ registry = registry.with_resource(root, resource)
1105
+ for base, keyword, ref in sorted({(base, keyword, schema[keyword])
1106
+ for base, schema in walked
1107
+ for keyword in ("$ref", "$dynamicRef")
1108
+ if keyword in schema}):
1109
+ try:
1110
+ address = base if ref.startswith("#") else urldefrag(urljoin(base, ref)).url
1111
+ if address not in registry and urlsplit(address).scheme:
1112
+ continue
1113
+ target = registry.resolver(base).lookup(ref).contents
1114
+ except Exception as error:
1115
+ # @req> REQ-30898926@Vu28eWaG_1mE hwsp72
1116
+ raise ReqctlError(f"{path.name}: {keyword} {ref!r} cannot be followed -- "
1117
+ "point it at a schema the file holds") from error
1118
+ if not isinstance(target, (dict, bool)):
1119
+ # @req> REQ-30898926@Vu28eWaG_1mE gphyzw
1120
+ raise ReqctlError(f"{path.name}: {keyword} {ref!r} names a value, not a "
1121
+ "schema -- point it at a schema")
1122
+
1123
+
1124
+ def _walked(resource, base):
1125
+ base = urljoin(base, resource.id() or "")
1126
+ if isinstance(resource.contents, dict):
1127
+ yield base, resource.contents
1128
+ for sub in DRAFT202012.subresources_of(resource.contents):
1129
+ yield from _walked(DRAFT202012.create_resource(sub), base)
1130
+
1131
+
1132
+ PACKAGED_SCHEMAS = Path(__file__).resolve().parent / "schemas"
1133
+
1134
+
1135
+ def packaged_schema_path(name):
1136
+ return PACKAGED_SCHEMAS / f"{name}{SCHEMA_SUFFIX}"
1137
+
1138
+
1139
+ def schema(root, name):
1140
+ # @req+ REQ-13148397@Oh5WbBj3LiW0 4fsesd
1141
+ stated = schema_path(root, name)
1142
+ path = stated
1143
+ # @req- 4fsesd
1144
+ # @req> REQ-44451070@AlFXBWPH1jnw idgq2r
1145
+ if not path.is_file():
1146
+ path = packaged_schema_path(name)
1147
+ if not path.is_file():
1148
+ # @req> REQ-30898926@Vu28eWaG_1mE 7oohd6
1149
+ raise ReqctlError(f"missing schema: {stated} -- add the schema there, or "
1150
+ "remove what refers to it")
1151
+ stat = path.stat()
1152
+ stamped = (stat.st_mtime_ns, stat.st_size)
1153
+ # @req> REQ-14895892@JjrTwHoJqTRe yz3x2d
1154
+ if path == stated and name in SCHEMA_NAMES.values():
1155
+ lacking = _unmarked(path, stamped, name, _data_held(root))
1156
+ if lacking:
1157
+ # @req> REQ-30898926@Vu28eWaG_1mE sa2ijn
1158
+ raise RefusedCopy(f"{stated}: the shipped schema carries {lacking}, "
1159
+ "which this copy lacks -- add them to the copy "
1160
+ "by hand from the schema in the installed reqctl "
1161
+ "package, or delete the copy to use the shipped one")
1162
+ return _parsed_schema(path, stamped)
1163
+
1164
+
1165
+ class RefusedCopy(ReqctlError):
1166
+ pass
1167
+
1168
+
1169
+ # @req+ REQ-14895892@JjrTwHoJqTRe a252bv
1170
+ @functools.lru_cache(maxsize=None)
1171
+ def _unmarked(path, stamped, name, data_held):
1172
+ from . import fields
1173
+ shipped = packaged_schema_path(name)
1174
+ stat = shipped.stat()
1175
+ # @req> REQ-77886246@8iAQsnTLrT4C i2zwhw
1176
+ held = _unheld_dropped(_parsed_schema(shipped, (stat.st_mtime_ns, stat.st_size)),
1177
+ data_held)
1178
+ stated = _parsed_schema(path, stamped)
1179
+ try:
1180
+ return fields.unmarked(
1181
+ held, stated, [kind for kind, named in SCHEMA_NAMES.items() if named == name])
1182
+ except ReqctlError as fault:
1183
+ raise RefusedCopy(f"{path}: {fault}") from fault
1184
+ # @req- a252bv
1185
+
1186
+
1187
+ ENTRY_OF = "x-entry-of"
1188
+
1189
+
1190
+ # @req> REQ-77886246@8iAQsnTLrT4C hghw3m
1191
+ def _data_held(root):
1192
+ held = set()
1193
+ for pattern in ("*.yml", "*.yaml"):
1194
+ for path in folder_for(root, "data").glob(pattern):
1195
+ held.add(path.stem)
1196
+ if path.stem.startswith("DATA-"):
1197
+ stat = path.stat()
1198
+ held.add(_data_name(path, (stat.st_mtime_ns, stat.st_size)))
1199
+ held.discard(None)
1200
+ return frozenset(held)
1201
+
1202
+
1203
+ @functools.lru_cache(maxsize=None)
1204
+ def _data_name(path, stamped):
1205
+ with contextlib.suppress(ReqctlError):
1206
+ data = read(path)
1207
+ return name_of(path.stem, data if isinstance(data, dict) else {})
1208
+ return None
1209
+
1210
+
1211
+ def _unheld_dropped(node, data_held):
1212
+ if isinstance(node, dict):
1213
+ return {key: _unheld_dropped(value, data_held) for key, value in node.items()
1214
+ if not (key == ENTRY_OF and value not in data_held)}
1215
+ if isinstance(node, list):
1216
+ return [_unheld_dropped(one, data_held) for one in node]
1217
+ return node
1218
+
1219
+
1220
+ SCHEMA_NAMES = {"requirement": "requirement", "guard": "guard",
1221
+ "parameter": "dictionary", "term": "dictionary",
1222
+ "data": "dictionary"}
1223
+ SCHEMA_SUFFIX = ".schema.yaml"
1224
+ RESERVED_SCHEMAS = frozenset(SCHEMA_NAMES.values()) | {"baseline"}
1225
+
1226
+
1227
+ def schema_names(root):
1228
+ folder = Path(root) / "requirements" / "schemas"
1229
+ return sorted(path.name[: -len(SCHEMA_SUFFIX)]
1230
+ for path in folder.glob(f"*{SCHEMA_SUFFIX}"))
1231
+
1232
+
1233
+ def schema_name_for(kind):
1234
+ if kind not in SCHEMA_NAMES:
1235
+ # @req> REQ-30898926@Vu28eWaG_1mE okae4a
1236
+ raise ReqctlError(f"{kind}: not a {KINDS_NAMED} kind -- use one of those kinds")
1237
+ return SCHEMA_NAMES[kind]
1238
+
1239
+
1240
+ def schema_name_at(root, kind, name=None):
1241
+ if (name is not None and name not in RESERVED_SCHEMAS
1242
+ and schema_path(root, name).is_file()):
1243
+ return name
1244
+ return schema_name_for(kind)
1245
+
1246
+
1247
+ def schema_for(root, kind, name=None):
1248
+ return schema(root, schema_name_at(root, kind, name))
1249
+
1250
+
1251
+ BINDING = "x-binding"
1252
+ ADDRESSABLE = "x-addressable"
1253
+
1254
+
1255
+ def addressable_blocks(root, uid, data):
1256
+ if kind_of(uid, data) != "data":
1257
+ return frozenset()
1258
+ from . import fields
1259
+ held = schema_for(root, "data", name_of(uid, data))
1260
+ return frozenset(name for name, stated in fields._merged(
1261
+ held if isinstance(held, dict) else {}, "data")[0].items()
1262
+ if name not in ITEM_SKIP and stated.get(ADDRESSABLE) is True)
1263
+
1264
+
1265
+ def held_blocks(root, uid, data):
1266
+ held = {name: block for name in data if (block := held_block(data, name)) is not None}
1267
+ marked = addressable_blocks(root, uid, data) if held else ()
1268
+ return {name: held[name] for name in sorted(marked) if name in held}
1269
+
1270
+
1271
+ def named_schemas(root):
1272
+ return [name for name in schema_names(root)
1273
+ if name not in RESERVED_SCHEMAS]
1274
+
1275
+
1276
+ def binding_dimensions(root):
1277
+ # @req> REQ-68873210@e7MRMGqsYrnx up7le5
1278
+ return [name for name in named_schemas(root)
1279
+ if schema(root, name).get(BINDING) is True]
1280
+
1281
+
1282
+ def is_test(relative_path):
1283
+ return bool(TEST_PATH.search(relative_path))