pyflowstep 0.2.0__tar.gz → 0.3.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.2.0
3
+ Version: 0.3.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,6 +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
36
  - registry-based registration keeps steps organized and discoverable
36
37
  - flow definitions can be compiled from dictionaries or JSON
37
38
  - every registry can describe its flow language as a JSON Schema
@@ -181,7 +182,7 @@ pipeline # Flow(add >> multiply >> add)
181
182
  pipeline(1) # 31
182
183
  ```
183
184
 
184
- `step` and `tap` do one thing: turn a function into a flow factory. Naming a step belongs to the [registry](#registry). Two markers can sit on a parameter: [`Parse`](#parsing-arguments) to convert the value passed for it, and [`Depends`](#dependencies) to inject an object nobody passes.
185
+ `step` and `tap` do one thing: turn a function into a flow factory. Naming a step belongs to the [registry](#registry). Three markers can sit on a parameter: [`Parse`](#parsing-arguments) to convert the value passed for it, [`Depends`](#dependencies) to inject an object nobody passes, and [`Input`](#run-inputs) to receive a value from whoever runs the flow.
185
186
 
186
187
  Arguments are bound against the function signature **immediately**, so mistakes fail fast:
187
188
 
@@ -532,6 +533,71 @@ See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow w
532
533
 
533
534
  ---
534
535
 
536
+ ## Run inputs
537
+
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:
539
+
540
+ ```python
541
+ from pyflowstep import Input, StepsRegistry
542
+
543
+ steps = StepsRegistry[Page]()
544
+
545
+
546
+ @steps.tap()
547
+ def login(page: Page, url: str, credentials: Input[Credentials]) -> None:
548
+ page.navigate(url)
549
+ page.fill("#user", credentials.username)
550
+ page.fill("#password", credentials.password)
551
+
552
+
553
+ @steps.tap()
554
+ def fill_year(page: Page, selector: str, voucher: Input[Voucher]) -> None:
555
+ page.fill(selector, voucher.year)
556
+
557
+
558
+ flow = login("https://example.com/login") >> fill_year("#year") # inputs are not arguments
559
+
560
+ flow(page, credentials=credentials, voucher=voucher) # they are given to the run
561
+ ```
562
+
563
+ ```json
564
+ [
565
+ {"name": "login", "args": ["https://example.com/login"]},
566
+ {"name": "fill_year", "args": ["#year"]}
567
+ ]
568
+ ```
569
+
570
+ The name of the input is the name of the parameter, and every step that declares it receives the same value.
571
+
572
+ ### Checked before anything runs
573
+
574
+ A flow knows the inputs its steps require, and checks them before the first step runs. A forgotten input never fails halfway through:
575
+
576
+ ```python
577
+ flow.inputs # frozenset({'credentials', 'voucher'})
578
+
579
+ flow(page, credentials=credentials)
580
+ # MissingInputError: missing run input 'voucher' for step 'fill_year'; run the flow as flow(subject, voucher=...)
581
+ ```
582
+
583
+ ### Rules
584
+
585
+ - **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`.
587
+ - **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
588
+ - **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.
590
+
591
+ ### `Input` or `Depends`?
592
+
593
+ | The object… | Use |
594
+ | ------------------------------------------------------------ | ------------------------------------- |
595
+ | is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
596
+ | 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]` |
598
+
599
+ ---
600
+
535
601
  ## Compiling flows from JSON
536
602
 
537
603
  `FlowCompiler` turns a flow definition — a list of step dictionaries — into a `Flow`.
@@ -738,13 +804,15 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
738
804
  | `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
739
805
  | `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
740
806
  | `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) |
808
+ | `Flow.inputs` | property | The names of the inputs a flow requires |
741
809
  | `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
742
810
  | `validate_step_dict(item, path)` | function | Validate one step dictionary |
743
811
  | `get_json_schema(annotation)` | function | JSON Schema of a type annotation |
744
812
  | `get_step_json_schema(name, step)` | function | JSON Schema of one step dictionary |
745
813
  | `get_flow_json_schema(steps)` | function | JSON Schema of a whole flow definition |
746
814
 
747
- All exceptions derive from `PyflowstepError`; argument errors, `InvalidParserError` and `InvalidDependencyError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
815
+ All exceptions derive from `PyflowstepError`; argument errors, `InvalidParserError`, `InvalidDependencyError`, `InvalidInputError` and `MissingInputError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
748
816
 
749
817
  ---
750
818
 
@@ -9,6 +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
13
  - registry-based registration keeps steps organized and discoverable
13
14
  - flow definitions can be compiled from dictionaries or JSON
14
15
  - every registry can describe its flow language as a JSON Schema
@@ -158,7 +159,7 @@ pipeline # Flow(add >> multiply >> add)
158
159
  pipeline(1) # 31
159
160
  ```
160
161
 
161
- `step` and `tap` do one thing: turn a function into a flow factory. Naming a step belongs to the [registry](#registry). Two markers can sit on a parameter: [`Parse`](#parsing-arguments) to convert the value passed for it, and [`Depends`](#dependencies) to inject an object nobody passes.
162
+ `step` and `tap` do one thing: turn a function into a flow factory. Naming a step belongs to the [registry](#registry). Three markers can sit on a parameter: [`Parse`](#parsing-arguments) to convert the value passed for it, [`Depends`](#dependencies) to inject an object nobody passes, and [`Input`](#run-inputs) to receive a value from whoever runs the flow.
162
163
 
163
164
  Arguments are bound against the function signature **immediately**, so mistakes fail fast:
164
165
 
@@ -509,6 +510,71 @@ See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow w
509
510
 
510
511
  ---
511
512
 
513
+ ## Run inputs
514
+
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:
516
+
517
+ ```python
518
+ from pyflowstep import Input, StepsRegistry
519
+
520
+ steps = StepsRegistry[Page]()
521
+
522
+
523
+ @steps.tap()
524
+ def login(page: Page, url: str, credentials: Input[Credentials]) -> None:
525
+ page.navigate(url)
526
+ page.fill("#user", credentials.username)
527
+ page.fill("#password", credentials.password)
528
+
529
+
530
+ @steps.tap()
531
+ def fill_year(page: Page, selector: str, voucher: Input[Voucher]) -> None:
532
+ page.fill(selector, voucher.year)
533
+
534
+
535
+ flow = login("https://example.com/login") >> fill_year("#year") # inputs are not arguments
536
+
537
+ flow(page, credentials=credentials, voucher=voucher) # they are given to the run
538
+ ```
539
+
540
+ ```json
541
+ [
542
+ {"name": "login", "args": ["https://example.com/login"]},
543
+ {"name": "fill_year", "args": ["#year"]}
544
+ ]
545
+ ```
546
+
547
+ The name of the input is the name of the parameter, and every step that declares it receives the same value.
548
+
549
+ ### Checked before anything runs
550
+
551
+ A flow knows the inputs its steps require, and checks them before the first step runs. A forgotten input never fails halfway through:
552
+
553
+ ```python
554
+ flow.inputs # frozenset({'credentials', 'voucher'})
555
+
556
+ flow(page, credentials=credentials)
557
+ # MissingInputError: missing run input 'voucher' for step 'fill_year'; run the flow as flow(subject, voucher=...)
558
+ ```
559
+
560
+ ### Rules
561
+
562
+ - **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`.
564
+ - **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
565
+ - **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.
567
+
568
+ ### `Input` or `Depends`?
569
+
570
+ | The object… | Use |
571
+ | ------------------------------------------------------------ | ------------------------------------- |
572
+ | is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
573
+ | 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]` |
575
+
576
+ ---
577
+
512
578
  ## Compiling flows from JSON
513
579
 
514
580
  `FlowCompiler` turns a flow definition — a list of step dictionaries — into a `Flow`.
@@ -715,13 +781,15 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
715
781
  | `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
716
782
  | `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
717
783
  | `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) |
785
+ | `Flow.inputs` | property | The names of the inputs a flow requires |
718
786
  | `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
719
787
  | `validate_step_dict(item, path)` | function | Validate one step dictionary |
720
788
  | `get_json_schema(annotation)` | function | JSON Schema of a type annotation |
721
789
  | `get_step_json_schema(name, step)` | function | JSON Schema of one step dictionary |
722
790
  | `get_flow_json_schema(steps)` | function | JSON Schema of a whole flow definition |
723
791
 
724
- All exceptions derive from `PyflowstepError`; argument errors, `InvalidParserError` and `InvalidDependencyError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
792
+ All exceptions derive from `PyflowstepError`; argument errors, `InvalidParserError`, `InvalidDependencyError`, `InvalidInputError` and `MissingInputError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
725
793
 
726
794
  ---
727
795
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyflowstep"
3
- version = "0.2.0"
3
+ version = "0.3.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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyflowstep"
3
- version = "0.2.0"
3
+ version = "0.3.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 = [
@@ -27,10 +27,12 @@ from .exceptions import (
27
27
  ArgumentError,
28
28
  InvalidDependencyError,
29
29
  InvalidFlowDefinitionError,
30
+ InvalidInputError,
30
31
  InvalidParserError,
31
32
  InvalidStepError,
32
33
  InvalidStepNameError,
33
34
  MissingArgumentError,
35
+ MissingInputError,
34
36
  MultipleValuesArgumentError,
35
37
  ParseArgumentError,
36
38
  PositionalOnlyArgumentError,
@@ -41,6 +43,7 @@ from .exceptions import (
41
43
  UnexpectedKeywordArgumentError,
42
44
  )
43
45
  from .flow import Action, Flow, compose
46
+ from .inputs import Input
44
47
  from .json_schema import get_flow_json_schema, get_json_schema, get_step_json_schema
45
48
  from .parsers import Parse
46
49
  from .registry import StepsRegistry
@@ -53,12 +56,15 @@ __all__ = [
53
56
  "Flow",
54
57
  "FlowCompiler",
55
58
  "FlowDefinition",
59
+ "Input",
56
60
  "InvalidDependencyError",
57
61
  "InvalidFlowDefinitionError",
62
+ "InvalidInputError",
58
63
  "InvalidParserError",
59
64
  "InvalidStepError",
60
65
  "InvalidStepNameError",
61
66
  "MissingArgumentError",
67
+ "MissingInputError",
62
68
  "MultipleValuesArgumentError",
63
69
  "Parse",
64
70
  "ParseArgumentError",
@@ -1,59 +1,90 @@
1
- """Read the annotations that carry markers such as `Parse` and `Depends`."""
2
-
3
- from collections.abc import Callable
4
- from functools import partial
5
- from inspect import get_annotations, isroutine
6
- from typing import Annotated, Any, TypeAliasType, get_args, get_origin
7
-
8
-
9
- def resolved_annotations(fn: Callable[..., Any]) -> dict[str, Any]:
10
- """Return the annotations describing a call to `fn`, with strings evaluated when possible.
11
-
12
- For a class this is its `__init__`, for a callable object its `__call__`,
13
- and for a `functools.partial` the function it wraps.
14
-
15
- Example:
16
- ```python
17
- >>> def wait(page, selector: "str", timeout: float = 5.0): ...
18
- >>> resolved_annotations(wait)
19
- {'selector': <class 'str'>, 'timeout': <class 'float'>}
20
-
21
- ```
22
-
23
- """
24
- target = _annotated_function(fn)
25
-
26
- try:
27
- return get_annotations(target, eval_str=True)
28
- except NameError:
29
- return get_annotations(target)
30
-
31
-
32
- def annotated_metadata(annotation: Any) -> tuple[Any, ...]:
33
- """Return the extra arguments of `Annotated[...]`, looking through `type` aliases.
34
-
35
- Example:
36
- ```python
37
- >>> type Port = Annotated[int, "tcp", 8080]
38
- >>> annotated_metadata(Port)
39
- ('tcp', 8080)
40
- >>> annotated_metadata(int)
41
- ()
42
-
43
- ```
44
-
45
- """
46
- while isinstance(annotation, TypeAliasType):
47
- annotation = annotation.__value__
48
-
49
- return get_args(annotation)[1:] if get_origin(annotation) is Annotated else ()
50
-
51
-
52
- def _annotated_function(fn: Callable[..., Any]) -> Callable[..., Any]:
53
- if isinstance(fn, partial):
54
- return _annotated_function(fn.func)
55
-
56
- if isinstance(fn, type):
57
- return fn.__init__
58
-
59
- return fn if isroutine(fn) else type(fn).__call__
1
+ """Read the annotations that carry markers such as `Parse` and `Depends`."""
2
+
3
+ from collections.abc import Callable
4
+ from functools import partial
5
+ from inspect import get_annotations, isroutine
6
+ from typing import Annotated, Any, TypeAliasType, get_args, get_origin
7
+
8
+
9
+ def resolved_annotations(fn: Callable[..., Any]) -> dict[str, Any]:
10
+ """Return the annotations describing a call to `fn`, with strings evaluated when possible.
11
+
12
+ For a class this is its `__init__`, for a callable object its `__call__`,
13
+ and for a `functools.partial` the function it wraps.
14
+
15
+ Example:
16
+ ```python
17
+ >>> def wait(page, selector: "str", timeout: float = 5.0): ...
18
+ >>> resolved_annotations(wait)
19
+ {'selector': <class 'str'>, 'timeout': <class 'float'>}
20
+
21
+ ```
22
+
23
+ """
24
+ target = _annotated_function(fn)
25
+
26
+ try:
27
+ return get_annotations(target, eval_str=True)
28
+ except NameError:
29
+ return get_annotations(target)
30
+
31
+
32
+ def annotated_metadata(annotation: Any) -> tuple[Any, ...]:
33
+ """Return the extra arguments of `Annotated[...]`, looking through `type` aliases.
34
+
35
+ Example:
36
+ ```python
37
+ >>> type Port = Annotated[int, "tcp", 8080]
38
+ >>> annotated_metadata(Port)
39
+ ('tcp', 8080)
40
+ >>> annotated_metadata(int)
41
+ ()
42
+ >>> type Tagged[T] = Annotated[T, "tag"]
43
+ >>> annotated_metadata(Tagged[int])
44
+ ('tag',)
45
+ >>> annotated_metadata(Annotated[Tagged[Port], "outer"])
46
+ ('tcp', 8080, 'tag', 'outer')
47
+ >>> type Fixed[T] = Annotated[int, "fixed"]
48
+ >>> annotated_metadata(Fixed[str])
49
+ ('fixed',)
50
+
51
+ ```
52
+
53
+ """
54
+ annotation = _alias_value(annotation)
55
+
56
+ if get_origin(annotation) is not Annotated:
57
+ return ()
58
+
59
+ inner, *metadata = get_args(annotation)
60
+ return (*annotated_metadata(inner), *metadata)
61
+
62
+
63
+ def _alias_value(annotation: Any) -> Any:
64
+ """Return what a `type` alias stands for, also when it is subscripted (`Alias[int]`)."""
65
+ while True:
66
+ origin = get_origin(annotation)
67
+
68
+ if isinstance(annotation, TypeAliasType):
69
+ annotation = annotation.__value__
70
+ elif isinstance(origin, TypeAliasType):
71
+ annotation = _substituted(origin.__value__, get_args(annotation))
72
+ else:
73
+ return annotation
74
+
75
+
76
+ def _substituted(value: Any, arguments: tuple[Any, ...]) -> Any:
77
+ try:
78
+ return value[arguments]
79
+ except TypeError: # the alias does not use its type parameters
80
+ return value
81
+
82
+
83
+ def _annotated_function(fn: Callable[..., Any]) -> Callable[..., Any]:
84
+ if isinstance(fn, partial):
85
+ return _annotated_function(fn.func)
86
+
87
+ if isinstance(fn, type):
88
+ return fn.__init__
89
+
90
+ return fn if isroutine(fn) else type(fn).__call__