effecton 0.2.0__tar.gz → 0.2.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 (30) hide show
  1. effecton-0.2.1/CHANGELOG.md +29 -0
  2. {effecton-0.2.0 → effecton-0.2.1}/PKG-INFO +130 -6
  3. {effecton-0.2.0 → effecton-0.2.1}/README.md +129 -5
  4. effecton-0.2.1/conftest.py +1 -0
  5. {effecton-0.2.0 → effecton-0.2.1}/pyproject.toml +5 -1
  6. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/__init__.py +17 -0
  7. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/attempt.py +1 -1
  8. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/effect.py +17 -1
  9. effecton-0.2.1/src/effecton/pytest_plugin.py +54 -0
  10. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/run_async.py +48 -2
  11. effecton-0.2.1/src/effecton/run_main.py +97 -0
  12. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/run_sync.py +10 -6
  13. effecton-0.2.1/src/effecton/std/clock.py +138 -0
  14. effecton-0.2.1/src/effecton/std/fiber.py +93 -0
  15. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/std/logger.py +3 -1
  16. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/std/pretty_logger.py +15 -4
  17. effecton-0.2.1/src/effecton/std/race.py +16 -0
  18. effecton-0.2.1/src/effecton/std/timeout.py +65 -0
  19. effecton-0.2.0/CHANGELOG.md +0 -19
  20. {effecton-0.2.0 → effecton-0.2.1}/.gitignore +0 -0
  21. {effecton-0.2.0 → effecton-0.2.1}/LICENSE +0 -0
  22. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/catch.py +0 -0
  23. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/exit.py +0 -0
  24. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/gen.py +0 -0
  25. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/implicit_requirement.py +0 -0
  26. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/provide.py +0 -0
  27. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/py.typed +0 -0
  28. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/std/__init__.py +0 -0
  29. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/std/scope.py +0 -0
  30. {effecton-0.2.0 → effecton-0.2.1}/src/effecton/suspend.py +0 -0
@@ -0,0 +1,29 @@
1
+ # effecton
2
+
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 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))
8
+ - 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))
9
+ - 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))
10
+ - 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))
11
+ - Add E.race_first(left, right) and E.timeout(duration) ([#18](https://github.com/krzkaczor/effecton/pull/18))
12
+
13
+ ## 0.2.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Add `run_async`, `coroutine` and `attempt_async` for awaitable-backed effects ([#12](https://github.com/krzkaczor/effecton/pull/12))
18
+ - 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))
19
+
20
+ ### Patch Changes
21
+
22
+ - Add `Effect.catch`: handle one error type and subtract it from the error channel. ([#10](https://github.com/krzkaczor/effecton/pull/10))
23
+ - Add `Interrupt` as a third `Cause` state, produced when a cancellation unwinds `run_async` ([#12](https://github.com/krzkaczor/effecton/pull/12))
24
+
25
+ ## 0.1.1
26
+
27
+ ### Patch Changes
28
+
29
+ - Migrate from mypy to ty. Simplify api where possible. ([#1](https://github.com/krzkaczor/effecton/pull/1))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: effecton
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A typed effect system for Python, inspired by Effect-TS
5
5
  Project-URL: Repository, https://github.com/krzkaczor/effecton
6
6
  Author-email: Krzysztof Kaczor <chris@kaczor.io>
@@ -129,7 +129,7 @@ More examples: [`test_run_sync.py`](https://github.com/krzkaczor/effecton/blob/m
129
129
 
130
130
  ### Running effects
131
131
 
132
- Effects are inert values; a runner interprets one. Each runner comes in a throwing form that returns the value and an `_exit` form that returns an `Exit`:
132
+ Effects are inert values; a runner interprets one. The sync and async runners come in a throwing form that returns the value and an `_exit` form that returns an `Exit`. Use `run_main` at a process entry point to report failures and choose exit codes:
133
133
 
134
134
  | Runner | Runs | Returns |
135
135
  |---|---|---|
@@ -137,9 +137,10 @@ Effects are inert values; a runner interprets one. Each runner comes in a throwi
137
137
  | `run_sync_exit(effect)` | synchronously | `Exit[A, E]` |
138
138
  | `run_async(effect)` | on a fresh asyncio loop (`asyncio.run` inside) | the value, raising on failure |
139
139
  | `run_async_exit(effect)` | on a fresh asyncio loop (`asyncio.run` inside) | `Exit[A, E]` |
140
+ | `run_main(effect)` | on a fresh asyncio loop | the value, logging and exiting on failure |
140
141
  | `await run_async_coroutine(effect)` | inside a loop you already own | `Exit[A, E]` |
141
142
 
142
- The throwing forms raise a typed failure as the error itself (every `EffectonError` is an `Exception`), re-raise an exception defect as it is, wrap any other defect in `UnhandledDefect`, and re-raise the exception carried by an interruption:
143
+ `run_sync` and `run_async` raise a typed failure as the error itself (every `EffectonError` is an `Exception`), re-raise an exception defect as it is, wrap any other defect in `UnhandledDefect`, and re-raise the exception carried by an interruption:
143
144
 
144
145
  ```python
145
146
  try:
@@ -170,6 +171,43 @@ Running an effect that contains a `coroutine` effect synchronously doesn't await
170
171
 
171
172
  More examples: [`test_run_sync.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_sync.py), [`test_run_async.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_async.py).
172
173
 
174
+ ### Main programs
175
+
176
+ `E.run_main(effect)` runs sync and async effects, returning the successful value so a CLI can print its result:
177
+
178
+ ```python
179
+ from dataclasses import dataclass
180
+ from typing import ClassVar, final
181
+
182
+ import effecton as E
183
+
184
+
185
+ @final
186
+ @dataclass(frozen=True)
187
+ class InvalidName(E.EffectonError):
188
+ name: str
189
+ exit_code: ClassVar[int] = 2
190
+
191
+ def __str__(self) -> str:
192
+ return f"Invalid name: {self.name!r}"
193
+
194
+
195
+ def greet(name: str) -> E.Effect[str, InvalidName]:
196
+ if not name.strip():
197
+ return E.fail(InvalidName(name))
198
+ return E.success(f"Hello, {name}!")
199
+
200
+
201
+ if __name__ == "__main__":
202
+ print(E.run_main(greet("world")))
203
+ ```
204
+
205
+ Typed failures log their message at ERROR; exception defects start with `Defect occurred`, followed by Python's exception formatting, including any traceback and exception chain, and other defects render as `Unhandled defect: ...`. The report uses the default effecton logger and pretty formatting, independently of logging requirements provided inside the program. It then raises `SystemExit(1)`, or uses an integer `exit_code` attribute on the error or defect. Missing and non-integer attributes (including booleans), as well as codes outside `0..255`, fall back to `1`. A successful integer is returned as a value, never treated as an exit code.
206
+
207
+ Ctrl+C and cancellation exit quietly with `130`; SIGTERM exits with `143`. The first signal determines the code. Finalizers finish and the event loop closes before control returns or `SystemExit` is raised, and previous signal handlers are restored. Repeated signals continue cancellation without bypassing finalizers; there is no cleanup timeout. Cancellation is cooperative, so blocking synchronous work can delay shutdown. An explicit `SystemExit` inside the effect retains its code after finalization.
208
+
209
+ Call `run_main` from the main thread, outside a running event loop. Both examples use it: [`skills-cli`](packages/examples/skills-cli/src/skills_cli/cli.py) and [`changesets`](packages/changesets/src/changesets/status/cli.py).
210
+
173
211
  ### Error handling
174
212
 
175
213
  Use `catch_all` to handle errors:
@@ -242,7 +280,7 @@ E.run_sync(E.require_implicit(Greeting)) # Greeting("hello") — nothing provid
242
280
  E.run_sync(E.provide_implicit(E.require_implicit(Greeting), Greeting("hi")))
243
281
  ```
244
282
 
245
- `provide_implicit(effect, value)` is keyed by `type(value)`, so mark implicit requirement classes `@final`. Overrides also compose in a `provide` chain: `effect.provide(Greeting)(Greeting("hi"))`. `require_implicit` is a separate accessor rather than an overload on `require` because the overload pair silently drops requirements from `R` in some inference positions (pinned in `test_types_implicit_requirement.py`). One footgun: the runtime check only tests that a `default` attribute exists, so a plain requirement class that defines one gets the default fallback instead of a `MissingRequirement` defect.
283
+ `provide_implicit(effect, value)` is keyed by `type(value)`, so mark implicit requirement classes `@final`. A `typing.Protocol` that extends `ImplicitRequirement` with a concrete `default()` is an implicit requirement too; its implementations are provided with `effect.provide(Protocol)(impl)` (the std `Clock` service works this way). Overrides also compose in a `provide` chain: `effect.provide(Greeting)(Greeting("hi"))`. `require_implicit` is a separate accessor rather than an overload on `require` because the overload pair silently drops requirements from `R` in some inference positions (pinned in `test_types_implicit_requirement.py`). One footgun: the runtime check only tests that a `default` attribute exists, so a plain requirement class that defines one gets the default fallback instead of a `MissingRequirement` defect.
246
284
 
247
285
  More examples: [`test_implicit_requirement.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_implicit_requirement.py).
248
286
 
@@ -314,7 +352,7 @@ More examples: [`test_attempt.py`](https://github.com/krzkaczor/effecton/blob/ma
314
352
 
315
353
  ### Wrapping async code
316
354
 
317
- `coroutine` defers an awaitable the way `sync` defers a thunk: the thunk builds a fresh awaitable on every run, because a coroutine object can be awaited only once. Exceptions become defects. `attempt_async` is the `attempt` counterpart that maps expected exceptions into the error channel. Only the `run_async` family can interpret either:
355
+ `coroutine` defers an awaitable the way `sync` defers a thunk: the thunk builds a fresh awaitable on every run, because a coroutine object can be awaited only once. Exceptions become defects. `attempt_async` is the `attempt` counterpart that maps expected exceptions into the error channel. `run_main` and the `run_async` family can interpret either:
318
356
 
319
357
  ```python
320
358
  client = httpx.AsyncClient()
@@ -370,12 +408,98 @@ E.run_sync(
370
408
 
371
409
  More examples: [`test_logger.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_logger.py), [`test_pretty_logger.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_pretty_logger.py).
372
410
 
411
+ ### Clock
412
+
413
+ `E.now()` reads the current time and `E.sleep(duration)` pauses, both through the implicit `E.Clock` service, so neither enters `R`. `now()` returns a timezone-aware UTC `datetime` and is what the logger stamps `LogData.date` with. Sleeping depends on the runner, so the `Clock` protocol has no `default()`; instead there are two live clocks and each runner injects the matching one: `run_sync` installs `E.Clock.SyncLive`, whose sleep blocks the thread, and the `run_async` family (including `run_main`) installs `E.Clock.AsyncLive`, whose sleep awaits `asyncio.sleep`, keeps the loop turning and is interrupted by a cancellation like any coroutine effect.
414
+
415
+ ```python
416
+ E.run_sync(E.now()) # datetime.now(UTC), no setup needed
417
+ E.run_sync(E.sleep(timedelta(seconds=1))) # blocks for a second
418
+ E.run_async(E.sleep(timedelta(seconds=1))) # awaits asyncio.sleep(1)
419
+ ```
420
+
421
+ In tests, use `E.Clock.Test` and move it by hand with `adjust(delta)` or `set_time(time)`. Both return effects and are async only, like the clock's `sleep`, which parks until a move reaches its wake time (a sleep of zero returns at once). effecton ships a pytest plugin, loaded automatically wherever the package is installed, that runs a test returning an effect (typically a `@E.gen` function) under the async runner and reports a failure as its cause. A test that requests the `test_clock` fixture gets that clock provided to its effect, so `E.now()`, `E.sleep()` and the movers all see it:
422
+
423
+ ```python
424
+ @E.gen
425
+ def test_reads_move_with_the_clock(test_clock: E.Clock.Test) -> E.EffectGen[None]:
426
+ yield from test_clock.set_time(datetime(2024, 1, 1, tzinfo=UTC))
427
+
428
+ first = yield from E.now()
429
+ yield from test_clock.adjust(timedelta(minutes=5))
430
+ second = yield from E.now()
431
+
432
+ assert second == first + timedelta(minutes=5)
433
+ ```
434
+
435
+ A sleeping program has to run concurrently with the moves: `E.fork` it and settle on the returned fiber. Each move yields to the loop before and after, so a program forked just before reaches its sleep, and a woken program progresses before the test continues.
436
+
437
+ Provide clocks with `.provide(E.Clock.Protocol)(...)`: `provide_implicit` is keyed by `type(value)`, so it would register a `Test` clock under `Test` rather than `Protocol`. This repo's ruff config bans direct time reads and sleeps (`datetime.now`, `time.time`, `time.monotonic`, `time.sleep`, `asyncio.sleep`, ...) outside the clock module, so all code goes through `E.now()` and `E.sleep()`.
438
+
439
+ More examples: [`test_clock.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_clock.py).
440
+
441
+ ### Fibers
442
+
443
+ `E.fork(effect)` starts an effect concurrently and returns an `E.Fiber`. `fiber.join()` is the fiber's value, failing with the fiber's own cause; `fiber.wait()` is its `Exit` and never fails; `fiber.poll()` peeks without waiting; `fiber.interrupt()` cancels it, lets its finalizers run and returns the `Exit`; `E.yield_now()` lets other fibers take a turn. Fibers are asyncio tasks underneath, so `fork` is async only and a forked run starts with a fresh environment: provide what it needs.
444
+
445
+ ```python
446
+ @E.gen
447
+ def program() -> E.EffectGen[int]:
448
+ fiber = yield from E.fork(fetch_total()) # runs concurrently
449
+ other = yield from do_other_work()
450
+ total = yield from fiber.join() # Effect[int, FetchError]
451
+ return total + other
452
+ ```
453
+
454
+ More examples: [`test_fiber.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_fiber.py).
455
+
456
+ ### Racing
457
+
458
+ `E.race_first(left, right)` returns the first completed outcome, whether success, typed failure, defect, or interruption. It interrupts the loser and awaits its finalizers; loser cleanup cannot replace the winner's outcome. The left wins when both are complete at selection. Cancelling the parent waits for both branches to finish cleanup.
459
+
460
+ Both branches inherit surrounding requirements, including the Clock, and the result carries the union of their value, error, and requirement types. Racing is async only: synchronous runners report `AsyncEffectInSyncRun`.
461
+
462
+ ```python
463
+ # Stop the heartbeat when the operation completes; a heartbeat failure
464
+ # also stops the operation.
465
+ program = E.race_first(fetch_total(), heartbeat_forever())
466
+ ```
467
+
468
+ Resource lifetime follows the owning scope: use `branch.scoped()` to release that branch's resources before the race returns. Resources acquired into a surrounding scope live until that scope closes. Racing `fiber.join()` interrupts the waiter; the independently forked fiber keeps running.
469
+
470
+ More examples: [`test_race.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_race.py).
471
+
472
+ ### Timeouts
473
+
474
+ `effect.timeout(duration)` composes `race_first` with Clock sleep followed by a typed `TimeoutException`. If the deadline wins, it interrupts the effect and awaits its finalizers. The effect's own outcome, or a defect in the clock, otherwise propagates unchanged. Timeouts are async only, with the same requirement inheritance and cleanup rules as racing.
475
+
476
+ ```python
477
+ fetch_total().timeout(
478
+ timedelta(seconds=5)
479
+ ) # Effect[int, FetchError | TimeoutException]
480
+
481
+
482
+ @E.timeout(timedelta(seconds=5))
483
+ @E.gen
484
+ def fetch_user(user_id: int) -> E.EffectGen[User, FetchError]: ...
485
+
486
+
487
+ fetch_user(1).catch(E.TimeoutException)(lambda _: E.success(None))
488
+ # Effect[User | None, FetchError]
489
+ ```
490
+
491
+ `E.timeout(duration)(effect)` is the curried form; decorating an effect-returning function wraps each result and preserves its call signature. Durations are `timedelta` values. Cancellation is cooperative: blocking synchronous work can delay a timeout, and cleanup can extend the total time beyond the deadline.
492
+
493
+ For deterministic tests, provide `E.Clock.Test` around the timeout, fork the program, then advance the clock and await the fiber. Both race branches start eagerly when the race runs, so the timer is registered before a subsequent clock adjustment.
494
+
495
+ More examples: [`test_timeout.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_timeout.py).
496
+
373
497
  ## Roadmap
374
498
 
375
499
  - [x] `ty` support
376
500
  - [x] Support for async/sync code
377
501
  - [ ] Retries
378
- - [ ] Timeouts
502
+ - [x] Timeouts
379
503
  - [ ] `Random` implicit service
380
504
  - [ ] More examples of integrations with existing ecosystem (fastapi, pydantic etc.)
381
505
 
@@ -113,7 +113,7 @@ More examples: [`test_run_sync.py`](https://github.com/krzkaczor/effecton/blob/m
113
113
 
114
114
  ### Running effects
115
115
 
116
- Effects are inert values; a runner interprets one. Each runner comes in a throwing form that returns the value and an `_exit` form that returns an `Exit`:
116
+ Effects are inert values; a runner interprets one. The sync and async runners come in a throwing form that returns the value and an `_exit` form that returns an `Exit`. Use `run_main` at a process entry point to report failures and choose exit codes:
117
117
 
118
118
  | Runner | Runs | Returns |
119
119
  |---|---|---|
@@ -121,9 +121,10 @@ Effects are inert values; a runner interprets one. Each runner comes in a throwi
121
121
  | `run_sync_exit(effect)` | synchronously | `Exit[A, E]` |
122
122
  | `run_async(effect)` | on a fresh asyncio loop (`asyncio.run` inside) | the value, raising on failure |
123
123
  | `run_async_exit(effect)` | on a fresh asyncio loop (`asyncio.run` inside) | `Exit[A, E]` |
124
+ | `run_main(effect)` | on a fresh asyncio loop | the value, logging and exiting on failure |
124
125
  | `await run_async_coroutine(effect)` | inside a loop you already own | `Exit[A, E]` |
125
126
 
126
- The throwing forms raise a typed failure as the error itself (every `EffectonError` is an `Exception`), re-raise an exception defect as it is, wrap any other defect in `UnhandledDefect`, and re-raise the exception carried by an interruption:
127
+ `run_sync` and `run_async` raise a typed failure as the error itself (every `EffectonError` is an `Exception`), re-raise an exception defect as it is, wrap any other defect in `UnhandledDefect`, and re-raise the exception carried by an interruption:
127
128
 
128
129
  ```python
129
130
  try:
@@ -154,6 +155,43 @@ Running an effect that contains a `coroutine` effect synchronously doesn't await
154
155
 
155
156
  More examples: [`test_run_sync.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_sync.py), [`test_run_async.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_async.py).
156
157
 
158
+ ### Main programs
159
+
160
+ `E.run_main(effect)` runs sync and async effects, returning the successful value so a CLI can print its result:
161
+
162
+ ```python
163
+ from dataclasses import dataclass
164
+ from typing import ClassVar, final
165
+
166
+ import effecton as E
167
+
168
+
169
+ @final
170
+ @dataclass(frozen=True)
171
+ class InvalidName(E.EffectonError):
172
+ name: str
173
+ exit_code: ClassVar[int] = 2
174
+
175
+ def __str__(self) -> str:
176
+ return f"Invalid name: {self.name!r}"
177
+
178
+
179
+ def greet(name: str) -> E.Effect[str, InvalidName]:
180
+ if not name.strip():
181
+ return E.fail(InvalidName(name))
182
+ return E.success(f"Hello, {name}!")
183
+
184
+
185
+ if __name__ == "__main__":
186
+ print(E.run_main(greet("world")))
187
+ ```
188
+
189
+ Typed failures log their message at ERROR; exception defects start with `Defect occurred`, followed by Python's exception formatting, including any traceback and exception chain, and other defects render as `Unhandled defect: ...`. The report uses the default effecton logger and pretty formatting, independently of logging requirements provided inside the program. It then raises `SystemExit(1)`, or uses an integer `exit_code` attribute on the error or defect. Missing and non-integer attributes (including booleans), as well as codes outside `0..255`, fall back to `1`. A successful integer is returned as a value, never treated as an exit code.
190
+
191
+ Ctrl+C and cancellation exit quietly with `130`; SIGTERM exits with `143`. The first signal determines the code. Finalizers finish and the event loop closes before control returns or `SystemExit` is raised, and previous signal handlers are restored. Repeated signals continue cancellation without bypassing finalizers; there is no cleanup timeout. Cancellation is cooperative, so blocking synchronous work can delay shutdown. An explicit `SystemExit` inside the effect retains its code after finalization.
192
+
193
+ Call `run_main` from the main thread, outside a running event loop. Both examples use it: [`skills-cli`](packages/examples/skills-cli/src/skills_cli/cli.py) and [`changesets`](packages/changesets/src/changesets/status/cli.py).
194
+
157
195
  ### Error handling
158
196
 
159
197
  Use `catch_all` to handle errors:
@@ -226,7 +264,7 @@ E.run_sync(E.require_implicit(Greeting)) # Greeting("hello") — nothing provid
226
264
  E.run_sync(E.provide_implicit(E.require_implicit(Greeting), Greeting("hi")))
227
265
  ```
228
266
 
229
- `provide_implicit(effect, value)` is keyed by `type(value)`, so mark implicit requirement classes `@final`. Overrides also compose in a `provide` chain: `effect.provide(Greeting)(Greeting("hi"))`. `require_implicit` is a separate accessor rather than an overload on `require` because the overload pair silently drops requirements from `R` in some inference positions (pinned in `test_types_implicit_requirement.py`). One footgun: the runtime check only tests that a `default` attribute exists, so a plain requirement class that defines one gets the default fallback instead of a `MissingRequirement` defect.
267
+ `provide_implicit(effect, value)` is keyed by `type(value)`, so mark implicit requirement classes `@final`. A `typing.Protocol` that extends `ImplicitRequirement` with a concrete `default()` is an implicit requirement too; its implementations are provided with `effect.provide(Protocol)(impl)` (the std `Clock` service works this way). Overrides also compose in a `provide` chain: `effect.provide(Greeting)(Greeting("hi"))`. `require_implicit` is a separate accessor rather than an overload on `require` because the overload pair silently drops requirements from `R` in some inference positions (pinned in `test_types_implicit_requirement.py`). One footgun: the runtime check only tests that a `default` attribute exists, so a plain requirement class that defines one gets the default fallback instead of a `MissingRequirement` defect.
230
268
 
231
269
  More examples: [`test_implicit_requirement.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_implicit_requirement.py).
232
270
 
@@ -298,7 +336,7 @@ More examples: [`test_attempt.py`](https://github.com/krzkaczor/effecton/blob/ma
298
336
 
299
337
  ### Wrapping async code
300
338
 
301
- `coroutine` defers an awaitable the way `sync` defers a thunk: the thunk builds a fresh awaitable on every run, because a coroutine object can be awaited only once. Exceptions become defects. `attempt_async` is the `attempt` counterpart that maps expected exceptions into the error channel. Only the `run_async` family can interpret either:
339
+ `coroutine` defers an awaitable the way `sync` defers a thunk: the thunk builds a fresh awaitable on every run, because a coroutine object can be awaited only once. Exceptions become defects. `attempt_async` is the `attempt` counterpart that maps expected exceptions into the error channel. `run_main` and the `run_async` family can interpret either:
302
340
 
303
341
  ```python
304
342
  client = httpx.AsyncClient()
@@ -354,12 +392,98 @@ E.run_sync(
354
392
 
355
393
  More examples: [`test_logger.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_logger.py), [`test_pretty_logger.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_pretty_logger.py).
356
394
 
395
+ ### Clock
396
+
397
+ `E.now()` reads the current time and `E.sleep(duration)` pauses, both through the implicit `E.Clock` service, so neither enters `R`. `now()` returns a timezone-aware UTC `datetime` and is what the logger stamps `LogData.date` with. Sleeping depends on the runner, so the `Clock` protocol has no `default()`; instead there are two live clocks and each runner injects the matching one: `run_sync` installs `E.Clock.SyncLive`, whose sleep blocks the thread, and the `run_async` family (including `run_main`) installs `E.Clock.AsyncLive`, whose sleep awaits `asyncio.sleep`, keeps the loop turning and is interrupted by a cancellation like any coroutine effect.
398
+
399
+ ```python
400
+ E.run_sync(E.now()) # datetime.now(UTC), no setup needed
401
+ E.run_sync(E.sleep(timedelta(seconds=1))) # blocks for a second
402
+ E.run_async(E.sleep(timedelta(seconds=1))) # awaits asyncio.sleep(1)
403
+ ```
404
+
405
+ In tests, use `E.Clock.Test` and move it by hand with `adjust(delta)` or `set_time(time)`. Both return effects and are async only, like the clock's `sleep`, which parks until a move reaches its wake time (a sleep of zero returns at once). effecton ships a pytest plugin, loaded automatically wherever the package is installed, that runs a test returning an effect (typically a `@E.gen` function) under the async runner and reports a failure as its cause. A test that requests the `test_clock` fixture gets that clock provided to its effect, so `E.now()`, `E.sleep()` and the movers all see it:
406
+
407
+ ```python
408
+ @E.gen
409
+ def test_reads_move_with_the_clock(test_clock: E.Clock.Test) -> E.EffectGen[None]:
410
+ yield from test_clock.set_time(datetime(2024, 1, 1, tzinfo=UTC))
411
+
412
+ first = yield from E.now()
413
+ yield from test_clock.adjust(timedelta(minutes=5))
414
+ second = yield from E.now()
415
+
416
+ assert second == first + timedelta(minutes=5)
417
+ ```
418
+
419
+ A sleeping program has to run concurrently with the moves: `E.fork` it and settle on the returned fiber. Each move yields to the loop before and after, so a program forked just before reaches its sleep, and a woken program progresses before the test continues.
420
+
421
+ Provide clocks with `.provide(E.Clock.Protocol)(...)`: `provide_implicit` is keyed by `type(value)`, so it would register a `Test` clock under `Test` rather than `Protocol`. This repo's ruff config bans direct time reads and sleeps (`datetime.now`, `time.time`, `time.monotonic`, `time.sleep`, `asyncio.sleep`, ...) outside the clock module, so all code goes through `E.now()` and `E.sleep()`.
422
+
423
+ More examples: [`test_clock.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_clock.py).
424
+
425
+ ### Fibers
426
+
427
+ `E.fork(effect)` starts an effect concurrently and returns an `E.Fiber`. `fiber.join()` is the fiber's value, failing with the fiber's own cause; `fiber.wait()` is its `Exit` and never fails; `fiber.poll()` peeks without waiting; `fiber.interrupt()` cancels it, lets its finalizers run and returns the `Exit`; `E.yield_now()` lets other fibers take a turn. Fibers are asyncio tasks underneath, so `fork` is async only and a forked run starts with a fresh environment: provide what it needs.
428
+
429
+ ```python
430
+ @E.gen
431
+ def program() -> E.EffectGen[int]:
432
+ fiber = yield from E.fork(fetch_total()) # runs concurrently
433
+ other = yield from do_other_work()
434
+ total = yield from fiber.join() # Effect[int, FetchError]
435
+ return total + other
436
+ ```
437
+
438
+ More examples: [`test_fiber.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_fiber.py).
439
+
440
+ ### Racing
441
+
442
+ `E.race_first(left, right)` returns the first completed outcome, whether success, typed failure, defect, or interruption. It interrupts the loser and awaits its finalizers; loser cleanup cannot replace the winner's outcome. The left wins when both are complete at selection. Cancelling the parent waits for both branches to finish cleanup.
443
+
444
+ Both branches inherit surrounding requirements, including the Clock, and the result carries the union of their value, error, and requirement types. Racing is async only: synchronous runners report `AsyncEffectInSyncRun`.
445
+
446
+ ```python
447
+ # Stop the heartbeat when the operation completes; a heartbeat failure
448
+ # also stops the operation.
449
+ program = E.race_first(fetch_total(), heartbeat_forever())
450
+ ```
451
+
452
+ Resource lifetime follows the owning scope: use `branch.scoped()` to release that branch's resources before the race returns. Resources acquired into a surrounding scope live until that scope closes. Racing `fiber.join()` interrupts the waiter; the independently forked fiber keeps running.
453
+
454
+ More examples: [`test_race.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_race.py).
455
+
456
+ ### Timeouts
457
+
458
+ `effect.timeout(duration)` composes `race_first` with Clock sleep followed by a typed `TimeoutException`. If the deadline wins, it interrupts the effect and awaits its finalizers. The effect's own outcome, or a defect in the clock, otherwise propagates unchanged. Timeouts are async only, with the same requirement inheritance and cleanup rules as racing.
459
+
460
+ ```python
461
+ fetch_total().timeout(
462
+ timedelta(seconds=5)
463
+ ) # Effect[int, FetchError | TimeoutException]
464
+
465
+
466
+ @E.timeout(timedelta(seconds=5))
467
+ @E.gen
468
+ def fetch_user(user_id: int) -> E.EffectGen[User, FetchError]: ...
469
+
470
+
471
+ fetch_user(1).catch(E.TimeoutException)(lambda _: E.success(None))
472
+ # Effect[User | None, FetchError]
473
+ ```
474
+
475
+ `E.timeout(duration)(effect)` is the curried form; decorating an effect-returning function wraps each result and preserves its call signature. Durations are `timedelta` values. Cancellation is cooperative: blocking synchronous work can delay a timeout, and cleanup can extend the total time beyond the deadline.
476
+
477
+ For deterministic tests, provide `E.Clock.Test` around the timeout, fork the program, then advance the clock and await the fiber. Both race branches start eagerly when the race runs, so the timer is registered before a subsequent clock adjustment.
478
+
479
+ More examples: [`test_timeout.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_timeout.py).
480
+
357
481
  ## Roadmap
358
482
 
359
483
  - [x] `ty` support
360
484
  - [x] Support for async/sync code
361
485
  - [ ] Retries
362
- - [ ] Timeouts
486
+ - [x] Timeouts
363
487
  - [ ] `Random` implicit service
364
488
  - [ ] More examples of integrations with existing ecosystem (fastapi, pydantic etc.)
365
489
 
@@ -0,0 +1 @@
1
+ pytest_plugins = ["pytester"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "effecton"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "A typed effect system for Python, inspired by Effect-TS"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,6 +20,9 @@ dependencies = [
20
20
  [project.urls]
21
21
  Repository = "https://github.com/krzkaczor/effecton"
22
22
 
23
+ [project.entry-points.pytest11]
24
+ effecton = "effecton.pytest_plugin"
25
+
23
26
  [build-system]
24
27
  requires = ["hatchling"]
25
28
  build-backend = "hatchling.build"
@@ -37,3 +40,4 @@ unused-ignore-comment = "error"
37
40
  [tool.pytest.ini_options]
38
41
  testpaths = ["src"]
39
42
  addopts = "-q"
43
+ collect_imported_tests = false
@@ -21,12 +21,17 @@ from effecton.implicit_requirement import (
21
21
  require_implicit,
22
22
  )
23
23
  from effecton.run_async import run_async, run_async_coroutine, run_async_exit
24
+ from effecton.run_main import run_main
24
25
  from effecton.run_sync import (
25
26
  AsyncEffectInSyncRun,
26
27
  MissingRequirement,
27
28
  run_sync,
28
29
  run_sync_exit,
29
30
  )
31
+ from effecton.std import clock as Clock
32
+ from effecton.std.clock import _now as now
33
+ from effecton.std.clock import _sleep as sleep
34
+ from effecton.std.fiber import Fiber, fork, yield_now
30
35
  from effecton.std.logger import (
31
36
  CurrentLogAnnotations,
32
37
  CurrentLoggers,
@@ -46,12 +51,15 @@ from effecton.std.logger import (
46
51
  log_warning,
47
52
  )
48
53
  from effecton.std.pretty_logger import PrettyFormatter, pretty_logger
54
+ from effecton.std.race import race_first
49
55
  from effecton.std.scope import Scope, acquire_and_release, add_finalizer, scoped
56
+ from effecton.std.timeout import TimeoutException, timeout
50
57
  from effecton.suspend import suspend
51
58
 
52
59
  __all__ = [
53
60
  "AsyncEffectInSyncRun",
54
61
  "Cause",
62
+ "Clock",
55
63
  "CurrentLogAnnotations",
56
64
  "CurrentLogLevel",
57
65
  "CurrentLoggers",
@@ -63,6 +71,7 @@ __all__ = [
63
71
  "Exit",
64
72
  "Fail",
65
73
  "Failure",
74
+ "Fiber",
66
75
  "ImplicitRequirement",
67
76
  "Interrupt",
68
77
  "LogData",
@@ -73,6 +82,7 @@ __all__ = [
73
82
  "Scope",
74
83
  "Severity",
75
84
  "Succeeded",
85
+ "TimeoutException",
76
86
  "UnhandledDefect",
77
87
  "acquire_and_release",
78
88
  "add_finalizer",
@@ -82,6 +92,7 @@ __all__ = [
82
92
  "coroutine",
83
93
  "die",
84
94
  "fail",
95
+ "fork",
85
96
  "gen",
86
97
  "log",
87
98
  "log_debug",
@@ -90,17 +101,23 @@ __all__ = [
90
101
  "log_info",
91
102
  "log_trace",
92
103
  "log_warning",
104
+ "now",
93
105
  "pretty_logger",
94
106
  "provide_implicit",
107
+ "race_first",
95
108
  "require",
96
109
  "require_implicit",
97
110
  "run_async",
98
111
  "run_async_coroutine",
99
112
  "run_async_exit",
113
+ "run_main",
100
114
  "run_sync",
101
115
  "run_sync_exit",
102
116
  "scoped",
117
+ "sleep",
103
118
  "success",
104
119
  "suspend",
105
120
  "sync",
121
+ "timeout",
122
+ "yield_now",
106
123
  ]
@@ -30,7 +30,7 @@ def attempt_async[A, E: EffectonError](
30
30
  once per run of the effect, like coroutine, and on_error maps an
31
31
  exception raised by the thunk or by the await into the typed error
32
32
  channel. Re-raise from on_error to keep an unexpected exception a
33
- defect. Only the run_async family can interpret the result.
33
+ defect. run_main and the run_async family can interpret the result.
34
34
  """
35
35
 
36
36
  async def go() -> Effect[A, E]:
@@ -1,5 +1,6 @@
1
1
  from collections.abc import Awaitable, Callable, Generator
2
2
  from dataclasses import dataclass
3
+ from datetime import timedelta
3
4
  from typing import TYPE_CHECKING, Any, Literal, Never, final
4
5
 
5
6
  from typing_extensions import TypeForm
@@ -8,6 +9,7 @@ if TYPE_CHECKING:
8
9
  from effecton.catch import CatchBinder
9
10
  from effecton.provide import ProvideBinder
10
11
  from effecton.std.scope import Scope
12
+ from effecton.std.timeout import TimeoutException
11
13
 
12
14
 
13
15
  @dataclass(frozen=True)
@@ -82,6 +84,11 @@ class Effect[A, E: EffectonError = Never, R = Never]:
82
84
 
83
85
  return scoped(self)
84
86
 
87
+ def timeout(self, duration: timedelta) -> Effect[A, E | TimeoutException, R]:
88
+ from effecton.std.timeout import timeout
89
+
90
+ return timeout(duration)(self)
91
+
85
92
  def __iter__(self) -> Generator[Effect[A, E, R], Any, A]:
86
93
  """Make ``x = yield from effect`` infer ``x`` as A inside @gen.
87
94
 
@@ -160,6 +167,14 @@ class OnExit[A, E: EffectonError, R](Effect[A, E, R]):
160
167
  kind: Literal["on_exit"] = "on_exit"
161
168
 
162
169
 
170
+ @final
171
+ @dataclass(frozen=True)
172
+ class RaceFirst[A, E: EffectonError, R](Effect[A, E, R]):
173
+ left: Effect[A, E, R]
174
+ right: Effect[A, E, R]
175
+ kind: Literal["race_first"] = "race_first"
176
+
177
+
163
178
  Node = (
164
179
  Success[Any]
165
180
  | Sync[Any]
@@ -170,6 +185,7 @@ Node = (
170
185
  | Require[Any]
171
186
  | ProvideRequirement[Any, Any, Any]
172
187
  | OnExit[Any, Any, Any]
188
+ | RaceFirst[Any, Any, Any]
173
189
  )
174
190
 
175
191
 
@@ -182,7 +198,7 @@ def sync[A](fn: Callable[[], A]) -> Effect[A]:
182
198
 
183
199
 
184
200
  def coroutine[A](fn: Callable[[], Awaitable[A]]) -> Effect[A]:
185
- """Defer an awaitable; only the run_async family can interpret it.
201
+ """Defer an awaitable; run_main and the run_async family can interpret it.
186
202
 
187
203
  The thunk runs once per run of the effect and must build a fresh
188
204
  awaitable each time, because a coroutine object can be awaited only
@@ -0,0 +1,54 @@
1
+ """pytest plugin that runs test functions written as effects.
2
+
3
+ Registered through the pytest11 entry point, so it loads wherever
4
+ effecton is installed. A test that returns an Effect, typically a
5
+ @E.gen generator function, is interpreted with run_async_exit and a
6
+ failure raises its cause, so pytest reports a typed error, a defect or
7
+ an interruption like any exception. A test that requests the test_clock
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.
10
+ """
11
+
12
+ import inspect
13
+ import warnings
14
+ from typing import Any, cast
15
+
16
+ import pytest
17
+
18
+ from effecton.effect import Effect
19
+ from effecton.exit import unwrap
20
+ from effecton.run_async import run_async_exit
21
+ from effecton.std import clock
22
+
23
+
24
+ @pytest.fixture
25
+ def test_clock() -> clock.Test:
26
+ """A Test clock at the Unix epoch, provided to the test's effect."""
27
+ return clock.Test()
28
+
29
+
30
+ @pytest.hookimpl(tryfirst=True)
31
+ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
32
+ function = pyfuncitem.obj
33
+ if inspect.iscoroutinefunction(function):
34
+ return None
35
+
36
+ arguments = {
37
+ name: pyfuncitem.funcargs[name] for name in pyfuncitem._fixtureinfo.argnames
38
+ }
39
+ result = function(**arguments)
40
+ if isinstance(result, Effect):
41
+ effect = cast("Effect[Any, Any]", result)
42
+ if "test_clock" in arguments:
43
+ provided = cast("clock.Test", arguments["test_clock"])
44
+ effect = effect.provide(clock.Protocol)(provided)
45
+ unwrap(run_async_exit(effect))
46
+ elif result is not None:
47
+ warnings.warn(
48
+ pytest.PytestReturnNotNoneWarning(
49
+ f"Test functions should return None, "
50
+ f"but {pyfuncitem.nodeid} returned {type(result)!r}"
51
+ ),
52
+ stacklevel=2,
53
+ )
54
+ return True