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