pyflowstep 0.3.0__tar.gz → 0.4.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.3.0 → pyflowstep-0.4.0}/PKG-INFO +29 -9
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/README.md +28 -8
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/pyproject.toml +3 -1
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/pyproject.toml.orig +8 -3
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/dependencies.py +8 -0
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/exceptions.py +4 -3
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/flow.py +1 -1
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/inputs.py +231 -178
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/steps.py +5 -5
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/__init__.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/annotations.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/compilers.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/json_schema.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/parsers.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/py.typed +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/registry.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.4.0}/src/pyflowstep/validators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyflowstep
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -32,7 +32,7 @@ A lightweight, typed Python library for composing functions into readable, reusa
|
|
|
32
32
|
- step arguments are validated and parsed when a flow is **built**, not halfway through running it
|
|
33
33
|
- raw JSON values become typed arguments with a `Parse` marker next to the parameter
|
|
34
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
|
|
35
|
+
- the caller hands per-run values to a flow by name: `flow(page, user=user)` fills every `user: User = Input()`
|
|
36
36
|
- registry-based registration keeps steps organized and discoverable
|
|
37
37
|
- flow definitions can be compiled from dictionaries or JSON
|
|
38
38
|
- every registry can describe its flow language as a JSON Schema
|
|
@@ -443,6 +443,8 @@ def send_email(order: Order, template: str, mailer: MailerDep) -> None: ...
|
|
|
443
443
|
def send_invoice(order: Order, mailer: MailerDep) -> None: ...
|
|
444
444
|
```
|
|
445
445
|
|
|
446
|
+
Both forms behave the same when the flow runs. Type checkers differ: with the `Annotated` form they still see `mailer` as a required argument and report `send_email("receipt")` as missing it. Use the default-value form for steps you call from Python; the `Annotated` form suits steps that are only used from JSON.
|
|
447
|
+
|
|
446
448
|
### One object per flow run
|
|
447
449
|
|
|
448
450
|
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.
|
|
@@ -535,7 +537,7 @@ See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow w
|
|
|
535
537
|
|
|
536
538
|
## Run inputs
|
|
537
539
|
|
|
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.
|
|
540
|
+
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. Give the parameter the default `Input()` and the caller supplies it, by name, when it runs the flow:
|
|
539
541
|
|
|
540
542
|
```python
|
|
541
543
|
from pyflowstep import Input, StepsRegistry
|
|
@@ -544,14 +546,14 @@ steps = StepsRegistry[Page]()
|
|
|
544
546
|
|
|
545
547
|
|
|
546
548
|
@steps.tap()
|
|
547
|
-
def login(page: Page, url: str, credentials: Input
|
|
549
|
+
def login(page: Page, url: str, credentials: Credentials = Input()) -> None:
|
|
548
550
|
page.navigate(url)
|
|
549
551
|
page.fill("#user", credentials.username)
|
|
550
552
|
page.fill("#password", credentials.password)
|
|
551
553
|
|
|
552
554
|
|
|
553
555
|
@steps.tap()
|
|
554
|
-
def fill_year(page: Page, selector: str, voucher: Input
|
|
556
|
+
def fill_year(page: Page, selector: str, voucher: Voucher = Input()) -> None:
|
|
555
557
|
page.fill(selector, voucher.year)
|
|
556
558
|
|
|
557
559
|
|
|
@@ -569,6 +571,8 @@ flow(page, credentials=credentials, voucher=voucher) # they are giv
|
|
|
569
571
|
|
|
570
572
|
The name of the input is the name of the parameter, and every step that declares it receives the same value.
|
|
571
573
|
|
|
574
|
+
`Input()` is written as the default value, never inside `Annotated`, so that type checkers see the parameter as optional and accept `fill_year("#year")`.
|
|
575
|
+
|
|
572
576
|
### Checked before anything runs
|
|
573
577
|
|
|
574
578
|
A flow knows the inputs its steps require, and checks them before the first step runs. A forgotten input never fails halfway through:
|
|
@@ -583,10 +587,26 @@ flow(page, credentials=credentials)
|
|
|
583
587
|
### Rules
|
|
584
588
|
|
|
585
589
|
- **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
|
-
-
|
|
590
|
+
- **`Input(default=...)` makes the input optional**: with `note: str = Input(default="")`, `""` is used when the caller passes no `note`. Optional inputs are not listed in `flow.inputs`.
|
|
587
591
|
- **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
|
|
588
592
|
- **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
|
|
593
|
+
- **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, on a parameter that is also a dependency, or written inside `Annotated`. A provider cannot take an input.
|
|
594
|
+
|
|
595
|
+
Using ruff? Add `Input` next to `Depends` for its `B008` rule:
|
|
596
|
+
|
|
597
|
+
```toml
|
|
598
|
+
[tool.ruff.lint.flake8-bugbear]
|
|
599
|
+
extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.Input"]
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Upgrading from 0.3
|
|
603
|
+
|
|
604
|
+
`Input[T]` is gone, because a type checker reported `fill_year("#year")` as missing its `voucher` argument. Move the marker to the default value:
|
|
605
|
+
|
|
606
|
+
| 0.3 | 0.4 |
|
|
607
|
+
| ---------------------------------- | ---------------------------------------- |
|
|
608
|
+
| `voucher: Input[Voucher]` | `voucher: Voucher = Input()` |
|
|
609
|
+
| `note: Input[str] = ""` | `note: str = Input(default="")` |
|
|
590
610
|
|
|
591
611
|
### `Input` or `Depends`?
|
|
592
612
|
|
|
@@ -594,7 +614,7 @@ flow(page, credentials=credentials)
|
|
|
594
614
|
| ------------------------------------------------------------ | ------------------------------------- |
|
|
595
615
|
| is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
|
|
596
616
|
| 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
|
|
617
|
+
| is only known to whoever runs the flow (a user, a record, credentials) | `Input()` |
|
|
598
618
|
|
|
599
619
|
---
|
|
600
620
|
|
|
@@ -804,7 +824,7 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
|
|
|
804
824
|
| `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
|
|
805
825
|
| `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
|
|
806
826
|
| `override_dependencies(mapping)` | context manager | Replace providers inside a `with` block, for tests |
|
|
807
|
-
| `Input
|
|
827
|
+
| `Input(default=...)` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
|
|
808
828
|
| `Flow.inputs` | property | The names of the inputs a flow requires |
|
|
809
829
|
| `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
|
|
810
830
|
| `validate_step_dict(item, path)` | function | Validate one step dictionary |
|
|
@@ -9,7 +9,7 @@ A lightweight, typed Python library for composing functions into readable, reusa
|
|
|
9
9
|
- step arguments are validated and parsed when a flow is **built**, not halfway through running it
|
|
10
10
|
- raw JSON values become typed arguments with a `Parse` marker next to the parameter
|
|
11
11
|
- steps get external objects (a mailer, a database session) through FastAPI-style `Depends`
|
|
12
|
-
- the caller hands per-run values to a flow by name: `flow(page, user=user)` fills every `user: Input
|
|
12
|
+
- the caller hands per-run values to a flow by name: `flow(page, user=user)` fills every `user: User = Input()`
|
|
13
13
|
- registry-based registration keeps steps organized and discoverable
|
|
14
14
|
- flow definitions can be compiled from dictionaries or JSON
|
|
15
15
|
- every registry can describe its flow language as a JSON Schema
|
|
@@ -420,6 +420,8 @@ def send_email(order: Order, template: str, mailer: MailerDep) -> None: ...
|
|
|
420
420
|
def send_invoice(order: Order, mailer: MailerDep) -> None: ...
|
|
421
421
|
```
|
|
422
422
|
|
|
423
|
+
Both forms behave the same when the flow runs. Type checkers differ: with the `Annotated` form they still see `mailer` as a required argument and report `send_email("receipt")` as missing it. Use the default-value form for steps you call from Python; the `Annotated` form suits steps that are only used from JSON.
|
|
424
|
+
|
|
423
425
|
### One object per flow run
|
|
424
426
|
|
|
425
427
|
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.
|
|
@@ -512,7 +514,7 @@ See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow w
|
|
|
512
514
|
|
|
513
515
|
## Run inputs
|
|
514
516
|
|
|
515
|
-
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.
|
|
517
|
+
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. Give the parameter the default `Input()` and the caller supplies it, by name, when it runs the flow:
|
|
516
518
|
|
|
517
519
|
```python
|
|
518
520
|
from pyflowstep import Input, StepsRegistry
|
|
@@ -521,14 +523,14 @@ steps = StepsRegistry[Page]()
|
|
|
521
523
|
|
|
522
524
|
|
|
523
525
|
@steps.tap()
|
|
524
|
-
def login(page: Page, url: str, credentials: Input
|
|
526
|
+
def login(page: Page, url: str, credentials: Credentials = Input()) -> None:
|
|
525
527
|
page.navigate(url)
|
|
526
528
|
page.fill("#user", credentials.username)
|
|
527
529
|
page.fill("#password", credentials.password)
|
|
528
530
|
|
|
529
531
|
|
|
530
532
|
@steps.tap()
|
|
531
|
-
def fill_year(page: Page, selector: str, voucher: Input
|
|
533
|
+
def fill_year(page: Page, selector: str, voucher: Voucher = Input()) -> None:
|
|
532
534
|
page.fill(selector, voucher.year)
|
|
533
535
|
|
|
534
536
|
|
|
@@ -546,6 +548,8 @@ flow(page, credentials=credentials, voucher=voucher) # they are giv
|
|
|
546
548
|
|
|
547
549
|
The name of the input is the name of the parameter, and every step that declares it receives the same value.
|
|
548
550
|
|
|
551
|
+
`Input()` is written as the default value, never inside `Annotated`, so that type checkers see the parameter as optional and accept `fill_year("#year")`.
|
|
552
|
+
|
|
549
553
|
### Checked before anything runs
|
|
550
554
|
|
|
551
555
|
A flow knows the inputs its steps require, and checks them before the first step runs. A forgotten input never fails halfway through:
|
|
@@ -560,10 +564,26 @@ flow(page, credentials=credentials)
|
|
|
560
564
|
### Rules
|
|
561
565
|
|
|
562
566
|
- **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`).
|
|
563
|
-
-
|
|
567
|
+
- **`Input(default=...)` makes the input optional**: with `note: str = Input(default="")`, `""` is used when the caller passes no `note`. Optional inputs are not listed in `flow.inputs`.
|
|
564
568
|
- **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
|
|
565
569
|
- **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.
|
|
566
|
-
- **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only
|
|
570
|
+
- **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, on a parameter that is also a dependency, or written inside `Annotated`. A provider cannot take an input.
|
|
571
|
+
|
|
572
|
+
Using ruff? Add `Input` next to `Depends` for its `B008` rule:
|
|
573
|
+
|
|
574
|
+
```toml
|
|
575
|
+
[tool.ruff.lint.flake8-bugbear]
|
|
576
|
+
extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.Input"]
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### Upgrading from 0.3
|
|
580
|
+
|
|
581
|
+
`Input[T]` is gone, because a type checker reported `fill_year("#year")` as missing its `voucher` argument. Move the marker to the default value:
|
|
582
|
+
|
|
583
|
+
| 0.3 | 0.4 |
|
|
584
|
+
| ---------------------------------- | ---------------------------------------- |
|
|
585
|
+
| `voucher: Input[Voucher]` | `voucher: Voucher = Input()` |
|
|
586
|
+
| `note: Input[str] = ""` | `note: str = Input(default="")` |
|
|
567
587
|
|
|
568
588
|
### `Input` or `Depends`?
|
|
569
589
|
|
|
@@ -571,7 +591,7 @@ flow(page, credentials=credentials)
|
|
|
571
591
|
| ------------------------------------------------------------ | ------------------------------------- |
|
|
572
592
|
| is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
|
|
573
593
|
| can be built by a function, the same way for every caller (a mailer, a session) | `Depends(provider)` |
|
|
574
|
-
| is only known to whoever runs the flow (a user, a record, credentials) | `Input
|
|
594
|
+
| is only known to whoever runs the flow (a user, a record, credentials) | `Input()` |
|
|
575
595
|
|
|
576
596
|
---
|
|
577
597
|
|
|
@@ -781,7 +801,7 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
|
|
|
781
801
|
| `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
|
|
782
802
|
| `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
|
|
783
803
|
| `override_dependencies(mapping)` | context manager | Replace providers inside a `with` block, for tests |
|
|
784
|
-
| `Input
|
|
804
|
+
| `Input(default=...)` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
|
|
785
805
|
| `Flow.inputs` | property | The names of the inputs a flow requires |
|
|
786
806
|
| `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
|
|
787
807
|
| `validate_step_dict(item, path)` | function | Validate one step dictionary |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyflowstep"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "A lightweight, typed Python library for composing functions into readable, reusable flows that can be defined as JSON."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "GPL-3.0-only"
|
|
@@ -101,6 +101,8 @@ ignore = [
|
|
|
101
101
|
extend-immutable-calls = [
|
|
102
102
|
"pyflowstep.Depends",
|
|
103
103
|
"pyflowstep.dependencies.Depends",
|
|
104
|
+
"pyflowstep.Input",
|
|
105
|
+
"pyflowstep.inputs.Input",
|
|
104
106
|
]
|
|
105
107
|
|
|
106
108
|
[tool.ruff.lint.per-file-ignores]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyflowstep"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "A lightweight, typed Python library for composing functions into readable, reusable flows that can be defined as JSON."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -80,8 +80,13 @@ ignore = [
|
|
|
80
80
|
]
|
|
81
81
|
|
|
82
82
|
[tool.ruff.lint.flake8-bugbear]
|
|
83
|
-
# `x: T = Depends(provider)`
|
|
84
|
-
extend-immutable-calls = [
|
|
83
|
+
# `x: T = Depends(provider)` and `x: T = Input()` are markers, not mutable defaults
|
|
84
|
+
extend-immutable-calls = [
|
|
85
|
+
"pyflowstep.Depends",
|
|
86
|
+
"pyflowstep.dependencies.Depends",
|
|
87
|
+
"pyflowstep.Input",
|
|
88
|
+
"pyflowstep.inputs.Input",
|
|
89
|
+
]
|
|
85
90
|
|
|
86
91
|
[tool.ruff.lint.per-file-ignores]
|
|
87
92
|
"tests/**" = ["S101", "PLR2004", "ANN", "ARG", "INP001", "E501"]
|
|
@@ -35,6 +35,7 @@ from typing import Any
|
|
|
35
35
|
|
|
36
36
|
from .annotations import annotated_metadata, resolved_annotations
|
|
37
37
|
from .exceptions import InvalidDependencyError
|
|
38
|
+
from .inputs import RunInput
|
|
38
39
|
|
|
39
40
|
type Provider = Callable[..., Any]
|
|
40
41
|
|
|
@@ -292,6 +293,13 @@ def _validate_provider(provider: Provider, path: tuple[Provider, ...]) -> None:
|
|
|
292
293
|
dependencies = find_dependencies(provider)
|
|
293
294
|
|
|
294
295
|
for name, parameter in parameters.items():
|
|
296
|
+
if isinstance(parameter.default, RunInput):
|
|
297
|
+
msg = (
|
|
298
|
+
f"Provider '{_name(provider)}' cannot take the run input '{name}'; "
|
|
299
|
+
"only steps receive run inputs"
|
|
300
|
+
)
|
|
301
|
+
raise InvalidDependencyError(msg)
|
|
302
|
+
|
|
295
303
|
if name in dependencies and parameter.kind in _UNSUPPORTED_KINDS:
|
|
296
304
|
msg = (
|
|
297
305
|
f"Provider '{_name(provider)}' cannot inject {parameter.kind.description} "
|
|
@@ -50,10 +50,11 @@ class InvalidDependencyError(PyflowstepError, TypeError):
|
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
class InvalidInputError(PyflowstepError, TypeError):
|
|
53
|
-
"""Raised when an `Input
|
|
53
|
+
"""Raised when an `Input()` declaration cannot work.
|
|
54
54
|
|
|
55
|
-
For example an input on the subject, on a positional-only,
|
|
56
|
-
|
|
55
|
+
For example an input on the subject, on a positional-only parameter, on a
|
|
56
|
+
parameter that is also a dependency, or written inside `Annotated` instead
|
|
57
|
+
of as the default value.
|
|
57
58
|
"""
|
|
58
59
|
|
|
59
60
|
|
|
@@ -18,7 +18,7 @@ class Flow[T]:
|
|
|
18
18
|
with `>>`, which always returns a *new* flow and never mutates its operands.
|
|
19
19
|
|
|
20
20
|
Keyword arguments given to the call are the run inputs: `flow(page, user=user)`
|
|
21
|
-
hands `user` to every step that declares `user: Input
|
|
21
|
+
hands `user` to every step that declares `user: User = Input()`.
|
|
22
22
|
|
|
23
23
|
Args:
|
|
24
24
|
*actions: The callables to run, in order. Each one takes the subject and
|
|
@@ -1,178 +1,231 @@
|
|
|
1
|
-
"""Run inputs: hand a flow the values only its caller has, when it runs.
|
|
2
|
-
|
|
3
|
-
A step parameter
|
|
4
|
-
nobody passes it while the flow is built, neither Python nor JSON. The caller
|
|
5
|
-
supplies it by name when it runs the flow:
|
|
6
|
-
|
|
7
|
-
@step
|
|
8
|
-
def fill_year(page: Page, selector: str, voucher: Input
|
|
9
|
-
|
|
10
|
-
flow = fill_year("#year") # only `selector` is an argument
|
|
11
|
-
flow(page, voucher=voucher) # `voucher` is supplied for this run
|
|
12
|
-
|
|
13
|
-
The name of the input is the name of the parameter. A flow checks the inputs
|
|
14
|
-
its steps require before the first step runs, so a forgotten one never fails
|
|
15
|
-
halfway through.
|
|
16
|
-
|
|
17
|
-
Use `Depends` when a provider can build the object, and `Input` when only the
|
|
18
|
-
caller of the flow has it.
|
|
19
|
-
|
|
20
|
-
Example:
|
|
21
|
-
```python
|
|
22
|
-
>>> from pyflowstep import step
|
|
23
|
-
>>> @step
|
|
24
|
-
... def add_tax(price: float, rate: Input
|
|
25
|
-
... return price * (1 + rate)
|
|
26
|
-
>>> add_tax()(100.0, rate=0.25)
|
|
27
|
-
125.0
|
|
28
|
-
>>> add_tax()(100.0)
|
|
29
|
-
Traceback (most recent call last):
|
|
30
|
-
...
|
|
31
|
-
pyflowstep.exceptions.MissingInputError: missing run input 'rate' for step 'add_tax'...
|
|
32
|
-
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
from collections.abc import Callable, Iterable, Mapping
|
|
38
|
-
from dataclasses import dataclass
|
|
39
|
-
from inspect import Parameter, signature
|
|
40
|
-
from typing import
|
|
41
|
-
|
|
42
|
-
from .annotations import annotated_metadata, resolved_annotations
|
|
43
|
-
from .
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
1
|
+
"""Run inputs: hand a flow the values only its caller has, when it runs.
|
|
2
|
+
|
|
3
|
+
A step parameter whose default is `Input()` is not an argument of the step:
|
|
4
|
+
nobody passes it while the flow is built, neither Python nor JSON. The caller
|
|
5
|
+
supplies it by name when it runs the flow:
|
|
6
|
+
|
|
7
|
+
@step
|
|
8
|
+
def fill_year(page: Page, selector: str, voucher: Voucher = Input()) -> Page: ...
|
|
9
|
+
|
|
10
|
+
flow = fill_year("#year") # only `selector` is an argument
|
|
11
|
+
flow(page, voucher=voucher) # `voucher` is supplied for this run
|
|
12
|
+
|
|
13
|
+
The name of the input is the name of the parameter. A flow checks the inputs
|
|
14
|
+
its steps require before the first step runs, so a forgotten one never fails
|
|
15
|
+
halfway through. `Input(default=...)` makes the input optional.
|
|
16
|
+
|
|
17
|
+
Use `Depends` when a provider can build the object, and `Input` when only the
|
|
18
|
+
caller of the flow has it.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
```python
|
|
22
|
+
>>> from pyflowstep import step
|
|
23
|
+
>>> @step
|
|
24
|
+
... def add_tax(price: float, rate: float = Input()) -> float:
|
|
25
|
+
... return price * (1 + rate)
|
|
26
|
+
>>> add_tax()(100.0, rate=0.25)
|
|
27
|
+
125.0
|
|
28
|
+
>>> add_tax()(100.0)
|
|
29
|
+
Traceback (most recent call last):
|
|
30
|
+
...
|
|
31
|
+
pyflowstep.exceptions.MissingInputError: missing run input 'rate' for step 'add_tax'...
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from inspect import Parameter, signature
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from .annotations import annotated_metadata, resolved_annotations
|
|
43
|
+
from .exceptions import InvalidInputError, MissingInputError
|
|
44
|
+
|
|
45
|
+
_REQUIRED_INPUTS = "__pyflowstep_inputs__"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Required:
|
|
49
|
+
"""The default of an input that has none: the caller must supply it."""
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
return "<required>"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_REQUIRED: Any = _Required()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class RunInput:
|
|
60
|
+
"""The marker created by `Input`: "the caller of the flow supplies this parameter"."""
|
|
61
|
+
|
|
62
|
+
default: Any = _REQUIRED
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def required(self) -> bool:
|
|
66
|
+
"""Whether the caller must supply the input."""
|
|
67
|
+
return self.default is _REQUIRED
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def Input(*, default: Any = _REQUIRED) -> Any: # noqa: N802
|
|
71
|
+
"""Mark a step parameter as supplied by the caller when the flow runs.
|
|
72
|
+
|
|
73
|
+
Use it as the default value of the parameter. The parameter stops being an
|
|
74
|
+
argument of the step; `flow(subject, name=value)` fills it for one run.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
default: The value used when the caller supplies none, which makes the
|
|
78
|
+
input optional. Without it the input is required.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
```python
|
|
82
|
+
>>> from pyflowstep import step
|
|
83
|
+
>>> @step
|
|
84
|
+
... def greet(names: list, user: str = Input(), mark: str = Input(default="!")) -> list:
|
|
85
|
+
... return [*names, user + mark]
|
|
86
|
+
>>> greet()([], user="Ada")
|
|
87
|
+
['Ada!']
|
|
88
|
+
>>> greet()([], user="Ada", mark="?")
|
|
89
|
+
['Ada?']
|
|
90
|
+
>>> sorted(greet().inputs)
|
|
91
|
+
['user']
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
return RunInput(default)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_inputs(fn: Callable[..., Any]) -> dict[str, RunInput]:
|
|
100
|
+
"""Return the parameters of `fn` whose default is `Input()`, by name.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
```python
|
|
104
|
+
>>> def add_tax(price: float, rate: float = Input(), bonus: float = Input(default=0.0)): ...
|
|
105
|
+
>>> {name: marker.required for name, marker in find_inputs(add_tax).items()}
|
|
106
|
+
{'rate': True, 'bonus': False}
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
return {
|
|
112
|
+
name: parameter.default
|
|
113
|
+
for name, parameter in signature(fn).parameters.items()
|
|
114
|
+
if isinstance(parameter.default, RunInput)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def step_inputs(fn: Callable[..., Any], step_name: str) -> dict[str, RunInput]:
|
|
119
|
+
"""Return the inputs of a step function after validating them.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
InvalidInputError: If the subject or a positional-only parameter is
|
|
123
|
+
marked, or `Input()` is used inside `Annotated` instead of as a default.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
inputs = find_inputs(fn)
|
|
127
|
+
parameters = signature(fn).parameters
|
|
128
|
+
annotations = resolved_annotations(fn)
|
|
129
|
+
subject = next(iter(parameters))
|
|
130
|
+
|
|
131
|
+
for name in parameters:
|
|
132
|
+
if any(isinstance(item, RunInput) for item in annotated_metadata(annotations.get(name))):
|
|
133
|
+
msg = (
|
|
134
|
+
f"Step '{step_name}' uses Input inside Annotated for '{name}'; write it as the "
|
|
135
|
+
f"default value, `{name}: <type> = Input()`, so type checkers see the parameter "
|
|
136
|
+
"as optional"
|
|
137
|
+
)
|
|
138
|
+
raise InvalidInputError(msg)
|
|
139
|
+
|
|
140
|
+
if subject in inputs:
|
|
141
|
+
msg = f"Step '{step_name}' cannot take its subject '{subject}' as a run input"
|
|
142
|
+
raise InvalidInputError(msg)
|
|
143
|
+
|
|
144
|
+
for name in inputs:
|
|
145
|
+
if parameters[name].kind is Parameter.POSITIONAL_ONLY:
|
|
146
|
+
msg = (
|
|
147
|
+
f"Step '{step_name}' cannot take positional-only parameter '{name}' as a run input"
|
|
148
|
+
)
|
|
149
|
+
raise InvalidInputError(msg)
|
|
150
|
+
|
|
151
|
+
return inputs
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def resolve_inputs(
|
|
155
|
+
given: Mapping[str, Any],
|
|
156
|
+
inputs: Mapping[str, RunInput],
|
|
157
|
+
step_name: str,
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""Return the value of every input: the one `given`, or the default of an optional input.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
MissingInputError: If a required input was not given.
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
missing = [name for name, marker in inputs.items() if marker.required and name not in given]
|
|
166
|
+
|
|
167
|
+
if missing:
|
|
168
|
+
raise MissingInputError(_missing_message((name, step_name) for name in missing))
|
|
169
|
+
|
|
170
|
+
return {name: given.get(name, marker.default) for name, marker in inputs.items()}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def mark_required_inputs(action: Callable[..., Any], inputs: Mapping[str, RunInput]) -> None:
|
|
174
|
+
"""Record on a step action the inputs it requires, for `required_inputs` to read."""
|
|
175
|
+
required = frozenset(name for name, marker in inputs.items() if marker.required)
|
|
176
|
+
setattr(action, _REQUIRED_INPUTS, required)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def required_inputs(action: Callable[..., Any]) -> frozenset[str]:
|
|
180
|
+
"""Return the names of the inputs an action requires; plain callables require none.
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
```python
|
|
184
|
+
>>> from pyflowstep import step
|
|
185
|
+
>>> @step
|
|
186
|
+
... def add_tax(price: float, rate: float = Input(), bonus: float = Input(default=0.0)):
|
|
187
|
+
... return price * (1 + rate) + bonus
|
|
188
|
+
>>> required_inputs(add_tax().actions[0])
|
|
189
|
+
frozenset({'rate'})
|
|
190
|
+
>>> required_inputs(abs)
|
|
191
|
+
frozenset()
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
"""
|
|
196
|
+
return getattr(action, _REQUIRED_INPUTS, frozenset())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def check_inputs(actions: Iterable[Callable[..., Any]], given: Mapping[str, Any]) -> None:
|
|
200
|
+
"""Raise if `given` lacks an input one of `actions` requires.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
MissingInputError: Naming every missing input and the step that needs it.
|
|
204
|
+
|
|
205
|
+
"""
|
|
206
|
+
missing = [
|
|
207
|
+
(name, getattr(action, "__name__", repr(action)))
|
|
208
|
+
for action in actions
|
|
209
|
+
for name in sorted(required_inputs(action) - given.keys())
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
if missing:
|
|
213
|
+
raise MissingInputError(_missing_message(missing))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _missing_message(missing: Iterable[tuple[str, str]]) -> str:
|
|
217
|
+
steps_by_input: dict[str, list[str]] = {}
|
|
218
|
+
|
|
219
|
+
for name, step_name in missing:
|
|
220
|
+
steps = steps_by_input.setdefault(name, [])
|
|
221
|
+
if step_name not in steps:
|
|
222
|
+
steps.append(step_name)
|
|
223
|
+
|
|
224
|
+
details = ", ".join(
|
|
225
|
+
f"'{name}' for step{'s' if len(steps) > 1 else ''} {', '.join(map(repr, steps))}"
|
|
226
|
+
for name, steps in steps_by_input.items()
|
|
227
|
+
)
|
|
228
|
+
names = ", ".join(f"{name}=..." for name in steps_by_input)
|
|
229
|
+
plural = "s" if len(steps_by_input) > 1 else ""
|
|
230
|
+
|
|
231
|
+
return f"missing run input{plural} {details}; run the flow as flow(subject, {names})"
|
|
@@ -15,8 +15,8 @@ Three markers can sit on a parameter:
|
|
|
15
15
|
see `pyflowstep.parsers`.
|
|
16
16
|
- `Depends(provider)` makes it no argument at all: it is injected when the flow
|
|
17
17
|
runs, see `pyflowstep.dependencies`.
|
|
18
|
-
- `Input
|
|
19
|
-
the flow, see `pyflowstep.inputs`.
|
|
18
|
+
- `Input()` as the default makes it no argument either: the caller supplies it
|
|
19
|
+
when it runs the flow, see `pyflowstep.inputs`.
|
|
20
20
|
|
|
21
21
|
Naming steps is a registry concern, see `StepsRegistry`.
|
|
22
22
|
"""
|
|
@@ -29,7 +29,7 @@ from typing import Any, Concatenate
|
|
|
29
29
|
from .dependencies import Dependency, resolve_dependencies, run_scope, step_dependencies
|
|
30
30
|
from .exceptions import InvalidInputError, InvalidParserError, InvalidStepError, to_argument_error
|
|
31
31
|
from .flow import Flow, action_name
|
|
32
|
-
from .inputs import mark_required_inputs, resolve_inputs, step_inputs
|
|
32
|
+
from .inputs import RunInput, mark_required_inputs, resolve_inputs, step_inputs
|
|
33
33
|
from .parsers import find_parsers, parse_arguments
|
|
34
34
|
|
|
35
35
|
type StepFn[T, **P] = Callable[Concatenate[T, P], T]
|
|
@@ -189,14 +189,14 @@ def _inject(
|
|
|
189
189
|
bound: BoundArguments,
|
|
190
190
|
*,
|
|
191
191
|
dependencies: Mapping[str, Dependency],
|
|
192
|
-
inputs: Mapping[str,
|
|
192
|
+
inputs: Mapping[str, RunInput],
|
|
193
193
|
) -> Callable[[Any], Any]:
|
|
194
194
|
"""Like `_call`, resolving the run inputs and the dependencies each time the step runs."""
|
|
195
195
|
|
|
196
196
|
def call(subject: Any) -> Any:
|
|
197
197
|
with run_scope() as run:
|
|
198
198
|
resolved = {
|
|
199
|
-
**resolve_inputs(run, inputs, step_name),
|
|
199
|
+
**resolve_inputs(run.inputs, inputs, step_name),
|
|
200
200
|
**resolve_dependencies(run, dependencies),
|
|
201
201
|
}
|
|
202
202
|
arguments = BoundArguments(full_signature, {**bound.arguments, **resolved}) # type: ignore[arg-type]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|