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/cli.py ADDED
@@ -0,0 +1,141 @@
1
+ """errpath CLI: ir | machine | diagram | review."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from errpath.analyse import analyse_path
11
+ from errpath.diagram import render_function
12
+ from errpath.ir import Function, to_dict
13
+ from errpath.machine import function_to_machine
14
+ from errpath.review import review_to_dict
15
+ from errpath.review import run as run_review
16
+
17
+
18
+ def _find_function(report_functions: list[Function], name: str) -> Function | None:
19
+ matches = [
20
+ f for f in report_functions if f.name == name or f.id == name or f.id.endswith("." + name)
21
+ ]
22
+ if not matches:
23
+ return None
24
+ if len(matches) > 1:
25
+ # Prefer exact name match in the given file scope
26
+ exact = [f for f in matches if f.name == name]
27
+ return exact[0] if exact else matches[0]
28
+ return matches[0]
29
+
30
+
31
+ def _emit(text: str, output: str | None) -> int:
32
+ if output:
33
+ Path(output).write_text(text + "\n", encoding="utf-8")
34
+ else:
35
+ print(text)
36
+ return 0
37
+
38
+
39
+ def _load_function(args: argparse.Namespace) -> Function | None:
40
+ report = analyse_path(Path(args.path))
41
+ fn = _find_function(report.functions, args.function)
42
+ if fn is None:
43
+ names = ", ".join(f.name for f in report.functions) or "(none)"
44
+ print(f"function {args.function!r} not found; have: {names}", file=sys.stderr)
45
+ return None
46
+ for u in report.unanalysed: # say what the output can't show, on stderr
47
+ if u.function == fn.id:
48
+ print(f"errpath: note: {u.file}:{u.line}: {u.reason}: {u.code}", file=sys.stderr)
49
+ return fn
50
+
51
+
52
+ def cmd_ir(args: argparse.Namespace) -> int:
53
+ report = analyse_path(Path(args.path))
54
+ return _emit(json.dumps(to_dict(report), indent=2), args.output)
55
+
56
+
57
+ def cmd_machine(args: argparse.Namespace) -> int:
58
+ fn = _load_function(args)
59
+ if fn is None:
60
+ return 1
61
+ return _emit(json.dumps(function_to_machine(fn), indent=2), args.output)
62
+
63
+
64
+ def cmd_diagram(args: argparse.Namespace) -> int:
65
+ fn = _load_function(args)
66
+ if fn is None:
67
+ return 1
68
+ return _emit(render_function(fn), args.output)
69
+
70
+
71
+ def cmd_review(args: argparse.Namespace) -> int:
72
+ review = run_review(
73
+ repo=Path(args.repo or ".").resolve(),
74
+ base=args.base,
75
+ head=args.head,
76
+ paths=args.paths or [],
77
+ diagrams=args.diagrams,
78
+ )
79
+ if args.format == "json":
80
+ text = json.dumps(review_to_dict(review), indent=2)
81
+ else:
82
+ text = review.markdown or review.summary
83
+ return _emit(text, args.output)
84
+
85
+
86
+ def build_parser() -> argparse.ArgumentParser:
87
+ parser = argparse.ArgumentParser(
88
+ prog="errpath",
89
+ description="Result type helpers and error-path analysis for Python",
90
+ )
91
+ sub = parser.add_subparsers(dest="command", required=True)
92
+
93
+ ir_p = sub.add_parser("ir", help="Emit versioned IR JSON for a path")
94
+ ir_p.add_argument("path", nargs="?", default=".", help="File or directory")
95
+ ir_p.add_argument("-o", "--output", help="Write to file instead of stdout")
96
+ ir_p.set_defaults(func=cmd_ir)
97
+
98
+ machine_p = sub.add_parser("machine", help="Emit XState MachineJSON for a function")
99
+ machine_p.add_argument("path", help="File or directory")
100
+ machine_p.add_argument("function", help="Function name")
101
+ machine_p.add_argument("-o", "--output", help="Write to file instead of stdout")
102
+ machine_p.set_defaults(func=cmd_machine)
103
+
104
+ diagram_p = sub.add_parser("diagram", help="Emit Mermaid flowchart for a function")
105
+ diagram_p.add_argument("path", help="File or directory")
106
+ diagram_p.add_argument("function", help="Function name")
107
+ diagram_p.add_argument("-o", "--output", help="Write to file instead of stdout")
108
+ diagram_p.set_defaults(func=cmd_diagram)
109
+
110
+ review_p = sub.add_parser("review", help="Compare error paths between git refs")
111
+ review_p.add_argument("paths", nargs="*", help="Optional paths to limit the review")
112
+ review_p.add_argument("--base", required=True, help="Base git ref")
113
+ review_p.add_argument("--head", default="HEAD", help="Head git ref (default HEAD)")
114
+ review_p.add_argument("--repo", default=".", help="Git repository root")
115
+ review_p.add_argument(
116
+ "--diagrams",
117
+ choices=("open", "collapsed"),
118
+ default="collapsed",
119
+ help="Mermaid diagrams in markdown",
120
+ )
121
+ review_p.add_argument(
122
+ "--format",
123
+ choices=("markdown", "json"),
124
+ default="markdown",
125
+ help="Output format",
126
+ )
127
+ review_p.add_argument("-o", "--output", help="Write to file instead of stdout")
128
+ review_p.set_defaults(func=cmd_review)
129
+
130
+ return parser
131
+
132
+
133
+ def main(argv: list[str] | None = None) -> None:
134
+ parser = build_parser()
135
+ args = parser.parse_args(argv)
136
+ code = args.func(args)
137
+ raise SystemExit(code)
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
errpath/diagram.py ADDED
@@ -0,0 +1,135 @@
1
+ """Render Mermaid flowchart LR from a function's IR nodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from errpath.ir import (
6
+ CATCH_ANY,
7
+ Branch,
8
+ Fail,
9
+ Function,
10
+ Jump,
11
+ Loop,
12
+ Node,
13
+ Panic,
14
+ Recover,
15
+ Step,
16
+ Succeed,
17
+ Swallow,
18
+ escaping,
19
+ )
20
+
21
+
22
+ def _text(label: str) -> str:
23
+ return label.replace('"', "'")
24
+
25
+
26
+ def render(fn_name: str, nodes: list[Node]) -> str:
27
+ """Mermaid flowchart of ``nodes``.
28
+
29
+ Built back to front, like the state machine: what follows a fork is drawn once and
30
+ every arm points at it, and each error type gets one shared box.
31
+ """
32
+ lines = ["flowchart LR", f' Start(["{_text(fn_name)}"])', " Done((Success))"]
33
+ counter = {"n": 0}
34
+ handlers: dict[str, str] = {} # "<try tag>#<arm index>" -> handler entry node
35
+ errors: dict[str, str] = {} # error label -> its box
36
+
37
+ def add(prefix: str, shape: str) -> str:
38
+ counter["n"] += 1
39
+ node_id = f"{prefix}{counter['n']}"
40
+ lines.append(f" {node_id}{shape}")
41
+ return node_id
42
+
43
+ def link(src: str, label: str, dst: str) -> None:
44
+ if not label:
45
+ lines.append(f" {src} --> {dst}")
46
+ elif label.replace("_", "").replace(" ", "").isalnum():
47
+ lines.append(f" {src} -->|{label}| {dst}")
48
+ else:
49
+ lines.append(f' {src} -->|"{_text(label)}"| {dst}')
50
+
51
+ def error_box(label: str) -> str:
52
+ if label not in errors:
53
+ errors[label] = add("E", f'[/"{_text(label)}"/]')
54
+ return errors[label]
55
+
56
+ def error_edges(src: str, edge: str, node: Step | Panic, escape_label: str | None) -> None:
57
+ """One edge per caught type to its handler, one for what escapes; through finally first."""
58
+ for key, catch in node.caught_by.items():
59
+ label = "any" if key == CATCH_ANY else key
60
+ link(src, f"{edge} {label}", emit(list(catch.cleanup), handlers[catch.handler]))
61
+ left = escaping(node.error, node.caught_by)
62
+ if left is not None:
63
+ link(src, edge, emit([*node.cleanup, Fail(error=escape_label or left)], "Done"))
64
+
65
+ def emit(nodes: list[Node], join: str) -> str:
66
+ """Draw ``nodes``, falling through to ``join``; return the first node's id."""
67
+ if not nodes:
68
+ return join
69
+ node, after = nodes[0], nodes[1:]
70
+ if isinstance(node, Succeed):
71
+ return "Done"
72
+ if isinstance(node, Fail):
73
+ return error_box(node.error or "Error")
74
+ rest = emit(after, join)
75
+
76
+ if isinstance(node, Step):
77
+ sid = add("S", f'["{_text(node.name)}"]')
78
+ link(sid, "ok", rest)
79
+ error_edges(sid, "err", node, None)
80
+ return sid
81
+
82
+ if isinstance(node, Panic):
83
+ pid = add("P", f'["{_text(node.name)}"]')
84
+ if not node.terminal:
85
+ link(pid, "ok", rest)
86
+ label = node.error if node.terminal and node.error else "Panic" # raise X: X
87
+ error_edges(pid, "raise" if node.terminal else "panic", node, label)
88
+ return pid
89
+
90
+ if isinstance(node, Swallow):
91
+ wid = add("W", f'["swallow {_text(node.name)}"]')
92
+ link(wid, "", rest)
93
+ return wid
94
+
95
+ if isinstance(node, Recover):
96
+ rid = add("R", f'["recover {_text(node.how)}"]')
97
+ link(rid, "", rest)
98
+ return rid
99
+
100
+ if isinstance(node, Jump):
101
+ jid = add("J", f'["{_text(node.name)}"]')
102
+ link(jid, "", rest)
103
+ return jid
104
+
105
+ if isinstance(node, Branch):
106
+ did = add("D", f'{{"{_text(node.label)}"}}')
107
+ targets: dict[int, str] = {}
108
+ # try: draw handlers first so steps in the body can point their errors at them
109
+ order = [*range(1, len(node.arms)), 0] if node.tag else range(len(node.arms))
110
+ for i in order:
111
+ targets[i] = emit(list(node.arms[i].body), rest)
112
+ if node.tag and i > 0:
113
+ handlers[f"{node.tag}#{i}"] = targets[i]
114
+ for i, arm in enumerate(node.arms):
115
+ link(did, arm.label, targets[i])
116
+ return did
117
+
118
+ if isinstance(node, Loop):
119
+ lid = add("L", f'["{_text(node.label)}"]')
120
+ if node.body:
121
+ link(lid, "next", emit(list(node.body), lid)) # the body loops back
122
+ link(lid, "done", rest)
123
+ return lid
124
+
125
+ return rest
126
+
127
+ link("Start", "", emit(nodes, "Done"))
128
+ if errors:
129
+ lines.append(" classDef error fill:#fdecea,stroke:#d93025,color:#a50e0e")
130
+ lines.append(f" class {','.join(errors.values())} error")
131
+ return "\n".join(lines)
132
+
133
+
134
+ def render_function(fn: Function) -> str:
135
+ return render(fn.name, fn.nodes)
errpath/ir.py ADDED
@@ -0,0 +1,274 @@
1
+ """Versioned error-path IR (aligned with Rust errpath schema_version 1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from enum import Enum, StrEnum
7
+ from typing import Any, Literal
8
+
9
+ SCHEMA_VERSION = 1
10
+ NO_PANIC = "no-panic"
11
+ NO_SWALLOW = "no-swallow"
12
+
13
+
14
+ class Confidence(StrEnum):
15
+ CERTAIN = "certain"
16
+ LIKELY = "likely"
17
+ POSSIBLE = "possible"
18
+
19
+
20
+ class ErrorSource(StrEnum):
21
+ CALLEE = "callee"
22
+ ENCLOSING = "enclosing"
23
+ UNKNOWN = "unknown"
24
+
25
+
26
+ @dataclass
27
+ class Arm:
28
+ label: str
29
+ body: list[Node]
30
+
31
+
32
+ @dataclass
33
+ class Catch:
34
+ """Where one exception type raised by a step goes: an ``except`` arm of an enclosing try."""
35
+
36
+ handler: str # "<try tag>#<arm index>"
37
+ how: str # the handler's label, e.g. "except ValueError"
38
+ cleanup: list[Node] = field(default_factory=list) # inner finally nodes run before the handler
39
+
40
+
41
+ CATCH_ANY = "*" # caught_by key: an error of unknown type, caught by a catch-all handler
42
+
43
+
44
+ def error_members(error: str | None) -> list[str]:
45
+ """``"ValueError | KeyError"`` -> ``["ValueError", "KeyError"]``; unknown -> ``[]``."""
46
+ return [m.strip() for m in error.split("|")] if error else []
47
+
48
+
49
+ def escaping(error: str | None, caught_by: dict[str, Catch]) -> str | None:
50
+ """The part of ``error`` that no handler catches, or None when every part is caught."""
51
+ members = error_members(error)
52
+ if not members:
53
+ return None if CATCH_ANY in caught_by else (error or "Error")
54
+ left = [m for m in members if m not in caught_by]
55
+ return " | ".join(left) if left else None
56
+
57
+
58
+ @dataclass
59
+ class Step:
60
+ kind: Literal["step"] = "step"
61
+ name: str = ""
62
+ error: str | None = None
63
+ source: ErrorSource = ErrorSource.UNKNOWN
64
+ conversions: list[str] = field(default_factory=list)
65
+ line: int = 0
66
+ cleanup: list[Node] = field(default_factory=list) # finally nodes run on the error exit
67
+ raises: bool = False # error exits by exception (unwrap), so except can catch it
68
+ # exception type -> handler; for an unknown error, keyed by each handler type it may match
69
+ caught_by: dict[str, Catch] = field(default_factory=dict)
70
+
71
+
72
+ @dataclass
73
+ class Branch:
74
+ kind: Literal["branch"] = "branch"
75
+ label: str = ""
76
+ arms: list[Arm] = field(default_factory=list)
77
+ tag: str | None = None # set on try branches; arm 0 is the normal path, the rest are handlers
78
+
79
+
80
+ @dataclass
81
+ class Loop:
82
+ kind: Literal["loop"] = "loop"
83
+ label: str = ""
84
+ tag: str | None = None
85
+ can_skip: bool = True
86
+ head: list[Node] = field(default_factory=list)
87
+ body: list[Node] = field(default_factory=list)
88
+
89
+
90
+ @dataclass
91
+ class Scope:
92
+ kind: Literal["scope"] = "scope"
93
+ label: str = ""
94
+ body: list[Node] = field(default_factory=list)
95
+
96
+
97
+ @dataclass
98
+ class Fail:
99
+ kind: Literal["fail"] = "fail"
100
+ error: str = ""
101
+ line: int = 0
102
+
103
+
104
+ @dataclass
105
+ class Succeed:
106
+ kind: Literal["succeed"] = "succeed"
107
+
108
+
109
+ @dataclass
110
+ class Block:
111
+ kind: Literal["block"] = "block"
112
+ tag: str = ""
113
+ body: list[Node] = field(default_factory=list)
114
+
115
+
116
+ @dataclass
117
+ class Jump:
118
+ kind: Literal["jump"] = "jump"
119
+ name: str = ""
120
+ line: int = 0
121
+
122
+
123
+ @dataclass
124
+ class Convert:
125
+ kind: Literal["convert"] = "convert"
126
+ name: str = ""
127
+ conversions: list[str] = field(default_factory=list)
128
+ line: int = 0
129
+
130
+
131
+ @dataclass
132
+ class Panic:
133
+ kind: Literal["panic"] = "panic"
134
+ name: str = ""
135
+ terminal: bool = False
136
+ line: int = 0
137
+ allowed: str | None = None
138
+ cleanup: list[Node] = field(default_factory=list) # finally nodes run on the error exit
139
+ error: str | None = None # exception type raised, when known
140
+ caught_by: dict[str, Catch] = field(default_factory=dict) # as on Step
141
+
142
+
143
+ @dataclass
144
+ class Swallow:
145
+ kind: Literal["swallow"] = "swallow"
146
+ name: str = ""
147
+ how: str = ""
148
+ confidence: Confidence = Confidence.LIKELY
149
+ line: int = 0
150
+ allowed: str | None = None
151
+
152
+
153
+ @dataclass
154
+ class Recover:
155
+ kind: Literal["recover"] = "recover"
156
+ name: str = ""
157
+ how: str = ""
158
+ arms: list[str] = field(default_factory=list)
159
+ line: int = 0
160
+
161
+
162
+ Node = (
163
+ Step
164
+ | Branch
165
+ | Loop
166
+ | Scope
167
+ | Fail
168
+ | Succeed
169
+ | Block
170
+ | Jump
171
+ | Convert
172
+ | Panic
173
+ | Swallow
174
+ | Recover
175
+ )
176
+
177
+
178
+ @dataclass
179
+ class ReturnsResult:
180
+ kind: Literal["result"] = "result"
181
+ error: str = ""
182
+
183
+
184
+ @dataclass
185
+ class ReturnsOption:
186
+ kind: Literal["option"] = "option"
187
+
188
+
189
+ @dataclass
190
+ class ReturnsOther:
191
+ kind: Literal["other"] = "other"
192
+
193
+
194
+ Returns = ReturnsResult | ReturnsOption | ReturnsOther
195
+
196
+
197
+ @dataclass
198
+ class Function:
199
+ id: str
200
+ name: str
201
+ file: str
202
+ line: int
203
+ returns: Returns
204
+ nodes: list[Node] = field(default_factory=list)
205
+ macro_try: int = 0
206
+
207
+
208
+ @dataclass
209
+ class Finding:
210
+ rule: str
211
+ file: str
212
+ line: int
213
+ message: str
214
+ confidence: Confidence = Confidence.CERTAIN
215
+ function: str | None = None
216
+ allowed: str | None = None
217
+
218
+
219
+ @dataclass
220
+ class Coverage:
221
+ functions: int = 0
222
+ steps: int = 0
223
+ error_from_callee: int = 0
224
+ error_from_enclosing: int = 0
225
+ error_unknown: int = 0
226
+ macro_try: int = 0
227
+ item_macro_try: int = 0
228
+ unanalysed: int = 0
229
+
230
+
231
+ @dataclass
232
+ class Unanalysed:
233
+ """Code errpath saw but couldn't follow, so a diagram may miss a path there."""
234
+
235
+ file: str
236
+ line: int
237
+ function: str
238
+ code: str
239
+ reason: str
240
+
241
+
242
+ @dataclass
243
+ class ParseFailure:
244
+ file: str
245
+ message: str
246
+
247
+
248
+ @dataclass
249
+ class Report:
250
+ schema_version: int = SCHEMA_VERSION
251
+ coverage: Coverage = field(default_factory=Coverage)
252
+ functions: list[Function] = field(default_factory=list)
253
+ findings: list[Finding] = field(default_factory=list)
254
+ parse_failures: list[ParseFailure] = field(default_factory=list)
255
+ unanalysed: list[Unanalysed] = field(default_factory=list)
256
+ support_files_skipped: int = 0
257
+
258
+
259
+ def _enum_to_value(obj: Any) -> Any:
260
+ if isinstance(obj, Enum):
261
+ return obj.value
262
+ if isinstance(obj, list):
263
+ return [_enum_to_value(x) for x in obj]
264
+ if isinstance(obj, dict):
265
+ return {k: _enum_to_value(v) for k, v in obj.items()}
266
+ return obj
267
+
268
+
269
+ def to_dict(obj: Any) -> Any:
270
+ """Serialize dataclasses (and nested Enums) to JSON-ready dicts."""
271
+ if hasattr(obj, "__dataclass_fields__"):
272
+ raw = asdict(obj)
273
+ return _enum_to_value(raw)
274
+ return _enum_to_value(obj)