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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyflowstep
3
- Version: 0.3.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[User]`
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
- ### Two ways to write it
430
+ ### Always the default value
431
431
 
432
- ```python
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
- # default value: quick, for a one-off
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
- # Annotated: name the dependency once, reuse it in many steps
440
- type MailerDep = Annotated[Mailer, Depends(get_mailer)]
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: MailerDep) -> None: ...
443
- def send_invoice(order: Order, mailer: MailerDep) -> None: ...
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
- type SessionDep = Annotated[Session, Depends(get_session)]
471
+ SESSION = Depends(get_session)
470
472
 
471
473
 
472
474
  @steps.tap()
473
- def save(order: Order, session: SessionDep) -> None:
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: SessionDep) -> None:
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, or on a positional-only, `*args` or `**kwargs` parameter.
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. Annotate the parameter with `Input[T]` and the caller supplies it, by name, when it runs the flow:
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[Credentials]) -> None:
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[Voucher]) -> None:
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
- - **A default value makes the input optional**: `note: Input[str] = ""` is used when the caller passes no `note`. Optional inputs are not listed in `flow.inputs`.
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, `*args` or `**kwargs` parameter, or on a parameter that is also a dependency. A provider cannot take an input.
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[T]` |
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: SessionDep) -> None: ...
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[T]` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
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[User]`
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
- ### Two ways to write it
407
+ ### Always the default value
408
408
 
409
- ```python
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
- # default value: quick, for a one-off
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
- # Annotated: name the dependency once, reuse it in many steps
417
- type MailerDep = Annotated[Mailer, Depends(get_mailer)]
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: MailerDep) -> None: ...
420
- def send_invoice(order: Order, mailer: MailerDep) -> None: ...
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
- type SessionDep = Annotated[Session, Depends(get_session)]
448
+ SESSION = Depends(get_session)
447
449
 
448
450
 
449
451
  @steps.tap()
450
- def save(order: Order, session: SessionDep) -> None:
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: SessionDep) -> None:
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, or on a positional-only, `*args` or `**kwargs` parameter.
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. Annotate the parameter with `Input[T]` and the caller supplies it, by name, when it runs the flow:
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[Credentials]) -> None:
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[Voucher]) -> None:
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
- - **A default value makes the input optional**: `note: Input[str] = ""` is used when the caller passes no `note`. Optional inputs are not listed in `flow.inputs`.
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, `*args` or `**kwargs` parameter, or on a parameter that is also a dependency. A provider cannot take an input.
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[T]` |
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: SessionDep) -> None: ...
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[T]` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
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.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.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)` is a marker, not a mutable default
84
- extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.dependencies.Depends"]
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 a default value, or inside `Annotated` to name a dependency once
55
- and reuse it:
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
- type MailerDep = Annotated[Mailer, Depends(get_mailer)]
60
- def send(order: Order, mailer: MailerDep) -> Order: ...
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` marked with `Depends`, by name.
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
- >>> from typing import Annotated
99
- >>> type Rate = Annotated[float, Depends(get_rate)]
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
- annotations = resolved_annotations(fn)
113
- markers = (
114
- (name, _marker(parameter, annotations.get(name))) for name, parameter in parameters.items()
115
- )
116
- return {name: marker for name, marker in markers if marker is not None}
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 the subject is marked, a dependency sits on a
124
- positional-only, `*args` or `**kwargs` parameter, or a provider is
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 in _UNSUPPORTED_KINDS:
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 _marker(parameter: Parameter, annotation: Any) -> Dependency | None:
272
- if isinstance(parameter.default, Dependency):
273
- return parameter.default
274
-
275
- return next(
276
- (item for item in annotated_metadata(annotation) if isinstance(item, Dependency)),
277
- None,
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 name in dependencies and parameter.kind in _UNSUPPORTED_KINDS:
299
+ if isinstance(parameter.default, RunInput):
296
300
  msg = (
297
- f"Provider '{_name(provider)}' cannot inject {parameter.kind.description} "
298
- f"parameter '{name}'"
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[...]` declaration cannot work.
54
+ """Raised when an `Input()` declaration cannot work.
54
55
 
55
- For example an input on the subject, on a positional-only, `*args` or
56
- `**kwargs` parameter, or on a parameter that is also a dependency.
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[User]`.
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 annotated with `Input[T]` 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: Input[Voucher]) -> 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. A parameter with a default value is an optional input.
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[float]) -> 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 Annotated, Any
41
-
42
- from .annotations import annotated_metadata, resolved_annotations
43
- from .dependencies import Run
44
- from .exceptions import InvalidInputError, MissingInputError
45
-
46
- _UNSUPPORTED_KINDS = (Parameter.POSITIONAL_ONLY, Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD)
47
- _REQUIRED_INPUTS = "__pyflowstep_inputs__"
48
-
49
-
50
- @dataclass(frozen=True, slots=True)
51
- class RunInput:
52
- """The marker carried by `Input[T]`: "the caller of the flow supplies this parameter"."""
53
-
54
-
55
- type Input[T] = Annotated[T, RunInput()]
56
-
57
-
58
- def find_inputs(fn: Callable[..., Any]) -> dict[str, bool]:
59
- """Return the parameters of `fn` marked with `Input`, mapped to whether they are required.
60
-
61
- Example:
62
- ```python
63
- >>> def add_tax(price: float, label: str, rate: Input[float], bonus: Input[float] = 0.0): ...
64
- >>> find_inputs(add_tax)
65
- {'rate': True, 'bonus': False}
66
-
67
- ```
68
-
69
- """
70
- annotations = resolved_annotations(fn)
71
- return {
72
- name: parameter.default is Parameter.empty
73
- for name, parameter in signature(fn).parameters.items()
74
- if any(isinstance(item, RunInput) for item in annotated_metadata(annotations.get(name)))
75
- }
76
-
77
-
78
- def step_inputs(fn: Callable[..., Any], step_name: str) -> dict[str, bool]:
79
- """Return the inputs of a step function after validating them.
80
-
81
- Raises:
82
- InvalidInputError: If the subject is marked, or an input sits on a
83
- positional-only, `*args` or `**kwargs` parameter.
84
-
85
- """
86
- inputs = find_inputs(fn)
87
- parameters = signature(fn).parameters
88
- subject = next(iter(parameters))
89
-
90
- if subject in inputs:
91
- msg = f"Step '{step_name}' cannot take its subject '{subject}' as a run input"
92
- raise InvalidInputError(msg)
93
-
94
- for name in inputs:
95
- if parameters[name].kind in _UNSUPPORTED_KINDS:
96
- msg = (
97
- f"Step '{step_name}' cannot take {parameters[name].kind.description} "
98
- f"parameter '{name}' as a run input"
99
- )
100
- raise InvalidInputError(msg)
101
-
102
- return inputs
103
-
104
-
105
- def resolve_inputs(run: Run, inputs: Mapping[str, bool], step_name: str) -> dict[str, Any]:
106
- """Return the value of every input the run was given, optional ones only when present.
107
-
108
- Raises:
109
- MissingInputError: If the run was not given a required input.
110
-
111
- """
112
- missing = [name for name, required in inputs.items() if required and name not in run.inputs]
113
-
114
- if missing:
115
- raise MissingInputError(_missing_message((name, step_name) for name in missing))
116
-
117
- return {name: run.inputs[name] for name in inputs if name in run.inputs}
118
-
119
-
120
- def mark_required_inputs(action: Callable[..., Any], inputs: Mapping[str, bool]) -> None:
121
- """Record on a step action the inputs it requires, for `required_inputs` to read."""
122
- required = frozenset(name for name, required in inputs.items() if required)
123
- setattr(action, _REQUIRED_INPUTS, required)
124
-
125
-
126
- def required_inputs(action: Callable[..., Any]) -> frozenset[str]:
127
- """Return the names of the inputs an action requires; plain callables require none.
128
-
129
- Example:
130
- ```python
131
- >>> from pyflowstep import step
132
- >>> @step
133
- ... def add_tax(price: float, rate: Input[float], bonus: Input[float] = 0.0) -> float:
134
- ... return price * (1 + rate) + bonus
135
- >>> required_inputs(add_tax().actions[0])
136
- frozenset({'rate'})
137
- >>> required_inputs(abs)
138
- frozenset()
139
-
140
- ```
141
-
142
- """
143
- return getattr(action, _REQUIRED_INPUTS, frozenset())
144
-
145
-
146
- def check_inputs(actions: Iterable[Callable[..., Any]], given: Mapping[str, Any]) -> None:
147
- """Raise if `given` lacks an input one of `actions` requires.
148
-
149
- Raises:
150
- MissingInputError: Naming every missing input and the step that needs it.
151
-
152
- """
153
- missing = [
154
- (name, getattr(action, "__name__", repr(action)))
155
- for action in actions
156
- for name in sorted(required_inputs(action) - given.keys())
157
- ]
158
-
159
- if missing:
160
- raise MissingInputError(_missing_message(missing))
161
-
162
-
163
- def _missing_message(missing: Iterable[tuple[str, str]]) -> str:
164
- steps_by_input: dict[str, list[str]] = {}
165
-
166
- for name, step_name in missing:
167
- steps = steps_by_input.setdefault(name, [])
168
- if step_name not in steps:
169
- steps.append(step_name)
170
-
171
- details = ", ".join(
172
- f"'{name}' for step{'s' if len(steps) > 1 else ''} {', '.join(map(repr, steps))}"
173
- for name, steps in steps_by_input.items()
174
- )
175
- names = ", ".join(f"{name}=..." for name in steps_by_input)
176
- plural = "s" if len(steps_by_input) > 1 else ""
177
-
178
- return f"missing run input{plural} {details}; run the flow as flow(subject, {names})"
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[T]` makes it no argument either: the caller supplies it when it runs
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 InvalidInputError, InvalidParserError, InvalidStepError, to_argument_error
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, bool],
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]