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/__init__.py +0 -0
- reqctl/baseline.py +380 -0
- reqctl/cite.py +1229 -0
- reqctl/cli.py +1514 -0
- reqctl/corpus.py +1283 -0
- reqctl/fields.py +361 -0
- reqctl/graph.py +207 -0
- reqctl/history.py +175 -0
- reqctl/portal.html +2213 -0
- reqctl/portal.py +131 -0
- reqctl/reach.py +425 -0
- reqctl/resolve.py +74 -0
- reqctl/schemas/baseline.schema.yaml +42 -0
- reqctl/schemas/dictionary.schema.yaml +256 -0
- reqctl/schemas/guard.schema.yaml +131 -0
- reqctl/schemas/requirement.schema.yaml +152 -0
- reqctl/settings.py +183 -0
- reqctl/typescript.py +143 -0
- reqctl/uncited.py +257 -0
- reqctl/validate.py +1929 -0
- reqctl/write.py +2004 -0
- reqctl-0.1.38.dist-info/METADATA +11 -0
- reqctl-0.1.38.dist-info/RECORD +26 -0
- reqctl-0.1.38.dist-info/WHEEL +5 -0
- reqctl-0.1.38.dist-info/entry_points.txt +2 -0
- reqctl-0.1.38.dist-info/top_level.txt +1 -0
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))
|