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.
- effecton-0.2.1/CHANGELOG.md +29 -0
- {effecton-0.2.0 → effecton-0.2.1}/PKG-INFO +130 -6
- {effecton-0.2.0 → effecton-0.2.1}/README.md +129 -5
- effecton-0.2.1/conftest.py +1 -0
- {effecton-0.2.0 → effecton-0.2.1}/pyproject.toml +5 -1
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/__init__.py +17 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/attempt.py +1 -1
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/effect.py +17 -1
- effecton-0.2.1/src/effecton/pytest_plugin.py +54 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/run_async.py +48 -2
- effecton-0.2.1/src/effecton/run_main.py +97 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/run_sync.py +10 -6
- effecton-0.2.1/src/effecton/std/clock.py +138 -0
- effecton-0.2.1/src/effecton/std/fiber.py +93 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/std/logger.py +3 -1
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/std/pretty_logger.py +15 -4
- effecton-0.2.1/src/effecton/std/race.py +16 -0
- effecton-0.2.1/src/effecton/std/timeout.py +65 -0
- effecton-0.2.0/CHANGELOG.md +0 -19
- {effecton-0.2.0 → effecton-0.2.1}/.gitignore +0 -0
- {effecton-0.2.0 → effecton-0.2.1}/LICENSE +0 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/catch.py +0 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/exit.py +0 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/gen.py +0 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/implicit_requirement.py +0 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/provide.py +0 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/py.typed +0 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/std/__init__.py +0 -0
- {effecton-0.2.0 → effecton-0.2.1}/src/effecton/std/scope.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
- [
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
- [
|
|
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.
|
|
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.
|
|
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;
|
|
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
|