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/__init__.py
ADDED
|
File without changes
|
reqctl/baseline.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from .corpus import (CONFLICTED, FOLDERS, ReqctlError, atomic_write,
|
|
7
|
+
carriers, items, loads, raw, read, schema, sides, stamp,
|
|
8
|
+
validator)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def path(root):
|
|
12
|
+
return Path(root) / "requirements" / "baseline.yml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _numbered(root):
|
|
16
|
+
folder = Path(root) / "requirements" / "baselines"
|
|
17
|
+
return sorted(
|
|
18
|
+
held
|
|
19
|
+
for pattern in ("BASELINE-*.yml", "BASELINE-*.yaml")
|
|
20
|
+
for held in folder.glob(pattern)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def derive(tree):
|
|
25
|
+
return {
|
|
26
|
+
item.uid: stamp(item)
|
|
27
|
+
for item in items(tree)
|
|
28
|
+
if raw(item).get("status") == "approved"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _git(root, *args):
|
|
33
|
+
try:
|
|
34
|
+
return subprocess.run(["git", "-C", str(root), *args],
|
|
35
|
+
capture_output=True, text=True)
|
|
36
|
+
except OSError as error:
|
|
37
|
+
# @req> REQ-30898926@Vu28eWaG_1mE cr6glq
|
|
38
|
+
raise ReqctlError(f"cannot run git: {error.strerror} -- install git "
|
|
39
|
+
"or put it on PATH") from error
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def head_commit(root):
|
|
43
|
+
result = _git(root, "rev-parse", "HEAD")
|
|
44
|
+
if result.returncode:
|
|
45
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ctk4fh
|
|
46
|
+
raise ReqctlError("cannot read HEAD; a baseline must name a commit "
|
|
47
|
+
"-- commit once, then run `reqctl baseline "
|
|
48
|
+
"--generate`")
|
|
49
|
+
return result.stdout.strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def uncommitted(root):
|
|
53
|
+
result = _git(root, "status", "--porcelain", "--untracked-files=all")
|
|
54
|
+
if result.returncode:
|
|
55
|
+
# @req> REQ-30898926@Vu28eWaG_1mE dj2esu
|
|
56
|
+
raise ReqctlError(
|
|
57
|
+
"cannot read git status; a baseline must record a committed corpus "
|
|
58
|
+
"-- run inside a git work tree"
|
|
59
|
+
)
|
|
60
|
+
folders = {f"requirements/{folder}" for folder in FOLDERS.values()}
|
|
61
|
+
dirty = set()
|
|
62
|
+
for line in result.stdout.splitlines():
|
|
63
|
+
for named in line[3:].split(" -> "):
|
|
64
|
+
parent = named.rpartition("/")[0]
|
|
65
|
+
if parent in folders and named.endswith((".yml", ".yaml")):
|
|
66
|
+
dirty.add(named)
|
|
67
|
+
return sorted(dirty)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _unresolved(where):
|
|
71
|
+
try:
|
|
72
|
+
text = Path(where).read_text()
|
|
73
|
+
except (OSError, UnicodeDecodeError):
|
|
74
|
+
return None
|
|
75
|
+
if not CONFLICTED.search(text):
|
|
76
|
+
return None
|
|
77
|
+
claimed = []
|
|
78
|
+
# @req> REQ-10024282@x7WPiNHGRX8n 7qnfkg
|
|
79
|
+
for side in sides(text):
|
|
80
|
+
try:
|
|
81
|
+
held = loads(side, where)
|
|
82
|
+
except ReqctlError:
|
|
83
|
+
held = None
|
|
84
|
+
# @req> REQ-50767938@YGIjAYt9M2fF 5k3r7e
|
|
85
|
+
if not isinstance(held, dict) or type(held.get("baseline")) is not int:
|
|
86
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ehhq6x
|
|
87
|
+
raise ReqctlError(
|
|
88
|
+
f"{Path(where).name} holds an unresolved merge, and a side of "
|
|
89
|
+
"it does not read as a baseline -- resolve the conflict by "
|
|
90
|
+
"hand, then generate"
|
|
91
|
+
)
|
|
92
|
+
claimed.append(held["baseline"])
|
|
93
|
+
return claimed
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
DEFAULT_HEAD = "refs/remotes/origin/HEAD"
|
|
97
|
+
REMOTE = "origin"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _default_ref(root):
|
|
101
|
+
# @req+ REQ-24681230@wxRn3BbY54kT 6zi4mx
|
|
102
|
+
listed = _git(root, "remote")
|
|
103
|
+
if listed.returncode:
|
|
104
|
+
# @req> REQ-30898926@Vu28eWaG_1mE zjwepm
|
|
105
|
+
raise ReqctlError(
|
|
106
|
+
"cannot read the remotes, so whether a default branch states a "
|
|
107
|
+
"baseline cannot be told apart from there being none -- repair "
|
|
108
|
+
"the remote configuration (`git remote -v` shows it), then retry"
|
|
109
|
+
)
|
|
110
|
+
# @req- 6zi4mx
|
|
111
|
+
if REMOTE not in listed.stdout.split():
|
|
112
|
+
return None
|
|
113
|
+
# @req+ REQ-24681230@wxRn3BbY54kT k6tk6l
|
|
114
|
+
named = _git(root, "symbolic-ref", "-q", DEFAULT_HEAD)
|
|
115
|
+
if named.returncode:
|
|
116
|
+
# @req> REQ-30898926@Vu28eWaG_1mE kkoqo5
|
|
117
|
+
raise ReqctlError(
|
|
118
|
+
"origin names no default branch, so which branch is the default "
|
|
119
|
+
"cannot be told -- name it with `git remote set-head origin BRANCH`"
|
|
120
|
+
)
|
|
121
|
+
ref = named.stdout.strip()
|
|
122
|
+
if _git(root, "rev-parse", "-q", "--verify", f"{ref}^{{commit}}").returncode:
|
|
123
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ykp666
|
|
124
|
+
raise ReqctlError(
|
|
125
|
+
f"{ref} names no commit here, so the baseline the default branch "
|
|
126
|
+
"states cannot be read -- fetch it"
|
|
127
|
+
)
|
|
128
|
+
# @req- k6tk6l
|
|
129
|
+
return ref
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _required_ref(root):
|
|
133
|
+
ref = _default_ref(root)
|
|
134
|
+
if ref is None:
|
|
135
|
+
# @req> REQ-30898926@Vu28eWaG_1mE kvnayw
|
|
136
|
+
raise ReqctlError(
|
|
137
|
+
"no remote is named origin, so which branch is the default cannot "
|
|
138
|
+
"be told -- add it with `git remote add origin URL`, fetch it "
|
|
139
|
+
"with `git fetch origin`, then name its default branch with `git "
|
|
140
|
+
"remote set-head origin BRANCH`"
|
|
141
|
+
)
|
|
142
|
+
return ref
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _carried(root, ref):
|
|
146
|
+
# @req+ REQ-24681230@wxRn3BbY54kT qgk6xq
|
|
147
|
+
where = path(root).relative_to(root).as_posix()
|
|
148
|
+
listed = _git(root, "ls-tree", ref, "--", where)
|
|
149
|
+
if listed.returncode:
|
|
150
|
+
# @req> REQ-30898926@Vu28eWaG_1mE wrwu46
|
|
151
|
+
raise ReqctlError(
|
|
152
|
+
f"cannot read the tree {ref} names, so whether it states a "
|
|
153
|
+
f"baseline cannot be told: {listed.stderr.strip()} -- run `git "
|
|
154
|
+
"fetch origin`, then retry"
|
|
155
|
+
)
|
|
156
|
+
# @req- qgk6xq
|
|
157
|
+
# @req> REQ-51424927@vQb-YtKja6pq zi26im
|
|
158
|
+
if not listed.stdout.strip():
|
|
159
|
+
return None
|
|
160
|
+
# @req+ REQ-24681230@wxRn3BbY54kT g7nmjj
|
|
161
|
+
found = _git(root, "show", f"{ref}:{where}")
|
|
162
|
+
if found.returncode:
|
|
163
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 7oljfr
|
|
164
|
+
raise ReqctlError(
|
|
165
|
+
f"{ref} states a baseline that cannot be read, and numbering "
|
|
166
|
+
f"from this branch would bury it: {found.stderr.strip()} -- run "
|
|
167
|
+
"`git fetch origin`, then retry"
|
|
168
|
+
)
|
|
169
|
+
held = loads(found.stdout, ref)
|
|
170
|
+
if not isinstance(held, dict) or type(held.get("baseline")) is not int:
|
|
171
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 2ydsz4
|
|
172
|
+
raise ReqctlError(
|
|
173
|
+
f"{ref} states a baseline that does not say which one it is, so "
|
|
174
|
+
"the number that follows it cannot be read -- on the default "
|
|
175
|
+
"branch, by pull request, set `baseline` in that file to an "
|
|
176
|
+
"integer by hand, then run `git fetch origin`"
|
|
177
|
+
)
|
|
178
|
+
# @req- g7nmjj
|
|
179
|
+
return held["baseline"]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _stated(root):
|
|
183
|
+
if not path(root).is_file():
|
|
184
|
+
return None
|
|
185
|
+
held = read(path(root))
|
|
186
|
+
if not isinstance(held, dict) or type(held.get("baseline")) is not int:
|
|
187
|
+
# @req> REQ-30898926@Vu28eWaG_1mE h4golz
|
|
188
|
+
raise ReqctlError(
|
|
189
|
+
f"{path(root).name} does not say which baseline it holds, and a "
|
|
190
|
+
"new one would bury that rather than settle it -- `reqctl "
|
|
191
|
+
"baseline --check` names the fault; set `baseline` in that file "
|
|
192
|
+
"to an integer by hand, then run `reqctl baseline --generate`"
|
|
193
|
+
)
|
|
194
|
+
return held["baseline"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _previous(root):
|
|
198
|
+
claimed = _unresolved(path(root)) if path(root).is_file() else None
|
|
199
|
+
# @req> REQ-25777470@4o7sac7TLlHi lmbowm
|
|
200
|
+
if claimed is None:
|
|
201
|
+
_stated(root)
|
|
202
|
+
# @req+ REQ-43924642@SDx_e7a7A1XM bbqmqm
|
|
203
|
+
# @req> REQ-34932139@bQA5L6Md3NdY grgv34
|
|
204
|
+
# @req> REQ-20411754@mcpvTzFw73NC falgdo
|
|
205
|
+
return _carried(root, _required_ref(root)), claimed
|
|
206
|
+
# @req- bbqmqm
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def generate(tree, root):
|
|
210
|
+
# @req> REQ-86940077@84kSMsse1S0X l6ekio
|
|
211
|
+
approved = derive(tree)
|
|
212
|
+
# @req> REQ-35349268@UFojthZ7FMKX xfjprt
|
|
213
|
+
if not approved:
|
|
214
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 7cwxrz
|
|
215
|
+
raise ReqctlError("nothing approved; a baseline of nothing states "
|
|
216
|
+
"nothing -- approve an item through a corpus pull "
|
|
217
|
+
"request, then run `reqctl baseline --generate`")
|
|
218
|
+
# @req+ REQ-63399155@6l4OsixGYKDz ygxzq3
|
|
219
|
+
numbered = _numbered(root)
|
|
220
|
+
if numbered:
|
|
221
|
+
# @req> REQ-30898926@Vu28eWaG_1mE rzgvee
|
|
222
|
+
raise ReqctlError(
|
|
223
|
+
f"{', '.join(held.name for held in numbered)}: the numbered layout "
|
|
224
|
+
"was replaced by one baseline; delete it, then generate"
|
|
225
|
+
)
|
|
226
|
+
# @req- ygxzq3
|
|
227
|
+
|
|
228
|
+
# @req+ REQ-85375321@NWiQynsqgVPX yvehmw
|
|
229
|
+
shared = sorted(name for name, wore in carriers(tree).items()
|
|
230
|
+
if len(wore) > 1)
|
|
231
|
+
if shared:
|
|
232
|
+
# @req> REQ-30898926@Vu28eWaG_1mE bfwfke
|
|
233
|
+
raise ReqctlError(
|
|
234
|
+
f"{', '.join(shared)}: more than one item has carried this name, "
|
|
235
|
+
"so a baseline stating it would name no one item -- rename one of "
|
|
236
|
+
"them, then generate"
|
|
237
|
+
)
|
|
238
|
+
# @req- yvehmw
|
|
239
|
+
|
|
240
|
+
# @req> REQ-43924642@SDx_e7a7A1XM mksxwc
|
|
241
|
+
# @req> REQ-28086776@7S0DPOiDLvSm ckkjqm
|
|
242
|
+
previous, claimed = _previous(root)
|
|
243
|
+
# @req+ REQ-73312524@Eij54nBVNhs0 mgwroy
|
|
244
|
+
dirty = uncommitted(root)
|
|
245
|
+
if dirty:
|
|
246
|
+
# @req> REQ-30898926@Vu28eWaG_1mE jfjqxs
|
|
247
|
+
raise ReqctlError(
|
|
248
|
+
f"{', '.join(dirty)}: uncommitted -- commit the corpus, "
|
|
249
|
+
"then generate"
|
|
250
|
+
)
|
|
251
|
+
# @req- mgwroy
|
|
252
|
+
|
|
253
|
+
# @req+ REQ-87283234@HOs3YEHAJSD_ cc4ozn
|
|
254
|
+
# @req> REQ-51424927@vQb-YtKja6pq n2tclj
|
|
255
|
+
# @req+ REQ-28086776@7S0DPOiDLvSm dk6vzq
|
|
256
|
+
# @req> REQ-43924642@SDx_e7a7A1XM bxobj4
|
|
257
|
+
# @req> REQ-16228838@AwqPpWdj7-35 bwglgo
|
|
258
|
+
# @req> REQ-21960639@DzQkjJjbuYa1 c5kvsx
|
|
259
|
+
# @req> REQ-58913096@FnqrGVG4gqHx v753bs
|
|
260
|
+
manifest = {
|
|
261
|
+
"baseline": (previous or 0) + 1,
|
|
262
|
+
"commit": head_commit(root),
|
|
263
|
+
"supersedes": previous,
|
|
264
|
+
"items": approved,
|
|
265
|
+
}
|
|
266
|
+
# @req> REQ-73708868@UVOWK-OX0AMJ lshf4m
|
|
267
|
+
atomic_write(
|
|
268
|
+
path(root),
|
|
269
|
+
yaml.safe_dump(manifest, default_flow_style=False, sort_keys=True),
|
|
270
|
+
)
|
|
271
|
+
# @req- cc4ozn
|
|
272
|
+
return manifest["baseline"], path(root), len(approved), claimed
|
|
273
|
+
# @req- dk6vzq
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _restated(recorded, tree):
|
|
277
|
+
wore = carriers(tree)
|
|
278
|
+
now = {item.uid for item in items(tree)}
|
|
279
|
+
held = {}
|
|
280
|
+
for name, pinned in recorded.items():
|
|
281
|
+
under = (wore[name][0] if name not in now
|
|
282
|
+
and len(wore.get(name, ())) == 1 else name)
|
|
283
|
+
held[name if under in held else under] = pinned
|
|
284
|
+
return held
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def check(tree, root):
|
|
288
|
+
# @req> REQ-40107714@PF63p4qjxWxa r2i5nz
|
|
289
|
+
problems = [
|
|
290
|
+
f"{held.name}: the numbered layout was replaced by one baseline; "
|
|
291
|
+
"delete it and run `reqctl baseline --generate`"
|
|
292
|
+
for held in _numbered(root)
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
if not path(root).is_file():
|
|
296
|
+
# @req> REQ-44642786@f_tuj97sj5Zn wywtng
|
|
297
|
+
problems += [f"{uid}: approved but no baseline states it -- run "
|
|
298
|
+
"`reqctl baseline --generate`" for uid in sorted(derive(tree))]
|
|
299
|
+
return None, problems
|
|
300
|
+
|
|
301
|
+
# @req+ REQ-52307457@2GAIsxcSUISq w27scf
|
|
302
|
+
# @req+ REQ-10024282@x7WPiNHGRX8n njzhlf
|
|
303
|
+
claimed = _unresolved(path(root))
|
|
304
|
+
if claimed is not None:
|
|
305
|
+
stated = " and ".join(str(one) for one in sorted(set(claimed)))
|
|
306
|
+
return None, problems + [
|
|
307
|
+
f"{path(root).name}: holds an unresolved merge claiming {stated} "
|
|
308
|
+
"-- `reqctl baseline --generate` cuts the one that follows both"
|
|
309
|
+
]
|
|
310
|
+
# @req- njzhlf
|
|
311
|
+
# @req- w27scf
|
|
312
|
+
|
|
313
|
+
# @req+ REQ-56595801@iNW4JGFmdxOc cmyqd3
|
|
314
|
+
manifest = read(path(root))
|
|
315
|
+
if not isinstance(manifest, dict):
|
|
316
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 5hz7xx
|
|
317
|
+
return None, problems + [
|
|
318
|
+
f"{path(root).name}: is not a mapping -- make it a mapping with "
|
|
319
|
+
"an integer `baseline` by hand, then run `reqctl baseline "
|
|
320
|
+
"--generate`"
|
|
321
|
+
]
|
|
322
|
+
number = manifest.get("baseline")
|
|
323
|
+
head = f"baseline {number}" if type(number) is int else path(root).name
|
|
324
|
+
|
|
325
|
+
for error in sorted(validator(schema(root, "baseline")).iter_errors(manifest), key=str):
|
|
326
|
+
where = ".".join(str(p) for p in error.absolute_path) or "(manifest)"
|
|
327
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ehqxmk
|
|
328
|
+
problems.append(
|
|
329
|
+
f"{head}: schema: {where}: {error.message} -- run `reqctl "
|
|
330
|
+
"baseline --generate`, after setting `baseline` to an integer by "
|
|
331
|
+
"hand if it is not one"
|
|
332
|
+
)
|
|
333
|
+
# @req- cmyqd3
|
|
334
|
+
|
|
335
|
+
# @req> REQ-60457693@ttVu9qsRnPG9 43d4hx
|
|
336
|
+
if type(number) is int:
|
|
337
|
+
stated = manifest.get("supersedes")
|
|
338
|
+
follows = number - 1 if number > 1 else None
|
|
339
|
+
if stated != follows:
|
|
340
|
+
problems.append(
|
|
341
|
+
f"{head}: supersedes {stated!r}, but the count before "
|
|
342
|
+
f"{number} is {follows!r} -- a hand edit or a bad merge; "
|
|
343
|
+
"run `reqctl baseline --generate` on the corrected corpus"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# @req+ REQ-56595801@iNW4JGFmdxOc ny5db2
|
|
347
|
+
recorded = manifest.get("items")
|
|
348
|
+
if not isinstance(recorded, dict):
|
|
349
|
+
# @req> REQ-30898926@Vu28eWaG_1mE klxvpe
|
|
350
|
+
return number, problems + [
|
|
351
|
+
f"{head}: items is not a mapping -- run `reqctl baseline "
|
|
352
|
+
"--generate`"
|
|
353
|
+
]
|
|
354
|
+
# @req- ny5db2
|
|
355
|
+
|
|
356
|
+
# @req+ REQ-44642786@f_tuj97sj5Zn rfw24o
|
|
357
|
+
current = derive(tree)
|
|
358
|
+
# @req> REQ-13861816@SpClrm8cjO-v db6w7v
|
|
359
|
+
recorded = _restated(recorded, tree)
|
|
360
|
+
for uid in sorted(set(recorded) - set(current)):
|
|
361
|
+
# @req> REQ-30898926@Vu28eWaG_1mE b6pw73
|
|
362
|
+
problems.append(
|
|
363
|
+
f"{uid}: in baseline {number} but no longer approved -- run "
|
|
364
|
+
"`reqctl baseline --generate`, or restore the approval with "
|
|
365
|
+
f"`reqctl revise {uid} --status approved`"
|
|
366
|
+
)
|
|
367
|
+
for uid in sorted(set(current) - set(recorded)):
|
|
368
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ulq5g2
|
|
369
|
+
problems.append(f"{uid}: approved but absent from baseline {number} "
|
|
370
|
+
"-- run `reqctl baseline --generate`")
|
|
371
|
+
for uid in sorted(set(recorded) & set(current)):
|
|
372
|
+
if recorded[uid] != current[uid]:
|
|
373
|
+
# @req> REQ-30898926@Vu28eWaG_1mE tf3w5a
|
|
374
|
+
problems.append(
|
|
375
|
+
f"{uid}: changed since baseline {number}, so the baseline "
|
|
376
|
+
"states a state the corpus no longer has -- run `reqctl "
|
|
377
|
+
"baseline --generate`, or revert the change to the item"
|
|
378
|
+
)
|
|
379
|
+
# @req- rfw24o
|
|
380
|
+
return number, problems
|