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 +1 -0
- punchlist/check.py +324 -0
- punchlist/cli.py +823 -0
- punchlist/comment.py +162 -0
- punchlist/config.py +321 -0
- punchlist/errors.py +34 -0
- punchlist/git.py +141 -0
- punchlist/history.py +261 -0
- punchlist/ids.py +72 -0
- punchlist/importer.py +299 -0
- punchlist/issue.py +351 -0
- punchlist/ops.py +287 -0
- punchlist/static/board.html +466 -0
- punchlist/web.py +408 -0
- punchlist-0.6.0.dist-info/METADATA +118 -0
- punchlist-0.6.0.dist-info/RECORD +19 -0
- punchlist-0.6.0.dist-info/WHEEL +4 -0
- punchlist-0.6.0.dist-info/entry_points.txt +3 -0
- punchlist-0.6.0.dist-info/licenses/LICENSE +21 -0
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
|