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 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