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/history.py ADDED
@@ -0,0 +1,175 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from . import corpus
5
+ from .corpus import FOLDERS, ReqctlError
6
+
7
+
8
+ def _git(root, *args, given=b"", absent=None):
9
+ try:
10
+ found = subprocess.run(["git", *args], cwd=root, input=given,
11
+ capture_output=True, check=False)
12
+ except OSError as error:
13
+ # @req> REQ-30898926@Vu28eWaG_1mE vvgdbf
14
+ raise ReqctlError(f"cannot run git: {error.strerror} -- install git or "
15
+ "put it on PATH") from error
16
+ if found.returncode not in (0, absent):
17
+ # @req> REQ-30898926@Vu28eWaG_1mE vcr2ti
18
+ raise ReqctlError(f"cannot read the history: git {args[0]}: "
19
+ f"{found.stderr.decode(errors='replace').strip()} -- "
20
+ "run inside a git work tree, or repair the "
21
+ "repository (`git fsck` names the damage)")
22
+ return None if found.returncode else found.stdout
23
+
24
+
25
+ def _uid(path):
26
+ parts = path.split("/")
27
+ if len(parts) == 3 and parts[2].endswith((".yml", ".yaml")) and not parts[2].startswith("."):
28
+ return parts[2].rpartition(".")[0]
29
+ return None
30
+
31
+
32
+ # @req> REQ-82027237@R-XinrGfTNzL q4dejo
33
+ # @req> REQ-73796163@qg5x9Wvk_AEp bdp26a
34
+ def carried(uid, data, blocks):
35
+ return corpus.stamp_of(uid, data), {
36
+ **{key: corpus.entry_stamp_of(uid, data, key) for key in corpus.entries(data) or {}},
37
+ **{block: corpus.held_block(data, block) for block in blocks}}
38
+
39
+
40
+ def _flat(value):
41
+ return " ".join(corpus.plain(value).split())
42
+
43
+
44
+ # @req> REQ-50929621@tSdGG-tCMu8G dvvyvi
45
+ # @req> REQ-24608716@rrTjNN2Ay0D0 w7fcu5
46
+ def lines(row):
47
+ width = max(len(key) for key in row["now"])
48
+ return "".join(
49
+ f"\n {key.replace('_', ' '):<{width}} pinned "
50
+ f"{'unknown' if row['then'] is None else _flat(row['then'].get(key))}"
51
+ f"\n {'':<{width}} now {_flat(held)}"
52
+ for key, held in row["now"].items())
53
+
54
+
55
+ class History:
56
+ def __init__(self, root, kinds=FOLDERS):
57
+ self.root = root
58
+ self.folders = [f"requirements/{FOLDERS[kind]}" for kind in kinds]
59
+ self._rows, self._head = None, {}
60
+ self._blobs, self._parsed, self._states = {}, {}, {}
61
+
62
+ def _load(self):
63
+ if self._rows is not None:
64
+ return
65
+ if _git(self.root, "rev-parse", "-q", "--verify", "HEAD", absent=1) is None:
66
+ self._rows = {}
67
+ return
68
+ # @req+ REQ-59306961@EGz7Ll3e9FKA dm7ihn
69
+ shallow = Path(self.root) / _git(self.root, "rev-parse", "--git-path",
70
+ "shallow").decode().strip()
71
+ grafted = set(shallow.read_text().split()) if shallow.is_file() else set()
72
+ # @req- dm7ihn
73
+ rows, commit = {}, None
74
+ # @req+ REQ-82027237@R-XinrGfTNzL efmrhh
75
+ for line in _git(self.root, "log", "--full-history", "--no-show-signature", "-c",
76
+ "--raw", "--root", "--no-renames", "--no-abbrev",
77
+ "--format=%H %ct %cI", "HEAD", "--",
78
+ *self.folders).decode(errors="replace").splitlines():
79
+ if not line.startswith(":"):
80
+ if line:
81
+ sha, when, iso = line.split()
82
+ commit = sha, int(when), None if sha in grafted else iso
83
+ continue
84
+ meta, _, path = line.partition("\t")
85
+ uid = _uid(path)
86
+ if uid is None:
87
+ continue
88
+ count = len(meta) - len(meta.lstrip(":"))
89
+ shas = [sha if sha.strip("0") else None
90
+ for sha in meta.split()[count + 1:2 * count + 2]]
91
+ row = rows.setdefault(uid, {}).setdefault(
92
+ commit[0], [*commit[1:], None, [None] * count])
93
+ row[2] = row[2] or shas[count]
94
+ row[3] = [was or held for was, held in zip(row[3], shas[:count])]
95
+ ordered = {uid: sorted(held.values(), key=lambda row: -row[0])
96
+ for uid, held in rows.items()}
97
+ # @req- efmrhh
98
+ head = {_uid(path): meta.split()[2] for meta, _, path in (
99
+ line.partition("\t") for line in _git(
100
+ self.root, "ls-tree", "-r", "HEAD", "--",
101
+ *self.folders).decode(errors="replace").splitlines())}
102
+ wanted = {sha for held in ordered.values() for row in held
103
+ for sha in (row[2], *row[3]) if sha} | set(head.values())
104
+ out = _git(self.root, "cat-file", "--batch",
105
+ given="".join(f"{sha}\n" for sha in wanted).encode())
106
+ at = 0
107
+ for sha in wanted:
108
+ end = out.index(b"\n", at)
109
+ _, kind, *size = out[at:end].decode().split()
110
+ if not size:
111
+ # @req> REQ-30898926@Vu28eWaG_1mE hz3bqm
112
+ raise ReqctlError(
113
+ f"cannot read the history: git cat-file: {sha} {kind} -- "
114
+ "fetch it with `git fetch origin`, or repair the "
115
+ "repository (`git fsck` names the damage)")
116
+ at = end + 2 + int(size[0])
117
+ if kind == "blob":
118
+ self._blobs[sha] = out[end + 1:at - 1]
119
+ self._head, self._rows = head, ordered
120
+
121
+ def _held(self, uid, sha):
122
+ if sha not in self._parsed:
123
+ try:
124
+ held = corpus.loads(self._blobs[sha].decode(), f"{uid} at {sha}",
125
+ aliases=False)
126
+ except (KeyError, UnicodeDecodeError, ReqctlError):
127
+ held = None
128
+ self._parsed[sha] = held if isinstance(held, dict) else None
129
+ return self._parsed[sha]
130
+
131
+ def _carried(self, uid, sha, blocks):
132
+ if (uid, sha) not in self._states:
133
+ held = self._held(uid, sha)
134
+ self._states[uid, sha] = None if held is None else carried(uid, held, blocks)
135
+ return self._states[uid, sha]
136
+
137
+ # @req> REQ-59306961@EGz7Ll3e9FKA jw6cdl
138
+ # @req> REQ-82027237@R-XinrGfTNzL aum3ke
139
+ # @req> REQ-73796163@qg5x9Wvk_AEp x43h3h
140
+ def updated(self, uid, data):
141
+ self._load()
142
+ blocks = corpus.held_blocks(self.root, uid, data)
143
+ now = carried(uid, data, blocks)
144
+ if self._carried(uid, self._head.get(uid), blocks) != now:
145
+ return None
146
+ return next((iso for _, iso, new, parents in self._rows.get(uid, ())
147
+ if self._carried(uid, new, blocks) == now
148
+ and all(self._carried(uid, parent, blocks) != now
149
+ for parent in parents)),
150
+ None)
151
+
152
+ # @req> REQ-50929621@tSdGG-tCMu8G qgp2gz
153
+ # @req> REQ-24608716@rrTjNN2Ay0D0 xyk6zv
154
+ def _beside(self, uid, data, pinned, stamped, fields):
155
+ then = None
156
+ if isinstance(pinned, str):
157
+ self._load()
158
+ then = next((fields(held) for row in self._rows.get(uid, ())
159
+ if (held := self._held(uid, row[2])) is not None
160
+ and stamped(held) == pinned), None)
161
+ return {"then": then, "now": fields(data)}
162
+
163
+ # @req> REQ-24608716@rrTjNN2Ay0D0 vjkk2b
164
+ def cited(self, uid, data, entry, pinned):
165
+ return self._beside(
166
+ uid, data, pinned,
167
+ lambda held: corpus.cited_stamp(corpus.Item(uid, None, held), entry),
168
+ lambda held: corpus.cited_fields(uid, held, entry))
169
+
170
+ # @req> REQ-24608716@rrTjNN2Ay0D0 dzvsq3
171
+ def pinned(self, uid, data, rest, pinned):
172
+ return self._beside(
173
+ uid, data, pinned,
174
+ lambda held: corpus.address_stamp_of(uid, held, rest),
175
+ lambda held: corpus.covered(uid, held, rest))