pyflowstep 0.1.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.
- pyflowstep-0.1.0/PKG-INFO +570 -0
- pyflowstep-0.1.0/README.md +547 -0
- pyflowstep-0.1.0/pyproject.toml +112 -0
- pyflowstep-0.1.0/pyproject.toml.orig +84 -0
- pyflowstep-0.1.0/src/pyflowstep/__init__.py +77 -0
- pyflowstep-0.1.0/src/pyflowstep/compilers.py +147 -0
- pyflowstep-0.1.0/src/pyflowstep/exceptions.py +116 -0
- pyflowstep-0.1.0/src/pyflowstep/flow.py +116 -0
- pyflowstep-0.1.0/src/pyflowstep/json_schema.py +244 -0
- pyflowstep-0.1.0/src/pyflowstep/processors.py +159 -0
- pyflowstep-0.1.0/src/pyflowstep/py.typed +0 -0
- pyflowstep-0.1.0/src/pyflowstep/registry.py +233 -0
- pyflowstep-0.1.0/src/pyflowstep/steps.py +158 -0
- pyflowstep-0.1.0/src/pyflowstep/validators.py +29 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyflowstep
|
|
3
|
+
Version: 0.1.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 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
|