pyflowstep 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyflowstep
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A lightweight, typed Python library for composing functions into readable, reusable flows that can be defined as JSON.
5
5
  Keywords: flow,pipeline,composition,functional,fluent-interface,json,json-schema,workflow,dsl
6
6
  Author: aaltatan
@@ -32,7 +32,7 @@ A lightweight, typed Python library for composing functions into readable, reusa
32
32
  - step arguments are validated and parsed when a flow is **built**, not halfway through running it
33
33
  - raw JSON values become typed arguments with a `Parse` marker next to the parameter
34
34
  - steps get external objects (a mailer, a database session) through FastAPI-style `Depends`
35
- - the caller hands per-run values to a flow by name: `flow(page, user=user)` fills every `user: Input[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
@@ -443,6 +443,8 @@ def send_email(order: Order, template: str, mailer: MailerDep) -> None: ...
443
443
  def send_invoice(order: Order, mailer: MailerDep) -> None: ...
444
444
  ```
445
445
 
446
+ Both forms behave the same when the flow runs. Type checkers differ: with the `Annotated` form they still see `mailer` as a required argument and report `send_email("receipt")` as missing it. Use the default-value form for steps you call from Python; the `Annotated` form suits steps that are only used from JSON.
447
+
446
448
  ### One object per flow run
447
449
 
448
450
  A flow run is one scope, like one request in a web framework. A provider is called **at most once per run**, and every step of that run receives the same object. The next run starts fresh.
@@ -535,7 +537,7 @@ See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow w
535
537
 
536
538
  ## Run inputs
537
539
 
538
- Some values exist only where the flow is run: the logged-in user, the record being processed, credentials read from a prompt. No provider can build them and JSON must not hold them. Annotate the parameter with `Input[T]` and the caller supplies it, by name, when it runs the flow:
540
+ Some values exist only where the flow is run: the logged-in user, the record being processed, credentials read from a prompt. No provider can build them and JSON must not hold them. Give the parameter the default `Input()` and the caller supplies it, by name, when it runs the flow:
539
541
 
540
542
  ```python
541
543
  from pyflowstep import Input, StepsRegistry
@@ -544,14 +546,14 @@ steps = StepsRegistry[Page]()
544
546
 
545
547
 
546
548
  @steps.tap()
547
- def login(page: Page, url: str, credentials: Input[Credentials]) -> None:
549
+ def login(page: Page, url: str, credentials: Credentials = Input()) -> None:
548
550
  page.navigate(url)
549
551
  page.fill("#user", credentials.username)
550
552
  page.fill("#password", credentials.password)
551
553
 
552
554
 
553
555
  @steps.tap()
554
- def fill_year(page: Page, selector: str, voucher: Input[Voucher]) -> None:
556
+ def fill_year(page: Page, selector: str, voucher: Voucher = Input()) -> None:
555
557
  page.fill(selector, voucher.year)
556
558
 
557
559
 
@@ -569,6 +571,8 @@ flow(page, credentials=credentials, voucher=voucher) # they are giv
569
571
 
570
572
  The name of the input is the name of the parameter, and every step that declares it receives the same value.
571
573
 
574
+ `Input()` is written as the default value, never inside `Annotated`, so that type checkers see the parameter as optional and accept `fill_year("#year")`.
575
+
572
576
  ### Checked before anything runs
573
577
 
574
578
  A flow knows the inputs its steps require, and checks them before the first step runs. A forgotten input never fails halfway through:
@@ -583,10 +587,26 @@ flow(page, credentials=credentials)
583
587
  ### Rules
584
588
 
585
589
  - **Invisible to JSON**, like a dependency: an input is absent from the JSON schema, cannot be parsed, and passing one while building the flow raises `UnexpectedKeywordArgumentError` (or `TooManyArgumentsError`).
586
- - **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`.
590
+ - **`Input(default=...)` makes the input optional**: with `note: str = Input(default="")`, `""` is used when the caller passes no `note`. Optional inputs are not listed in `flow.inputs`.
587
591
  - **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
588
592
  - **Nested flows see the inputs of the run they join.** A flow called from inside a step can be given inputs of its own, `inner(page, voucher=other)`; they are laid over the outer ones for that call only.
589
- - **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only, `*args` or `**kwargs` parameter, or on a parameter that is also a dependency. A provider cannot take an input.
593
+ - **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, on a parameter that is also a dependency, or written inside `Annotated`. A provider cannot take an input.
594
+
595
+ Using ruff? Add `Input` next to `Depends` for its `B008` rule:
596
+
597
+ ```toml
598
+ [tool.ruff.lint.flake8-bugbear]
599
+ extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.Input"]
600
+ ```
601
+
602
+ ### Upgrading from 0.3
603
+
604
+ `Input[T]` is gone, because a type checker reported `fill_year("#year")` as missing its `voucher` argument. Move the marker to the default value:
605
+
606
+ | 0.3 | 0.4 |
607
+ | ---------------------------------- | ---------------------------------------- |
608
+ | `voucher: Input[Voucher]` | `voucher: Voucher = Input()` |
609
+ | `note: Input[str] = ""` | `note: str = Input(default="")` |
590
610
 
591
611
  ### `Input` or `Depends`?
592
612
 
@@ -594,7 +614,7 @@ flow(page, credentials=credentials)
594
614
  | ------------------------------------------------------------ | ------------------------------------- |
595
615
  | is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
596
616
  | can be built by a function, the same way for every caller (a mailer, a session) | `Depends(provider)` |
597
- | is only known to whoever runs the flow (a user, a record, credentials) | `Input[T]` |
617
+ | is only known to whoever runs the flow (a user, a record, credentials) | `Input()` |
598
618
 
599
619
  ---
600
620
 
@@ -804,7 +824,7 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
804
824
  | `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
805
825
  | `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
806
826
  | `override_dependencies(mapping)` | context manager | Replace providers inside a `with` block, for tests |
807
- | `Input[T]` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
827
+ | `Input(default=...)` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
808
828
  | `Flow.inputs` | property | The names of the inputs a flow requires |
809
829
  | `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
810
830
  | `validate_step_dict(item, path)` | function | Validate one step dictionary |
@@ -9,7 +9,7 @@ A lightweight, typed Python library for composing functions into readable, reusa
9
9
  - step arguments are validated and parsed when a flow is **built**, not halfway through running it
10
10
  - raw JSON values become typed arguments with a `Parse` marker next to the parameter
11
11
  - steps get external objects (a mailer, a database session) through FastAPI-style `Depends`
12
- - the caller hands per-run values to a flow by name: `flow(page, user=user)` fills every `user: Input[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
@@ -420,6 +420,8 @@ def send_email(order: Order, template: str, mailer: MailerDep) -> None: ...
420
420
  def send_invoice(order: Order, mailer: MailerDep) -> None: ...
421
421
  ```
422
422
 
423
+ Both forms behave the same when the flow runs. Type checkers differ: with the `Annotated` form they still see `mailer` as a required argument and report `send_email("receipt")` as missing it. Use the default-value form for steps you call from Python; the `Annotated` form suits steps that are only used from JSON.
424
+
423
425
  ### One object per flow run
424
426
 
425
427
  A flow run is one scope, like one request in a web framework. A provider is called **at most once per run**, and every step of that run receives the same object. The next run starts fresh.
@@ -512,7 +514,7 @@ See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow w
512
514
 
513
515
  ## Run inputs
514
516
 
515
- Some values exist only where the flow is run: the logged-in user, the record being processed, credentials read from a prompt. No provider can build them and JSON must not hold them. Annotate the parameter with `Input[T]` and the caller supplies it, by name, when it runs the flow:
517
+ Some values exist only where the flow is run: the logged-in user, the record being processed, credentials read from a prompt. No provider can build them and JSON must not hold them. Give the parameter the default `Input()` and the caller supplies it, by name, when it runs the flow:
516
518
 
517
519
  ```python
518
520
  from pyflowstep import Input, StepsRegistry
@@ -521,14 +523,14 @@ steps = StepsRegistry[Page]()
521
523
 
522
524
 
523
525
  @steps.tap()
524
- def login(page: Page, url: str, credentials: Input[Credentials]) -> None:
526
+ def login(page: Page, url: str, credentials: Credentials = Input()) -> None:
525
527
  page.navigate(url)
526
528
  page.fill("#user", credentials.username)
527
529
  page.fill("#password", credentials.password)
528
530
 
529
531
 
530
532
  @steps.tap()
531
- def fill_year(page: Page, selector: str, voucher: Input[Voucher]) -> None:
533
+ def fill_year(page: Page, selector: str, voucher: Voucher = Input()) -> None:
532
534
  page.fill(selector, voucher.year)
533
535
 
534
536
 
@@ -546,6 +548,8 @@ flow(page, credentials=credentials, voucher=voucher) # they are giv
546
548
 
547
549
  The name of the input is the name of the parameter, and every step that declares it receives the same value.
548
550
 
551
+ `Input()` is written as the default value, never inside `Annotated`, so that type checkers see the parameter as optional and accept `fill_year("#year")`.
552
+
549
553
  ### Checked before anything runs
550
554
 
551
555
  A flow knows the inputs its steps require, and checks them before the first step runs. A forgotten input never fails halfway through:
@@ -560,10 +564,26 @@ flow(page, credentials=credentials)
560
564
  ### Rules
561
565
 
562
566
  - **Invisible to JSON**, like a dependency: an input is absent from the JSON schema, cannot be parsed, and passing one while building the flow raises `UnexpectedKeywordArgumentError` (or `TooManyArgumentsError`).
563
- - **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`.
567
+ - **`Input(default=...)` makes the input optional**: with `note: str = Input(default="")`, `""` is used when the caller passes no `note`. Optional inputs are not listed in `flow.inputs`.
564
568
  - **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
565
569
  - **Nested flows see the inputs of the run they join.** A flow called from inside a step can be given inputs of its own, `inner(page, voucher=other)`; they are laid over the outer ones for that call only.
566
- - **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only, `*args` or `**kwargs` parameter, or on a parameter that is also a dependency. A provider cannot take an input.
570
+ - **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, on a parameter that is also a dependency, or written inside `Annotated`. A provider cannot take an input.
571
+
572
+ Using ruff? Add `Input` next to `Depends` for its `B008` rule:
573
+
574
+ ```toml
575
+ [tool.ruff.lint.flake8-bugbear]
576
+ extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.Input"]
577
+ ```
578
+
579
+ ### Upgrading from 0.3
580
+
581
+ `Input[T]` is gone, because a type checker reported `fill_year("#year")` as missing its `voucher` argument. Move the marker to the default value:
582
+
583
+ | 0.3 | 0.4 |
584
+ | ---------------------------------- | ---------------------------------------- |
585
+ | `voucher: Input[Voucher]` | `voucher: Voucher = Input()` |
586
+ | `note: Input[str] = ""` | `note: str = Input(default="")` |
567
587
 
568
588
  ### `Input` or `Depends`?
569
589
 
@@ -571,7 +591,7 @@ flow(page, credentials=credentials)
571
591
  | ------------------------------------------------------------ | ------------------------------------- |
572
592
  | is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
573
593
  | can be built by a function, the same way for every caller (a mailer, a session) | `Depends(provider)` |
574
- | is only known to whoever runs the flow (a user, a record, credentials) | `Input[T]` |
594
+ | is only known to whoever runs the flow (a user, a record, credentials) | `Input()` |
575
595
 
576
596
  ---
577
597
 
@@ -781,7 +801,7 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
781
801
  | `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
782
802
  | `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
783
803
  | `override_dependencies(mapping)` | context manager | Replace providers inside a `with` block, for tests |
784
- | `Input[T]` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
804
+ | `Input(default=...)` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
785
805
  | `Flow.inputs` | property | The names of the inputs a flow requires |
786
806
  | `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
787
807
  | `validate_step_dict(item, path)` | function | Validate one step dictionary |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyflowstep"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "A lightweight, typed Python library for composing functions into readable, reusable flows that can be defined as JSON."
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-only"
@@ -101,6 +101,8 @@ ignore = [
101
101
  extend-immutable-calls = [
102
102
  "pyflowstep.Depends",
103
103
  "pyflowstep.dependencies.Depends",
104
+ "pyflowstep.Input",
105
+ "pyflowstep.inputs.Input",
104
106
  ]
105
107
 
106
108
  [tool.ruff.lint.per-file-ignores]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyflowstep"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "A lightweight, typed Python library for composing functions into readable, reusable flows that can be defined as JSON."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -80,8 +80,13 @@ ignore = [
80
80
  ]
81
81
 
82
82
  [tool.ruff.lint.flake8-bugbear]
83
- # `x: T = Depends(provider)` 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,6 +35,7 @@ from typing import Any
35
35
 
36
36
  from .annotations import annotated_metadata, resolved_annotations
37
37
  from .exceptions import InvalidDependencyError
38
+ from .inputs import RunInput
38
39
 
39
40
  type Provider = Callable[..., Any]
40
41
 
@@ -292,6 +293,13 @@ def _validate_provider(provider: Provider, path: tuple[Provider, ...]) -> None:
292
293
  dependencies = find_dependencies(provider)
293
294
 
294
295
  for name, parameter in parameters.items():
296
+ if isinstance(parameter.default, RunInput):
297
+ msg = (
298
+ f"Provider '{_name(provider)}' cannot take the run input '{name}'; "
299
+ "only steps receive run inputs"
300
+ )
301
+ raise InvalidDependencyError(msg)
302
+
295
303
  if name in dependencies and parameter.kind in _UNSUPPORTED_KINDS:
296
304
  msg = (
297
305
  f"Provider '{_name(provider)}' cannot inject {parameter.kind.description} "
@@ -50,10 +50,11 @@ class InvalidDependencyError(PyflowstepError, TypeError):
50
50
 
51
51
 
52
52
  class InvalidInputError(PyflowstepError, TypeError):
53
- """Raised when an `Input[...]` declaration cannot work.
53
+ """Raised when an `Input()` declaration cannot work.
54
54
 
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.
55
+ For example an input on the subject, on a positional-only parameter, on a
56
+ parameter that is also a dependency, or written inside `Annotated` instead
57
+ of as the default value.
57
58
  """
58
59
 
59
60
 
@@ -18,7 +18,7 @@ class Flow[T]:
18
18
  with `>>`, which always returns a *new* flow and never mutates its operands.
19
19
 
20
20
  Keyword arguments given to the call are the run inputs: `flow(page, user=user)`
21
- hands `user` to every step that declares `user: Input[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
  """
@@ -29,7 +29,7 @@ from typing import Any, Concatenate
29
29
  from .dependencies import Dependency, resolve_dependencies, run_scope, step_dependencies
30
30
  from .exceptions import InvalidInputError, InvalidParserError, InvalidStepError, to_argument_error
31
31
  from .flow import Flow, action_name
32
- from .inputs import mark_required_inputs, resolve_inputs, step_inputs
32
+ from .inputs import RunInput, mark_required_inputs, resolve_inputs, step_inputs
33
33
  from .parsers import find_parsers, parse_arguments
34
34
 
35
35
  type StepFn[T, **P] = Callable[Concatenate[T, P], T]
@@ -189,14 +189,14 @@ def _inject(
189
189
  bound: BoundArguments,
190
190
  *,
191
191
  dependencies: Mapping[str, Dependency],
192
- inputs: Mapping[str, bool],
192
+ inputs: Mapping[str, RunInput],
193
193
  ) -> Callable[[Any], Any]:
194
194
  """Like `_call`, resolving the run inputs and the dependencies each time the step runs."""
195
195
 
196
196
  def call(subject: Any) -> Any:
197
197
  with run_scope() as run:
198
198
  resolved = {
199
- **resolve_inputs(run, inputs, step_name),
199
+ **resolve_inputs(run.inputs, inputs, step_name),
200
200
  **resolve_dependencies(run, dependencies),
201
201
  }
202
202
  arguments = BoundArguments(full_signature, {**bound.arguments, **resolved}) # type: ignore[arg-type]