pyflowstep 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyflowstep
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A lightweight, typed Python library for composing functions into readable, reusable flows that can be defined as JSON.
5
5
  Keywords: flow,pipeline,composition,functional,fluent-interface,json,json-schema,workflow,dsl
6
6
  Author: aaltatan
@@ -21,550 +21,742 @@ Project-URL: Repository, https://github.com/aaltatan/pyflowstep
21
21
  Project-URL: Issues, https://github.com/aaltatan/pyflowstep/issues
22
22
  Description-Content-Type: text/markdown
23
23
 
24
- # pyflowstep
25
-
26
- A lightweight, typed Python library for composing functions into readable, reusable **flows** — a functional replacement for the fluent-interface (method-chaining) pattern, with flows that can be defined, validated and stored as **JSON**.
27
-
28
- `pyflowstep` focuses on a functional style:
29
-
30
- - steps are plain functions: `(subject, *args, **kwargs) -> subject`
31
- - flows are immutable values that compose with `>>`
32
- - step arguments are validated and processed when a flow is **built**, not halfway through running it
33
- - registry-based registration keeps steps organized and discoverable
34
- - flow definitions can be compiled from dictionaries or JSON
35
- - every registry can describe its flow language as a JSON Schema
36
-
37
- It pairs naturally with its siblings [pyspecification](https://github.com/aaltatan/pyspecification) (predicates) and [pyformula](https://github.com/aaltatan/pyformula) (formulas), and has **zero dependencies**.
38
-
39
- ---
40
-
41
- ## Why use pyflowstep?
42
-
43
- A fluent API makes you put every operation on the class and `return self` from each method:
44
-
45
- ```python
46
- page.navigate("https://www.google.com").fill("input[name=q]", "pyflowstep").click("#go").wait("#result")
47
- ```
48
-
49
- That couples *what can be done* to *one class*, you cannot store the chain, reuse half of it, build it from data, or add an operation without editing the class.
50
-
51
- With `pyflowstep`, operations are small functions and the chain is a value:
52
-
53
- ```python
54
- search = navigate("https://www.google.com") >> fill("input[name=q]", "pyflowstep") >> click("#go")
55
-
56
- search(page) # run it
57
- search >> wait("#result") # extend it (a new flow, `search` is unchanged)
58
- login >> search >> logout # combine flows
59
- ```
60
-
61
- …and the same flow can come from JSON:
62
-
63
- ```json
64
- [
65
- {"name": "navigate", "args": ["https://www.google.com"]},
66
- {"name": "fill", "args": ["input[name=q]", "pyflowstep"]},
67
- {"name": "click", "kwargs": {"selector": "#go"}}
68
- ]
69
- ```
70
-
71
- ---
72
-
73
- ## Installation
74
-
75
- ```bash
76
- pip install pyflowstep
77
- ```
78
-
79
- or with uv:
80
-
81
- ```bash
82
- uv add pyflowstep
83
- ```
84
-
85
- Requires Python 3.12+.
86
-
87
- ---
88
-
89
- ## Quick start
90
-
91
- ```python
92
- from typing import Any
93
-
94
- from pyflowstep import step
95
-
96
-
97
- class Page:
98
- def navigate(self, url: str) -> None:
99
- print(f"Navigating to {url}")
100
-
101
- def click(self, selector: str) -> None:
102
- print(f"Clicking on {selector}")
103
-
104
- def fill(self, selector: str, value: Any) -> None:
105
- print(f"Filling {selector} with {value}")
106
-
107
-
108
- @step
109
- def navigate(page: Page, url: str) -> Page:
110
- page.navigate(url)
111
- return page
112
-
113
-
114
- @step
115
- def fill(page: Page, selector: str, value: Any) -> Page:
116
- page.fill(selector, value)
117
- return page
118
-
119
-
120
- @step
121
- def click(page: Page, selector: str) -> Page:
122
- page.click(selector)
123
- return page
124
-
125
-
126
- search = navigate("https://www.google.com") >> fill("input[name=q]", 1111) >> click("#go")
127
-
128
- print(search) # Flow(navigate >> fill >> click)
129
- search(Page())
130
- # Navigating to https://www.google.com
131
- # Filling input[name=q] with 1111
132
- # Clicking on #go
133
- ```
134
-
135
- ---
136
-
137
- ## Core concepts
138
-
139
- ### Flow
140
-
141
- A `Flow[T]` is an immutable sequence of `T -> T` actions. Calling it threads the subject through every action in order.
142
-
143
- ```python
144
- from pyflowstep import Flow, compose
145
-
146
- increment = Flow[int](lambda n: n + 1)
147
- double = Flow[int](lambda n: n * 2)
148
-
149
- (increment >> double)(3) # 8
150
- (double >> increment)(3) # 7
151
- compose(increment, double)(3) # 8, same as increment >> double
152
- Flow[int]()(3) # 3, an empty flow is the identity
153
- ```
154
-
155
- - `>>` accepts flows **and** plain callables, on either side: `flow >> fn`, `fn >> flow`.
156
- - Composition flattens: `(a >> b) >> c` and `a >> (b >> c)` have the same three actions.
157
- - Flows support `len()`, iteration, `.actions` and a readable `repr`.
158
-
159
- ### step
160
-
161
- `@step` turns a function whose **first positional parameter is the subject** into a *step factory*. Calling the factory with the remaining arguments returns a single-step flow.
162
-
163
- ```python
164
- from pyflowstep import step
165
-
166
-
167
- @step
168
- def add(total: int, amount: int) -> int:
169
- return total + amount
170
-
171
-
172
- @step
173
- def multiply(total: int, factor: int) -> int:
174
- return total * factor
175
-
176
-
177
- pipeline = add(2) >> multiply(10) >> add(amount=1)
178
- pipeline # Flow(add >> multiply >> add)
179
- pipeline(1) # 31
180
- ```
181
-
182
- `step` and `tap` do one thing: turn a function into a flow factory. Naming a step and processing its arguments belong to the [registry](#registry).
183
-
184
- Arguments are bound against the function signature **immediately**, so mistakes fail fast:
185
-
186
- ```python
187
- add() # MissingArgumentError: missing a required argument: 'amount' for step 'add'
188
- add(1, 2) # TooManyArgumentsError: too many positional arguments for step 'add'
189
- add(1, x=2) # UnexpectedKeywordArgumentError: got an unexpected keyword argument 'x' ...
190
- ```
191
-
192
- ### tap
193
-
194
- For side-effect steps on a mutable subject, `@tap` ignores the return value and passes the subject on unchanged — no more `return page` boilerplate:
195
-
196
- ```python
197
- from pyflowstep import tap
198
-
199
-
200
- @tap
201
- def click(page: Page, selector: str) -> None:
202
- page.click(selector)
203
- ```
204
-
205
- ---
206
-
207
- ## Registry
208
-
209
- `StepsRegistry[T]` collects named steps for one subject type. It is the vocabulary of your flow language: the compiler and the JSON schema only know about the steps registered in it.
210
-
211
- ```python
212
- from pyflowstep import StepsRegistry
213
-
214
- page_steps = StepsRegistry[Page]()
215
-
216
-
217
- @page_steps.tap()
218
- def navigate(page: Page, url: str) -> None:
219
- """Open a url in the page."""
220
- page.navigate(url)
221
-
222
-
223
- @page_steps.tap()
224
- def click(page: Page, selector: str) -> None:
225
- """Click the element matching a CSS selector."""
226
- page.click(selector)
227
-
228
-
229
- @page_steps.tap(name="type", processors={"value": str})
230
- def fill(page: Page, selector: str, value: str) -> None:
231
- """Type a value into a field."""
232
- page.fill(selector, value)
233
-
234
-
235
- @page_steps.tap(hidden=True)
236
- def debug_dump(page: Page) -> None:
237
- """Python-only helper, never exposed to JSON."""
238
-
239
-
240
- page_steps["click"]("#go") # look up a step factory by name
241
- "type" in page_steps # True
242
- list(page_steps.steps) # ['navigate', 'click', 'type'] (hidden steps are excluded)
243
-
244
- page_steps.register(lambda page: page, name="noop") # register without a decorator
245
- ```
246
-
247
- | Method / attribute | Description |
248
- | ---------------------------------------------------------------- | ----------------------------------------------------------- |
249
- | `step(name=, description=, processors=, hidden=)` | Decorator for `(subject, ...) -> subject` functions |
250
- | `tap(name=, description=, processors=, hidden=)` | Decorator for side-effect functions (return value ignored) |
251
- | `register(fn, *, name=, description=, processors=, hidden=, passthrough=)` | Register without decorator syntax |
252
- | `steps` | Read-only mapping of visible steps |
253
- | `registry[name]`, `name in registry`, `len()`, iteration | Lookup over visible steps |
254
-
255
- - `name` is the step's public name: the compiler looks it up, and `repr` and argument errors show it (`Flow(type)` above, not `Flow(fill)`).
256
- - `processors` transform the raw arguments every time the factory is called, before they reach the step. See [Processors](#processors).
257
- - `description` overrides the docstring, which becomes the step description in the JSON schema.
258
-
259
- ---
260
-
261
- ## Processors
262
-
263
- Processors transform raw arguments before a step is built. They matter most for JSON and web forms, where every value arrives as a string, number, boolean, list, object or null, while your steps want dates, decimals, enums and clean text.
264
-
265
- Processors are a registry option: pass `processors=` to `registry.step()`, `registry.tap()` or `registry.register()`.
266
-
267
- ### The four forms
268
-
269
- | `processors=` | Effect |
270
- | ------------------------------------------ | -------------------------------------------------------------- |
271
- | *(omitted, `None`)* | Arguments reach the step exactly as given |
272
- | `fn` | `fn` is applied to **every** argument |
273
- | `{"name": fn, ...}` | Only the named arguments are processed, the rest are untouched |
274
- | `{"name": fn, ...: other}` | The named arguments use their own processor, `...` covers **all the others** |
275
-
276
- ```python
277
- from decimal import Decimal
278
-
279
-
280
- @page_steps.tap() # nothing to process
281
- def click(page: Page, selector: str) -> None: ...
282
-
283
-
284
- @barista.step(processors=Decimal) # every argument
285
- def price_between(drink: Drink, low: Decimal, high: Decimal) -> Drink: ...
286
-
287
-
288
- @page_steps.tap(processors={"timeout": float}) # only `timeout`, `selector` untouched
289
- def wait(page: Page, selector: str, timeout: float = 5.0) -> None: ...
290
-
291
-
292
- @barista.step(processors={"pumps": int, ...: str.strip}) # `pumps`, and `...` for the rest
293
- def add_syrup(drink: Drink, flavor: str, pumps: int = 1) -> Drink: ...
294
- ```
295
-
296
- ### The `...` key
297
-
298
- `...` (Python's `Ellipsis`) reads as "every argument not named here", just like in `tuple[int, ...]`. A named entry always wins over `...`, and a mapping holding only `...` behaves like a single callable. Because `...` can never be a parameter name, it can never clash with one.
299
-
300
- It also covers the extra keywords a step collects with `**kwargs`, which is handy for open-ended filters:
301
-
302
- ```python
303
- def normalize(text: str) -> str:
304
- return text.strip().lower()
305
-
306
-
307
- @catalog_steps.step(processors={"in_stock": parse_bool, ...: normalize})
308
- def where(products: Catalog, *, in_stock: bool = False, **fields: str) -> Catalog: ...
309
-
310
-
311
- where(in_stock="yes", brand=" SONIC ") # in_stock=True, brand="sonic"
312
- ```
313
-
314
- ### Which values are processed
315
-
316
- - A processor for a parameter applies whether the value was passed positionally **or** by keyword.
317
- - For `*args` it applies to each item. For `**kwargs`, each extra keyword is looked up by its own name, falling back to `...`.
318
- - Default values are never processed: only arguments that were actually passed go through a processor.
319
- - The subject (the step's first parameter) is never processed, since it is not a step argument.
320
-
321
- ### When they run
322
-
323
- Processors run **once per factory call**, while the flow is being built, not every time the flow runs:
324
-
325
- ```python
326
- flow = add_syrup(" Vanilla ", "2") # processors run here: flavor="Vanilla", pumps=2
327
- flow(drink) # the step runs with the processed values
328
- flow(another_drink) # no processing again
329
- ```
330
-
331
- The arguments are first bound to the step signature, so a missing or extra argument is reported as an `ArgumentError` before any processor runs.
332
-
333
- ### Errors
334
-
335
- Mistakes in the `processors` option are caught **when the step is registered**, with `InvalidProcessorsError`:
336
-
337
- ```python
338
- @page_steps.tap(processors={"timout": float}) # typo
339
- def wait(page: Page, selector: str, timeout: float = 5.0) -> None: ...
340
- # InvalidProcessorsError: Processors of step 'wait' name unknown parameters ['timout'], available parameters: selector, timeout
341
- ```
342
-
343
- | Problem | Message |
344
- | ---------------------------------------------- | ---------------------------------------------------------------- |
345
- | A key is not a parameter of the step | `... name unknown parameters ['timout'], available parameters: ...` |
346
- | A value is not callable, e.g. `{"pumps": "int"}` | `... must be callables, not for ['pumps']` |
347
- | Neither a callable nor a mapping | `... must be a callable or a mapping of parameter names to callables, got ...` |
348
-
349
- Steps that accept `**kwargs` allow any key, since any keyword name is valid for them.
350
-
351
- A processor that fails on a value raises `ProcessArgumentError`, chained to the original exception. Inside a compiled flow it also carries the JSON path of the step:
352
-
353
- ```text
354
- pyflowstep.exceptions.ProcessArgumentError: Argument 'price' with value 'cheap' failed to process, [<class 'decimal.ConversionSyntax'>]
355
- at $[0]
356
- ```
357
-
358
- See [`examples/processors.py`](examples/processors.py) for every form working together on data from a web form.
359
-
360
- ---
361
-
362
- ## Compiling flows from JSON
363
-
364
- `FlowCompiler` turns a flow definition — a list of step dictionaries — into a `Flow`.
365
-
366
- ```python
367
- from pyflowstep import FlowCompiler
368
-
369
- compiler = FlowCompiler(page_steps.steps)
370
-
371
- login = compiler.compile(
372
- [
373
- {"name": "navigate", "args": ["https://example.com/login"]},
374
- {"name": "type", "args": ["#email", "ada@example.com"]},
375
- {"name": "click", "kwargs": {"selector": "button[type=submit]"}},
376
- ],
377
- )
378
-
379
- login(Page())
380
- ```
381
-
382
- The compiler takes already-parsed data. Reading JSON, YAML, a database row or an API payload is up to you:
383
-
384
- ```python
385
- import json
386
- from pathlib import Path
387
-
388
- login = compiler.compile(json.loads(Path("login.json").read_text()))
389
- ```
390
-
391
- Each step dictionary has this shape — only `name` is required:
392
-
393
- ```json
394
- {"name": "fill", "args": ["#email"], "kwargs": {"value": "ada@example.com"}}
395
- ```
396
-
397
- Everything is validated while compiling, before any step runs. Errors carry a note with their JSON path:
398
-
399
- ```text
400
- pyflowstep.exceptions.ProcessArgumentError: Argument 'url' with value 'http://insecure.example.com' failed to process, only https urls are allowed
401
- at $[1]
402
- ```
403
-
404
- | Problem | Exception |
405
- | ------------------------------------------------ | ------------------------------------------------------ |
406
- | Not a list / malformed step dict | `InvalidFlowDefinitionError` |
407
- | Unknown or hidden step name | `StepDoesNotExistError` (lists the available steps) |
408
- | Missing / extra / duplicated arguments | `MissingArgumentError`, `TooManyArgumentsError`, ... |
409
- | A processor rejects a value | `ProcessArgumentError` |
410
-
411
- A compiled flow is an ordinary `Flow`, so it composes with Python steps: `login >> click("#profile")`.
412
-
413
- ---
414
-
415
- ## JSON Schema
416
-
417
- Describe your flow language so a UI, a validator, or an LLM can produce valid flows:
418
-
419
- ```python
420
- import json
421
-
422
- from pyflowstep import get_flow_json_schema, get_step_json_schema
423
-
424
- print(json.dumps(get_flow_json_schema(page_steps.steps), indent=2))
425
- ```
426
-
427
- Output, trimmed to the `click` step:
428
-
429
- ```json
430
- {
431
- "$schema": "https://json-schema.org/draft/2020-12/schema",
432
- "type": "array",
433
- "items": {
434
- "oneOf": [
435
- {
436
- "type": "object",
437
- "properties": {
438
- "name": {"const": "click"},
439
- "args": {"type": "array", "prefixItems": [{"type": "string"}], "items": false},
440
- "kwargs": {
441
- "type": "object",
442
- "properties": {"selector": {"type": "string"}},
443
- "required": [],
444
- "additionalProperties": false
445
- }
446
- },
447
- "required": ["name"],
448
- "additionalProperties": false,
449
- "description": "Click the element matching a CSS selector."
450
- }
451
- ]
452
- }
453
- }
454
- ```
455
-
456
- Supported annotations: `str`, `int`, `float`, `bool`, `None`, `Decimal`, `datetime`, `date`, `time`, `UUID`, `list`/`tuple`/`set`/`frozenset`, `dict`, `Literal`, `Enum`, unions, `Annotated`, `TypedDict`, and `type` aliases. Anything else maps to `{}` (any value). JSON-compatible default values are included as `default`.
457
-
458
- ---
459
-
460
- ## Examples
461
-
462
- Runnable examples live in [`examples/`](examples):
463
-
464
- - [`examples/browser.py`](examples/browser.py) — the `Page` automation above: `tap` steps, an https-only processor, reusable sub-flows and a JSON login scenario.
465
-
466
- ```bash
467
- uv run python -m examples.browser
468
- ```
469
-
470
- - [`examples/coffee.py`](examples/coffee.py) — a coffee shop where every step returns a **new** immutable `Drink`. The menu is JSON, orders are customized with Python steps, and a hidden staff-only `discount` step is invisible to the menu.
471
-
472
- ```bash
473
- uv run python -m examples.coffee
474
- ```
475
-
476
- ```python
477
- order("latte", size("L"), add_syrup("vanilla")).describe()
478
- # 'L espresso, 1 shot(s), 200ml whole milk, 1x vanilla syrup -> $3.65'
479
- ```
480
-
481
- - [`examples/processors.py`](examples/processors.py) — every `processors` form side by side, including `...` for "all other arguments" and for `**kwargs`. Shop filters arrive from a web form as raw strings (`"4"`, `"yes"`, `" SONIC "`) and are turned into typed, clean arguments:
482
-
483
- ```bash
484
- uv run python -m examples.processors
485
- ```
486
-
487
- ```python
488
- @catalog_steps.step(processors={"min_stars": float, ...: normalize})
489
- def search(products: Catalog, text: str, min_stars: float = 0) -> Catalog: ...
490
-
491
- @catalog_steps.step(processors={"in_stock": parse_bool, ...: normalize})
492
- def where(products: Catalog, *, in_stock: bool = False, **fields: str) -> Catalog: ...
493
- ```
494
-
495
- ### Working with pyspecification and pyformula
496
-
497
- Predicates and formulas are just callables, so they plug straight into steps:
498
-
499
- ```python
500
- from dataclasses import dataclass, replace
501
- from decimal import Decimal
502
-
503
- from pyformula import variable
504
- from pyspecification import object_rule
505
- from pyflowstep import step
506
-
507
-
508
- @dataclass(frozen=True)
509
- class Invoice:
510
- subtotal: Decimal
511
- tax: Decimal = Decimal(0)
512
- notes: tuple[str, ...] = ()
513
-
514
-
515
- @object_rule()
516
- def is_large(invoice: Invoice, limit: Decimal) -> bool:
517
- return invoice.subtotal >= limit
518
-
519
-
520
- @variable()
521
- def subtotal(invoice: Invoice) -> Decimal:
522
- return invoice.subtotal
523
-
524
-
525
- @step
526
- def note_if(invoice: Invoice, rule, note: str) -> Invoice:
527
- return replace(invoice, notes=(*invoice.notes, note)) if rule(invoice) else invoice
528
-
529
-
530
- @step
531
- def apply_tax(invoice: Invoice, formula) -> Invoice:
532
- return replace(invoice, tax=Decimal(str(formula(invoice))))
533
-
534
-
535
- checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(subtotal * 0.15, 2))
536
- ```
537
-
538
- ---
539
-
540
- ## API reference
541
-
542
- | Name | Kind | Description |
543
- | ------------------------------------------------------------ | --------- | --------------------------------------------------------------- |
544
- | `Flow[T]` | class | Immutable, callable sequence of actions; composes with `>>` |
545
- | `compose(*actions)` | function | Combine actions and flows into one flat flow |
546
- | `step` / `tap` | decorator | Turn a function into a step factory |
547
- | `StepsRegistry[T]` | class | Named collection of steps |
548
- | `Processors` | type | `Callable \| Mapping[str \| EllipsisType, Callable]`, see [Processors](#processors) |
549
- | `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
550
- | `validate_step_dict(item, path)` | function | Validate one step dictionary |
551
- | `get_json_schema(annotation)` | function | JSON Schema of a type annotation |
552
- | `get_step_json_schema(name, step)` | function | JSON Schema of one step dictionary |
553
- | `get_flow_json_schema(steps)` | function | JSON Schema of a whole flow definition |
554
-
555
- All exceptions derive from `PyflowstepError`; argument errors and `InvalidProcessorsError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
556
-
557
- ---
558
-
559
- ## Development
560
-
561
- ```bash
562
- uv sync
563
- uv run pytest --cov --cov-report term-missing
564
- ```
565
-
566
- The test suite also runs every docstring example (`--doctest-modules`).
567
-
568
- ## License
569
-
570
- GPL-3.0
24
+ # pyflowstep
25
+
26
+ A lightweight, typed Python library for composing functions into readable, reusable **flows** — a functional replacement for the fluent-interface (method-chaining) pattern, with flows that can be defined, validated and stored as **JSON**.
27
+
28
+ `pyflowstep` focuses on a functional style:
29
+
30
+ - steps are plain functions: `(subject, *args, **kwargs) -> subject`
31
+ - flows are immutable values that compose with `>>`
32
+ - step arguments are validated and parsed when a flow is **built**, not halfway through running it
33
+ - raw JSON values become typed arguments with a `Parse` marker next to the parameter
34
+ - steps get external objects (a mailer, a database session) through FastAPI-style `Depends`
35
+ - registry-based registration keeps steps organized and discoverable
36
+ - flow definitions can be compiled from dictionaries or JSON
37
+ - every registry can describe its flow language as a JSON Schema
38
+
39
+ It pairs naturally with its siblings [pyspecification](https://github.com/aaltatan/pyspecification) (predicates) and [pyformula](https://github.com/aaltatan/pyformula) (formulas), and has **zero dependencies**.
40
+
41
+ ---
42
+
43
+ ## Why use pyflowstep?
44
+
45
+ A fluent API makes you put every operation on the class and `return self` from each method:
46
+
47
+ ```python
48
+ page.navigate("https://www.google.com").fill("input[name=q]", "pyflowstep").click("#go").wait("#result")
49
+ ```
50
+
51
+ That couples *what can be done* to *one class*, you cannot store the chain, reuse half of it, build it from data, or add an operation without editing the class.
52
+
53
+ With `pyflowstep`, operations are small functions and the chain is a value:
54
+
55
+ ```python
56
+ search = navigate("https://www.google.com") >> fill("input[name=q]", "pyflowstep") >> click("#go")
57
+
58
+ search(page) # run it
59
+ search >> wait("#result") # extend it (a new flow, `search` is unchanged)
60
+ login >> search >> logout # combine flows
61
+ ```
62
+
63
+ …and the same flow can come from JSON:
64
+
65
+ ```json
66
+ [
67
+ {"name": "navigate", "args": ["https://www.google.com"]},
68
+ {"name": "fill", "args": ["input[name=q]", "pyflowstep"]},
69
+ {"name": "click", "kwargs": {"selector": "#go"}}
70
+ ]
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Installation
76
+
77
+ ```bash
78
+ pip install pyflowstep
79
+ ```
80
+
81
+ or with uv:
82
+
83
+ ```bash
84
+ uv add pyflowstep
85
+ ```
86
+
87
+ Requires Python 3.12+.
88
+
89
+ ---
90
+
91
+ ## Quick start
92
+
93
+ ```python
94
+ from typing import Any
95
+
96
+ from pyflowstep import step
97
+
98
+
99
+ class Page:
100
+ def navigate(self, url: str) -> None:
101
+ print(f"Navigating to {url}")
102
+
103
+ def click(self, selector: str) -> None:
104
+ print(f"Clicking on {selector}")
105
+
106
+ def fill(self, selector: str, value: Any) -> None:
107
+ print(f"Filling {selector} with {value}")
108
+
109
+
110
+ @step
111
+ def navigate(page: Page, url: str) -> Page:
112
+ page.navigate(url)
113
+ return page
114
+
115
+
116
+ @step
117
+ def fill(page: Page, selector: str, value: Any) -> Page:
118
+ page.fill(selector, value)
119
+ return page
120
+
121
+
122
+ @step
123
+ def click(page: Page, selector: str) -> Page:
124
+ page.click(selector)
125
+ return page
126
+
127
+
128
+ search = navigate("https://www.google.com") >> fill("input[name=q]", 1111) >> click("#go")
129
+
130
+ print(search) # Flow(navigate >> fill >> click)
131
+ search(Page())
132
+ # Navigating to https://www.google.com
133
+ # Filling input[name=q] with 1111
134
+ # Clicking on #go
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Core concepts
140
+
141
+ ### Flow
142
+
143
+ A `Flow[T]` is an immutable sequence of `T -> T` actions. Calling it threads the subject through every action in order.
144
+
145
+ ```python
146
+ from pyflowstep import Flow, compose
147
+
148
+ increment = Flow[int](lambda n: n + 1)
149
+ double = Flow[int](lambda n: n * 2)
150
+
151
+ (increment >> double)(3) # 8
152
+ (double >> increment)(3) # 7
153
+ compose(increment, double)(3) # 8, same as increment >> double
154
+ Flow[int]()(3) # 3, an empty flow is the identity
155
+ ```
156
+
157
+ - `>>` accepts flows **and** plain callables, on either side: `flow >> fn`, `fn >> flow`.
158
+ - Composition flattens: `(a >> b) >> c` and `a >> (b >> c)` have the same three actions.
159
+ - Flows support `len()`, iteration, `.actions` and a readable `repr`.
160
+
161
+ ### step
162
+
163
+ `@step` turns a function whose **first positional parameter is the subject** into a *step factory*. Calling the factory with the remaining arguments returns a single-step flow.
164
+
165
+ ```python
166
+ from pyflowstep import step
167
+
168
+
169
+ @step
170
+ def add(total: int, amount: int) -> int:
171
+ return total + amount
172
+
173
+
174
+ @step
175
+ def multiply(total: int, factor: int) -> int:
176
+ return total * factor
177
+
178
+
179
+ pipeline = add(2) >> multiply(10) >> add(amount=1)
180
+ pipeline # Flow(add >> multiply >> add)
181
+ pipeline(1) # 31
182
+ ```
183
+
184
+ `step` and `tap` do one thing: turn a function into a flow factory. Naming a step belongs to the [registry](#registry). Two markers can sit on a parameter: [`Parse`](#parsing-arguments) to convert the value passed for it, and [`Depends`](#dependencies) to inject an object nobody passes.
185
+
186
+ Arguments are bound against the function signature **immediately**, so mistakes fail fast:
187
+
188
+ ```python
189
+ add() # MissingArgumentError: missing a required argument: 'amount' for step 'add'
190
+ add(1, 2) # TooManyArgumentsError: too many positional arguments for step 'add'
191
+ add(1, x=2) # UnexpectedKeywordArgumentError: got an unexpected keyword argument 'x' ...
192
+ ```
193
+
194
+ ### tap
195
+
196
+ For side-effect steps on a mutable subject, `@tap` ignores the return value and passes the subject on unchanged — no more `return page` boilerplate:
197
+
198
+ ```python
199
+ from pyflowstep import tap
200
+
201
+
202
+ @tap
203
+ def click(page: Page, selector: str) -> None:
204
+ page.click(selector)
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Registry
210
+
211
+ `StepsRegistry[T]` collects named steps for one subject type. It is the vocabulary of your flow language: the compiler and the JSON schema only know about the steps registered in it.
212
+
213
+ ```python
214
+ from pyflowstep import StepsRegistry
215
+
216
+ page_steps = StepsRegistry[Page]()
217
+
218
+
219
+ @page_steps.tap()
220
+ def navigate(page: Page, url: str) -> None:
221
+ """Open a url in the page."""
222
+ page.navigate(url)
223
+
224
+
225
+ @page_steps.tap()
226
+ def click(page: Page, selector: str) -> None:
227
+ """Click the element matching a CSS selector."""
228
+ page.click(selector)
229
+
230
+
231
+ @page_steps.tap(name="type")
232
+ def fill(page: Page, selector: str, value: str) -> None:
233
+ """Type a value into a field."""
234
+ page.fill(selector, value)
235
+
236
+
237
+ @page_steps.tap(hidden=True)
238
+ def debug_dump(page: Page) -> None:
239
+ """Python-only helper, never exposed to JSON."""
240
+
241
+
242
+ page_steps["click"]("#go") # look up a step factory by name
243
+ "type" in page_steps # True
244
+ list(page_steps.steps) # ['navigate', 'click', 'type'] (hidden steps are excluded)
245
+
246
+ page_steps.register(lambda page: page, name="noop") # register without a decorator
247
+ ```
248
+
249
+ | Method / attribute | Description |
250
+ | ---------------------------------------------------------------- | ----------------------------------------------------------- |
251
+ | `step(name=, description=, hidden=)` | Decorator for `(subject, ...) -> subject` functions |
252
+ | `tap(name=, description=, hidden=)` | Decorator for side-effect functions (return value ignored) |
253
+ | `register(fn, *, name=, description=, hidden=, passthrough=)` | Register without decorator syntax |
254
+ | `steps` | Read-only mapping of visible steps |
255
+ | `registry[name]`, `name in registry`, `len()`, iteration | Lookup over visible steps |
256
+
257
+ - `name` is the step's public name: the compiler looks it up, and `repr` and argument errors show it (`Flow(type)` above, not `Flow(fill)`).
258
+ - `description` overrides the docstring, which becomes the step description in the JSON schema.
259
+
260
+ ---
261
+
262
+ ## Parsing arguments
263
+
264
+ Flows often come from JSON or a web form, where every value arrives as a string, number, boolean, list, object or null, while your steps want dates, decimals, enums, clean text or loaded data. Mark the parameter with `Parse(fn)` inside `Annotated`, and `fn` is applied to the value that is passed for it:
265
+
266
+ ```python
267
+ from typing import Annotated
268
+
269
+ from pyflowstep import Parse
270
+
271
+
272
+ @page_steps.tap()
273
+ def wait(page: Page, selector: str, timeout: Annotated[float, Parse(float)] = 5.0) -> None:
274
+ page.wait(selector, timeout)
275
+
276
+
277
+ wait("#result", "10") # timeout is 10.0
278
+ ```
279
+
280
+ ```json
281
+ {"name": "wait", "args": ["#result", "10"]}
282
+ ```
283
+
284
+ The marker sits next to the parameter it changes, and it works with the plain `@step` and `@tap` decorators too; no registry is required.
285
+
286
+ ### Name it once, reuse it
287
+
288
+ A `type` alias gives a parsed type a name, so many steps can share it:
289
+
290
+ ```python
291
+ from decimal import Decimal
292
+
293
+ type Money = Annotated[Decimal, Parse(Decimal)]
294
+ type Text = Annotated[str, Parse(str.strip), Parse(str.lower)] # several parsers run left to right
295
+
296
+
297
+ @catalog_steps.step()
298
+ def price_between(products: Catalog, low: Money, high: Money) -> Catalog: ...
299
+
300
+
301
+ @catalog_steps.step()
302
+ def search(products: Catalog, text: Text, min_stars: Annotated[float, Parse(float)] = 0) -> Catalog: ...
303
+ ```
304
+
305
+ ### Loading data from a value in the JSON
306
+
307
+ A parser can be any function of one value, so the JSON can send a path and the step can receive what was loaded from it:
308
+
309
+ ```python
310
+ def load_names(path: str) -> frozenset[str]:
311
+ return frozenset(Path(path).read_text().splitlines())
312
+
313
+
314
+ type Names = Annotated[frozenset[str], Parse(load_names)]
315
+
316
+
317
+ @catalog_steps.step()
318
+ def exclude(products: Catalog, names: Names) -> Catalog:
319
+ return tuple(product for product in products if product.name not in names)
320
+ ```
321
+
322
+ ```json
323
+ {"name": "exclude", "args": ["discontinued.txt"]}
324
+ ```
325
+
326
+ The file is read **once, when the flow is built**. Every run of that flow reuses the loaded names. For an object that must be fresh on every run, or that the JSON must not choose at all, use a [dependency](#dependencies) instead.
327
+
328
+ ### `*args` and `**kwargs`
329
+
330
+ On `*args` the parser applies to each item, and on `**kwargs` to each value:
331
+
332
+ ```python
333
+ @barista.step()
334
+ def top_with(drink: Drink, *toppings: Text) -> Drink: ...
335
+
336
+
337
+ @catalog_steps.step()
338
+ def where(products: Catalog, *, in_stock: Annotated[bool, Parse(parse_bool)] = False, **fields: Text) -> Catalog: ...
339
+
340
+
341
+ where(in_stock="yes", brand=" SONIC ") # in_stock=True, brand="sonic"
342
+ ```
343
+
344
+ ### Which values are parsed, and when
345
+
346
+ - Only values that are **actually passed**, positionally or by keyword. Default values are never parsed.
347
+ - Parsing runs **once per factory call**, while the flow is being built, not every time the flow runs.
348
+ - Arguments are checked against the step's signature first, so a missing or extra argument is reported as an `ArgumentError` before any parser runs.
349
+
350
+ ```python
351
+ flow = add_syrup(" Vanilla ", "2") # parsed here: flavor="vanilla", pumps=2
352
+ flow(drink) # the step runs with the parsed values
353
+ flow(another_drink) # nothing is parsed again
354
+ ```
355
+
356
+ ### Errors
357
+
358
+ A parser that fails on a value raises `ParseArgumentError`, chained to the original exception. Inside a compiled flow it also carries the JSON path of the step, so a bad value is found before anything runs:
359
+
360
+ ```text
361
+ pyflowstep.exceptions.ParseArgumentError: Argument 'limit' with value 'two' failed to parse, invalid literal for int() with base 10: 'two'
362
+ at $[1]
363
+ ```
364
+
365
+ A marker that cannot work raises `InvalidParserError` when the step is created:
366
+
367
+ | Problem | Example |
368
+ | ---------------------------------------------------- | ---------------------------------------------------- |
369
+ | The parser is not callable | `Parse("int")` |
370
+ | The marker is used as a default value | `amount: int = Parse(int)`, write `Annotated[int, Parse(int)]` |
371
+ | The marker is on the subject (the first parameter) | `def step(page: Annotated[Page, Parse(...)], ...)` |
372
+ | One parameter has both `Parse` and `Depends` | nothing is passed for a dependency, so there is nothing to parse |
373
+
374
+ ### In the JSON schema
375
+
376
+ A parsed parameter is described by **what the JSON must send**. The schema takes it from the type hint of the parser's own parameter (`load_names(path: str)` means `string`). If the parser has no type hint, as with `int`, `Decimal` or an enum class, the schema falls back to the parameter's type.
377
+
378
+ | Parameter | Schema |
379
+ | -------------------------------------------- | ------------------------------- |
380
+ | `names: Annotated[frozenset[str], Parse(load_names)]` | `{"type": "string"}` |
381
+ | `limit: Annotated[int, Parse(int)]` | `{"type": "integer"}` |
382
+ | `kind: Annotated[Milk, Parse(Milk)]` | `{"enum": ["whole", "oat", "almond"], "type": "string"}` |
383
+
384
+ ### Upgrading from 0.1
385
+
386
+ The `processors=` option of the registry is gone. Move each entry onto its parameter:
387
+
388
+ | 0.1 | 0.2 |
389
+ | ------------------------------------------------- | ------------------------------------------------------- |
390
+ | `processors={"timeout": float}` | `timeout: Annotated[float, Parse(float)]` |
391
+ | `processors=normalize` (every argument) | annotate each parameter, usually with a shared alias such as `Text` |
392
+ | `processors={"pumps": int, ...: normalize}` | `pumps: Annotated[int, Parse(int)]`, the others `Text` |
393
+ | `...` covering `**kwargs` | `**fields: Text` |
394
+ | `ProcessArgumentError` | `ParseArgumentError` |
395
+ | `InvalidProcessorsError` | removed; a key can no longer be misspelled |
396
+
397
+ See [`examples/parsing.py`](examples/parsing.py) for every form working together on data from a web form.
398
+
399
+ ---
400
+
401
+ ## Dependencies
402
+
403
+ Some steps need objects that cannot be written in JSON: a mailer, a database session, an API client. Mark the parameter with `Depends(provider)` and it stops being an argument. Nobody passes it, neither Python nor JSON; `provider` is called to produce it when the flow runs.
404
+
405
+ ```python
406
+ from pyflowstep import Depends, StepsRegistry
407
+
408
+ steps = StepsRegistry[Order]()
409
+
410
+
411
+ def get_mailer() -> Mailer:
412
+ return Mailer("smtp.example.com")
413
+
414
+
415
+ @steps.tap()
416
+ def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None:
417
+ mailer.send(order.customer, template)
418
+
419
+
420
+ send_email("receipt") # only `template` is an argument
421
+ ```
422
+
423
+ ```json
424
+ [{"name": "send_email", "args": ["receipt"]}]
425
+ ```
426
+
427
+ It works the same with the plain `@step` and `@tap` decorators; no registry is required.
428
+
429
+ ### Two ways to write it
430
+
431
+ ```python
432
+ from typing import Annotated
433
+
434
+ # default value: quick, for a one-off
435
+ def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
436
+
437
+
438
+ # Annotated: name the dependency once, reuse it in many steps
439
+ type MailerDep = Annotated[Mailer, Depends(get_mailer)]
440
+
441
+ def send_email(order: Order, template: str, mailer: MailerDep) -> None: ...
442
+ def send_invoice(order: Order, mailer: MailerDep) -> None: ...
443
+ ```
444
+
445
+ ### One object per flow run
446
+
447
+ A flow run is one scope, like one request in a web framework. A provider is called **at most once per run**, and every step of that run receives the same object. The next run starts fresh.
448
+
449
+ A provider written as a generator is cleaned up when the run ends. If a step fails, the error is raised inside the provider at its `yield`, so it can roll back:
450
+
451
+ ```python
452
+ from collections.abc import Iterator
453
+
454
+
455
+ def get_session() -> Iterator[Session]:
456
+ session = Session()
457
+ try:
458
+ yield session # shared by every step of this run
459
+ except Exception:
460
+ session.rollback() # a step failed
461
+ raise
462
+ else:
463
+ session.commit() # the whole flow succeeded
464
+ finally:
465
+ session.close()
466
+
467
+
468
+ type SessionDep = Annotated[Session, Depends(get_session)]
469
+
470
+
471
+ @steps.tap()
472
+ def save(order: Order, session: SessionDep) -> None:
473
+ session.add(order)
474
+
475
+
476
+ @steps.tap()
477
+ def audit(order: Order, action: str, session: SessionDep) -> None:
478
+ session.add(AuditRow(order.id, action)) # the same session `save` used
479
+
480
+
481
+ flow = save() >> audit("fulfilled")
482
+
483
+ flow(order_1) # session A: opened, used twice, committed, closed
484
+ flow(order_2) # session B
485
+ ```
486
+
487
+ Cleanups run in reverse order, and a provider cannot swallow a step's error: it is always re-raised. Flows nested inside a flow, or called from inside a step, join the run that is already open.
488
+
489
+ ### Sub-dependencies
490
+
491
+ A provider's own parameters can use `Depends` too:
492
+
493
+ ```python
494
+ def get_settings() -> Settings:
495
+ return Settings()
496
+
497
+
498
+ def get_mailer(settings: Settings = Depends(get_settings)) -> Mailer:
499
+ return Mailer(settings.smtp_host)
500
+ ```
501
+
502
+ Any other provider parameter must have a default. A provider can be any callable: a function, a class, a `functools.partial`, or an object with `__call__`.
503
+
504
+ ### Replacing a dependency in tests
505
+
506
+ ```python
507
+ from pyflowstep import override_dependencies
508
+
509
+ with override_dependencies({get_mailer: FakeMailer}):
510
+ flow(order) # every step that asked for get_mailer gets a FakeMailer
511
+
512
+ flow(order) # the real mailer again
513
+ ```
514
+
515
+ Keys are the original providers and values the providers to call instead. Nested blocks add up, and everything is restored when the block exits, even after an error.
516
+
517
+ ### Rules
518
+
519
+ - **Invisible to JSON.** A dependency is absent from the JSON schema, cannot be parsed, and passing one raises `UnexpectedKeywordArgumentError` (or `TooManyArgumentsError`) when the flow is built.
520
+ - **Declarations are checked early**, when the step is created, with `InvalidDependencyError`: a provider that is not callable, is circular, or has a required parameter that is not a dependency; a dependency on the subject, or on a positional-only, `*args` or `**kwargs` parameter.
521
+ - **Providers run late**, when the flow runs. An error inside a provider surfaces then, not at compile time.
522
+ - A dependency is for an object the JSON must **not** choose. When the JSON should pick one by name (`"via": "email"`), use [`Parse`](#parsing-arguments) with a function that turns the name into the object.
523
+
524
+ Using ruff? Its `B008` rule flags function calls in argument defaults. Tell it `Depends` is a marker:
525
+
526
+ ```toml
527
+ [tool.ruff.lint.flake8-bugbear]
528
+ extend-immutable-calls = ["pyflowstep.Depends"]
529
+ ```
530
+
531
+ See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow with a mailer, a per-run session and a test override.
532
+
533
+ ---
534
+
535
+ ## Compiling flows from JSON
536
+
537
+ `FlowCompiler` turns a flow definition — a list of step dictionaries — into a `Flow`.
538
+
539
+ ```python
540
+ from pyflowstep import FlowCompiler
541
+
542
+ compiler = FlowCompiler(page_steps.steps)
543
+
544
+ login = compiler.compile(
545
+ [
546
+ {"name": "navigate", "args": ["https://example.com/login"]},
547
+ {"name": "type", "args": ["#email", "ada@example.com"]},
548
+ {"name": "click", "kwargs": {"selector": "button[type=submit]"}},
549
+ ],
550
+ )
551
+
552
+ login(Page())
553
+ ```
554
+
555
+ The compiler takes already-parsed data. Reading JSON, YAML, a database row or an API payload is up to you:
556
+
557
+ ```python
558
+ import json
559
+ from pathlib import Path
560
+
561
+ login = compiler.compile(json.loads(Path("login.json").read_text()))
562
+ ```
563
+
564
+ Each step dictionary has this shape — only `name` is required:
565
+
566
+ ```json
567
+ {"name": "fill", "args": ["#email"], "kwargs": {"value": "ada@example.com"}}
568
+ ```
569
+
570
+ Everything is validated while compiling, before any step runs. Errors carry a note with their JSON path:
571
+
572
+ ```text
573
+ pyflowstep.exceptions.ParseArgumentError: Argument 'url' with value 'http://insecure.example.com' failed to parse, only https urls are allowed
574
+ at $[1]
575
+ ```
576
+
577
+ | Problem | Exception |
578
+ | ------------------------------------------------ | ------------------------------------------------------ |
579
+ | Not a list / malformed step dict | `InvalidFlowDefinitionError` |
580
+ | Unknown or hidden step name | `StepDoesNotExistError` (lists the available steps) |
581
+ | Missing / extra / duplicated arguments | `MissingArgumentError`, `TooManyArgumentsError`, ... |
582
+ | A parser rejects a value | `ParseArgumentError` |
583
+
584
+ A compiled flow is an ordinary `Flow`, so it composes with Python steps: `login >> click("#profile")`.
585
+
586
+ ---
587
+
588
+ ## JSON Schema
589
+
590
+ Describe your flow language so a UI, a validator, or an LLM can produce valid flows:
591
+
592
+ ```python
593
+ import json
594
+
595
+ from pyflowstep import get_flow_json_schema, get_step_json_schema
596
+
597
+ print(json.dumps(get_flow_json_schema(page_steps.steps), indent=2))
598
+ ```
599
+
600
+ Output, trimmed to the `click` step:
601
+
602
+ ```json
603
+ {
604
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
605
+ "type": "array",
606
+ "items": {
607
+ "oneOf": [
608
+ {
609
+ "type": "object",
610
+ "properties": {
611
+ "name": {"const": "click"},
612
+ "args": {"type": "array", "prefixItems": [{"type": "string"}], "items": false},
613
+ "kwargs": {
614
+ "type": "object",
615
+ "properties": {"selector": {"type": "string"}},
616
+ "required": [],
617
+ "additionalProperties": false
618
+ }
619
+ },
620
+ "required": ["name"],
621
+ "additionalProperties": false,
622
+ "description": "Click the element matching a CSS selector."
623
+ }
624
+ ]
625
+ }
626
+ }
627
+ ```
628
+
629
+ Supported annotations: `str`, `int`, `float`, `bool`, `None`, `Decimal`, `datetime`, `date`, `time`, `UUID`, `list`/`tuple`/`set`/`frozenset`, `dict`, `Literal`, `Enum`, unions, `Annotated`, `TypedDict`, and `type` aliases. Anything else maps to `{}` (any value). JSON-compatible default values are included as `default`.
630
+
631
+ ---
632
+
633
+ ## Examples
634
+
635
+ Runnable examples live in [`examples/`](examples):
636
+
637
+ - [`examples/browser.py`](examples/browser.py) — the `Page` automation above: `tap` steps, an https-only parser, reusable sub-flows and a JSON login scenario.
638
+
639
+ ```bash
640
+ uv run python -m examples.browser
641
+ ```
642
+
643
+ - [`examples/coffee.py`](examples/coffee.py) — a coffee shop where every step returns a **new** immutable `Drink`. The menu is JSON, orders are customized with Python steps, and a hidden staff-only `discount` step is invisible to the menu.
644
+
645
+ ```bash
646
+ uv run python -m examples.coffee
647
+ ```
648
+
649
+ ```python
650
+ order("latte", size("L"), add_syrup("vanilla")).describe()
651
+ # 'L espresso, 1 shot(s), 200ml whole milk, 1x vanilla syrup -> $3.65'
652
+ ```
653
+
654
+ - [`examples/parsing.py`](examples/parsing.py) — every way to use `Parse`, side by side. Shop filters arrive from a web form as raw strings (`"4"`, `"yes"`, `" SONIC "`, a file path) and are turned into typed, clean arguments, including a set of names loaded from that path:
655
+
656
+ ```bash
657
+ uv run python -m examples.parsing
658
+ ```
659
+
660
+ ```python
661
+ type Text = Annotated[str, Parse(str.strip), Parse(str.lower)]
662
+ type Names = Annotated[frozenset[str], Parse(load_names)]
663
+
664
+ @catalog_steps.step()
665
+ def where(products: Catalog, *, in_stock: Annotated[bool, Parse(parse_bool)] = False, **fields: Text) -> Catalog: ...
666
+
667
+ @catalog_steps.step()
668
+ def exclude(products: Catalog, names: Names) -> Catalog: ...
669
+ ```
670
+
671
+ - [`examples/dependencies.py`](examples/dependencies.py) — steps that need objects JSON cannot describe. A fulfilment flow stored as JSON uses a mailer (with a sub-dependency) and a database session that is opened once per run, shared by two steps, then committed or rolled back. Also shows swapping the mailer for a fake in a test:
672
+
673
+ ```bash
674
+ uv run python -m examples.dependencies
675
+ ```
676
+
677
+ ```python
678
+ @fulfilment_steps.tap()
679
+ def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
680
+
681
+ @fulfilment_steps.tap()
682
+ def save(order: Order, session: SessionDep) -> None: ...
683
+ ```
684
+
685
+ ### Working with pyspecification and pyformula
686
+
687
+ Predicates and formulas are just callables, so they plug straight into steps:
688
+
689
+ ```python
690
+ from dataclasses import dataclass, replace
691
+ from decimal import Decimal
692
+
693
+ from pyformula import variable
694
+ from pyspecification import object_rule
695
+ from pyflowstep import step
696
+
697
+
698
+ @dataclass(frozen=True)
699
+ class Invoice:
700
+ subtotal: Decimal
701
+ tax: Decimal = Decimal(0)
702
+ notes: tuple[str, ...] = ()
703
+
704
+
705
+ @object_rule()
706
+ def is_large(invoice: Invoice, limit: Decimal) -> bool:
707
+ return invoice.subtotal >= limit
708
+
709
+
710
+ @variable()
711
+ def subtotal(invoice: Invoice) -> Decimal:
712
+ return invoice.subtotal
713
+
714
+
715
+ @step
716
+ def note_if(invoice: Invoice, rule, note: str) -> Invoice:
717
+ return replace(invoice, notes=(*invoice.notes, note)) if rule(invoice) else invoice
718
+
719
+
720
+ @step
721
+ def apply_tax(invoice: Invoice, formula) -> Invoice:
722
+ return replace(invoice, tax=Decimal(str(formula(invoice))))
723
+
724
+
725
+ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(subtotal * 0.15, 2))
726
+ ```
727
+
728
+ ---
729
+
730
+ ## API reference
731
+
732
+ | Name | Kind | Description |
733
+ | ------------------------------------------------------------ | --------- | --------------------------------------------------------------- |
734
+ | `Flow[T]` | class | Immutable, callable sequence of actions; composes with `>>` |
735
+ | `compose(*actions)` | function | Combine actions and flows into one flat flow |
736
+ | `step` / `tap` | decorator | Turn a function into a step factory |
737
+ | `StepsRegistry[T]` | class | Named collection of steps |
738
+ | `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
739
+ | `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
740
+ | `override_dependencies(mapping)` | context manager | Replace providers inside a `with` block, for tests |
741
+ | `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
742
+ | `validate_step_dict(item, path)` | function | Validate one step dictionary |
743
+ | `get_json_schema(annotation)` | function | JSON Schema of a type annotation |
744
+ | `get_step_json_schema(name, step)` | function | JSON Schema of one step dictionary |
745
+ | `get_flow_json_schema(steps)` | function | JSON Schema of a whole flow definition |
746
+
747
+ All exceptions derive from `PyflowstepError`; argument errors, `InvalidParserError` and `InvalidDependencyError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
748
+
749
+ ---
750
+
751
+ ## Development
752
+
753
+ ```bash
754
+ uv sync
755
+ uv run pytest --cov --cov-report term-missing
756
+ ```
757
+
758
+ The test suite also runs every docstring example (`--doctest-modules`).
759
+
760
+ ## License
761
+
762
+ GPL-3.0