errpath 0.1.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.
- errpath/__init__.py +19 -0
- errpath/__main__.py +4 -0
- errpath/_pydantic.py +102 -0
- errpath/analyse.py +1035 -0
- errpath/cli.py +141 -0
- errpath/diagram.py +135 -0
- errpath/ir.py +274 -0
- errpath/machine.py +165 -0
- errpath/py.typed +0 -0
- errpath/result.py +254 -0
- errpath/review.py +517 -0
- errpath/safe.py +129 -0
- errpath-0.1.0.dist-info/METADATA +622 -0
- errpath-0.1.0.dist-info/RECORD +17 -0
- errpath-0.1.0.dist-info/WHEEL +4 -0
- errpath-0.1.0.dist-info/entry_points.txt +2 -0
- errpath-0.1.0.dist-info/licenses/LICENSE +21 -0
errpath/review.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Compare error railways between two git refs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import subprocess
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from errpath.analyse import analyse_path
|
|
16
|
+
from errpath.diagram import render_function
|
|
17
|
+
from errpath.ir import (
|
|
18
|
+
SCHEMA_VERSION,
|
|
19
|
+
Branch,
|
|
20
|
+
Coverage,
|
|
21
|
+
Fail,
|
|
22
|
+
Finding,
|
|
23
|
+
Function,
|
|
24
|
+
Jump,
|
|
25
|
+
Loop,
|
|
26
|
+
Node,
|
|
27
|
+
Panic,
|
|
28
|
+
Recover,
|
|
29
|
+
Report,
|
|
30
|
+
Step,
|
|
31
|
+
Succeed,
|
|
32
|
+
Swallow,
|
|
33
|
+
Unanalysed,
|
|
34
|
+
to_dict,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
MARKER = "<!-- errpath-review -->"
|
|
38
|
+
MAX_FUNCTIONS = 20
|
|
39
|
+
MAX_DIAGRAMS = 5
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Risk(StrEnum):
|
|
43
|
+
LOW = "low"
|
|
44
|
+
MODERATE = "moderate"
|
|
45
|
+
HIGH = "high"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Status(StrEnum):
|
|
49
|
+
CHANGED = "changed"
|
|
50
|
+
ADDED = "added"
|
|
51
|
+
REMOVED = "removed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ItemKind(StrEnum):
|
|
55
|
+
STEP = "step"
|
|
56
|
+
FAIL = "fail"
|
|
57
|
+
PANIC = "panic"
|
|
58
|
+
SWALLOW = "swallow"
|
|
59
|
+
RECOVER = "recover"
|
|
60
|
+
RETURN = "return"
|
|
61
|
+
JUMP = "jump"
|
|
62
|
+
FORK = "fork"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Item:
|
|
67
|
+
kind: ItemKind
|
|
68
|
+
label: str
|
|
69
|
+
line: int
|
|
70
|
+
error: str | None = None
|
|
71
|
+
context: str = ""
|
|
72
|
+
key: str = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class FnChange:
|
|
77
|
+
id: str
|
|
78
|
+
status: Status
|
|
79
|
+
file: str
|
|
80
|
+
line: int
|
|
81
|
+
added: list[Item] = field(default_factory=list)
|
|
82
|
+
removed: list[Item] = field(default_factory=list)
|
|
83
|
+
diagram: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class NewFinding:
|
|
88
|
+
severity: str
|
|
89
|
+
finding: Finding
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Review:
|
|
94
|
+
schema_version: int = SCHEMA_VERSION
|
|
95
|
+
changed: bool = False
|
|
96
|
+
risk: Risk = Risk.LOW
|
|
97
|
+
markdown: str = ""
|
|
98
|
+
new_findings: list[NewFinding] = field(default_factory=list)
|
|
99
|
+
new_allows: list[Finding] = field(default_factory=list)
|
|
100
|
+
functions: list[FnChange] = field(default_factory=list)
|
|
101
|
+
moved: int = 0
|
|
102
|
+
new_functions: int = 0
|
|
103
|
+
removed_functions: int = 0
|
|
104
|
+
notes: list[str] = field(default_factory=list)
|
|
105
|
+
coverage: Coverage = field(default_factory=Coverage)
|
|
106
|
+
unparsed_files: int = 0
|
|
107
|
+
support_files_skipped: int = 0
|
|
108
|
+
# gaps new in this change, plus existing ones in changed functions
|
|
109
|
+
unanalysed: list[Unanalysed] = field(default_factory=list)
|
|
110
|
+
new_unanalysed: list[Unanalysed] = field(default_factory=list)
|
|
111
|
+
summary: str = ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _nested(context: str, part: str) -> str:
|
|
115
|
+
return part if not context else f"{context} / {part}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _routing(n: Step | Panic, tries: list[str]) -> str:
|
|
119
|
+
"""Which handler catches which exception type, e.g. ``ValueError -> except ValueError``.
|
|
120
|
+
|
|
121
|
+
Part of the comparison key, so moving a call out of a try's protection is a change.
|
|
122
|
+
``tries`` are the enclosing try tags, innermost last; a handler of an outer try is
|
|
123
|
+
marked with how many tries out it is, so same-named nested handlers stay distinct.
|
|
124
|
+
"""
|
|
125
|
+
parts = []
|
|
126
|
+
for k, c in n.caught_by.items():
|
|
127
|
+
tag = c.handler.split("#")[0]
|
|
128
|
+
up = len(tries) - 1 - tries.index(tag) if tag in tries else -1
|
|
129
|
+
parts.append(
|
|
130
|
+
f"{k} -> {c.how}" + (f" ({up} {'try' if up == 1 else 'tries'} out)" if up else "")
|
|
131
|
+
)
|
|
132
|
+
return ", ".join(sorted(parts))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _with_routing(label: str, routing: str) -> str:
|
|
136
|
+
return f"{label} (caught: {routing})" if routing else label
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def flatten(nodes: list[Node], context: str = "", tries: list[str] | None = None) -> list[Item]:
|
|
140
|
+
tries = tries or []
|
|
141
|
+
items: list[Item] = []
|
|
142
|
+
for n in nodes:
|
|
143
|
+
if isinstance(n, Step):
|
|
144
|
+
routing = _routing(n, tries)
|
|
145
|
+
items.append(
|
|
146
|
+
Item(
|
|
147
|
+
kind=ItemKind.STEP,
|
|
148
|
+
label=_with_routing(n.name, routing),
|
|
149
|
+
error=n.error,
|
|
150
|
+
line=n.line,
|
|
151
|
+
context=context,
|
|
152
|
+
key=f"step:{context}:{n.name}:{n.error or ''}:{routing}",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
elif isinstance(n, Fail):
|
|
156
|
+
items.append(
|
|
157
|
+
Item(
|
|
158
|
+
kind=ItemKind.FAIL,
|
|
159
|
+
label=n.error,
|
|
160
|
+
error=n.error,
|
|
161
|
+
line=n.line,
|
|
162
|
+
context=context,
|
|
163
|
+
key=f"fail:{context}:{n.error}",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
elif isinstance(n, Succeed):
|
|
167
|
+
items.append(
|
|
168
|
+
Item(
|
|
169
|
+
kind=ItemKind.RETURN,
|
|
170
|
+
label="Ok",
|
|
171
|
+
line=0,
|
|
172
|
+
context=context,
|
|
173
|
+
key=f"return:{context}:Ok",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
elif isinstance(n, Panic):
|
|
177
|
+
routing = _routing(n, tries)
|
|
178
|
+
items.append(
|
|
179
|
+
Item(
|
|
180
|
+
kind=ItemKind.PANIC,
|
|
181
|
+
label=_with_routing(n.name, routing),
|
|
182
|
+
line=n.line,
|
|
183
|
+
context=context,
|
|
184
|
+
key=f"panic:{context}:{n.name}:{routing}",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
elif isinstance(n, Swallow):
|
|
188
|
+
items.append(
|
|
189
|
+
Item(
|
|
190
|
+
kind=ItemKind.SWALLOW,
|
|
191
|
+
label=n.name,
|
|
192
|
+
line=n.line,
|
|
193
|
+
context=context,
|
|
194
|
+
key=f"swallow:{context}:{n.name}:{n.how}",
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
elif isinstance(n, Recover):
|
|
198
|
+
items.append(
|
|
199
|
+
Item(
|
|
200
|
+
kind=ItemKind.RECOVER,
|
|
201
|
+
label=n.name,
|
|
202
|
+
line=n.line,
|
|
203
|
+
context=context,
|
|
204
|
+
key=f"recover:{context}:{n.name}:{n.how}",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
elif isinstance(n, Jump):
|
|
208
|
+
items.append(
|
|
209
|
+
Item(
|
|
210
|
+
kind=ItemKind.JUMP,
|
|
211
|
+
label=n.name,
|
|
212
|
+
line=n.line,
|
|
213
|
+
context=context,
|
|
214
|
+
key=f"jump:{context}:{n.name}",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
elif isinstance(n, Branch):
|
|
218
|
+
items.append(
|
|
219
|
+
Item(
|
|
220
|
+
kind=ItemKind.FORK,
|
|
221
|
+
label=n.label,
|
|
222
|
+
line=0,
|
|
223
|
+
context=context,
|
|
224
|
+
key=f"fork:{context}:{n.label}",
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
inner = [*tries, n.tag] if n.tag else tries
|
|
228
|
+
for arm in n.arms:
|
|
229
|
+
items.extend(flatten(arm.body, _nested(context, arm.label), inner))
|
|
230
|
+
elif isinstance(n, Loop):
|
|
231
|
+
items.extend(flatten(n.head, _nested(context, n.label), tries))
|
|
232
|
+
items.extend(flatten(n.body, _nested(context, n.label), tries))
|
|
233
|
+
return items
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _diff(old: list[Item], new: list[Item]) -> tuple[list[Item], list[Item]]:
|
|
237
|
+
"""(added, removed) by sequence, so repeats and reordering both count as changes."""
|
|
238
|
+
matcher = difflib.SequenceMatcher(
|
|
239
|
+
a=[i.key for i in old], b=[i.key for i in new], autojunk=False
|
|
240
|
+
)
|
|
241
|
+
added: list[Item] = []
|
|
242
|
+
removed: list[Item] = []
|
|
243
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
244
|
+
if tag != "equal":
|
|
245
|
+
removed.extend(old[i1:i2])
|
|
246
|
+
added.extend(new[j1:j2])
|
|
247
|
+
return added, removed
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _fn_map(report: Report) -> dict[str, Function]:
|
|
251
|
+
return {fn.id: fn for fn in report.functions}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _finding_key(f: Finding) -> str:
|
|
255
|
+
# No line number: code moving up or down must not make an old finding look new.
|
|
256
|
+
return f"{f.rule}:{f.file}:{f.function}:{f.message}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def compare(
|
|
260
|
+
base: Report, head: Report, *, diagrams: Literal["open", "collapsed"] = "collapsed"
|
|
261
|
+
) -> Review:
|
|
262
|
+
review = Review(
|
|
263
|
+
coverage=head.coverage,
|
|
264
|
+
unparsed_files=len(head.parse_failures),
|
|
265
|
+
support_files_skipped=head.support_files_skipped,
|
|
266
|
+
)
|
|
267
|
+
base_fns = _fn_map(base)
|
|
268
|
+
head_fns = _fn_map(head)
|
|
269
|
+
|
|
270
|
+
base_ids = set(base_fns)
|
|
271
|
+
head_ids = set(head_fns)
|
|
272
|
+
|
|
273
|
+
review.new_functions = len(head_ids - base_ids)
|
|
274
|
+
review.removed_functions = len(base_ids - head_ids)
|
|
275
|
+
|
|
276
|
+
for fid in sorted(base_ids | head_ids):
|
|
277
|
+
b, h = base_fns.get(fid), head_fns.get(fid)
|
|
278
|
+
b_items = flatten(b.nodes) if b else []
|
|
279
|
+
h_items = flatten(h.nodes) if h else []
|
|
280
|
+
added, removed = _diff(b_items, h_items)
|
|
281
|
+
if not added and not removed:
|
|
282
|
+
continue
|
|
283
|
+
shown = h or b
|
|
284
|
+
assert shown is not None
|
|
285
|
+
review.functions.append(
|
|
286
|
+
FnChange(
|
|
287
|
+
id=fid,
|
|
288
|
+
status=Status.CHANGED if b and h else Status.ADDED if h else Status.REMOVED,
|
|
289
|
+
file=shown.file,
|
|
290
|
+
line=shown.line,
|
|
291
|
+
added=added,
|
|
292
|
+
removed=removed,
|
|
293
|
+
diagram=render_function(h) if h else None,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Counted, so a second identical unwrap in the same function is still new.
|
|
298
|
+
base_fkeys = Counter(_finding_key(f) for f in base.findings)
|
|
299
|
+
for finding in head.findings:
|
|
300
|
+
key = _finding_key(finding)
|
|
301
|
+
if base_fkeys[key] > 0:
|
|
302
|
+
base_fkeys[key] -= 1
|
|
303
|
+
continue
|
|
304
|
+
severity = "error" if finding.rule == "no-panic" else "warning"
|
|
305
|
+
review.new_findings.append(NewFinding(severity=severity, finding=finding))
|
|
306
|
+
|
|
307
|
+
# A new gap is a change too: the review can no longer vouch for that code.
|
|
308
|
+
# Counted, and keyed without the line, like findings.
|
|
309
|
+
base_gaps = Counter(_gap_key(u) for u in base.unanalysed)
|
|
310
|
+
for gap in head.unanalysed:
|
|
311
|
+
key = _gap_key(gap)
|
|
312
|
+
if base_gaps[key] > 0:
|
|
313
|
+
base_gaps[key] -= 1
|
|
314
|
+
else:
|
|
315
|
+
review.new_unanalysed.append(gap)
|
|
316
|
+
changed_ids = {c.id for c in review.functions}
|
|
317
|
+
review.unanalysed = [
|
|
318
|
+
u
|
|
319
|
+
for u in head.unanalysed
|
|
320
|
+
if u.function in changed_ids or any(u is new for new in review.new_unanalysed)
|
|
321
|
+
]
|
|
322
|
+
review.changed = bool(review.functions or review.new_findings or review.new_unanalysed)
|
|
323
|
+
review.risk = _risk(review)
|
|
324
|
+
review.summary = _summary(review)
|
|
325
|
+
review.markdown = _markdown(review, diagrams=diagrams) if review.changed else ""
|
|
326
|
+
return review
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _gap_key(u: Unanalysed) -> str:
|
|
330
|
+
return f"{u.file}:{u.function}:{u.code}:{u.reason}"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _risk(review: Review) -> Risk:
|
|
334
|
+
if any(nf.finding.rule == "no-panic" for nf in review.new_findings):
|
|
335
|
+
return Risk.HIGH
|
|
336
|
+
if any(nf.finding.rule == "no-swallow" for nf in review.new_findings):
|
|
337
|
+
return Risk.HIGH
|
|
338
|
+
if review.functions or review.new_unanalysed:
|
|
339
|
+
return Risk.MODERATE
|
|
340
|
+
return Risk.LOW
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _summary(review: Review) -> str:
|
|
344
|
+
if not review.changed:
|
|
345
|
+
return "No error-path changes."
|
|
346
|
+
parts = [
|
|
347
|
+
f"{len(review.functions)} function(s) changed",
|
|
348
|
+
f"{len(review.new_findings)} new finding(s)",
|
|
349
|
+
]
|
|
350
|
+
if review.new_unanalysed:
|
|
351
|
+
parts.append(f"{len(review.new_unanalysed)} new spot(s) errpath can't follow")
|
|
352
|
+
parts.append(f"risk={review.risk.value}")
|
|
353
|
+
return ", ".join(parts)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _markdown(review: Review, *, diagrams: Literal["open", "collapsed"]) -> str:
|
|
357
|
+
lines = [
|
|
358
|
+
MARKER,
|
|
359
|
+
"",
|
|
360
|
+
f"## Error path review: {review.risk.value} risk",
|
|
361
|
+
"",
|
|
362
|
+
review.summary,
|
|
363
|
+
"",
|
|
364
|
+
]
|
|
365
|
+
if review.new_findings:
|
|
366
|
+
lines.append("### New findings")
|
|
367
|
+
lines.append("")
|
|
368
|
+
for nf in review.new_findings:
|
|
369
|
+
f = nf.finding
|
|
370
|
+
lines.append(f"- `{f.rule}` {f.message} at `{f.file}:{f.line}`")
|
|
371
|
+
lines.append("")
|
|
372
|
+
|
|
373
|
+
for i, change in enumerate(review.functions[:MAX_FUNCTIONS]):
|
|
374
|
+
status = "" if change.status == Status.CHANGED else f" ({change.status.value})"
|
|
375
|
+
lines.append(f"### `{change.id}`{status}")
|
|
376
|
+
lines.append("")
|
|
377
|
+
for item in change.added:
|
|
378
|
+
lines.append(f"- added {item.kind.value} `{item.label}`")
|
|
379
|
+
for item in change.removed:
|
|
380
|
+
lines.append(f"- removed {item.kind.value} `{item.label}`")
|
|
381
|
+
if change.diagram and i < MAX_DIAGRAMS:
|
|
382
|
+
open_attr = " open" if diagrams == "open" else ""
|
|
383
|
+
lines.append("")
|
|
384
|
+
lines.append(f"<details{open_attr}>")
|
|
385
|
+
lines.append("<summary>Diagram</summary>")
|
|
386
|
+
lines.append("")
|
|
387
|
+
lines.append("```mermaid")
|
|
388
|
+
lines.append(change.diagram)
|
|
389
|
+
lines.append("```")
|
|
390
|
+
lines.append("")
|
|
391
|
+
lines.append("</details>")
|
|
392
|
+
lines.append("")
|
|
393
|
+
|
|
394
|
+
if review.unanalysed:
|
|
395
|
+
lines.append("### Not analysed")
|
|
396
|
+
lines.append("")
|
|
397
|
+
lines.append("errpath couldn't follow these, so this review may miss error paths there:")
|
|
398
|
+
lines.append("")
|
|
399
|
+
for u in review.unanalysed:
|
|
400
|
+
new = " (new)" if any(u is n for n in review.new_unanalysed) else ""
|
|
401
|
+
lines.append(f"- `{u.file}:{u.line}` `{u.code}`{new}: {u.reason}")
|
|
402
|
+
lines.append("")
|
|
403
|
+
return "\n".join(lines)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def review_to_dict(review: Review) -> dict[str, Any]:
|
|
407
|
+
def item_dict(item: Item) -> dict[str, Any]:
|
|
408
|
+
return {
|
|
409
|
+
"kind": item.kind.value,
|
|
410
|
+
"label": item.label,
|
|
411
|
+
"error": item.error,
|
|
412
|
+
"line": item.line,
|
|
413
|
+
"context": item.context,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
"schema_version": review.schema_version,
|
|
418
|
+
"changed": review.changed,
|
|
419
|
+
"risk": review.risk.value,
|
|
420
|
+
"markdown": review.markdown,
|
|
421
|
+
"newFindings": [
|
|
422
|
+
{
|
|
423
|
+
"severity": nf.severity,
|
|
424
|
+
**to_dict(nf.finding),
|
|
425
|
+
}
|
|
426
|
+
for nf in review.new_findings
|
|
427
|
+
],
|
|
428
|
+
"new_allows": [to_dict(f) for f in review.new_allows],
|
|
429
|
+
"functions": [
|
|
430
|
+
{
|
|
431
|
+
"id": c.id,
|
|
432
|
+
"status": c.status.value,
|
|
433
|
+
"file": c.file,
|
|
434
|
+
"line": c.line,
|
|
435
|
+
"added": [item_dict(i) for i in c.added],
|
|
436
|
+
"removed": [item_dict(i) for i in c.removed],
|
|
437
|
+
}
|
|
438
|
+
for c in review.functions
|
|
439
|
+
],
|
|
440
|
+
"moved": review.moved,
|
|
441
|
+
"new_functions": review.new_functions,
|
|
442
|
+
"removed_functions": review.removed_functions,
|
|
443
|
+
"notes": review.notes,
|
|
444
|
+
"coverage": to_dict(review.coverage),
|
|
445
|
+
"unparsed_files": review.unparsed_files,
|
|
446
|
+
"support_files_skipped": review.support_files_skipped,
|
|
447
|
+
"unanalysed": [
|
|
448
|
+
{**to_dict(u), "new": any(u is n for n in review.new_unanalysed)}
|
|
449
|
+
for u in review.unanalysed
|
|
450
|
+
],
|
|
451
|
+
"summary": review.summary,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _git_export(repo: Path, ref: str, paths: list[str], dest: Path) -> Path:
|
|
456
|
+
"""Write ``ref``'s .py files (limited to ``paths`` if given) under ``dest``.
|
|
457
|
+
|
|
458
|
+
One ``git archive`` stream instead of one ``git show`` per file.
|
|
459
|
+
"""
|
|
460
|
+
prefixes = [p.rstrip("/") for p in paths]
|
|
461
|
+
|
|
462
|
+
def wanted(name: str) -> bool:
|
|
463
|
+
if not name.endswith(".py"):
|
|
464
|
+
return False
|
|
465
|
+
return not prefixes or any(name == p or name.startswith(p + "/") for p in prefixes)
|
|
466
|
+
|
|
467
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
468
|
+
root = dest.resolve()
|
|
469
|
+
# `with` closes both pipes and waits for git, on every path out.
|
|
470
|
+
with subprocess.Popen(
|
|
471
|
+
["git", "-C", str(repo), "archive", "--format=tar", ref],
|
|
472
|
+
stdout=subprocess.PIPE,
|
|
473
|
+
stderr=subprocess.PIPE,
|
|
474
|
+
) as proc:
|
|
475
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
476
|
+
try:
|
|
477
|
+
with tarfile.open(fileobj=proc.stdout, mode="r|") as tar:
|
|
478
|
+
for member in tar:
|
|
479
|
+
if not member.isfile() or not wanted(member.name):
|
|
480
|
+
continue
|
|
481
|
+
target = (dest / member.name).resolve()
|
|
482
|
+
if not target.is_relative_to(root):
|
|
483
|
+
continue # never write outside dest
|
|
484
|
+
fileobj = tar.extractfile(member)
|
|
485
|
+
if fileobj is None:
|
|
486
|
+
continue
|
|
487
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
488
|
+
target.write_bytes(fileobj.read())
|
|
489
|
+
except tarfile.ReadError:
|
|
490
|
+
if proc.wait() == 0:
|
|
491
|
+
raise # git succeeded but the stream is broken
|
|
492
|
+
# else: git failed (bad ref); raised with its message below
|
|
493
|
+
stderr = proc.stderr.read()
|
|
494
|
+
if proc.wait() != 0:
|
|
495
|
+
raise subprocess.CalledProcessError(proc.returncode, proc.args, stderr=stderr)
|
|
496
|
+
return dest
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def run(
|
|
500
|
+
*,
|
|
501
|
+
repo: Path,
|
|
502
|
+
base: str,
|
|
503
|
+
head: str = "HEAD",
|
|
504
|
+
paths: list[str] | None = None,
|
|
505
|
+
diagrams: Literal["open", "collapsed"] = "collapsed",
|
|
506
|
+
) -> Review:
|
|
507
|
+
paths = paths or []
|
|
508
|
+
with tempfile.TemporaryDirectory(prefix="errpath-") as tmp:
|
|
509
|
+
base_dir = _git_export(repo, base, paths, Path(tmp) / "base")
|
|
510
|
+
head_dir = _git_export(repo, head, paths, Path(tmp) / "head")
|
|
511
|
+
# Both trees share the repo layout, so file paths in the reports are repo-relative.
|
|
512
|
+
return compare(analyse_path(base_dir), analyse_path(head_dir), diagrams=diagrams)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def compare_paths(base_path: Path, head_path: Path, **kwargs: Any) -> Review:
|
|
516
|
+
"""Compare two directories or files without git (handy for tests)."""
|
|
517
|
+
return compare(analyse_path(base_path), analyse_path(head_path), **kwargs)
|
errpath/safe.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Decorators that wrap function returns in Result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any, ParamSpec, TypeVar, overload
|
|
9
|
+
|
|
10
|
+
from errpath.result import Err, Ok, Result
|
|
11
|
+
|
|
12
|
+
P = ParamSpec("P")
|
|
13
|
+
R = TypeVar("R")
|
|
14
|
+
E1 = TypeVar("E1", bound=BaseException)
|
|
15
|
+
E2 = TypeVar("E2", bound=BaseException)
|
|
16
|
+
E3 = TypeVar("E3", bound=BaseException)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def safe(func: Callable[P, R]) -> Callable[P, Result[R, Exception]]:
|
|
20
|
+
"""Catch any Exception and return ``Result[R, Exception]``."""
|
|
21
|
+
|
|
22
|
+
@functools.wraps(func)
|
|
23
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, Exception]:
|
|
24
|
+
try:
|
|
25
|
+
return Ok(func(*args, **kwargs))
|
|
26
|
+
except Exception as e:
|
|
27
|
+
return Err(e)
|
|
28
|
+
|
|
29
|
+
return wrapper
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Overloads keep the exact error types for up to three (e.g. Err[ValueError | KeyError]);
|
|
33
|
+
# with more, the error is typed as BaseException.
|
|
34
|
+
@overload
|
|
35
|
+
def safe_with(e1: type[E1], /) -> Callable[[Callable[P, R]], Callable[P, Result[R, E1]]]: ...
|
|
36
|
+
@overload
|
|
37
|
+
def safe_with(
|
|
38
|
+
e1: type[E1], e2: type[E2], /
|
|
39
|
+
) -> Callable[[Callable[P, R]], Callable[P, Result[R, E1 | E2]]]: ...
|
|
40
|
+
@overload
|
|
41
|
+
def safe_with(
|
|
42
|
+
e1: type[E1], e2: type[E2], e3: type[E3], /
|
|
43
|
+
) -> Callable[[Callable[P, R]], Callable[P, Result[R, E1 | E2 | E3]]]: ...
|
|
44
|
+
@overload
|
|
45
|
+
def safe_with(
|
|
46
|
+
*exc_types: type[BaseException],
|
|
47
|
+
) -> Callable[[Callable[P, R]], Callable[P, Result[R, BaseException]]]: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def safe_with(
|
|
51
|
+
*exc_types: type[BaseException],
|
|
52
|
+
) -> Callable[[Callable[P, R]], Callable[P, Result[R, Any]]]:
|
|
53
|
+
"""Catch only the given exception types; others propagate."""
|
|
54
|
+
if not exc_types:
|
|
55
|
+
raise TypeError("safe_with requires at least one exception type")
|
|
56
|
+
|
|
57
|
+
def decorator(func: Callable[P, R]) -> Callable[P, Result[R, BaseException]]:
|
|
58
|
+
@functools.wraps(func)
|
|
59
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, BaseException]:
|
|
60
|
+
try:
|
|
61
|
+
return Ok(func(*args, **kwargs))
|
|
62
|
+
except exc_types as e:
|
|
63
|
+
return Err(e)
|
|
64
|
+
|
|
65
|
+
return wrapper
|
|
66
|
+
|
|
67
|
+
return decorator
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def safe_async(
|
|
71
|
+
func: Callable[P, Awaitable[R]],
|
|
72
|
+
) -> Callable[P, Awaitable[Result[R, Exception]]]:
|
|
73
|
+
"""Async variant of ``@safe``. Never catches ``CancelledError``."""
|
|
74
|
+
|
|
75
|
+
@functools.wraps(func)
|
|
76
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, Exception]:
|
|
77
|
+
try:
|
|
78
|
+
return Ok(await func(*args, **kwargs))
|
|
79
|
+
except asyncio.CancelledError:
|
|
80
|
+
raise
|
|
81
|
+
except Exception as e:
|
|
82
|
+
return Err(e)
|
|
83
|
+
|
|
84
|
+
return wrapper
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@overload
|
|
88
|
+
def safe_async_with(
|
|
89
|
+
e1: type[E1], /
|
|
90
|
+
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[Result[R, E1]]]]: ...
|
|
91
|
+
@overload
|
|
92
|
+
def safe_async_with(
|
|
93
|
+
e1: type[E1], e2: type[E2], /
|
|
94
|
+
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[Result[R, E1 | E2]]]]: ...
|
|
95
|
+
@overload
|
|
96
|
+
def safe_async_with(
|
|
97
|
+
e1: type[E1], e2: type[E2], e3: type[E3], /
|
|
98
|
+
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[Result[R, E1 | E2 | E3]]]]: ...
|
|
99
|
+
@overload
|
|
100
|
+
def safe_async_with(
|
|
101
|
+
*exc_types: type[BaseException],
|
|
102
|
+
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[Result[R, BaseException]]]]: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def safe_async_with(
|
|
106
|
+
*exc_types: type[BaseException],
|
|
107
|
+
) -> Callable[
|
|
108
|
+
[Callable[P, Awaitable[R]]],
|
|
109
|
+
Callable[P, Awaitable[Result[R, Any]]],
|
|
110
|
+
]:
|
|
111
|
+
"""Async variant of ``@safe_with``. Never catches ``CancelledError``."""
|
|
112
|
+
if not exc_types:
|
|
113
|
+
raise TypeError("safe_async_with requires at least one exception type")
|
|
114
|
+
|
|
115
|
+
def decorator(
|
|
116
|
+
func: Callable[P, Awaitable[R]],
|
|
117
|
+
) -> Callable[P, Awaitable[Result[R, BaseException]]]:
|
|
118
|
+
@functools.wraps(func)
|
|
119
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, BaseException]:
|
|
120
|
+
try:
|
|
121
|
+
return Ok(await func(*args, **kwargs))
|
|
122
|
+
except asyncio.CancelledError:
|
|
123
|
+
raise
|
|
124
|
+
except exc_types as e:
|
|
125
|
+
return Err(e)
|
|
126
|
+
|
|
127
|
+
return wrapper
|
|
128
|
+
|
|
129
|
+
return decorator
|