punchlist 0.6.0__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.
punchlist/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """punchlist: a file-based issue tracker, one markdown file per issue in the project's own repo."""
punchlist/check.py ADDED
@@ -0,0 +1,324 @@
1
+ """`punch check`: validate every issue file and report every problem, not just the first.
2
+
3
+ Per file it reports a file outside its bucket and the first parse error (the
4
+ parser stops there), then checks across files: each `id:` matches its
5
+ filename, which with correct buckets also makes ids unique; each `parent`
6
+ names an existing issue file; and no chain of parents loops back on itself.
7
+ With a `depends-on` field: each dependency names an existing issue file, and
8
+ no group of issues depends on each other in a cycle. With a `related` field:
9
+ each related id names another existing issue, which lists this one back.
10
+ Opt-in (config `check_references`, or `punch check --references`): each id
11
+ of this tracker's shape mentioned in a body names an existing issue file.
12
+ Comments: every folder beside an issue file belongs to an existing issue, and
13
+ every file in a `comments/` folder is a valid comment matching its name.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from pathlib import Path
20
+
21
+ from .comment import read_comment
22
+ from .config import DEPENDS_ON, RELATED, Config
23
+ from .errors import PunchError
24
+ from .ids import ALPHABET
25
+ from .issue import Issue, issue_files, placement_error, read_issue
26
+
27
+
28
+ def parent_loop(config: Config, issue_id: str, parent: str) -> list[str] | None:
29
+ """The loop that giving `issue_id` this `parent` would close, as `[a, b, a]`; else None.
30
+
31
+ Walks up from `parent` through each ancestor's file. A dangling parent or
32
+ a loop elsewhere ends the walk; `punch check` reports those.
33
+ """
34
+ chain: list[str] = [issue_id]
35
+ node: str | None = parent
36
+ while node is not None and node not in chain[1:]:
37
+ chain.append(node)
38
+ if node == issue_id:
39
+ return chain
40
+ path: Path = config.issue_path(node)
41
+ if not path.is_file():
42
+ return None
43
+ node = read_issue(path, config).text("parent")
44
+ return None
45
+
46
+
47
+ def dependency_loop(config: Config, issue_id: str,
48
+ dependencies: list[str]) -> list[str] | None:
49
+ """The loop giving `issue_id` these dependencies would close, as `[a, b, a]`; else None.
50
+
51
+ Searches breadth-first from each dependency through the `depends-on` of
52
+ each issue file it reaches, so the loop reported is a shortest one. A
53
+ missing file ends that branch; `punch check` reports it.
54
+ """
55
+ previous: dict[str, str] = {}
56
+ queue: list[str] = []
57
+ for dependency in dependencies:
58
+ if dependency == issue_id:
59
+ return [issue_id, issue_id]
60
+ if dependency not in previous:
61
+ previous[dependency] = issue_id
62
+ queue.append(dependency)
63
+ while queue:
64
+ node: str = queue.pop(0)
65
+ path: Path = config.issue_path(node)
66
+ if not path.is_file():
67
+ continue
68
+ for successor in read_issue(path, config).items(DEPENDS_ON):
69
+ if successor == issue_id:
70
+ return _loop_path(previous, issue_id, node)
71
+ if successor not in previous:
72
+ previous[successor] = node
73
+ queue.append(successor)
74
+ return None
75
+
76
+
77
+ def _loop_path(previous: dict[str, str], start: str, last: str) -> list[str]:
78
+ """`[start, ..., last, start]` from the breadth-first `previous` links."""
79
+ path: list[str] = [last]
80
+ while path[0] != start:
81
+ path.insert(0, previous[path[0]])
82
+ return [*path, start]
83
+
84
+
85
+ def check_issues(config: Config,
86
+ references: bool | None = None) -> tuple[int, list[PunchError]]:
87
+ """The number of issue files, and every problem found in them, in file and line order.
88
+
89
+ `references` turns the body reference check on or off; None follows the
90
+ config's `check_references`.
91
+ """
92
+ if references is None:
93
+ references = config.check_references
94
+ paths: list[Path] = issue_files(config)
95
+ # Parents are checked against filenames, so a corrupt parent file is one
96
+ # problem rather than one per child.
97
+ stems: set[str] = {path.stem for path in paths}
98
+ problems: list[PunchError] = []
99
+ parsed: dict[str, Issue] = {}
100
+ for path in paths:
101
+ placement: PunchError | None = placement_error(path, config)
102
+ if placement is not None:
103
+ problems.append(placement)
104
+ try:
105
+ issue: Issue = read_issue(path, config)
106
+ except PunchError as exc:
107
+ problems.append(exc)
108
+ continue
109
+ parsed[path.stem] = issue
110
+ if issue.id != path.stem:
111
+ problems.append(PunchError(
112
+ f"id {issue.id!r} does not match the filename", path, issue.lines["id"]
113
+ ))
114
+ parent: str | None = issue.text("parent")
115
+ if parent is not None and parent not in stems:
116
+ problems.append(PunchError(
117
+ f"parent {parent!r} does not exist", path, issue.lines["parent"]
118
+ ))
119
+ if config.has_links:
120
+ for dependency in issue.items(DEPENDS_ON):
121
+ if dependency not in stems:
122
+ problems.append(PunchError(
123
+ f"{DEPENDS_ON} {dependency!r} does not exist", path,
124
+ issue.lines[DEPENDS_ON],
125
+ ))
126
+ if config.has_related:
127
+ for other in issue.items(RELATED):
128
+ if other == issue.id:
129
+ problems.append(PunchError(
130
+ f"{RELATED} lists the issue itself", path, issue.lines[RELATED]
131
+ ))
132
+ elif other not in stems:
133
+ problems.append(PunchError(
134
+ f"{RELATED} {other!r} does not exist", path, issue.lines[RELATED]
135
+ ))
136
+ if references:
137
+ problems.extend(_dangling_references(config, issue, stems))
138
+ problems.extend(_parent_loops(parsed))
139
+ if config.has_links:
140
+ problems.extend(_dependency_loops(parsed))
141
+ if config.has_related:
142
+ problems.extend(_one_sided_related(parsed))
143
+ problems.extend(_comment_problems(config))
144
+ problems.sort(key=lambda p: (p.path or Path(), p.line or 0))
145
+ return len(paths), problems
146
+
147
+
148
+ def _comment_problems(config: Config) -> list[PunchError]:
149
+ """Problems with comment folders and files.
150
+
151
+ A folder beside an issue file (`issues/<bucket>/<id>/`) must belong to an
152
+ existing, correctly placed issue; every entry of its `comments/` folder
153
+ must be a comment file whose header parses and matches its name.
154
+ """
155
+ problems: list[PunchError] = []
156
+ if not config.issues_path.is_dir():
157
+ return problems
158
+ for folder in sorted(config.issues_path.glob("*/*/")):
159
+ try:
160
+ expected: Path = config.issue_path(folder.name).with_suffix("")
161
+ except PunchError:
162
+ expected = Path()
163
+ if folder != expected or not (folder.parent / f"{folder.name}.md").is_file():
164
+ problems.append(PunchError(
165
+ f"folder for issue {folder.name!r}, which does not exist here", folder
166
+ ))
167
+ continue
168
+ comments: Path = folder / "comments"
169
+ if not comments.is_dir():
170
+ continue
171
+ for path in sorted(comments.iterdir()):
172
+ if not path.is_file() or path.suffix != ".md":
173
+ problems.append(PunchError(
174
+ "not a comment file (expected <time>-<author>.md)", path
175
+ ))
176
+ continue
177
+ try:
178
+ read_comment(path)
179
+ except PunchError as exc:
180
+ problems.append(exc)
181
+ return problems
182
+
183
+
184
+ def _dangling_references(config: Config, issue: Issue, stems: set[str]) -> list[PunchError]:
185
+ """Each body mention of this tracker's id shape that names no issue file, once per line.
186
+
187
+ Only `<prefix>-` plus `id_length` id-alphabet characters, on word
188
+ boundaries, counts, so another tracker's ids (another prefix) never match.
189
+ """
190
+ pattern: re.Pattern[str] = re.compile(
191
+ rf"\b{re.escape(config.prefix)}-[{ALPHABET}]{{{config.id_length}}}\b"
192
+ )
193
+ problems: list[PunchError] = []
194
+ for offset, line in enumerate(issue.body.split("\n")):
195
+ for mention in dict.fromkeys(pattern.findall(line)):
196
+ if mention not in stems:
197
+ problems.append(PunchError(
198
+ f"reference to {mention}, which does not exist",
199
+ issue.path, issue.body_line + offset,
200
+ ))
201
+ return problems
202
+
203
+
204
+ def _parent_loops(issues: dict[str, Issue]) -> list[PunchError]:
205
+ """One problem per parent loop, at the `parent:` line of the loop's lowest id."""
206
+ parents: dict[str, str] = {}
207
+ for stem, issue in issues.items():
208
+ parent: str | None = issue.text("parent")
209
+ if parent is not None:
210
+ parents[stem] = parent
211
+ problems: list[PunchError] = []
212
+ done: set[str] = set()
213
+ for start in sorted(parents):
214
+ chain: list[str] = []
215
+ node: str | None = start
216
+ while node is not None and node not in done and node not in chain:
217
+ chain.append(node)
218
+ node = parents.get(node)
219
+ if node is not None and node in chain:
220
+ loop: list[str] = chain[chain.index(node):]
221
+ first: int = loop.index(min(loop))
222
+ loop = loop[first:] + loop[:first]
223
+ head: Issue = issues[loop[0]]
224
+ problems.append(PunchError(
225
+ f"parent loop: {' -> '.join([*loop, loop[0]])}", head.path, head.lines["parent"]
226
+ ))
227
+ done.update(chain)
228
+ return problems
229
+
230
+
231
+ def _one_sided_related(issues: dict[str, Issue]) -> list[PunchError]:
232
+ """A related link the other issue doesn't list back, on the side that has it.
233
+
234
+ `set` and `new` write both sides, so this comes from a hand edit or a merge
235
+ that kept one file's side. A partner that didn't parse is skipped; its own
236
+ problem is already reported.
237
+ """
238
+ problems: list[PunchError] = []
239
+ for stem, issue in issues.items():
240
+ for other in issue.items(RELATED):
241
+ if other != stem and other in issues and stem not in issues[other].items(RELATED):
242
+ problems.append(PunchError(
243
+ f"{RELATED} {other!r} does not list {stem!r} back", issue.path,
244
+ issue.lines[RELATED],
245
+ ))
246
+ return problems
247
+
248
+
249
+ def _dependency_loops(issues: dict[str, Issue]) -> list[PunchError]:
250
+ """One problem per group of issues that depend on each other in a cycle.
251
+
252
+ Each strongly connected component with a cycle is reported once, at the
253
+ `depends-on` line of its lowest id, naming a shortest loop through it.
254
+ """
255
+ graph: dict[str, list[str]] = {
256
+ stem: [d for d in issue.items(DEPENDS_ON) if d in issues]
257
+ for stem, issue in issues.items()
258
+ }
259
+ problems: list[PunchError] = []
260
+ for component in _components(graph):
261
+ start: str = min(component)
262
+ if len(component) == 1 and start not in graph[start]:
263
+ continue
264
+ members: set[str] = set(component)
265
+ previous: dict[str, str] = {}
266
+ queue: list[str] = [start]
267
+ last: str | None = None
268
+ while queue and last is None:
269
+ node: str = queue.pop(0)
270
+ for successor in sorted(graph[node]):
271
+ if successor == start:
272
+ last = node
273
+ break
274
+ if successor in members and successor not in previous:
275
+ previous[successor] = node
276
+ queue.append(successor)
277
+ assert last is not None # a component with a cycle has a loop through any member
278
+ head: Issue = issues[start]
279
+ loop: list[str] = _loop_path(previous, start, last)
280
+ problems.append(PunchError(
281
+ f"dependency loop: {' -> '.join(loop)}", head.path, head.lines[DEPENDS_ON]
282
+ ))
283
+ return problems
284
+
285
+
286
+ def _components(graph: dict[str, list[str]]) -> list[list[str]]:
287
+ """Strongly connected components of `graph` (Tarjan's algorithm, iterative)."""
288
+ index: dict[str, int] = {}
289
+ low: dict[str, int] = {}
290
+ stack: list[str] = []
291
+ on_stack: set[str] = set()
292
+ components: list[list[str]] = []
293
+ for root in sorted(graph):
294
+ if root in index:
295
+ continue
296
+ work: list[tuple[str, int]] = [(root, 0)]
297
+ while work:
298
+ node, position = work[-1]
299
+ if position == 0:
300
+ index[node] = low[node] = len(index)
301
+ stack.append(node)
302
+ on_stack.add(node)
303
+ successors: list[str] = graph[node]
304
+ if position < len(successors):
305
+ work[-1] = (node, position + 1)
306
+ successor: str = successors[position]
307
+ if successor not in index:
308
+ work.append((successor, 0))
309
+ elif successor in on_stack:
310
+ low[node] = min(low[node], index[successor])
311
+ continue
312
+ work.pop()
313
+ if work:
314
+ low[work[-1][0]] = min(low[work[-1][0]], low[node])
315
+ if low[node] == index[node]:
316
+ component: list[str] = []
317
+ while True:
318
+ member: str = stack.pop()
319
+ on_stack.discard(member)
320
+ component.append(member)
321
+ if member == node:
322
+ break
323
+ components.append(component)
324
+ return components