pyflowstep 0.3.0__tar.gz → 0.5.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.5.0}/PKG-INFO +52 -22
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/README.md +51 -21
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/pyproject.toml +3 -1
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/pyproject.toml.orig +8 -3
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/dependencies.py +41 -33
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/exceptions.py +5 -4
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/flow.py +1 -1
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/inputs.py +231 -178
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/steps.py +6 -10
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/__init__.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/annotations.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/compilers.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/json_schema.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/parsers.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/py.typed +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.5.0}/src/pyflowstep/registry.py +0 -0
- {pyflowstep-0.3.0 → pyflowstep-0.5.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.5.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
|
|
@@ -427,22 +427,24 @@ send_email("receipt") # only `template` is an argument
|
|
|
427
427
|
|
|
428
428
|
It works the same with the plain `@step` and `@tap` decorators; no registry is required.
|
|
429
429
|
|
|
430
|
-
###
|
|
430
|
+
### Always the default value
|
|
431
431
|
|
|
432
|
-
|
|
433
|
-
from typing import Annotated
|
|
432
|
+
`Depends(...)` is written as the default value of the parameter, in steps and in providers. To name a dependency once and reuse it in many steps, keep the marker in a constant:
|
|
434
433
|
|
|
435
|
-
|
|
434
|
+
```python
|
|
435
|
+
# inline: quick, for a one-off
|
|
436
436
|
def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
|
|
437
437
|
|
|
438
438
|
|
|
439
|
-
#
|
|
440
|
-
|
|
439
|
+
# a constant: name the dependency once, reuse it
|
|
440
|
+
MAILER = Depends(get_mailer)
|
|
441
441
|
|
|
442
|
-
def send_email(order: Order, template: str, mailer:
|
|
443
|
-
def send_invoice(order: Order, mailer:
|
|
442
|
+
def send_email(order: Order, template: str, mailer: Mailer = MAILER) -> None: ...
|
|
443
|
+
def send_invoice(order: Order, mailer: Mailer = MAILER) -> None: ...
|
|
444
444
|
```
|
|
445
445
|
|
|
446
|
+
It is never written inside `Annotated`. Only a default value tells a type checker that the parameter is not passed, so `send_email("receipt")` type-checks; `Annotated[Mailer, Depends(get_mailer)]` raises `InvalidDependencyError` when the step is created.
|
|
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.
|
|
@@ -466,16 +468,16 @@ def get_session() -> Iterator[Session]:
|
|
|
466
468
|
session.close()
|
|
467
469
|
|
|
468
470
|
|
|
469
|
-
|
|
471
|
+
SESSION = Depends(get_session)
|
|
470
472
|
|
|
471
473
|
|
|
472
474
|
@steps.tap()
|
|
473
|
-
def save(order: Order, session:
|
|
475
|
+
def save(order: Order, session: Session = SESSION) -> None:
|
|
474
476
|
session.add(order)
|
|
475
477
|
|
|
476
478
|
|
|
477
479
|
@steps.tap()
|
|
478
|
-
def audit(order: Order, action: str, session:
|
|
480
|
+
def audit(order: Order, action: str, session: Session = SESSION) -> None:
|
|
479
481
|
session.add(AuditRow(order.id, action)) # the same session `save` used
|
|
480
482
|
|
|
481
483
|
|
|
@@ -518,7 +520,7 @@ Keys are the original providers and values the providers to call instead. Nested
|
|
|
518
520
|
### Rules
|
|
519
521
|
|
|
520
522
|
- **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
|
|
523
|
+
- **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 parameter; `Depends` written inside `Annotated`.
|
|
522
524
|
- **Providers run late**, when the flow runs. An error inside a provider surfaces then, not at compile time.
|
|
523
525
|
- 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
526
|
|
|
@@ -531,11 +533,21 @@ extend-immutable-calls = ["pyflowstep.Depends"]
|
|
|
531
533
|
|
|
532
534
|
See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow with a mailer, a per-run session and a test override.
|
|
533
535
|
|
|
536
|
+
### Upgrading from 0.4
|
|
537
|
+
|
|
538
|
+
`Annotated[T, Depends(provider)]` is gone, for the same reason `Input[T]` went in 0.4: a type checker reported `send_email("receipt")` as missing its `mailer` argument. Move the marker to the default value, in steps and in providers:
|
|
539
|
+
|
|
540
|
+
| 0.4 | 0.5 |
|
|
541
|
+
| --------------------------------------------------------- | -------------------------------------------- |
|
|
542
|
+
| `mailer: Annotated[Mailer, Depends(get_mailer)]` | `mailer: Mailer = Depends(get_mailer)` |
|
|
543
|
+
| `type MailerDep = Annotated[Mailer, Depends(get_mailer)]` | `MAILER = Depends(get_mailer)` |
|
|
544
|
+
| `mailer: MailerDep` | `mailer: Mailer = MAILER` |
|
|
545
|
+
|
|
534
546
|
---
|
|
535
547
|
|
|
536
548
|
## Run inputs
|
|
537
549
|
|
|
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.
|
|
550
|
+
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
551
|
|
|
540
552
|
```python
|
|
541
553
|
from pyflowstep import Input, StepsRegistry
|
|
@@ -544,14 +556,14 @@ steps = StepsRegistry[Page]()
|
|
|
544
556
|
|
|
545
557
|
|
|
546
558
|
@steps.tap()
|
|
547
|
-
def login(page: Page, url: str, credentials: Input
|
|
559
|
+
def login(page: Page, url: str, credentials: Credentials = Input()) -> None:
|
|
548
560
|
page.navigate(url)
|
|
549
561
|
page.fill("#user", credentials.username)
|
|
550
562
|
page.fill("#password", credentials.password)
|
|
551
563
|
|
|
552
564
|
|
|
553
565
|
@steps.tap()
|
|
554
|
-
def fill_year(page: Page, selector: str, voucher: Input
|
|
566
|
+
def fill_year(page: Page, selector: str, voucher: Voucher = Input()) -> None:
|
|
555
567
|
page.fill(selector, voucher.year)
|
|
556
568
|
|
|
557
569
|
|
|
@@ -569,6 +581,8 @@ flow(page, credentials=credentials, voucher=voucher) # they are giv
|
|
|
569
581
|
|
|
570
582
|
The name of the input is the name of the parameter, and every step that declares it receives the same value.
|
|
571
583
|
|
|
584
|
+
`Input()` is written as the default value, never inside `Annotated`, so that type checkers see the parameter as optional and accept `fill_year("#year")`.
|
|
585
|
+
|
|
572
586
|
### Checked before anything runs
|
|
573
587
|
|
|
574
588
|
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 +597,26 @@ flow(page, credentials=credentials)
|
|
|
583
597
|
### Rules
|
|
584
598
|
|
|
585
599
|
- **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
|
-
-
|
|
600
|
+
- **`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
601
|
- **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
|
|
588
602
|
- **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
|
|
603
|
+
- **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, or written inside `Annotated`. A provider cannot take an input.
|
|
604
|
+
|
|
605
|
+
Using ruff? Add `Input` next to `Depends` for its `B008` rule:
|
|
606
|
+
|
|
607
|
+
```toml
|
|
608
|
+
[tool.ruff.lint.flake8-bugbear]
|
|
609
|
+
extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.Input"]
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### Upgrading from 0.3
|
|
613
|
+
|
|
614
|
+
`Input[T]` is gone, because a type checker reported `fill_year("#year")` as missing its `voucher` argument. Move the marker to the default value:
|
|
615
|
+
|
|
616
|
+
| 0.3 | 0.4 |
|
|
617
|
+
| ---------------------------------- | ---------------------------------------- |
|
|
618
|
+
| `voucher: Input[Voucher]` | `voucher: Voucher = Input()` |
|
|
619
|
+
| `note: Input[str] = ""` | `note: str = Input(default="")` |
|
|
590
620
|
|
|
591
621
|
### `Input` or `Depends`?
|
|
592
622
|
|
|
@@ -594,7 +624,7 @@ flow(page, credentials=credentials)
|
|
|
594
624
|
| ------------------------------------------------------------ | ------------------------------------- |
|
|
595
625
|
| is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
|
|
596
626
|
| 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
|
|
627
|
+
| is only known to whoever runs the flow (a user, a record, credentials) | `Input()` |
|
|
598
628
|
|
|
599
629
|
---
|
|
600
630
|
|
|
@@ -745,7 +775,7 @@ Runnable examples live in [`examples/`](examples):
|
|
|
745
775
|
def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
|
|
746
776
|
|
|
747
777
|
@fulfilment_steps.tap()
|
|
748
|
-
def save(order: Order, session:
|
|
778
|
+
def save(order: Order, session: Session = SESSION) -> None: ...
|
|
749
779
|
```
|
|
750
780
|
|
|
751
781
|
### Working with pyspecification and pyformula
|
|
@@ -804,7 +834,7 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
|
|
|
804
834
|
| `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
|
|
805
835
|
| `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
|
|
806
836
|
| `override_dependencies(mapping)` | context manager | Replace providers inside a `with` block, for tests |
|
|
807
|
-
| `Input
|
|
837
|
+
| `Input(default=...)` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
|
|
808
838
|
| `Flow.inputs` | property | The names of the inputs a flow requires |
|
|
809
839
|
| `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
|
|
810
840
|
| `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
|
|
@@ -404,22 +404,24 @@ send_email("receipt") # only `template` is an argument
|
|
|
404
404
|
|
|
405
405
|
It works the same with the plain `@step` and `@tap` decorators; no registry is required.
|
|
406
406
|
|
|
407
|
-
###
|
|
407
|
+
### Always the default value
|
|
408
408
|
|
|
409
|
-
|
|
410
|
-
from typing import Annotated
|
|
409
|
+
`Depends(...)` is written as the default value of the parameter, in steps and in providers. To name a dependency once and reuse it in many steps, keep the marker in a constant:
|
|
411
410
|
|
|
412
|
-
|
|
411
|
+
```python
|
|
412
|
+
# inline: quick, for a one-off
|
|
413
413
|
def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
|
|
414
414
|
|
|
415
415
|
|
|
416
|
-
#
|
|
417
|
-
|
|
416
|
+
# a constant: name the dependency once, reuse it
|
|
417
|
+
MAILER = Depends(get_mailer)
|
|
418
418
|
|
|
419
|
-
def send_email(order: Order, template: str, mailer:
|
|
420
|
-
def send_invoice(order: Order, mailer:
|
|
419
|
+
def send_email(order: Order, template: str, mailer: Mailer = MAILER) -> None: ...
|
|
420
|
+
def send_invoice(order: Order, mailer: Mailer = MAILER) -> None: ...
|
|
421
421
|
```
|
|
422
422
|
|
|
423
|
+
It is never written inside `Annotated`. Only a default value tells a type checker that the parameter is not passed, so `send_email("receipt")` type-checks; `Annotated[Mailer, Depends(get_mailer)]` raises `InvalidDependencyError` when the step is created.
|
|
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.
|
|
@@ -443,16 +445,16 @@ def get_session() -> Iterator[Session]:
|
|
|
443
445
|
session.close()
|
|
444
446
|
|
|
445
447
|
|
|
446
|
-
|
|
448
|
+
SESSION = Depends(get_session)
|
|
447
449
|
|
|
448
450
|
|
|
449
451
|
@steps.tap()
|
|
450
|
-
def save(order: Order, session:
|
|
452
|
+
def save(order: Order, session: Session = SESSION) -> None:
|
|
451
453
|
session.add(order)
|
|
452
454
|
|
|
453
455
|
|
|
454
456
|
@steps.tap()
|
|
455
|
-
def audit(order: Order, action: str, session:
|
|
457
|
+
def audit(order: Order, action: str, session: Session = SESSION) -> None:
|
|
456
458
|
session.add(AuditRow(order.id, action)) # the same session `save` used
|
|
457
459
|
|
|
458
460
|
|
|
@@ -495,7 +497,7 @@ Keys are the original providers and values the providers to call instead. Nested
|
|
|
495
497
|
### Rules
|
|
496
498
|
|
|
497
499
|
- **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.
|
|
498
|
-
- **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
|
|
500
|
+
- **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 parameter; `Depends` written inside `Annotated`.
|
|
499
501
|
- **Providers run late**, when the flow runs. An error inside a provider surfaces then, not at compile time.
|
|
500
502
|
- 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.
|
|
501
503
|
|
|
@@ -508,11 +510,21 @@ extend-immutable-calls = ["pyflowstep.Depends"]
|
|
|
508
510
|
|
|
509
511
|
See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow with a mailer, a per-run session and a test override.
|
|
510
512
|
|
|
513
|
+
### Upgrading from 0.4
|
|
514
|
+
|
|
515
|
+
`Annotated[T, Depends(provider)]` is gone, for the same reason `Input[T]` went in 0.4: a type checker reported `send_email("receipt")` as missing its `mailer` argument. Move the marker to the default value, in steps and in providers:
|
|
516
|
+
|
|
517
|
+
| 0.4 | 0.5 |
|
|
518
|
+
| --------------------------------------------------------- | -------------------------------------------- |
|
|
519
|
+
| `mailer: Annotated[Mailer, Depends(get_mailer)]` | `mailer: Mailer = Depends(get_mailer)` |
|
|
520
|
+
| `type MailerDep = Annotated[Mailer, Depends(get_mailer)]` | `MAILER = Depends(get_mailer)` |
|
|
521
|
+
| `mailer: MailerDep` | `mailer: Mailer = MAILER` |
|
|
522
|
+
|
|
511
523
|
---
|
|
512
524
|
|
|
513
525
|
## Run inputs
|
|
514
526
|
|
|
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.
|
|
527
|
+
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
528
|
|
|
517
529
|
```python
|
|
518
530
|
from pyflowstep import Input, StepsRegistry
|
|
@@ -521,14 +533,14 @@ steps = StepsRegistry[Page]()
|
|
|
521
533
|
|
|
522
534
|
|
|
523
535
|
@steps.tap()
|
|
524
|
-
def login(page: Page, url: str, credentials: Input
|
|
536
|
+
def login(page: Page, url: str, credentials: Credentials = Input()) -> None:
|
|
525
537
|
page.navigate(url)
|
|
526
538
|
page.fill("#user", credentials.username)
|
|
527
539
|
page.fill("#password", credentials.password)
|
|
528
540
|
|
|
529
541
|
|
|
530
542
|
@steps.tap()
|
|
531
|
-
def fill_year(page: Page, selector: str, voucher: Input
|
|
543
|
+
def fill_year(page: Page, selector: str, voucher: Voucher = Input()) -> None:
|
|
532
544
|
page.fill(selector, voucher.year)
|
|
533
545
|
|
|
534
546
|
|
|
@@ -546,6 +558,8 @@ flow(page, credentials=credentials, voucher=voucher) # they are giv
|
|
|
546
558
|
|
|
547
559
|
The name of the input is the name of the parameter, and every step that declares it receives the same value.
|
|
548
560
|
|
|
561
|
+
`Input()` is written as the default value, never inside `Annotated`, so that type checkers see the parameter as optional and accept `fill_year("#year")`.
|
|
562
|
+
|
|
549
563
|
### Checked before anything runs
|
|
550
564
|
|
|
551
565
|
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 +574,26 @@ flow(page, credentials=credentials)
|
|
|
560
574
|
### Rules
|
|
561
575
|
|
|
562
576
|
- **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
|
-
-
|
|
577
|
+
- **`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
578
|
- **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
|
|
565
579
|
- **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
|
|
580
|
+
- **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, or written inside `Annotated`. A provider cannot take an input.
|
|
581
|
+
|
|
582
|
+
Using ruff? Add `Input` next to `Depends` for its `B008` rule:
|
|
583
|
+
|
|
584
|
+
```toml
|
|
585
|
+
[tool.ruff.lint.flake8-bugbear]
|
|
586
|
+
extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.Input"]
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### Upgrading from 0.3
|
|
590
|
+
|
|
591
|
+
`Input[T]` is gone, because a type checker reported `fill_year("#year")` as missing its `voucher` argument. Move the marker to the default value:
|
|
592
|
+
|
|
593
|
+
| 0.3 | 0.4 |
|
|
594
|
+
| ---------------------------------- | ---------------------------------------- |
|
|
595
|
+
| `voucher: Input[Voucher]` | `voucher: Voucher = Input()` |
|
|
596
|
+
| `note: Input[str] = ""` | `note: str = Input(default="")` |
|
|
567
597
|
|
|
568
598
|
### `Input` or `Depends`?
|
|
569
599
|
|
|
@@ -571,7 +601,7 @@ flow(page, credentials=credentials)
|
|
|
571
601
|
| ------------------------------------------------------------ | ------------------------------------- |
|
|
572
602
|
| is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
|
|
573
603
|
| 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
|
|
604
|
+
| is only known to whoever runs the flow (a user, a record, credentials) | `Input()` |
|
|
575
605
|
|
|
576
606
|
---
|
|
577
607
|
|
|
@@ -722,7 +752,7 @@ Runnable examples live in [`examples/`](examples):
|
|
|
722
752
|
def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
|
|
723
753
|
|
|
724
754
|
@fulfilment_steps.tap()
|
|
725
|
-
def save(order: Order, session:
|
|
755
|
+
def save(order: Order, session: Session = SESSION) -> None: ...
|
|
726
756
|
```
|
|
727
757
|
|
|
728
758
|
### Working with pyspecification and pyformula
|
|
@@ -781,7 +811,7 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
|
|
|
781
811
|
| `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
|
|
782
812
|
| `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
|
|
783
813
|
| `override_dependencies(mapping)` | context manager | Replace providers inside a `with` block, for tests |
|
|
784
|
-
| `Input
|
|
814
|
+
| `Input(default=...)` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
|
|
785
815
|
| `Flow.inputs` | property | The names of the inputs a flow requires |
|
|
786
816
|
| `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
|
|
787
817
|
| `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.5.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.5.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,11 +35,10 @@ 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
|
|
|
41
|
-
_UNSUPPORTED_KINDS = (Parameter.POSITIONAL_ONLY, Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD)
|
|
42
|
-
|
|
43
42
|
|
|
44
43
|
@dataclass(frozen=True, slots=True)
|
|
45
44
|
class Dependency:
|
|
@@ -51,13 +50,16 @@ class Dependency:
|
|
|
51
50
|
def Depends(provider: Provider, /) -> Any: # noqa: N802
|
|
52
51
|
"""Mark a step (or provider) parameter as injected by calling `provider`.
|
|
53
52
|
|
|
54
|
-
Use it as
|
|
55
|
-
|
|
53
|
+
Use it as the default value of the parameter. To name a dependency once and
|
|
54
|
+
reuse it, keep the marker in a constant:
|
|
56
55
|
|
|
57
56
|
def send(order: Order, mailer: Mailer = Depends(get_mailer)) -> Order: ...
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
def send(order: Order, mailer:
|
|
58
|
+
MAILER = Depends(get_mailer)
|
|
59
|
+
def send(order: Order, mailer: Mailer = MAILER) -> Order: ...
|
|
60
|
+
|
|
61
|
+
It is never written inside `Annotated`: only a default value lets type
|
|
62
|
+
checkers see that the parameter is not passed, so `send()` type-checks.
|
|
61
63
|
|
|
62
64
|
A provider is a function returning the object, or a generator function
|
|
63
65
|
that yields it once and cleans up after the `yield`. Its own parameters may
|
|
@@ -89,15 +91,14 @@ def Depends(provider: Provider, /) -> Any: # noqa: N802
|
|
|
89
91
|
|
|
90
92
|
|
|
91
93
|
def find_dependencies(fn: Callable[..., Any]) -> dict[str, Dependency]:
|
|
92
|
-
"""Return the parameters of `fn`
|
|
94
|
+
"""Return the parameters of `fn` whose default is `Depends(...)`, by name.
|
|
93
95
|
|
|
94
96
|
Example:
|
|
95
97
|
```python
|
|
96
98
|
>>> def get_rate() -> float:
|
|
97
99
|
... return 0.25
|
|
98
|
-
>>>
|
|
99
|
-
>>>
|
|
100
|
-
>>> def add_tax(price: float, label: str, rate: Rate, extra: float = Depends(get_rate)): ...
|
|
100
|
+
>>> RATE = Depends(get_rate)
|
|
101
|
+
>>> def add_tax(price: float, rate: float = RATE, extra: float = Depends(get_rate)): ...
|
|
101
102
|
>>> list(find_dependencies(add_tax))
|
|
102
103
|
['rate', 'extra']
|
|
103
104
|
|
|
@@ -109,22 +110,24 @@ def find_dependencies(fn: Callable[..., Any]) -> dict[str, Dependency]:
|
|
|
109
110
|
except ValueError: # builtins without a signature take no dependencies
|
|
110
111
|
return {}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
return {
|
|
114
|
+
name: parameter.default
|
|
115
|
+
for name, parameter in parameters.items()
|
|
116
|
+
if isinstance(parameter.default, Dependency)
|
|
117
|
+
}
|
|
117
118
|
|
|
118
119
|
|
|
119
120
|
def step_dependencies(fn: Callable[..., Any], step_name: str) -> dict[str, Dependency]:
|
|
120
121
|
"""Return the dependencies of a step function after validating them.
|
|
121
122
|
|
|
122
123
|
Raises:
|
|
123
|
-
InvalidDependencyError: If
|
|
124
|
-
positional-only
|
|
124
|
+
InvalidDependencyError: If `Depends` is written inside `Annotated`, the
|
|
125
|
+
subject or a positional-only parameter is marked, or a provider is
|
|
125
126
|
circular or has a required parameter that is not a dependency.
|
|
126
127
|
|
|
127
128
|
"""
|
|
129
|
+
_reject_annotated_markers(fn, f"Step '{step_name}'")
|
|
130
|
+
|
|
128
131
|
dependencies = find_dependencies(fn)
|
|
129
132
|
parameters = signature(fn).parameters
|
|
130
133
|
subject = next(iter(parameters))
|
|
@@ -134,11 +137,8 @@ def step_dependencies(fn: Callable[..., Any], step_name: str) -> dict[str, Depen
|
|
|
134
137
|
raise InvalidDependencyError(msg)
|
|
135
138
|
|
|
136
139
|
for name, dependency in dependencies.items():
|
|
137
|
-
if parameters[name].kind
|
|
138
|
-
msg =
|
|
139
|
-
f"Step '{step_name}' cannot inject {parameters[name].kind.description} "
|
|
140
|
-
f"parameter '{name}'"
|
|
141
|
-
)
|
|
140
|
+
if parameters[name].kind is Parameter.POSITIONAL_ONLY:
|
|
141
|
+
msg = f"Step '{step_name}' cannot inject positional-only parameter '{name}'"
|
|
142
142
|
raise InvalidDependencyError(msg)
|
|
143
143
|
_validate_provider(dependency.provider, ())
|
|
144
144
|
|
|
@@ -268,14 +268,17 @@ def _provider_dependencies(provider: Provider) -> Mapping[str, Dependency]:
|
|
|
268
268
|
return MappingProxyType(find_dependencies(provider))
|
|
269
269
|
|
|
270
270
|
|
|
271
|
-
def
|
|
272
|
-
if
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
271
|
+
def _reject_annotated_markers(fn: Callable[..., Any], owner: str) -> None:
|
|
272
|
+
"""Raise if a parameter of `fn` carries `Depends` inside `Annotated`."""
|
|
273
|
+
for name, annotation in resolved_annotations(fn).items():
|
|
274
|
+
for item in annotated_metadata(annotation):
|
|
275
|
+
if isinstance(item, Dependency):
|
|
276
|
+
msg = (
|
|
277
|
+
f"{owner} uses Depends inside Annotated for '{name}'; write it as the "
|
|
278
|
+
f"default value, `{name}: <type> = Depends({_name(item.provider)})`, so type "
|
|
279
|
+
"checkers see the parameter as optional"
|
|
280
|
+
)
|
|
281
|
+
raise InvalidDependencyError(msg)
|
|
279
282
|
|
|
280
283
|
|
|
281
284
|
def _validate_provider(provider: Provider, path: tuple[Provider, ...]) -> None:
|
|
@@ -289,16 +292,21 @@ def _validate_provider(provider: Provider, path: tuple[Provider, ...]) -> None:
|
|
|
289
292
|
except ValueError: # builtins without a signature are called with no arguments
|
|
290
293
|
return
|
|
291
294
|
|
|
295
|
+
_reject_annotated_markers(provider, f"Provider '{_name(provider)}'")
|
|
292
296
|
dependencies = find_dependencies(provider)
|
|
293
297
|
|
|
294
298
|
for name, parameter in parameters.items():
|
|
295
|
-
if
|
|
299
|
+
if isinstance(parameter.default, RunInput):
|
|
296
300
|
msg = (
|
|
297
|
-
f"Provider '{_name(provider)}' cannot
|
|
298
|
-
|
|
301
|
+
f"Provider '{_name(provider)}' cannot take the run input '{name}'; "
|
|
302
|
+
"only steps receive run inputs"
|
|
299
303
|
)
|
|
300
304
|
raise InvalidDependencyError(msg)
|
|
301
305
|
|
|
306
|
+
if name in dependencies and parameter.kind is Parameter.POSITIONAL_ONLY:
|
|
307
|
+
msg = f"Provider '{_name(provider)}' cannot inject positional-only parameter '{name}'"
|
|
308
|
+
raise InvalidDependencyError(msg)
|
|
309
|
+
|
|
302
310
|
if name in dependencies:
|
|
303
311
|
_validate_provider(dependencies[name].provider, (*path, provider))
|
|
304
312
|
elif _is_required(parameter):
|
|
@@ -45,15 +45,16 @@ class InvalidDependencyError(PyflowstepError, TypeError):
|
|
|
45
45
|
"""Raised when a `Depends(...)` declaration cannot work.
|
|
46
46
|
|
|
47
47
|
For example a provider that is not callable, is circular, or has a required
|
|
48
|
-
parameter that is not itself a dependency
|
|
48
|
+
parameter that is not itself a dependency, or a marker written inside
|
|
49
|
+
`Annotated` instead of as the default value.
|
|
49
50
|
"""
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
class InvalidInputError(PyflowstepError, TypeError):
|
|
53
|
-
"""Raised when an `Input
|
|
54
|
+
"""Raised when an `Input()` declaration cannot work.
|
|
54
55
|
|
|
55
|
-
For example an input on the subject, on a positional-only,
|
|
56
|
-
|
|
56
|
+
For example an input on the subject, on a positional-only parameter, or
|
|
57
|
+
written inside `Annotated` instead 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
|
"""
|
|
@@ -27,9 +27,9 @@ from inspect import BoundArguments, Parameter, Signature, signature
|
|
|
27
27
|
from typing import Any, Concatenate
|
|
28
28
|
|
|
29
29
|
from .dependencies import Dependency, resolve_dependencies, run_scope, step_dependencies
|
|
30
|
-
from .exceptions import
|
|
30
|
+
from .exceptions import 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]
|
|
@@ -130,10 +130,6 @@ def _make_step[T, **P](fn: TapFn[T, P], *, passthrough: bool) -> StepFactory[T,
|
|
|
130
130
|
parsers = find_parsers(fn, step_name)
|
|
131
131
|
injected = dependencies.keys() | inputs.keys()
|
|
132
132
|
|
|
133
|
-
if both := sorted(dependencies.keys() & inputs.keys()):
|
|
134
|
-
msg = f"Step '{step_name}' cannot take {both} both as a dependency and as a run input"
|
|
135
|
-
raise InvalidInputError(msg)
|
|
136
|
-
|
|
137
133
|
if both := sorted(parsers.keys() & injected):
|
|
138
134
|
msg = f"Step '{step_name}' cannot both parse and inject {both}: nothing is passed for them"
|
|
139
135
|
raise InvalidParserError(msg)
|
|
@@ -189,14 +185,14 @@ def _inject(
|
|
|
189
185
|
bound: BoundArguments,
|
|
190
186
|
*,
|
|
191
187
|
dependencies: Mapping[str, Dependency],
|
|
192
|
-
inputs: Mapping[str,
|
|
188
|
+
inputs: Mapping[str, RunInput],
|
|
193
189
|
) -> Callable[[Any], Any]:
|
|
194
190
|
"""Like `_call`, resolving the run inputs and the dependencies each time the step runs."""
|
|
195
191
|
|
|
196
192
|
def call(subject: Any) -> Any:
|
|
197
193
|
with run_scope() as run:
|
|
198
194
|
resolved = {
|
|
199
|
-
**resolve_inputs(run, inputs, step_name),
|
|
195
|
+
**resolve_inputs(run.inputs, inputs, step_name),
|
|
200
196
|
**resolve_dependencies(run, dependencies),
|
|
201
197
|
}
|
|
202
198
|
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
|