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/machine.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Emit XState MachineJSON from a function's IR nodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from errpath.ir import (
|
|
8
|
+
CATCH_ANY,
|
|
9
|
+
Branch,
|
|
10
|
+
Fail,
|
|
11
|
+
Function,
|
|
12
|
+
Jump,
|
|
13
|
+
Loop,
|
|
14
|
+
Node,
|
|
15
|
+
Panic,
|
|
16
|
+
Recover,
|
|
17
|
+
Step,
|
|
18
|
+
Succeed,
|
|
19
|
+
Swallow,
|
|
20
|
+
escaping,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _safe_id(name: str, used: set[str]) -> str:
|
|
25
|
+
base = "".join(c if c.isalnum() or c in "_-" else "_" for c in name).strip("_") or "state"
|
|
26
|
+
if base[0].isdigit():
|
|
27
|
+
base = f"s_{base}"
|
|
28
|
+
candidate = base
|
|
29
|
+
n = 2
|
|
30
|
+
while candidate in used:
|
|
31
|
+
candidate = f"{base}_{n}"
|
|
32
|
+
n += 1
|
|
33
|
+
used.add(candidate)
|
|
34
|
+
return candidate
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _event_name(label: str) -> str:
|
|
38
|
+
cleaned = "".join(c if c.isalnum() or c in "_-" else "_" for c in label).strip("_")
|
|
39
|
+
return cleaned[:40] or "arm"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def function_to_machine(fn: Function) -> dict[str, Any]:
|
|
43
|
+
"""Build data-only MachineJSON suitable for autovisualizer ``definition``."""
|
|
44
|
+
used: set[str] = set()
|
|
45
|
+
states: dict[str, Any] = {}
|
|
46
|
+
error_finals: dict[str, str] = {}
|
|
47
|
+
handlers: dict[str, str] = {} # "<try tag>#<arm index>" -> handler entry state
|
|
48
|
+
counters = {"step": 0}
|
|
49
|
+
|
|
50
|
+
def error_state(label: str) -> str:
|
|
51
|
+
if label not in error_finals:
|
|
52
|
+
sid = _safe_id(label, used)
|
|
53
|
+
error_finals[label] = sid
|
|
54
|
+
states[sid] = {"type": "final", "tags": ["error"]}
|
|
55
|
+
return error_finals[label]
|
|
56
|
+
|
|
57
|
+
success_id = _safe_id("Success", used)
|
|
58
|
+
states[success_id] = {"type": "final"}
|
|
59
|
+
|
|
60
|
+
def emit(nodes: list[Node], join: str) -> str:
|
|
61
|
+
def at(index: int) -> str:
|
|
62
|
+
if index >= len(nodes):
|
|
63
|
+
return join
|
|
64
|
+
node = nodes[index]
|
|
65
|
+
rest = at(index + 1)
|
|
66
|
+
|
|
67
|
+
if isinstance(node, Step):
|
|
68
|
+
counters["step"] += 1
|
|
69
|
+
sid = _safe_id(node.name or f"step_{counters['step']}", used)
|
|
70
|
+
states[sid] = {"on": {"ok": {"target": rest}, **error_events(node, None)}}
|
|
71
|
+
return sid
|
|
72
|
+
|
|
73
|
+
if isinstance(node, Succeed):
|
|
74
|
+
return success_id
|
|
75
|
+
|
|
76
|
+
if isinstance(node, Fail):
|
|
77
|
+
return error_state(node.error or "Error")
|
|
78
|
+
|
|
79
|
+
if isinstance(node, Panic):
|
|
80
|
+
panic_sid = _safe_id(node.name or "panic", used)
|
|
81
|
+
# a raise exits with its own type; an unwrap outside @safe is a Panic
|
|
82
|
+
on_panic = error_events(
|
|
83
|
+
node, node.error if node.terminal and node.error else "Panic"
|
|
84
|
+
)
|
|
85
|
+
if not node.terminal:
|
|
86
|
+
on_panic["ok"] = {"target": rest}
|
|
87
|
+
states[panic_sid] = {"tags": ["error"], "on": on_panic}
|
|
88
|
+
return panic_sid
|
|
89
|
+
|
|
90
|
+
if isinstance(node, Swallow):
|
|
91
|
+
sid = _safe_id(f"swallow_{node.name}", used)
|
|
92
|
+
states[sid] = {
|
|
93
|
+
"on": {"ok": {"target": rest}},
|
|
94
|
+
"description": f"swallowed ({node.how})",
|
|
95
|
+
}
|
|
96
|
+
return sid
|
|
97
|
+
|
|
98
|
+
if isinstance(node, Recover):
|
|
99
|
+
sid = _safe_id(f"recover_{node.name}", used)
|
|
100
|
+
states[sid] = {"on": {"ok": {"target": rest}}, "description": node.how}
|
|
101
|
+
return sid
|
|
102
|
+
|
|
103
|
+
if isinstance(node, Jump):
|
|
104
|
+
sid = _safe_id(node.name, used)
|
|
105
|
+
states[sid] = {"on": {"ok": {"target": rest}}}
|
|
106
|
+
return sid
|
|
107
|
+
|
|
108
|
+
if isinstance(node, Branch):
|
|
109
|
+
sid = _safe_id("branch", used)
|
|
110
|
+
targets: dict[int, str] = {}
|
|
111
|
+
# try: emit handlers first so steps in the body can point their errors at them
|
|
112
|
+
order = [*range(1, len(node.arms)), 0] if node.tag else range(len(node.arms))
|
|
113
|
+
for i in order:
|
|
114
|
+
targets[i] = emit(list(node.arms[i].body), rest)
|
|
115
|
+
if node.tag and i > 0:
|
|
116
|
+
handlers[f"{node.tag}#{i}"] = targets[i]
|
|
117
|
+
on: dict[str, Any] = {}
|
|
118
|
+
for i, arm in enumerate(node.arms):
|
|
119
|
+
event = arm.label if arm.label in {"yes", "no"} else _event_name(arm.label)
|
|
120
|
+
on[event] = {"target": targets[i]}
|
|
121
|
+
states[sid] = {"on": on, "description": node.label}
|
|
122
|
+
return sid
|
|
123
|
+
|
|
124
|
+
if isinstance(node, Loop):
|
|
125
|
+
sid = _safe_id("loop", used)
|
|
126
|
+
body_entry = emit(list(node.body), sid)
|
|
127
|
+
states[sid] = {
|
|
128
|
+
"on": {
|
|
129
|
+
"next": {"target": body_entry},
|
|
130
|
+
"done": {"target": rest},
|
|
131
|
+
},
|
|
132
|
+
"description": node.label,
|
|
133
|
+
}
|
|
134
|
+
return sid
|
|
135
|
+
|
|
136
|
+
return rest
|
|
137
|
+
|
|
138
|
+
return at(0)
|
|
139
|
+
|
|
140
|
+
def error_events(node: Step | Panic, escape_label: str | None) -> dict[str, Any]:
|
|
141
|
+
"""``err_<Type>`` per caught type to its handler; ``err`` for what escapes.
|
|
142
|
+
|
|
143
|
+
Each goes through its ``finally`` cleanup first.
|
|
144
|
+
"""
|
|
145
|
+
on: dict[str, Any] = {}
|
|
146
|
+
for key, catch in node.caught_by.items():
|
|
147
|
+
handler = handlers[catch.handler]
|
|
148
|
+
event = "err_any" if key == CATCH_ANY else f"err_{_event_name(key)}"
|
|
149
|
+
on[event] = {"target": emit(list(catch.cleanup), handler) if catch.cleanup else handler}
|
|
150
|
+
left = escaping(node.error, node.caught_by)
|
|
151
|
+
if left is not None:
|
|
152
|
+
label = escape_label or left
|
|
153
|
+
target = emit([*node.cleanup, Fail(error=label)], error_state(label))
|
|
154
|
+
on["err"] = {"target": target}
|
|
155
|
+
return on
|
|
156
|
+
|
|
157
|
+
initial = emit(list(fn.nodes), success_id)
|
|
158
|
+
if initial not in states:
|
|
159
|
+
initial = success_id
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"id": fn.name,
|
|
163
|
+
"initial": initial,
|
|
164
|
+
"states": states,
|
|
165
|
+
}
|
errpath/py.typed
ADDED
|
File without changes
|
errpath/result.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Ok / Err Result type inspired by Rust and safe-result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import traceback
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Generic, Literal, NoReturn, TypeGuard, TypeVar
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
# Type checkers ship typing_extensions, so this needs no runtime dependency.
|
|
12
|
+
from typing_extensions import TypeIs
|
|
13
|
+
else:
|
|
14
|
+
# A runtime name too, so typing.get_type_hints(ok) works.
|
|
15
|
+
try:
|
|
16
|
+
from typing import TypeIs # Python 3.13+
|
|
17
|
+
except ImportError:
|
|
18
|
+
try:
|
|
19
|
+
from typing_extensions import TypeIs
|
|
20
|
+
except ImportError:
|
|
21
|
+
TypeIs = TypeGuard # only seen by runtime introspection; checkers use the line above
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
E = TypeVar("E")
|
|
25
|
+
# Results can't be changed, so they are covariant like tuple: an Err[KeyError] is also an
|
|
26
|
+
# Err[KeyError | ValueError], and an early `return err` type-checks against a wider Result.
|
|
27
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
28
|
+
E_co = TypeVar("E_co", covariant=True)
|
|
29
|
+
R = TypeVar("R")
|
|
30
|
+
U = TypeVar("U")
|
|
31
|
+
F = TypeVar("F")
|
|
32
|
+
ExcT = TypeVar("ExcT", bound=BaseException)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Callbacks a variant ignores take Any: on a Result, checkers test the callback against
|
|
36
|
+
# both Ok and Err, and an `object` parameter would turn `lambda v: v * 2` into an error.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Frozen:
|
|
40
|
+
"""Results can't be changed after they're made: they are hashable, so a change
|
|
41
|
+
would silently break any set or dict that holds them."""
|
|
42
|
+
|
|
43
|
+
__slots__ = ()
|
|
44
|
+
|
|
45
|
+
def __setattr__(self, name: str, _: object) -> NoReturn:
|
|
46
|
+
kind = type(self).__name__
|
|
47
|
+
raise AttributeError(f"{kind} can't be changed; make a new one: {kind}(...)")
|
|
48
|
+
|
|
49
|
+
def __delattr__(self, name: str) -> NoReturn:
|
|
50
|
+
raise AttributeError(f"{type(self).__name__} can't be changed")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Ok(_Frozen, Generic[T_co]):
|
|
54
|
+
"""A successful result. Read the value with ``.value``."""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("value",)
|
|
57
|
+
__match_args__ = ("value",)
|
|
58
|
+
|
|
59
|
+
value: T_co
|
|
60
|
+
|
|
61
|
+
def __init__(self, value: T_co) -> None:
|
|
62
|
+
object.__setattr__(self, "value", value)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def error(self) -> None:
|
|
66
|
+
"""Always ``None`` on ``Ok``."""
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def is_ok(self) -> Literal[True]:
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
def is_err(self) -> Literal[False]:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def unwrap(self) -> T_co:
|
|
76
|
+
"""Return the value. (On ``Err`` this raises the error.)"""
|
|
77
|
+
return self.value
|
|
78
|
+
|
|
79
|
+
def unwrap_or(self, default: object) -> T_co: # noqa: ARG002 - same signature as Err
|
|
80
|
+
"""Return the value. (On ``Err`` this returns ``default``.)"""
|
|
81
|
+
return self.value
|
|
82
|
+
|
|
83
|
+
def map(self, func: Callable[[T_co], R]) -> Ok[R]:
|
|
84
|
+
"""Change the value with ``func``. (On ``Err`` this does nothing.)"""
|
|
85
|
+
return Ok(func(self.value))
|
|
86
|
+
|
|
87
|
+
async def map_async(self, func: Callable[[T_co], Awaitable[R]]) -> Ok[R]:
|
|
88
|
+
"""Like ``map``, for an async ``func``."""
|
|
89
|
+
return Ok(await func(self.value))
|
|
90
|
+
|
|
91
|
+
def map_err(self, func: Callable[[Any], Any]) -> Ok[T_co]: # noqa: ARG002 - same signature as Err
|
|
92
|
+
"""Change the error with ``func``. ``Ok`` has no error, so this returns it unchanged."""
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def and_then(self, func: Callable[[T_co], Result[R, E]]) -> Result[R, E]:
|
|
96
|
+
"""Run the next step, ``func``, which can itself fail. (On ``Err`` it is skipped.)"""
|
|
97
|
+
return func(self.value)
|
|
98
|
+
|
|
99
|
+
async def and_then_async(self, func: Callable[[T_co], Awaitable[Result[R, E]]]) -> Result[R, E]:
|
|
100
|
+
"""Like ``and_then``, for an async ``func``."""
|
|
101
|
+
return await func(self.value)
|
|
102
|
+
|
|
103
|
+
def flatten(self) -> Result[T_co, E]:
|
|
104
|
+
"""Turn ``Ok(Ok(x))`` into ``Ok(x)`` and ``Ok(Err(e))`` into ``Err(e)``."""
|
|
105
|
+
result: Result[Any, Any] = self
|
|
106
|
+
while isinstance(result, Ok) and isinstance(result.value, (Ok, Err)):
|
|
107
|
+
result = result.value
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def __get_pydantic_core_schema__(cls, source: Any, handler: Any) -> Any:
|
|
112
|
+
from errpath._pydantic import result_schema # only loaded when Pydantic asks
|
|
113
|
+
|
|
114
|
+
return result_schema(cls, source, handler, ok=True)
|
|
115
|
+
|
|
116
|
+
def __reduce__(self) -> tuple[type[Ok[T_co]], tuple[T_co]]:
|
|
117
|
+
return (Ok, (self.value,)) # copy and pickle rebuild via __init__, not setattr
|
|
118
|
+
|
|
119
|
+
def __deepcopy__(self, memo: dict[int, object]) -> Ok[T_co]:
|
|
120
|
+
# Register the copy before copying the value, so a value that refers back
|
|
121
|
+
# to this result points at the copy instead of making a second one.
|
|
122
|
+
new = object.__new__(type(self))
|
|
123
|
+
memo[id(self)] = new
|
|
124
|
+
object.__setattr__(new, "value", copy.deepcopy(self.value, memo))
|
|
125
|
+
return new
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
return f"Ok({self.value!r})"
|
|
129
|
+
|
|
130
|
+
def __eq__(self, other: object) -> bool:
|
|
131
|
+
return isinstance(other, Ok) and other.value == self.value
|
|
132
|
+
|
|
133
|
+
def __hash__(self) -> int:
|
|
134
|
+
return hash((True, self.value))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Err(_Frozen, Generic[E_co]):
|
|
138
|
+
"""A failed result. Read the error with ``.error``."""
|
|
139
|
+
|
|
140
|
+
__slots__ = ("error",)
|
|
141
|
+
__match_args__ = ("error",)
|
|
142
|
+
|
|
143
|
+
error: E_co
|
|
144
|
+
|
|
145
|
+
def __init__(self, error: E_co) -> None:
|
|
146
|
+
object.__setattr__(self, "error", error)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def value(self) -> None:
|
|
150
|
+
"""Always ``None`` on ``Err``."""
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def is_ok(self) -> Literal[False]:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
def is_err(self) -> Literal[True]:
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
def unwrap(self: Err[BaseException]) -> NoReturn:
|
|
160
|
+
"""Raise the error (as itself, so ``except`` and ``@safe`` see its real type).
|
|
161
|
+
|
|
162
|
+
Only for exception errors: Python can't raise a ``str`` or a model, so type
|
|
163
|
+
checkers reject ``.unwrap()`` on, say, ``Result[int, str]``. Use ``match``,
|
|
164
|
+
``unwrap_or(default)``, or ``map_err`` to turn the error into an exception first.
|
|
165
|
+
"""
|
|
166
|
+
if isinstance(self.error, BaseException):
|
|
167
|
+
raise self.error
|
|
168
|
+
# Unchecked code can still get here; fail loudly with what to do instead.
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f"unwrap() was called on {self!r}, whose error isn't an exception. "
|
|
171
|
+
"Use match, unwrap_or(default), or map_err(...) to turn it into one."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def unwrap_or(self, default: U) -> U:
|
|
175
|
+
"""Return ``default``, because there is no value."""
|
|
176
|
+
return default
|
|
177
|
+
|
|
178
|
+
def map(self, _: Callable[[Any], Any]) -> Err[E_co]:
|
|
179
|
+
"""Change the value. ``Err`` has no value, so this returns it unchanged."""
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
async def map_async(self, _: Callable[[Any], Awaitable[Any]]) -> Err[E_co]:
|
|
183
|
+
"""Like ``map``, for an async function."""
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
def map_err(self, func: Callable[[E_co], F]) -> Err[F]:
|
|
187
|
+
"""Change the error with ``func``, e.g. wrap a low-level error in your own type."""
|
|
188
|
+
return Err(func(self.error))
|
|
189
|
+
|
|
190
|
+
def and_then(self, _: Callable[[Any], Any]) -> Err[E_co]:
|
|
191
|
+
"""Skip the next step, because this already failed."""
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
async def and_then_async(self, _: Callable[[Any], Awaitable[Any]]) -> Err[E_co]:
|
|
195
|
+
"""Like ``and_then``, for an async function."""
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def flatten(self) -> Err[E_co]:
|
|
199
|
+
"""Already flat: returns itself."""
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def __get_pydantic_core_schema__(cls, source: Any, handler: Any) -> Any:
|
|
204
|
+
from errpath._pydantic import result_schema # only loaded when Pydantic asks
|
|
205
|
+
|
|
206
|
+
return result_schema(cls, source, handler, ok=False)
|
|
207
|
+
|
|
208
|
+
def __reduce__(self) -> tuple[type[Err[E_co]], tuple[E_co]]:
|
|
209
|
+
return (Err, (self.error,)) # copy and pickle rebuild via __init__, not setattr
|
|
210
|
+
|
|
211
|
+
def __deepcopy__(self, memo: dict[int, object]) -> Err[E_co]:
|
|
212
|
+
new = object.__new__(type(self)) # registered first, as in Ok.__deepcopy__
|
|
213
|
+
memo[id(self)] = new
|
|
214
|
+
object.__setattr__(new, "error", copy.deepcopy(self.error, memo))
|
|
215
|
+
return new
|
|
216
|
+
|
|
217
|
+
def __repr__(self) -> str:
|
|
218
|
+
return f"Err({self.error!r})"
|
|
219
|
+
|
|
220
|
+
def __eq__(self, other: object) -> bool:
|
|
221
|
+
return isinstance(other, Err) and other.error == self.error
|
|
222
|
+
|
|
223
|
+
def __hash__(self) -> int:
|
|
224
|
+
return hash((False, self.error))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
Result = Ok[T] | Err[E]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def ok(result: Result[T, E]) -> TypeIs[Ok[T]]:
|
|
231
|
+
"""True when ``result`` succeeded.
|
|
232
|
+
|
|
233
|
+
Type checkers narrow both branches: after ``if ok(r):`` you get ``r.value``
|
|
234
|
+
with its real type, and in the ``else`` branch ``r.error`` with its real type.
|
|
235
|
+
"""
|
|
236
|
+
return isinstance(result, Ok)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def err_type(result: object, exc_type: type[ExcT]) -> TypeGuard[Err[ExcT]]:
|
|
240
|
+
"""True when ``result`` failed with ``exc_type`` (or a subclass of it).
|
|
241
|
+
|
|
242
|
+
After ``if err_type(r, KeyError):`` type checkers know ``r.error`` is a ``KeyError``.
|
|
243
|
+
"""
|
|
244
|
+
return isinstance(result, Err) and isinstance(result.error, exc_type)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def traceback_of(result: object) -> str:
|
|
248
|
+
"""Formatted traceback if ``result`` is an ``Err`` holding an Exception."""
|
|
249
|
+
if not isinstance(result, Err):
|
|
250
|
+
return ""
|
|
251
|
+
error = result.error
|
|
252
|
+
if not isinstance(error, BaseException):
|
|
253
|
+
return ""
|
|
254
|
+
return "".join(traceback.format_exception(type(error), error, error.__traceback__))
|