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/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__))