effecton 0.2.1__tar.gz → 0.3.1__tar.gz

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.
Files changed (44) hide show
  1. effecton-0.3.1/.gitignore +26 -0
  2. effecton-0.3.1/CHANGELOG.md +54 -0
  3. effecton-0.3.1/PKG-INFO +43 -0
  4. effecton-0.3.1/README.md +25 -0
  5. {effecton-0.2.1 → effecton-0.3.1}/pyproject.toml +6 -1
  6. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/__init__.py +26 -0
  7. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/effect.py +56 -5
  8. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/exit.py +9 -3
  9. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/pytest_plugin.py +23 -2
  10. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/run_async.py +7 -2
  11. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/run_sync.py +14 -3
  12. effecton-0.3.1/src/effecton/std/cli.py +658 -0
  13. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/std/clock.py +9 -4
  14. effecton-0.3.1/src/effecton/std/duration.py +32 -0
  15. effecton-0.3.1/src/effecton/std/file_system.py +816 -0
  16. effecton-0.3.1/src/effecton/std/http_client.py +372 -0
  17. effecton-0.3.1/src/effecton/std/path.py +68 -0
  18. effecton-0.3.1/src/effecton/std/process.py +69 -0
  19. effecton-0.3.1/src/effecton/std/random.py +119 -0
  20. effecton-0.3.1/src/effecton/std/retry.py +54 -0
  21. effecton-0.3.1/src/effecton/std/schedule.py +79 -0
  22. effecton-0.3.1/src/effecton/std/schema.py +816 -0
  23. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/std/timeout.py +6 -4
  24. effecton-0.3.1/src/effecton/std/tracer.py +289 -0
  25. effecton-0.2.1/.gitignore +0 -13
  26. effecton-0.2.1/CHANGELOG.md +0 -29
  27. effecton-0.2.1/PKG-INFO +0 -513
  28. effecton-0.2.1/README.md +0 -497
  29. {effecton-0.2.1 → effecton-0.3.1}/LICENSE +0 -0
  30. {effecton-0.2.1 → effecton-0.3.1}/conftest.py +0 -0
  31. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/attempt.py +0 -0
  32. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/catch.py +0 -0
  33. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/gen.py +0 -0
  34. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/implicit_requirement.py +0 -0
  35. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/provide.py +0 -0
  36. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/py.typed +0 -0
  37. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/run_main.py +0 -0
  38. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/std/__init__.py +0 -0
  39. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/std/fiber.py +0 -0
  40. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/std/logger.py +0 -0
  41. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/std/pretty_logger.py +0 -0
  42. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/std/race.py +0 -0
  43. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/std/scope.py +0 -0
  44. {effecton-0.2.1 → effecton-0.3.1}/src/effecton/suspend.py +0 -0
@@ -0,0 +1,26 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Copied from the repo root by the release workflow
13
+ packages/effecton/LICENSE
14
+
15
+ # Docs site (vocs)
16
+ docs/node_modules
17
+ docs/dist
18
+ docs/.vocs
19
+ docs/src/pages.gen.ts
20
+ # Generated by `uv run api-reference` (docs predev/prebuild)
21
+ docs/src/pages/api.md
22
+
23
+ # Agent working artifacts: implementation plans and SDD ledgers stay local.
24
+ # Design specs are committed under docs/specs instead (see AGENTS.md).
25
+ docs/superpowers/
26
+ .superpowers/
@@ -0,0 +1,54 @@
1
+ # effecton
2
+
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Add E.Schema: two-way schemas with Struct classes, combinators, guarded transforms (`to=`, `S.instance_of`) and `Check` refinements applied with `schema.check(...)`, whose decode and encode fail with a ParseError listing every issue ([#32](https://github.com/krzkaczor/effecton/pull/32))
8
+ - Add E.Cli: annotated Args classes decoded through E.Schema, nested commands, --help, --version and a UsageError carrying the reason; add E.Process.argv() ([#34](https://github.com/krzkaczor/effecton/pull/34))
9
+
10
+ ## 0.3.0
11
+
12
+ ### Minor Changes
13
+
14
+ - Launch the docs site: a landing page, an introduction, and a generated API Reference, with every Python snippet type-checked by ty at build time. ([#26](https://github.com/krzkaczor/effecton/pull/26))
15
+
16
+ ### Patch Changes
17
+
18
+ - Add the E.HttpClient service: SyncLive and AsyncLive on httpx2, Test with canned responses or a handler, Request/Response values and filter_status_ok; HTTP client libraries are banned outside the service ([#24](https://github.com/krzkaczor/effecton/pull/24))
19
+ - sleep, timeout and Schedule.spaced/exponential accept a duration as timedelta's keyword parts (seconds=5, minutes=1, ...) in place of a timedelta. ([#26](https://github.com/krzkaczor/effecton/pull/26))
20
+ - Add the `E.Tracer` service: `E.with_span(effect, name, kind=..., **attributes)` opens a span around an effect (also as `effect.with_span`), `E.annotate_current_span` adds attributes, `E.current_span` reads the open span, `E.Tracer.Live` is the in-memory default, `E.Tracer.Test` records its spans, and the `test_tracer` pytest fixture provides one ([#25](https://github.com/krzkaczor/effecton/pull/25))
21
+ - Add `effect.retry(schedule, until=...)` with `E.Schedule.recurs`, `E.Schedule.spaced`, and `E.Schedule.exponential` ([#19](https://github.com/krzkaczor/effecton/pull/19))
22
+ - Effect.retry takes an optional schedule and a `times` keyword, as in Effect-TS: `retry(times=3)` retries up to three times without waiting, `retry(schedule, times=5)` caps the schedule at five more recurrences, and `retry()` with neither recurs forever. ([#26](https://github.com/krzkaczor/effecton/pull/26))
23
+ - Add `schedule.jittered(min=0.8, max=1.2)`, which scales every delay by a factor drawn through `E.random()`; schedule steps are now effects, so build a plain custom schedule with `E.Schedule.from_delays(...)` and an effectful one with `E.Schedule(steps=...)` ([#22](https://github.com/krzkaczor/effecton/pull/22))
24
+ - Add the `E.FileSystem` service (`SyncLive`, `AsyncLive` on aiofiles, in-memory `Test`) the immutable `E.Path`, and the `E.Process` service (`cwd`, `home`); stdlib path and file APIs are banned outside the service ([#23](https://github.com/krzkaczor/effecton/pull/23))
25
+ - `on_exit` also accepts a function receiving the effect's `Exit` and returning the finalizer, so cleanup can depend on how the effect settled ([#25](https://github.com/krzkaczor/effecton/pull/25))
26
+ - Add the `E.Random` implicit service: `E.random()` resolves it, its methods keep the `random` module names (`random`, `uniform`, `randint`, `choice`, `shuffle`), `E.Random.Live` is the default, `E.Random.Test(seed)` is deterministic, and the `test_random` pytest fixture provides one seeded with 0 ([#22](https://github.com/krzkaczor/effecton/pull/22))
27
+
28
+ ## 0.2.1
29
+
30
+ ### Patch Changes
31
+
32
+ - Ship a pytest plugin, registered through the pytest11 entry point so it loads wherever effecton is installed. A test that returns an Effect (typically an @E.gen function) runs under the async runner and a failure is reported as its cause. The test_clock fixture provides an E.Clock.Test to the test's effect. ([#16](https://github.com/krzkaczor/effecton/pull/16))
33
+ - Add E.now() and E.sleep() backed by the implicit E.Clock service, with SyncLive, AsyncLive and Test clocks; each runner installs the matching live clock. ([#16](https://github.com/krzkaczor/effecton/pull/16))
34
+ - Add `E.run_main` to execute sync and async programs with failure logging, defect tracebacks, custom exit codes, and graceful signal handling. ([#14](https://github.com/krzkaczor/effecton/pull/14))
35
+ - Add E.fork, E.Fiber (join, wait, poll, interrupt) and E.yield_now backed by asyncio tasks, and make the Test clock's adjust and set_time return effects. ([#16](https://github.com/krzkaczor/effecton/pull/16))
36
+ - Add E.race_first(left, right) and E.timeout(duration) ([#18](https://github.com/krzkaczor/effecton/pull/18))
37
+
38
+ ## 0.2.0
39
+
40
+ ### Minor Changes
41
+
42
+ - Add `run_async`, `coroutine` and `attempt_async` for awaitable-backed effects ([#12](https://github.com/krzkaczor/effecton/pull/12))
43
+ - Split the runners: `run_sync` and `run_async` now return the value and raise on failure (the error, the defect, or `UnhandledDefect` for a non-exception defect); `run_sync_exit`, `run_async_exit` and the coroutine `run_async_coroutine` return the `Exit` ([#13](https://github.com/krzkaczor/effecton/pull/13))
44
+
45
+ ### Patch Changes
46
+
47
+ - Add `Effect.catch`: handle one error type and subtract it from the error channel. ([#10](https://github.com/krzkaczor/effecton/pull/10))
48
+ - Add `Interrupt` as a third `Cause` state, produced when a cancellation unwinds `run_async` ([#12](https://github.com/krzkaczor/effecton/pull/12))
49
+
50
+ ## 0.1.1
51
+
52
+ ### Patch Changes
53
+
54
+ - Migrate from mypy to ty. Simplify api where possible. ([#1](https://github.com/krzkaczor/effecton/pull/1))
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.5
2
+ Name: effecton
3
+ Version: 0.3.1
4
+ Summary: A typed effect system for Python, inspired by Effect-TS
5
+ Project-URL: Repository, https://github.com/krzkaczor/effecton
6
+ Author-email: Krzysztof Kaczor <chris@kaczor.io>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: dependency-injection,effect-system,effect-ts,functional,typed
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.14
14
+ Requires-Dist: aiofiles>=25.1
15
+ Requires-Dist: httpx2>=2.12
16
+ Requires-Dist: typing-extensions>=4.16.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ <p align="center">
20
+ <a href="https://effecton.dev">
21
+ <picture>
22
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/krzkaczor/effecton/main/docs/public/logo-wordmark-white.svg">
23
+ <img alt="effecton" src="https://raw.githubusercontent.com/krzkaczor/effecton/main/docs/public/logo-wordmark.svg" width="320">
24
+ </picture>
25
+ </a>
26
+ </p>
27
+
28
+ <p align="center">A typed effect system for Python, inspired by <a href="https://effect.website/">Effect-TS</a>.</p>
29
+
30
+ <p align="center">
31
+ <a href="https://pypi.org/project/effecton/"><img alt="PyPI" src="https://img.shields.io/pypi/v/effecton?logo=pypi&logoColor=white"></a>
32
+ <a href="https://discord.gg/fNhY7AxMyh"><img alt="Discord" src="https://img.shields.io/badge/Discord-join%20chat-5865F2?logo=discord&logoColor=white"></a>
33
+ </p>
34
+
35
+ <p align="center">
36
+ <a href="https://effecton.dev/introduction">Documentation</a> ·
37
+ <a href="https://effecton.dev/api">API Reference</a> ·
38
+ <a href="https://github.com/krzkaczor/effecton/blob/main/CONTRIBUTING.md">Contributing</a>
39
+ </p>
40
+
41
+ ```sh
42
+ uv add effecton
43
+ ```
@@ -0,0 +1,25 @@
1
+ <p align="center">
2
+ <a href="https://effecton.dev">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/krzkaczor/effecton/main/docs/public/logo-wordmark-white.svg">
5
+ <img alt="effecton" src="https://raw.githubusercontent.com/krzkaczor/effecton/main/docs/public/logo-wordmark.svg" width="320">
6
+ </picture>
7
+ </a>
8
+ </p>
9
+
10
+ <p align="center">A typed effect system for Python, inspired by <a href="https://effect.website/">Effect-TS</a>.</p>
11
+
12
+ <p align="center">
13
+ <a href="https://pypi.org/project/effecton/"><img alt="PyPI" src="https://img.shields.io/pypi/v/effecton?logo=pypi&logoColor=white"></a>
14
+ <a href="https://discord.gg/fNhY7AxMyh"><img alt="Discord" src="https://img.shields.io/badge/Discord-join%20chat-5865F2?logo=discord&logoColor=white"></a>
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="https://effecton.dev/introduction">Documentation</a> ·
19
+ <a href="https://effecton.dev/api">API Reference</a> ·
20
+ <a href="https://github.com/krzkaczor/effecton/blob/main/CONTRIBUTING.md">Contributing</a>
21
+ </p>
22
+
23
+ ```sh
24
+ uv add effecton
25
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "effecton"
3
- version = "0.2.1"
3
+ version = "0.3.1"
4
4
  description = "A typed effect system for Python, inspired by Effect-TS"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -14,6 +14,8 @@ classifiers = [
14
14
  ]
15
15
  requires-python = ">=3.14"
16
16
  dependencies = [
17
+ "aiofiles>=25.1",
18
+ "httpx2>=2.12",
17
19
  "typing-extensions>=4.16.0",
18
20
  ]
19
21
 
@@ -23,6 +25,9 @@ Repository = "https://github.com/krzkaczor/effecton"
23
25
  [project.entry-points.pytest11]
24
26
  effecton = "effecton.pytest_plugin"
25
27
 
28
+ [dependency-groups]
29
+ dev = ["types-aiofiles>=25.1"]
30
+
26
31
  [build-system]
27
32
  requires = ["hatchling"]
28
33
  build-backend = "hatchling.build"
@@ -28,7 +28,14 @@ from effecton.run_sync import (
28
28
  run_sync,
29
29
  run_sync_exit,
30
30
  )
31
+ from effecton.std import cli as Cli
31
32
  from effecton.std import clock as Clock
33
+ from effecton.std import file_system as FileSystem
34
+ from effecton.std import http_client as HttpClient
35
+ from effecton.std import process as Process
36
+ from effecton.std import random as Random
37
+ from effecton.std import schema as Schema
38
+ from effecton.std import tracer as Tracer
32
39
  from effecton.std.clock import _now as now
33
40
  from effecton.std.clock import _sleep as sleep
34
41
  from effecton.std.fiber import Fiber, fork, yield_now
@@ -50,15 +57,22 @@ from effecton.std.logger import (
50
57
  log_trace,
51
58
  log_warning,
52
59
  )
60
+ from effecton.std.path import Path
53
61
  from effecton.std.pretty_logger import PrettyFormatter, pretty_logger
54
62
  from effecton.std.race import race_first
63
+ from effecton.std.random import _random as random
64
+ from effecton.std.schedule import Schedule
55
65
  from effecton.std.scope import Scope, acquire_and_release, add_finalizer, scoped
56
66
  from effecton.std.timeout import TimeoutException, timeout
67
+ from effecton.std.tracer import _annotate_current_span as annotate_current_span
68
+ from effecton.std.tracer import _current_span as current_span
69
+ from effecton.std.tracer import _with_span as with_span
57
70
  from effecton.suspend import suspend
58
71
 
59
72
  __all__ = [
60
73
  "AsyncEffectInSyncRun",
61
74
  "Cause",
75
+ "Cli",
62
76
  "Clock",
63
77
  "CurrentLogAnnotations",
64
78
  "CurrentLogLevel",
@@ -72,24 +86,34 @@ __all__ = [
72
86
  "Fail",
73
87
  "Failure",
74
88
  "Fiber",
89
+ "FileSystem",
90
+ "HttpClient",
75
91
  "ImplicitRequirement",
76
92
  "Interrupt",
77
93
  "LogData",
78
94
  "LogLevel",
79
95
  "MinimumLogLevel",
80
96
  "MissingRequirement",
97
+ "Path",
81
98
  "PrettyFormatter",
99
+ "Process",
100
+ "Random",
101
+ "Schedule",
102
+ "Schema",
82
103
  "Scope",
83
104
  "Severity",
84
105
  "Succeeded",
85
106
  "TimeoutException",
107
+ "Tracer",
86
108
  "UnhandledDefect",
87
109
  "acquire_and_release",
88
110
  "add_finalizer",
111
+ "annotate_current_span",
89
112
  "annotate_logs",
90
113
  "attempt",
91
114
  "attempt_async",
92
115
  "coroutine",
116
+ "current_span",
93
117
  "die",
94
118
  "fail",
95
119
  "fork",
@@ -105,6 +129,7 @@ __all__ = [
105
129
  "pretty_logger",
106
130
  "provide_implicit",
107
131
  "race_first",
132
+ "random",
108
133
  "require",
109
134
  "require_implicit",
110
135
  "run_async",
@@ -119,5 +144,6 @@ __all__ = [
119
144
  "suspend",
120
145
  "sync",
121
146
  "timeout",
147
+ "with_span",
122
148
  "yield_now",
123
149
  ]
@@ -1,15 +1,19 @@
1
1
  from collections.abc import Awaitable, Callable, Generator
2
2
  from dataclasses import dataclass
3
3
  from datetime import timedelta
4
- from typing import TYPE_CHECKING, Any, Literal, Never, final
4
+ from typing import TYPE_CHECKING, Any, Literal, Never, Unpack, final, overload
5
5
 
6
6
  from typing_extensions import TypeForm
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from effecton.catch import CatchBinder
10
+ from effecton.exit import Exit, Failure, Succeeded
10
11
  from effecton.provide import ProvideBinder
12
+ from effecton.std.duration import Parts
13
+ from effecton.std.schedule import Schedule
11
14
  from effecton.std.scope import Scope
12
15
  from effecton.std.timeout import TimeoutException
16
+ from effecton.std.tracer import SpanKind
13
17
 
14
18
 
15
19
  @dataclass(frozen=True)
@@ -69,7 +73,32 @@ class Effect[A, E: EffectonError = Never, R = Never]:
69
73
 
70
74
  return CatchBinder(effect=self, error_type=error_type)
71
75
 
72
- def on_exit[R2](self, finalizer: Effect[Any, Never, R2]) -> Effect[A, E, R | R2]:
76
+ @overload
77
+ def on_exit[R2](
78
+ self, finalizer: Effect[Any, Never, R2]
79
+ ) -> Effect[A, E, R | R2]: ...
80
+
81
+ @overload
82
+ def on_exit[R2](
83
+ # Spelled out rather than Exit[A, E]: ty infers variance through
84
+ # the alias as invariant, which would break covariance in A and E.
85
+ self,
86
+ finalizer: Callable[[Succeeded[A] | Failure[E]], Effect[Any, Never, R2]],
87
+ ) -> Effect[A, E, R | R2]: ...
88
+
89
+ def on_exit(
90
+ self,
91
+ finalizer: Effect[Any, Any, Any] | Callable[[Any], Effect[Any, Any, Any]],
92
+ ) -> Effect[Any, Any, Any]:
93
+ """Run a finalizer once this effect settles, on any outcome.
94
+
95
+ The finalizer is an effect, or a function receiving the Exit
96
+ (Succeeded, or Failure carrying Fail, Die or Interrupt) that
97
+ returns one. A finalizer that fails or raises replaces the
98
+ outcome with its defect.
99
+ """
100
+ if isinstance(finalizer, Effect):
101
+ return OnExit(self, lambda _: finalizer)
73
102
  return OnExit(self, finalizer)
74
103
 
75
104
  def provide[T](self, requirement_type: TypeForm[T]) -> ProvideBinder[A, E, R, T]:
@@ -84,10 +113,32 @@ class Effect[A, E: EffectonError = Never, R = Never]:
84
113
 
85
114
  return scoped(self)
86
115
 
87
- def timeout(self, duration: timedelta) -> Effect[A, E | TimeoutException, R]:
116
+ def timeout(
117
+ self, duration: timedelta | None = None, **parts: Unpack[Parts]
118
+ ) -> Effect[A, E | TimeoutException, R]:
119
+ """Fail with TimeoutException after a timedelta or its parts (seconds=...)."""
88
120
  from effecton.std.timeout import timeout
89
121
 
90
- return timeout(duration)(self)
122
+ return timeout(duration, **parts)(self)
123
+
124
+ def retry(
125
+ self,
126
+ schedule: Schedule | None = None,
127
+ *,
128
+ times: int | None = None,
129
+ until: Callable[[E], bool] | None = None,
130
+ ) -> Effect[A, E, R]:
131
+ """Re-run on failure while the schedule recurs, at most times more times."""
132
+ from effecton.std.retry import retry
133
+
134
+ return retry(self, schedule, times=times, until=until)
135
+
136
+ def with_span(
137
+ self, name: str, *, kind: SpanKind = "internal", **attributes: object
138
+ ) -> Effect[A, E, R]:
139
+ from effecton.std.tracer import _with_span
140
+
141
+ return _with_span(self, name, kind=kind, **attributes)
91
142
 
92
143
  def __iter__(self) -> Generator[Effect[A, E, R], Any, A]:
93
144
  """Make ``x = yield from effect`` infer ``x`` as A inside @gen.
@@ -163,7 +214,7 @@ class ProvideRequirement[A, E: EffectonError, R](Effect[A, E, R]):
163
214
  @dataclass(frozen=True)
164
215
  class OnExit[A, E: EffectonError, R](Effect[A, E, R]):
165
216
  first: Effect[Any, Any, Any]
166
- finalizer: Effect[Any, Any, Any]
217
+ finalizer: Callable[[Exit[Any, Any]], Effect[Any, Any, Any]]
167
218
  kind: Literal["on_exit"] = "on_exit"
168
219
 
169
220
 
@@ -1,19 +1,25 @@
1
1
  from dataclasses import dataclass
2
- from typing import Any, Literal, Never, assert_never, final
2
+ from typing import Any, Generic, Literal, Never, TypeVar, assert_never, final
3
3
 
4
4
  from effecton.effect import Cause, Die, EffectonError, Fail, Interrupt
5
5
 
6
+ # Old-style TypeVars declare the covariance ty does not infer for a
7
+ # generic dataclass; without it, an Exit in an Effect method signature
8
+ # would make A and E invariant. See the ty inference notes in CLAUDE.md.
9
+ A = TypeVar("A", covariant=True)
10
+ E = TypeVar("E", bound=EffectonError, covariant=True, default=Never)
11
+
6
12
 
7
13
  @final
8
14
  @dataclass(frozen=True)
9
- class Succeeded[A]:
15
+ class Succeeded(Generic[A]): # noqa: UP046
10
16
  value: A
11
17
  kind: Literal["succeeded"] = "succeeded"
12
18
 
13
19
 
14
20
  @final
15
21
  @dataclass(frozen=True)
16
- class Failure[E: EffectonError = Never]:
22
+ class Failure(Generic[E]):
17
23
  cause: Cause[E]
18
24
  kind: Literal["failure"] = "failure"
19
25
 
@@ -6,7 +6,10 @@ effecton is installed. A test that returns an Effect, typically a
6
6
  failure raises its cause, so pytest reports a typed error, a defect or
7
7
  an interruption like any exception. A test that requests the test_clock
8
8
  fixture has that clock provided to its effect, so E.now(), E.sleep()
9
- and the clock's movers all see the same clock.
9
+ and the clock's movers all see the same clock; likewise test_random
10
+ provides a seeded Random, so every draw through E.random() is
11
+ reproducible, and test_tracer provides a recording Tracer, so every
12
+ span the test opens can be inspected afterwards.
10
13
  """
11
14
 
12
15
  import inspect
@@ -18,7 +21,7 @@ import pytest
18
21
  from effecton.effect import Effect
19
22
  from effecton.exit import unwrap
20
23
  from effecton.run_async import run_async_exit
21
- from effecton.std import clock
24
+ from effecton.std import clock, random, tracer
22
25
 
23
26
 
24
27
  @pytest.fixture
@@ -27,6 +30,18 @@ def test_clock() -> clock.Test:
27
30
  return clock.Test()
28
31
 
29
32
 
33
+ @pytest.fixture
34
+ def test_random() -> random.Test:
35
+ """A Random seeded with 0, provided to the test's effect."""
36
+ return random.Test()
37
+
38
+
39
+ @pytest.fixture
40
+ def test_tracer() -> tracer.Test:
41
+ """A recording Tracer, provided to the test's effect."""
42
+ return tracer.Test()
43
+
44
+
30
45
  @pytest.hookimpl(tryfirst=True)
31
46
  def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
32
47
  function = pyfuncitem.obj
@@ -42,6 +57,12 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
42
57
  if "test_clock" in arguments:
43
58
  provided = cast("clock.Test", arguments["test_clock"])
44
59
  effect = effect.provide(clock.Protocol)(provided)
60
+ if "test_random" in arguments:
61
+ seeded = cast("random.Test", arguments["test_random"])
62
+ effect = effect.provide(random.Protocol)(seeded)
63
+ if "test_tracer" in arguments:
64
+ recording = cast("tracer.Test", arguments["test_tracer"])
65
+ effect = effect.provide(tracer.Protocol)(recording)
45
66
  unwrap(run_async_exit(effect))
46
67
  elif result is not None:
47
68
  warnings.warn(
@@ -29,6 +29,7 @@ from effecton.run_sync import (
29
29
  OnExitFrame,
30
30
  RestoreEnv,
31
31
  default_or_die,
32
+ exit_of,
32
33
  run_fn_or_die,
33
34
  )
34
35
  from effecton.std import clock
@@ -179,7 +180,9 @@ async def _interpret[A, E: EffectonError](
179
180
  case OnExitFrame(finalizer):
180
181
  stack.append(Finalizing(outcome=current))
181
182
  finalizing += 1
182
- current = finalizer # ty: ignore[invalid-assignment]
183
+ current = guarded(
184
+ run_fn_or_die, finalizer, exit_of(current)
185
+ )
183
186
  break
184
187
  case Finalizing(outcome):
185
188
  finalizing -= 1
@@ -205,7 +208,9 @@ async def _interpret[A, E: EffectonError](
205
208
  case OnExitFrame(finalizer):
206
209
  stack.append(Finalizing(outcome=current))
207
210
  finalizing += 1
208
- current = finalizer # ty: ignore[invalid-assignment]
211
+ current = guarded(
212
+ run_fn_or_die, finalizer, exit_of(current)
213
+ )
209
214
  break
210
215
  case Finalizing():
211
216
  # The finalizer died; its defect replaces the
@@ -65,7 +65,7 @@ class RestoreEnv:
65
65
  @final
66
66
  @dataclass(frozen=True)
67
67
  class OnExitFrame:
68
- finalizer: Effect[Any, Any, Any]
68
+ finalizer: Callable[[Exit[Any, Any]], Effect[Any, Any, Any]]
69
69
 
70
70
 
71
71
  Frame = FlatMap[Any, Any, Any] | OnFailure[Any, Any, Any] | RestoreEnv | OnExitFrame
@@ -103,7 +103,8 @@ def run_sync_exit[A, E: EffectonError](effect: Effect[A, E]) -> Exit[A, E]:
103
103
  case RestoreEnv():
104
104
  env = item.env
105
105
  case OnExitFrame(finalizer):
106
- current = finalizer.flat_map(resume(current)) # ty: ignore[invalid-assignment]
106
+ cleanup = run_fn_or_die(finalizer, exit_of(current))
107
+ current = cleanup.flat_map(resume(current)) # ty: ignore[invalid-assignment]
107
108
  break
108
109
  case FlatMap():
109
110
  current = run_fn_or_die(item.and_then, value)
@@ -123,7 +124,8 @@ def run_sync_exit[A, E: EffectonError](effect: Effect[A, E]) -> Exit[A, E]:
123
124
  case RestoreEnv():
124
125
  env = item.env
125
126
  case OnExitFrame(finalizer):
126
- current = finalizer.flat_map(resume(current)) # ty: ignore[invalid-assignment]
127
+ cleanup = run_fn_or_die(finalizer, exit_of(current))
128
+ current = cleanup.flat_map(resume(current)) # ty: ignore[invalid-assignment]
127
129
  break
128
130
  case FlatMap():
129
131
  continue
@@ -195,6 +197,15 @@ def run_fn_or_die(f: Callable[[Any], Effect[Any, Any]], value: object) -> Node:
195
197
  return FailCause(cause=Die(defect=e))
196
198
 
197
199
 
200
+ def exit_of(outcome: Success[Any] | FailCause[Any]) -> Exit[Any, Any]:
201
+ """The settled outcome as the Exit an on_exit finalizer receives."""
202
+ match outcome:
203
+ case Success(value):
204
+ return Succeeded(value=value)
205
+ case FailCause(cause):
206
+ return Failure(cause=cause)
207
+
208
+
198
209
  # Captures the current outcome by closure.
199
210
  def resume(outcome: Node) -> Callable[[Any], Effect[Any, Any, Any]]:
200
211
  def resume(_: object) -> Effect[Any, Any, Any]: