effecton 0.2.1__tar.gz → 0.3.0__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 → effecton-0.3.0}/.gitignore +8 -0
- effecton-0.3.0/CHANGELOG.md +47 -0
- {effecton-0.2.1 → effecton-0.3.0}/PKG-INFO +206 -5
- {effecton-0.2.1 → effecton-0.3.0}/README.md +203 -4
- {effecton-0.2.1 → effecton-0.3.0}/pyproject.toml +6 -1
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/__init__.py +22 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/effect.py +56 -5
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/exit.py +9 -3
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/pytest_plugin.py +23 -2
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/run_async.py +7 -2
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/run_sync.py +14 -3
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/std/clock.py +9 -4
- effecton-0.3.0/src/effecton/std/duration.py +32 -0
- effecton-0.3.0/src/effecton/std/file_system.py +816 -0
- effecton-0.3.0/src/effecton/std/http_client.py +372 -0
- effecton-0.3.0/src/effecton/std/path.py +68 -0
- effecton-0.3.0/src/effecton/std/process.py +57 -0
- effecton-0.3.0/src/effecton/std/random.py +119 -0
- effecton-0.3.0/src/effecton/std/retry.py +54 -0
- effecton-0.3.0/src/effecton/std/schedule.py +79 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/std/timeout.py +6 -4
- effecton-0.3.0/src/effecton/std/tracer.py +289 -0
- effecton-0.2.1/CHANGELOG.md +0 -29
- {effecton-0.2.1 → effecton-0.3.0}/LICENSE +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/conftest.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/attempt.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/catch.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/gen.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/implicit_requirement.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/provide.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/py.typed +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/run_main.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/std/__init__.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/std/fiber.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/std/logger.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/std/pretty_logger.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/std/race.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/std/scope.py +0 -0
- {effecton-0.2.1 → effecton-0.3.0}/src/effecton/suspend.py +0 -0
|
@@ -11,3 +11,11 @@ wheels/
|
|
|
11
11
|
|
|
12
12
|
# Copied from the repo root by the release workflow
|
|
13
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
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# effecton
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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))
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 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))
|
|
12
|
+
- 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))
|
|
13
|
+
- 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))
|
|
14
|
+
- Add `effect.retry(schedule, until=...)` with `E.Schedule.recurs`, `E.Schedule.spaced`, and `E.Schedule.exponential` ([#19](https://github.com/krzkaczor/effecton/pull/19))
|
|
15
|
+
- 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))
|
|
16
|
+
- 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))
|
|
17
|
+
- 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))
|
|
18
|
+
- `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))
|
|
19
|
+
- 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))
|
|
20
|
+
|
|
21
|
+
## 0.2.1
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- 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))
|
|
26
|
+
- 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))
|
|
27
|
+
- 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))
|
|
28
|
+
- 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))
|
|
29
|
+
- Add E.race_first(left, right) and E.timeout(duration) ([#18](https://github.com/krzkaczor/effecton/pull/18))
|
|
30
|
+
|
|
31
|
+
## 0.2.0
|
|
32
|
+
|
|
33
|
+
### Minor Changes
|
|
34
|
+
|
|
35
|
+
- Add `run_async`, `coroutine` and `attempt_async` for awaitable-backed effects ([#12](https://github.com/krzkaczor/effecton/pull/12))
|
|
36
|
+
- 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))
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- Add `Effect.catch`: handle one error type and subtract it from the error channel. ([#10](https://github.com/krzkaczor/effecton/pull/10))
|
|
41
|
+
- Add `Interrupt` as a third `Cause` state, produced when a cancellation unwinds `run_async` ([#12](https://github.com/krzkaczor/effecton/pull/12))
|
|
42
|
+
|
|
43
|
+
## 0.1.1
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- 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.
|
|
3
|
+
Version: 0.3.0
|
|
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>
|
|
@@ -11,6 +11,8 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.14
|
|
12
12
|
Classifier: Typing :: Typed
|
|
13
13
|
Requires-Python: >=3.14
|
|
14
|
+
Requires-Dist: aiofiles>=25.1
|
|
15
|
+
Requires-Dist: httpx2>=2.12
|
|
14
16
|
Requires-Dist: typing-extensions>=4.16.0
|
|
15
17
|
Description-Content-Type: text/markdown
|
|
16
18
|
|
|
@@ -223,9 +225,9 @@ E.run_sync(p) # "recovered from oops"
|
|
|
223
225
|
Use `catch` to handle one error type and leave the rest in the error channel. The handler receives the narrowed error, and defects (`Die`) pass through untouched:
|
|
224
226
|
|
|
225
227
|
```python
|
|
226
|
-
|
|
228
|
+
roll = E.random().flat_map(lambda rng: rng.randint(1, 4))
|
|
227
229
|
|
|
228
|
-
p =
|
|
230
|
+
p = roll.flat_map(
|
|
229
231
|
lambda r: E.fail(FatalError()) if r == 2 else E.fail(RecoverableError())
|
|
230
232
|
) # Effect[Never, FatalError | RecoverableError]
|
|
231
233
|
|
|
@@ -301,6 +303,14 @@ program = conn.flat_map(
|
|
|
301
303
|
).scoped() # Scope discharged; close() runs when program settles
|
|
302
304
|
```
|
|
303
305
|
|
|
306
|
+
`on_exit` also takes a function that receives the `Exit` and returns the finalizer, so cleanup can depend on how the effect settled:
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
E.success(21).on_exit(
|
|
310
|
+
lambda exit: E.log_info("settled", exit)
|
|
311
|
+
) # Succeeded(21), or Failure(cause) carrying Fail, Die or Interrupt
|
|
312
|
+
```
|
|
313
|
+
|
|
304
314
|
A finalizer that dies doesn't skip the remaining finalizers; its defect surfaces in the final `Exit`.
|
|
305
315
|
|
|
306
316
|
More examples: [`test_scope.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_scope.py).
|
|
@@ -438,6 +448,168 @@ Provide clocks with `.provide(E.Clock.Protocol)(...)`: `provide_implicit` is key
|
|
|
438
448
|
|
|
439
449
|
More examples: [`test_clock.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_clock.py).
|
|
440
450
|
|
|
451
|
+
### Random
|
|
452
|
+
|
|
453
|
+
`E.random()` resolves the implicit `E.Random` service, so it never enters `R`. Its methods keep the names of the `random` standard library and each returns an effect: `random()`, `uniform(a, b)`, `randint(a, b)`, `choice(seq)` and `shuffle(seq)`, where `shuffle` returns a new list and leaves its input untouched. The default is `E.Random.Live`, which draws from the process-global generator behind the `random` module.
|
|
454
|
+
|
|
455
|
+
```python
|
|
456
|
+
@E.gen
|
|
457
|
+
def roll() -> E.EffectGen[int]:
|
|
458
|
+
rng = yield from E.random()
|
|
459
|
+
|
|
460
|
+
return (yield from rng.randint(1, 6))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
E.run_sync(roll()) # 1..6, no setup needed
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
In tests, provide `E.Random.Test(seed)`: every draw is a function of the seed, and one instance advances as a single sequence, so the same seed always reproduces the same run. A test that requests the `test_random` fixture gets a `Test` seeded with 0 provided to its effect:
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
@E.gen
|
|
470
|
+
def test_rolls_are_reproducible(test_random: E.Random.Test) -> E.EffectGen[None]:
|
|
471
|
+
first = yield from roll()
|
|
472
|
+
second = yield from roll()
|
|
473
|
+
|
|
474
|
+
assert (first, second) == (4, 4)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Provide generators with `.provide(E.Random.Protocol)(...)`, as with the Clock. This repo's ruff config bans the `random` module's drawing functions (`random.random`, `random.randint`, `random.choice`, ...) outside the service module, so all code goes through `E.random()`; building a `random.Random(seed)` to compute expected values in a test is still allowed.
|
|
478
|
+
|
|
479
|
+
More examples: [`test_random.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_random.py).
|
|
480
|
+
|
|
481
|
+
### Tracing
|
|
482
|
+
|
|
483
|
+
`E.with_span(effect, name)` opens a span around an effect: one named, timed unit that nests. A span opened inside another becomes its child and shares its trace id, and it ends with the effect's `Exit` when the effect settles, on success, typed failure, defect and interruption alike. Spans go through the implicit `E.Tracer` service, so nothing enters `R`; timestamps come from `E.now()` and ids are drawn through `E.random()`. `with_span` takes the effect first, like `annotate_logs`, and is also a method:
|
|
484
|
+
|
|
485
|
+
```python
|
|
486
|
+
@E.gen
|
|
487
|
+
def fetch_user(user_id: int) -> E.EffectGen[User, FetchError]:
|
|
488
|
+
yield from E.annotate_current_span(user_id=user_id) # attributes on the open span
|
|
489
|
+
...
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
traced = E.with_span(fetch_user(1), "fetch_user")
|
|
493
|
+
program = traced.with_span("handle_request", kind="server", route="/users/1")
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`E.annotate_current_span(**attributes)` adds attributes to the innermost open span and is a no-op outside one; `E.current_span()` returns that span and fails with `E.Tracer.NoCurrentSpan` outside one. The default tracer, `E.Tracer.Live`, opens in-memory spans that nothing collects, so tracing costs nothing until a tracer that exports them is provided: an OpenTelemetry exporter would be another implementation of `E.Tracer.Protocol`, and the hex ids already match the W3C `traceparent` header. A forked fiber starts with a fresh environment, so it opens root spans unless the parent is passed along with `E.provide_implicit(forked, E.Tracer.ParentSpan(span))`, `span` being the result of `E.current_span()`; `race_first` and `timeout` inherit the current span.
|
|
497
|
+
|
|
498
|
+
In tests, provide `E.Tracer.Test`, which records every span it opens in `spans`, in opening order. Each span carries `name`, `kind`, `attributes`, `parent`, `trace_id`, `span_id` and a `status` that is `Started(start_time)` while open and `Ended(start_time, end_time, exit)` afterwards. A test that requests the `test_tracer` fixture gets one provided to its effect:
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
@E.gen
|
|
502
|
+
def test_opens_a_span_per_request(test_tracer: E.Tracer.Test) -> E.EffectGen[None]:
|
|
503
|
+
yield from fetch_user(1).with_span("handle_request", kind="server")
|
|
504
|
+
|
|
505
|
+
request, user = test_tracer.spans
|
|
506
|
+
assert (request.name, user.parent) == ("handle_request", request)
|
|
507
|
+
assert isinstance(user.status, E.Tracer.Ended)
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
More examples: [`test_tracer.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_tracer.py).
|
|
511
|
+
|
|
512
|
+
### FileSystem
|
|
513
|
+
|
|
514
|
+
`E.FileSystem` reads and writes the disk. It is an explicit requirement: a program that needs it says so in `R` through `E.require(E.FileSystem.Protocol)`, and forgetting to provide an implementation is a type error rather than a surprise write to the real disk. Its methods follow the names of Effect-TS's FileSystem and each returns an effect with a precise error union: `exists`, `stat`, `read_file`, `read_file_string`, `write_file`, `write_file_string`, `make_directory(path, recursive=...)`, `read_directory`, `remove(path, recursive=...)`, `rename`, `copy_file`, `symlink(target, link)` and `read_link`. The working and home directories are not file I/O and live on `E.Process`. Errors are cause-specific (`FileNotFound`, `PermissionDenied`, `PathIsADirectory`, `PathIsNotADirectory`, `PathAlreadyExists`, `DirectoryNotEmpty`), so `catch(E.FileSystem.FileNotFound)` handles exactly the missing-file case; anything else the disk can do, such as running out of space, stays a defect.
|
|
515
|
+
|
|
516
|
+
```python
|
|
517
|
+
@E.gen
|
|
518
|
+
def read_config(
|
|
519
|
+
root: E.Path,
|
|
520
|
+
) -> E.EffectGen[str, E.FileSystem.ReadError, E.FileSystem.Protocol]:
|
|
521
|
+
fs = yield from E.require(E.FileSystem.Protocol)
|
|
522
|
+
|
|
523
|
+
return (yield from fs.read_file_string(root / "config.toml"))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
program = read_config(E.Path("/repo"))
|
|
527
|
+
E.run_sync(program.provide(E.FileSystem.Protocol)(E.FileSystem.SyncLive()))
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
There are two live implementations, one per runner, like the Clock: `E.FileSystem.SyncLive` blocks the thread on the `os` calls and suits `run_sync`; `E.FileSystem.AsyncLive` makes the same calls through [aiofiles](https://github.com/Tinche/aiofiles), so the loop keeps turning under `run_async` and `run_main`. aiofiles is a dependency of effecton, so both implementations are always available.
|
|
531
|
+
|
|
532
|
+
In tests, provide `E.FileSystem.Test`, an in-memory tree seeded with `files` (text or bytes), `directories` and `links`; every ancestor of a seeded path is created for you. It enforces the same rules as the disk, so a program gets the same `Exit` against `Test` as against a live implementation, and its state stays inspectable afterwards:
|
|
533
|
+
|
|
534
|
+
```python
|
|
535
|
+
def test_reads_the_config():
|
|
536
|
+
fs = E.FileSystem.Test(files={E.Path("/repo/config.toml"): "[packages]\n"})
|
|
537
|
+
|
|
538
|
+
result = E.run_sync(read_config(E.Path("/repo")).provide(E.FileSystem.Protocol)(fs))
|
|
539
|
+
|
|
540
|
+
assert result == "[packages]\n"
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
More examples: [`test_file_system.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_file_system.py).
|
|
544
|
+
|
|
545
|
+
### Path
|
|
546
|
+
|
|
547
|
+
`E.Path` is the only path type effecton code touches: an immutable value that joins with `/`, compares, hashes and sorts, and exposes `parent`, `parents`, `name`, `suffix`, `stem` and `parts`. Unlike `pathlib.Path` it has no I/O methods, so a path can never reach the disk on its own; every read and write goes through `E.FileSystem`. Joining follows the standard library's rules: an absolute right-hand side replaces the left, and redundant separators collapse.
|
|
548
|
+
|
|
549
|
+
```python
|
|
550
|
+
config = E.Path("/repo") / ".changeset" / "config.toml"
|
|
551
|
+
config.parent # Path('/repo/.changeset')
|
|
552
|
+
config.suffix # '.toml'
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
This repo's ruff config bans `pathlib`, `os.path`, `shutil`, `tempfile` and the `os` file functions outside the FileSystem and Process modules, so all code goes through `E.FileSystem`, `E.Process` and `E.Path`.
|
|
556
|
+
|
|
557
|
+
### Process
|
|
558
|
+
|
|
559
|
+
`E.Process` is what the running process knows about its environment: `cwd()` and `home()` today, with environment variables to follow. Both return an `E.Path`. It is an explicit requirement like the FileSystem, so a CLI reads its starting directory through it and a test pins that directory with `E.Process.Test(current_directory=..., home_directory=...)` instead of depending on where pytest happens to run.
|
|
560
|
+
|
|
561
|
+
```python
|
|
562
|
+
def from_cwd(program):
|
|
563
|
+
return E.require(E.Process.Protocol).flat_map(lambda p: p.cwd()).flat_map(program)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
E.run_main(
|
|
567
|
+
from_cwd(status)
|
|
568
|
+
.provide(E.Process.Protocol)(E.Process.Live())
|
|
569
|
+
.provide(E.FileSystem.Protocol)(E.FileSystem.AsyncLive())
|
|
570
|
+
)
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
### HttpClient
|
|
574
|
+
|
|
575
|
+
`E.HttpClient` talks HTTP. It is an explicit requirement like the FileSystem: a program that needs it says so in `R` through `E.require(E.HttpClient.Protocol)`, and forgetting to provide an implementation is a type error rather than a surprise request. `execute(request)` sends an `E.HttpClient.Request`, and the conveniences build one for you: `get`, `head`, `options` and `delete` take `url, *, headers, params`; `post`, `put` and `patch` add `body` (text or bytes) or `json`, which is serialized and sets the content type. Each returns an `E.HttpClient.Response` with `status`, lowercase `headers`, `body` bytes, a `text` property decoded with the content-type charset and a `json()` effect that fails with `InvalidJson`. As in Effect-TS, every status is a success: `E.HttpClient.filter_status_ok` is the opt-in that turns a non-2xx into `StatusError`. The one request error is `TransportError`, the Transport reason of Effect-TS: the connection could not be made or was lost, or the reply was not HTTP, so `catch(E.HttpClient.TransportError)` handles exactly the network misbehaving. A URL without a scheme is a programming error and stays a defect, as does anything else.
|
|
576
|
+
|
|
577
|
+
```python
|
|
578
|
+
@E.gen
|
|
579
|
+
def fetch_readme(
|
|
580
|
+
url: str,
|
|
581
|
+
) -> E.EffectGen[
|
|
582
|
+
str, E.HttpClient.TransportError | E.HttpClient.StatusError, E.HttpClient.Protocol
|
|
583
|
+
]:
|
|
584
|
+
http = yield from E.require(E.HttpClient.Protocol)
|
|
585
|
+
|
|
586
|
+
response = yield from http.get(url)
|
|
587
|
+
response = yield from E.HttpClient.filter_status_ok(response)
|
|
588
|
+
return response.text
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
program = fetch_readme(
|
|
592
|
+
"https://raw.githubusercontent.com/krzkaczor/effecton/main/README.md"
|
|
593
|
+
)
|
|
594
|
+
E.run_sync(program.provide(E.HttpClient.Protocol)(E.HttpClient.SyncLive()))
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
There are two live implementations, one per runner, like the Clock: `E.HttpClient.SyncLive` blocks the thread on an [httpx2](https://github.com/pydantic/httpx2) client and suits `run_sync`; `E.HttpClient.AsyncLive` awaits `httpx2.AsyncClient`, so the loop keeps turning under `run_async` and `run_main`. httpx2 is a dependency of effecton, so both are always available. Neither sets an HTTP-level timeout or follows redirects: bound a request the effecton way with `.timeout(...)` (under the async runners), and a 3xx comes back as a `Response`. Each request opens a fresh client for now, so there is no connection pooling yet.
|
|
598
|
+
|
|
599
|
+
In tests, provide `E.HttpClient.Test`. Seed it with `responses`, a URL-to-body mapping answered with a 200 where any other URL gets a 404, or give it a `handler` that receives the normalized `Request` and returns a `Response` or a request error to fail with. Either way every request is recorded in `requests`:
|
|
600
|
+
|
|
601
|
+
```python
|
|
602
|
+
def test_fetches_the_readme():
|
|
603
|
+
http = E.HttpClient.Test(responses={URL: "# Effecton"})
|
|
604
|
+
|
|
605
|
+
result = E.run_sync(fetch_readme(URL).provide(E.HttpClient.Protocol)(http))
|
|
606
|
+
|
|
607
|
+
assert result == "# Effecton"
|
|
608
|
+
assert http.requests == [E.HttpClient.Request("GET", URL)]
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
More examples: [`test_http_client.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_http_client.py). This repo's ruff config bans `httpx`, `httpx2`, `urllib.request` and `http.client` outside the service module, so all requests go through `E.HttpClient`.
|
|
612
|
+
|
|
441
613
|
### Fibers
|
|
442
614
|
|
|
443
615
|
`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.
|
|
@@ -494,13 +666,42 @@ For deterministic tests, provide `E.Clock.Test` around the timeout, fork the pro
|
|
|
494
666
|
|
|
495
667
|
More examples: [`test_timeout.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_timeout.py).
|
|
496
668
|
|
|
669
|
+
### Retries
|
|
670
|
+
|
|
671
|
+
`effect.retry(schedule=None, times=None, until=...)` re-runs an effect on a typed failure while the schedule recurs, sleeping through the Clock for each delay. `times` caps the retries at that many more attempts; `retry(times=3)` alone retries up to three times without waiting, and `retry()` with neither recurs forever. Defects and interrupts are never retried. When the schedule is exhausted, `times` is used up, or `until` holds for the error, the retry fails with that last error, so the error channel is unchanged.
|
|
672
|
+
|
|
673
|
+
```python
|
|
674
|
+
fetch_total().retry(times=3) # Effect[int, FetchError]
|
|
675
|
+
|
|
676
|
+
fetch_total().retry(E.Schedule.exponential(timedelta(seconds=1)), times=5)
|
|
677
|
+
|
|
678
|
+
fetch_user(1).retry(
|
|
679
|
+
E.Schedule.exponential(timedelta(seconds=1)),
|
|
680
|
+
until=lambda e: isinstance(e, NotFound),
|
|
681
|
+
) # Effect[User, FetchError | NotFound]
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
`E.Schedule.recurs(times)` recurs up to `times` more times without waiting, `E.Schedule.spaced(delay)` waits `delay` before every attempt, and `E.Schedule.exponential(base, factor=2.0)` waits `base`, then `base * factor`, and so on. The last two are unbounded; bound them with `times=` or `until`. `schedule.jittered(min=0.8, max=1.2)` scales every delay by a factor drawn uniformly from `[min, max]` through the `E.Random` service, so `E.Random.Test(seed)` makes the jitter reproducible. Every run of the retried effect starts its schedule over.
|
|
685
|
+
|
|
686
|
+
```python
|
|
687
|
+
fetch_user(1).retry(E.Schedule.exponential(timedelta(seconds=1)).jittered())
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
A schedule is a factory of steps, and each step is an effect yielding the next delay, which is what lets `jittered` consult `Random`. Build a custom one with `E.Schedule.from_delays(...)` from any callable that yields a fresh iterator of `timedelta` values, or with `E.Schedule(steps=...)` when the delays are computed by effects.
|
|
691
|
+
|
|
692
|
+
There is no decorator form because `until` is typed by the effect's error channel, which a decorator cannot see. `recurs` needs no waiting and works under `run_sync`; for deterministic tests of delayed schedules, provide `E.Clock.Test`, fork the program, then advance the clock once per gap and await the fiber. A forked run starts with a fresh environment, so provide the test clock and a seeded `E.Random.Test` inside the forked program.
|
|
693
|
+
|
|
694
|
+
More examples: [`test_retry.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_retry.py).
|
|
695
|
+
|
|
497
696
|
## Roadmap
|
|
498
697
|
|
|
499
698
|
- [x] `ty` support
|
|
500
699
|
- [x] Support for async/sync code
|
|
501
|
-
- [
|
|
700
|
+
- [x] Retries
|
|
502
701
|
- [x] Timeouts
|
|
503
|
-
- [
|
|
702
|
+
- [x] `Random` implicit service
|
|
703
|
+
- [x] `FileSystem` service and `Path`
|
|
704
|
+
- [x] `HttpClient` service
|
|
504
705
|
- [ ] More examples of integrations with existing ecosystem (fastapi, pydantic etc.)
|
|
505
706
|
|
|
506
707
|
## Inspirations
|
|
@@ -207,9 +207,9 @@ E.run_sync(p) # "recovered from oops"
|
|
|
207
207
|
Use `catch` to handle one error type and leave the rest in the error channel. The handler receives the narrowed error, and defects (`Die`) pass through untouched:
|
|
208
208
|
|
|
209
209
|
```python
|
|
210
|
-
|
|
210
|
+
roll = E.random().flat_map(lambda rng: rng.randint(1, 4))
|
|
211
211
|
|
|
212
|
-
p =
|
|
212
|
+
p = roll.flat_map(
|
|
213
213
|
lambda r: E.fail(FatalError()) if r == 2 else E.fail(RecoverableError())
|
|
214
214
|
) # Effect[Never, FatalError | RecoverableError]
|
|
215
215
|
|
|
@@ -285,6 +285,14 @@ program = conn.flat_map(
|
|
|
285
285
|
).scoped() # Scope discharged; close() runs when program settles
|
|
286
286
|
```
|
|
287
287
|
|
|
288
|
+
`on_exit` also takes a function that receives the `Exit` and returns the finalizer, so cleanup can depend on how the effect settled:
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
E.success(21).on_exit(
|
|
292
|
+
lambda exit: E.log_info("settled", exit)
|
|
293
|
+
) # Succeeded(21), or Failure(cause) carrying Fail, Die or Interrupt
|
|
294
|
+
```
|
|
295
|
+
|
|
288
296
|
A finalizer that dies doesn't skip the remaining finalizers; its defect surfaces in the final `Exit`.
|
|
289
297
|
|
|
290
298
|
More examples: [`test_scope.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_scope.py).
|
|
@@ -422,6 +430,168 @@ Provide clocks with `.provide(E.Clock.Protocol)(...)`: `provide_implicit` is key
|
|
|
422
430
|
|
|
423
431
|
More examples: [`test_clock.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_clock.py).
|
|
424
432
|
|
|
433
|
+
### Random
|
|
434
|
+
|
|
435
|
+
`E.random()` resolves the implicit `E.Random` service, so it never enters `R`. Its methods keep the names of the `random` standard library and each returns an effect: `random()`, `uniform(a, b)`, `randint(a, b)`, `choice(seq)` and `shuffle(seq)`, where `shuffle` returns a new list and leaves its input untouched. The default is `E.Random.Live`, which draws from the process-global generator behind the `random` module.
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
@E.gen
|
|
439
|
+
def roll() -> E.EffectGen[int]:
|
|
440
|
+
rng = yield from E.random()
|
|
441
|
+
|
|
442
|
+
return (yield from rng.randint(1, 6))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
E.run_sync(roll()) # 1..6, no setup needed
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
In tests, provide `E.Random.Test(seed)`: every draw is a function of the seed, and one instance advances as a single sequence, so the same seed always reproduces the same run. A test that requests the `test_random` fixture gets a `Test` seeded with 0 provided to its effect:
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
@E.gen
|
|
452
|
+
def test_rolls_are_reproducible(test_random: E.Random.Test) -> E.EffectGen[None]:
|
|
453
|
+
first = yield from roll()
|
|
454
|
+
second = yield from roll()
|
|
455
|
+
|
|
456
|
+
assert (first, second) == (4, 4)
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Provide generators with `.provide(E.Random.Protocol)(...)`, as with the Clock. This repo's ruff config bans the `random` module's drawing functions (`random.random`, `random.randint`, `random.choice`, ...) outside the service module, so all code goes through `E.random()`; building a `random.Random(seed)` to compute expected values in a test is still allowed.
|
|
460
|
+
|
|
461
|
+
More examples: [`test_random.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_random.py).
|
|
462
|
+
|
|
463
|
+
### Tracing
|
|
464
|
+
|
|
465
|
+
`E.with_span(effect, name)` opens a span around an effect: one named, timed unit that nests. A span opened inside another becomes its child and shares its trace id, and it ends with the effect's `Exit` when the effect settles, on success, typed failure, defect and interruption alike. Spans go through the implicit `E.Tracer` service, so nothing enters `R`; timestamps come from `E.now()` and ids are drawn through `E.random()`. `with_span` takes the effect first, like `annotate_logs`, and is also a method:
|
|
466
|
+
|
|
467
|
+
```python
|
|
468
|
+
@E.gen
|
|
469
|
+
def fetch_user(user_id: int) -> E.EffectGen[User, FetchError]:
|
|
470
|
+
yield from E.annotate_current_span(user_id=user_id) # attributes on the open span
|
|
471
|
+
...
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
traced = E.with_span(fetch_user(1), "fetch_user")
|
|
475
|
+
program = traced.with_span("handle_request", kind="server", route="/users/1")
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
`E.annotate_current_span(**attributes)` adds attributes to the innermost open span and is a no-op outside one; `E.current_span()` returns that span and fails with `E.Tracer.NoCurrentSpan` outside one. The default tracer, `E.Tracer.Live`, opens in-memory spans that nothing collects, so tracing costs nothing until a tracer that exports them is provided: an OpenTelemetry exporter would be another implementation of `E.Tracer.Protocol`, and the hex ids already match the W3C `traceparent` header. A forked fiber starts with a fresh environment, so it opens root spans unless the parent is passed along with `E.provide_implicit(forked, E.Tracer.ParentSpan(span))`, `span` being the result of `E.current_span()`; `race_first` and `timeout` inherit the current span.
|
|
479
|
+
|
|
480
|
+
In tests, provide `E.Tracer.Test`, which records every span it opens in `spans`, in opening order. Each span carries `name`, `kind`, `attributes`, `parent`, `trace_id`, `span_id` and a `status` that is `Started(start_time)` while open and `Ended(start_time, end_time, exit)` afterwards. A test that requests the `test_tracer` fixture gets one provided to its effect:
|
|
481
|
+
|
|
482
|
+
```python
|
|
483
|
+
@E.gen
|
|
484
|
+
def test_opens_a_span_per_request(test_tracer: E.Tracer.Test) -> E.EffectGen[None]:
|
|
485
|
+
yield from fetch_user(1).with_span("handle_request", kind="server")
|
|
486
|
+
|
|
487
|
+
request, user = test_tracer.spans
|
|
488
|
+
assert (request.name, user.parent) == ("handle_request", request)
|
|
489
|
+
assert isinstance(user.status, E.Tracer.Ended)
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
More examples: [`test_tracer.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_tracer.py).
|
|
493
|
+
|
|
494
|
+
### FileSystem
|
|
495
|
+
|
|
496
|
+
`E.FileSystem` reads and writes the disk. It is an explicit requirement: a program that needs it says so in `R` through `E.require(E.FileSystem.Protocol)`, and forgetting to provide an implementation is a type error rather than a surprise write to the real disk. Its methods follow the names of Effect-TS's FileSystem and each returns an effect with a precise error union: `exists`, `stat`, `read_file`, `read_file_string`, `write_file`, `write_file_string`, `make_directory(path, recursive=...)`, `read_directory`, `remove(path, recursive=...)`, `rename`, `copy_file`, `symlink(target, link)` and `read_link`. The working and home directories are not file I/O and live on `E.Process`. Errors are cause-specific (`FileNotFound`, `PermissionDenied`, `PathIsADirectory`, `PathIsNotADirectory`, `PathAlreadyExists`, `DirectoryNotEmpty`), so `catch(E.FileSystem.FileNotFound)` handles exactly the missing-file case; anything else the disk can do, such as running out of space, stays a defect.
|
|
497
|
+
|
|
498
|
+
```python
|
|
499
|
+
@E.gen
|
|
500
|
+
def read_config(
|
|
501
|
+
root: E.Path,
|
|
502
|
+
) -> E.EffectGen[str, E.FileSystem.ReadError, E.FileSystem.Protocol]:
|
|
503
|
+
fs = yield from E.require(E.FileSystem.Protocol)
|
|
504
|
+
|
|
505
|
+
return (yield from fs.read_file_string(root / "config.toml"))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
program = read_config(E.Path("/repo"))
|
|
509
|
+
E.run_sync(program.provide(E.FileSystem.Protocol)(E.FileSystem.SyncLive()))
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
There are two live implementations, one per runner, like the Clock: `E.FileSystem.SyncLive` blocks the thread on the `os` calls and suits `run_sync`; `E.FileSystem.AsyncLive` makes the same calls through [aiofiles](https://github.com/Tinche/aiofiles), so the loop keeps turning under `run_async` and `run_main`. aiofiles is a dependency of effecton, so both implementations are always available.
|
|
513
|
+
|
|
514
|
+
In tests, provide `E.FileSystem.Test`, an in-memory tree seeded with `files` (text or bytes), `directories` and `links`; every ancestor of a seeded path is created for you. It enforces the same rules as the disk, so a program gets the same `Exit` against `Test` as against a live implementation, and its state stays inspectable afterwards:
|
|
515
|
+
|
|
516
|
+
```python
|
|
517
|
+
def test_reads_the_config():
|
|
518
|
+
fs = E.FileSystem.Test(files={E.Path("/repo/config.toml"): "[packages]\n"})
|
|
519
|
+
|
|
520
|
+
result = E.run_sync(read_config(E.Path("/repo")).provide(E.FileSystem.Protocol)(fs))
|
|
521
|
+
|
|
522
|
+
assert result == "[packages]\n"
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
More examples: [`test_file_system.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_file_system.py).
|
|
526
|
+
|
|
527
|
+
### Path
|
|
528
|
+
|
|
529
|
+
`E.Path` is the only path type effecton code touches: an immutable value that joins with `/`, compares, hashes and sorts, and exposes `parent`, `parents`, `name`, `suffix`, `stem` and `parts`. Unlike `pathlib.Path` it has no I/O methods, so a path can never reach the disk on its own; every read and write goes through `E.FileSystem`. Joining follows the standard library's rules: an absolute right-hand side replaces the left, and redundant separators collapse.
|
|
530
|
+
|
|
531
|
+
```python
|
|
532
|
+
config = E.Path("/repo") / ".changeset" / "config.toml"
|
|
533
|
+
config.parent # Path('/repo/.changeset')
|
|
534
|
+
config.suffix # '.toml'
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
This repo's ruff config bans `pathlib`, `os.path`, `shutil`, `tempfile` and the `os` file functions outside the FileSystem and Process modules, so all code goes through `E.FileSystem`, `E.Process` and `E.Path`.
|
|
538
|
+
|
|
539
|
+
### Process
|
|
540
|
+
|
|
541
|
+
`E.Process` is what the running process knows about its environment: `cwd()` and `home()` today, with environment variables to follow. Both return an `E.Path`. It is an explicit requirement like the FileSystem, so a CLI reads its starting directory through it and a test pins that directory with `E.Process.Test(current_directory=..., home_directory=...)` instead of depending on where pytest happens to run.
|
|
542
|
+
|
|
543
|
+
```python
|
|
544
|
+
def from_cwd(program):
|
|
545
|
+
return E.require(E.Process.Protocol).flat_map(lambda p: p.cwd()).flat_map(program)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
E.run_main(
|
|
549
|
+
from_cwd(status)
|
|
550
|
+
.provide(E.Process.Protocol)(E.Process.Live())
|
|
551
|
+
.provide(E.FileSystem.Protocol)(E.FileSystem.AsyncLive())
|
|
552
|
+
)
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### HttpClient
|
|
556
|
+
|
|
557
|
+
`E.HttpClient` talks HTTP. It is an explicit requirement like the FileSystem: a program that needs it says so in `R` through `E.require(E.HttpClient.Protocol)`, and forgetting to provide an implementation is a type error rather than a surprise request. `execute(request)` sends an `E.HttpClient.Request`, and the conveniences build one for you: `get`, `head`, `options` and `delete` take `url, *, headers, params`; `post`, `put` and `patch` add `body` (text or bytes) or `json`, which is serialized and sets the content type. Each returns an `E.HttpClient.Response` with `status`, lowercase `headers`, `body` bytes, a `text` property decoded with the content-type charset and a `json()` effect that fails with `InvalidJson`. As in Effect-TS, every status is a success: `E.HttpClient.filter_status_ok` is the opt-in that turns a non-2xx into `StatusError`. The one request error is `TransportError`, the Transport reason of Effect-TS: the connection could not be made or was lost, or the reply was not HTTP, so `catch(E.HttpClient.TransportError)` handles exactly the network misbehaving. A URL without a scheme is a programming error and stays a defect, as does anything else.
|
|
558
|
+
|
|
559
|
+
```python
|
|
560
|
+
@E.gen
|
|
561
|
+
def fetch_readme(
|
|
562
|
+
url: str,
|
|
563
|
+
) -> E.EffectGen[
|
|
564
|
+
str, E.HttpClient.TransportError | E.HttpClient.StatusError, E.HttpClient.Protocol
|
|
565
|
+
]:
|
|
566
|
+
http = yield from E.require(E.HttpClient.Protocol)
|
|
567
|
+
|
|
568
|
+
response = yield from http.get(url)
|
|
569
|
+
response = yield from E.HttpClient.filter_status_ok(response)
|
|
570
|
+
return response.text
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
program = fetch_readme(
|
|
574
|
+
"https://raw.githubusercontent.com/krzkaczor/effecton/main/README.md"
|
|
575
|
+
)
|
|
576
|
+
E.run_sync(program.provide(E.HttpClient.Protocol)(E.HttpClient.SyncLive()))
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
There are two live implementations, one per runner, like the Clock: `E.HttpClient.SyncLive` blocks the thread on an [httpx2](https://github.com/pydantic/httpx2) client and suits `run_sync`; `E.HttpClient.AsyncLive` awaits `httpx2.AsyncClient`, so the loop keeps turning under `run_async` and `run_main`. httpx2 is a dependency of effecton, so both are always available. Neither sets an HTTP-level timeout or follows redirects: bound a request the effecton way with `.timeout(...)` (under the async runners), and a 3xx comes back as a `Response`. Each request opens a fresh client for now, so there is no connection pooling yet.
|
|
580
|
+
|
|
581
|
+
In tests, provide `E.HttpClient.Test`. Seed it with `responses`, a URL-to-body mapping answered with a 200 where any other URL gets a 404, or give it a `handler` that receives the normalized `Request` and returns a `Response` or a request error to fail with. Either way every request is recorded in `requests`:
|
|
582
|
+
|
|
583
|
+
```python
|
|
584
|
+
def test_fetches_the_readme():
|
|
585
|
+
http = E.HttpClient.Test(responses={URL: "# Effecton"})
|
|
586
|
+
|
|
587
|
+
result = E.run_sync(fetch_readme(URL).provide(E.HttpClient.Protocol)(http))
|
|
588
|
+
|
|
589
|
+
assert result == "# Effecton"
|
|
590
|
+
assert http.requests == [E.HttpClient.Request("GET", URL)]
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
More examples: [`test_http_client.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_http_client.py). This repo's ruff config bans `httpx`, `httpx2`, `urllib.request` and `http.client` outside the service module, so all requests go through `E.HttpClient`.
|
|
594
|
+
|
|
425
595
|
### Fibers
|
|
426
596
|
|
|
427
597
|
`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.
|
|
@@ -478,13 +648,42 @@ For deterministic tests, provide `E.Clock.Test` around the timeout, fork the pro
|
|
|
478
648
|
|
|
479
649
|
More examples: [`test_timeout.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_timeout.py).
|
|
480
650
|
|
|
651
|
+
### Retries
|
|
652
|
+
|
|
653
|
+
`effect.retry(schedule=None, times=None, until=...)` re-runs an effect on a typed failure while the schedule recurs, sleeping through the Clock for each delay. `times` caps the retries at that many more attempts; `retry(times=3)` alone retries up to three times without waiting, and `retry()` with neither recurs forever. Defects and interrupts are never retried. When the schedule is exhausted, `times` is used up, or `until` holds for the error, the retry fails with that last error, so the error channel is unchanged.
|
|
654
|
+
|
|
655
|
+
```python
|
|
656
|
+
fetch_total().retry(times=3) # Effect[int, FetchError]
|
|
657
|
+
|
|
658
|
+
fetch_total().retry(E.Schedule.exponential(timedelta(seconds=1)), times=5)
|
|
659
|
+
|
|
660
|
+
fetch_user(1).retry(
|
|
661
|
+
E.Schedule.exponential(timedelta(seconds=1)),
|
|
662
|
+
until=lambda e: isinstance(e, NotFound),
|
|
663
|
+
) # Effect[User, FetchError | NotFound]
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
`E.Schedule.recurs(times)` recurs up to `times` more times without waiting, `E.Schedule.spaced(delay)` waits `delay` before every attempt, and `E.Schedule.exponential(base, factor=2.0)` waits `base`, then `base * factor`, and so on. The last two are unbounded; bound them with `times=` or `until`. `schedule.jittered(min=0.8, max=1.2)` scales every delay by a factor drawn uniformly from `[min, max]` through the `E.Random` service, so `E.Random.Test(seed)` makes the jitter reproducible. Every run of the retried effect starts its schedule over.
|
|
667
|
+
|
|
668
|
+
```python
|
|
669
|
+
fetch_user(1).retry(E.Schedule.exponential(timedelta(seconds=1)).jittered())
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
A schedule is a factory of steps, and each step is an effect yielding the next delay, which is what lets `jittered` consult `Random`. Build a custom one with `E.Schedule.from_delays(...)` from any callable that yields a fresh iterator of `timedelta` values, or with `E.Schedule(steps=...)` when the delays are computed by effects.
|
|
673
|
+
|
|
674
|
+
There is no decorator form because `until` is typed by the effect's error channel, which a decorator cannot see. `recurs` needs no waiting and works under `run_sync`; for deterministic tests of delayed schedules, provide `E.Clock.Test`, fork the program, then advance the clock once per gap and await the fiber. A forked run starts with a fresh environment, so provide the test clock and a seeded `E.Random.Test` inside the forked program.
|
|
675
|
+
|
|
676
|
+
More examples: [`test_retry.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_retry.py).
|
|
677
|
+
|
|
481
678
|
## Roadmap
|
|
482
679
|
|
|
483
680
|
- [x] `ty` support
|
|
484
681
|
- [x] Support for async/sync code
|
|
485
|
-
- [
|
|
682
|
+
- [x] Retries
|
|
486
683
|
- [x] Timeouts
|
|
487
|
-
- [
|
|
684
|
+
- [x] `Random` implicit service
|
|
685
|
+
- [x] `FileSystem` service and `Path`
|
|
686
|
+
- [x] `HttpClient` service
|
|
488
687
|
- [ ] More examples of integrations with existing ecosystem (fastapi, pydantic etc.)
|
|
489
688
|
|
|
490
689
|
## Inspirations
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "effecton"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
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"
|