pyflowstep 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyflowstep/__init__.py +77 -0
- pyflowstep/compilers.py +147 -0
- pyflowstep/exceptions.py +116 -0
- pyflowstep/flow.py +116 -0
- pyflowstep/json_schema.py +244 -0
- pyflowstep/processors.py +159 -0
- pyflowstep/py.typed +0 -0
- pyflowstep/registry.py +233 -0
- pyflowstep/steps.py +158 -0
- pyflowstep/validators.py +29 -0
- pyflowstep-0.1.0.dist-info/METADATA +570 -0
- pyflowstep-0.1.0.dist-info/RECORD +13 -0
- pyflowstep-0.1.0.dist-info/WHEEL +4 -0
pyflowstep/processors.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Argument processors: transform raw step arguments before a step is built.
|
|
2
|
+
|
|
3
|
+
Processors are most useful when a flow comes from JSON, where every value is a
|
|
4
|
+
string, number, boolean, list, dict or null. They turn those raw values into
|
|
5
|
+
the rich objects your step functions expect (dates, enums, decimals, ...).
|
|
6
|
+
|
|
7
|
+
The `processors` option of a registry accepts:
|
|
8
|
+
|
|
9
|
+
- `None`: arguments are passed through untouched (the default).
|
|
10
|
+
- a callable: applied to every argument.
|
|
11
|
+
- a mapping from parameter name to callable: applied to those arguments only.
|
|
12
|
+
The key `...` (Ellipsis) means "every argument not named here".
|
|
13
|
+
|
|
14
|
+
processors=float
|
|
15
|
+
processors={"timeout": float}
|
|
16
|
+
processors={"pumps": int, ...: str.strip}
|
|
17
|
+
|
|
18
|
+
A processor for a parameter applies whether the value was passed positionally
|
|
19
|
+
or by keyword. For `*args` it applies to each item, and for `**kwargs` each
|
|
20
|
+
extra keyword name is looked up individually.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from collections.abc import Callable, Mapping
|
|
24
|
+
from inspect import BoundArguments, Parameter
|
|
25
|
+
from types import EllipsisType
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .exceptions import InvalidProcessorsError, ProcessArgumentError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def processor_lookup(
|
|
32
|
+
processors: Callable[[Any], Any] | Mapping[str | EllipsisType, Callable[[Any], Any]],
|
|
33
|
+
parameters: Mapping[str, Parameter],
|
|
34
|
+
step_name: str,
|
|
35
|
+
) -> Callable[[str], Callable[[Any], Any] | None]:
|
|
36
|
+
"""Validate a `processors` option and turn it into a name-to-processor lookup.
|
|
37
|
+
|
|
38
|
+
Mapping keys are checked against `parameters`, so a typo fails at
|
|
39
|
+
registration instead of silently never running. Steps accepting `**kwargs`
|
|
40
|
+
allow any key.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
InvalidProcessorsError: If the option has the wrong type, a processor is
|
|
44
|
+
not callable, or a key names no parameter.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
```python
|
|
48
|
+
>>> from inspect import signature
|
|
49
|
+
>>> parameters = signature(lambda flavor, pumps: None).parameters
|
|
50
|
+
>>> lookup = processor_lookup({"pumps": int, ...: str.strip}, parameters, "add_syrup")
|
|
51
|
+
>>> lookup("pumps"), lookup("flavor")
|
|
52
|
+
(<class 'int'>, <method 'strip' of 'str' objects>)
|
|
53
|
+
>>> lookup = processor_lookup({"pumps": int}, parameters, "add_syrup")
|
|
54
|
+
>>> lookup("flavor") is None
|
|
55
|
+
True
|
|
56
|
+
>>> processor_lookup({"pums": int}, parameters, "add_syrup")
|
|
57
|
+
Traceback (most recent call last):
|
|
58
|
+
...
|
|
59
|
+
pyflowstep.exceptions.InvalidProcessorsError: Processors of step 'add_syrup' name unknown ...
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(processors, Mapping):
|
|
65
|
+
_validate_mapping(processors, parameters, step_name)
|
|
66
|
+
rest = processors.get(...)
|
|
67
|
+
return lambda name: processors.get(name, rest)
|
|
68
|
+
|
|
69
|
+
if callable(processors):
|
|
70
|
+
return lambda _: processors
|
|
71
|
+
|
|
72
|
+
msg = (
|
|
73
|
+
f"Processors of step '{step_name}' must be a callable or a mapping of "
|
|
74
|
+
f"parameter names to callables, got {processors!r}"
|
|
75
|
+
)
|
|
76
|
+
raise InvalidProcessorsError(msg)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def process_arguments(
|
|
80
|
+
lookup: Callable[[str], Callable[[Any], Any] | None],
|
|
81
|
+
bound: BoundArguments,
|
|
82
|
+
) -> BoundArguments:
|
|
83
|
+
"""Return new bound arguments with every value run through its processor.
|
|
84
|
+
|
|
85
|
+
Values without a processor are kept as they are, and the original `bound`
|
|
86
|
+
object is left untouched.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ProcessArgumentError: If any processor raises; the original exception is chained.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
```python
|
|
93
|
+
>>> from inspect import signature
|
|
94
|
+
>>> def brew(shots: int, *toppings: str, **extras: str): ...
|
|
95
|
+
>>> bound = signature(brew).bind("2", " foam ", " cocoa ", size="L", note=" hot ")
|
|
96
|
+
>>> lookup = {"shots": int, "toppings": str.strip, "size": str.lower}.get
|
|
97
|
+
>>> process_arguments(lookup, bound).arguments
|
|
98
|
+
{'shots': 2, 'toppings': ('foam', 'cocoa'), 'extras': {'size': 'l', 'note': ' hot '}}
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
parameters = bound.signature.parameters
|
|
104
|
+
processed = {
|
|
105
|
+
name: _process_parameter(lookup, parameters[name], value)
|
|
106
|
+
for name, value in bound.arguments.items()
|
|
107
|
+
}
|
|
108
|
+
return BoundArguments(bound.signature, processed) # type: ignore[arg-type]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_mapping(
|
|
112
|
+
processors: Mapping[Any, Any],
|
|
113
|
+
parameters: Mapping[str, Parameter],
|
|
114
|
+
step_name: str,
|
|
115
|
+
) -> None:
|
|
116
|
+
if not_callable := sorted(str(key) for key, fn in processors.items() if not callable(fn)):
|
|
117
|
+
msg = f"Processors of step '{step_name}' must be callables, not for {not_callable}"
|
|
118
|
+
raise InvalidProcessorsError(msg)
|
|
119
|
+
|
|
120
|
+
if any(parameter.kind is Parameter.VAR_KEYWORD for parameter in parameters.values()):
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if unknown := sorted(map(str, processors.keys() - parameters.keys() - {...})):
|
|
124
|
+
msg = (
|
|
125
|
+
f"Processors of step '{step_name}' name unknown parameters {unknown}, "
|
|
126
|
+
f"available parameters: {', '.join(parameters) or '(none)'}"
|
|
127
|
+
)
|
|
128
|
+
raise InvalidProcessorsError(msg)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _process_parameter(
|
|
132
|
+
lookup: Callable[[str], Callable[[Any], Any] | None],
|
|
133
|
+
parameter: Parameter,
|
|
134
|
+
value: Any,
|
|
135
|
+
) -> Any:
|
|
136
|
+
match parameter.kind:
|
|
137
|
+
case Parameter.VAR_POSITIONAL:
|
|
138
|
+
return tuple(_process_value(lookup, parameter.name, item) for item in value)
|
|
139
|
+
case Parameter.VAR_KEYWORD:
|
|
140
|
+
return {key: _process_value(lookup, key, item) for key, item in value.items()}
|
|
141
|
+
case _:
|
|
142
|
+
return _process_value(lookup, parameter.name, value)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _process_value(
|
|
146
|
+
lookup: Callable[[str], Callable[[Any], Any] | None],
|
|
147
|
+
name: str,
|
|
148
|
+
value: Any,
|
|
149
|
+
) -> Any:
|
|
150
|
+
process = lookup(name)
|
|
151
|
+
|
|
152
|
+
if process is None:
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
return process(value)
|
|
157
|
+
except Exception as error:
|
|
158
|
+
msg = f"Argument '{name}' with value {value!r} failed to process, {error}"
|
|
159
|
+
raise ProcessArgumentError(msg) from error
|
pyflowstep/py.typed
ADDED
|
File without changes
|
pyflowstep/registry.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""A registry that collects named steps for one subject type."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from inspect import signature
|
|
6
|
+
from types import EllipsisType, MappingProxyType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .exceptions import StepAlreadyRegisteredError, StepDoesNotExistError
|
|
10
|
+
from .flow import Flow
|
|
11
|
+
from .processors import process_arguments, processor_lookup
|
|
12
|
+
from .steps import StepFactory, StepFn, TapFn, bind_arguments, step, tap
|
|
13
|
+
from .validators import validate_step_name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StepsRegistry[T]:
|
|
17
|
+
"""Collect named steps for subjects of type `T`.
|
|
18
|
+
|
|
19
|
+
A registry is the vocabulary of a flow language: its visible steps are what
|
|
20
|
+
a `FlowCompiler` understands and what `get_flow_json_schema` describes.
|
|
21
|
+
Hidden steps stay usable from Python but are invisible to both.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
```python
|
|
25
|
+
>>> from pyflowstep import FlowCompiler
|
|
26
|
+
>>> registry = StepsRegistry[list[str]]()
|
|
27
|
+
>>> @registry.step()
|
|
28
|
+
... def push(stack: list[str], item: str) -> list[str]:
|
|
29
|
+
... '''Push an item on top of the stack.'''
|
|
30
|
+
... return [*stack, item]
|
|
31
|
+
>>> @registry.step(name="pop")
|
|
32
|
+
... def pop_item(stack: list[str]) -> list[str]:
|
|
33
|
+
... return stack[:-1]
|
|
34
|
+
>>> sorted(registry.steps)
|
|
35
|
+
['pop', 'push']
|
|
36
|
+
>>> (registry["push"]("a") >> registry["push"]("b") >> registry["pop"]())([])
|
|
37
|
+
['a']
|
|
38
|
+
>>> FlowCompiler(registry.steps).compile([{"name": "push", "args": ["x"]}])([])
|
|
39
|
+
['x']
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._steps: dict[str, StepFactory[T, ...]] = {}
|
|
47
|
+
self._hidden: set[str] = set()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def steps(self) -> Mapping[str, StepFactory[T, ...]]:
|
|
51
|
+
"""A read-only mapping of every visible step, by name."""
|
|
52
|
+
return MappingProxyType(
|
|
53
|
+
{name: factory for name, factory in self._steps.items() if name not in self._hidden},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def __getitem__(self, name: str) -> StepFactory[T, ...]:
|
|
57
|
+
if name not in self.steps:
|
|
58
|
+
raise StepDoesNotExistError(name, self.steps.keys())
|
|
59
|
+
return self._steps[name]
|
|
60
|
+
|
|
61
|
+
def __contains__(self, name: object) -> bool:
|
|
62
|
+
return name in self.steps
|
|
63
|
+
|
|
64
|
+
def __iter__(self) -> Iterator[str]:
|
|
65
|
+
return iter(self.steps)
|
|
66
|
+
|
|
67
|
+
def __len__(self) -> int:
|
|
68
|
+
return len(self.steps)
|
|
69
|
+
|
|
70
|
+
def step[**P](
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
name: str | None = None,
|
|
74
|
+
description: str | None = None,
|
|
75
|
+
processors: (
|
|
76
|
+
Callable[[Any], Any] | Mapping[str | EllipsisType, Callable[[Any], Any]] | None
|
|
77
|
+
) = None,
|
|
78
|
+
hidden: bool = False,
|
|
79
|
+
) -> Callable[[StepFn[T, P]], StepFactory[T, P]]:
|
|
80
|
+
"""Return a decorator registering a `(subject, ...) -> subject` step function.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
name: The step name; defaults to the function name.
|
|
84
|
+
description: Overrides the function docstring (shown in the JSON schema).
|
|
85
|
+
processors (`((Any) -> Any) | Mapping[str | EllipsisType, (Any) -> Any] | None`):
|
|
86
|
+
How to transform arguments before the step is built: a callable for
|
|
87
|
+
every argument, or a mapping of parameter names to callables where
|
|
88
|
+
the key `...` covers the remaining arguments. `None` leaves them as-is.
|
|
89
|
+
hidden: Keep the step out of the registry lookups (`steps`, `[]`, `in`),
|
|
90
|
+
hence out of the compiler and the JSON schema. The returned
|
|
91
|
+
factory stays fully usable from Python.
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def decorator(fn: StepFn[T, P]) -> StepFactory[T, P]:
|
|
96
|
+
return self.register(
|
|
97
|
+
fn,
|
|
98
|
+
name=name,
|
|
99
|
+
description=description,
|
|
100
|
+
processors=processors,
|
|
101
|
+
hidden=hidden,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return decorator
|
|
105
|
+
|
|
106
|
+
def tap[**P](
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
name: str | None = None,
|
|
110
|
+
description: str | None = None,
|
|
111
|
+
processors: (
|
|
112
|
+
Callable[[Any], Any] | Mapping[str | EllipsisType, Callable[[Any], Any]] | None
|
|
113
|
+
) = None,
|
|
114
|
+
hidden: bool = False,
|
|
115
|
+
) -> Callable[[TapFn[T, P]], StepFactory[T, P]]:
|
|
116
|
+
"""Return a decorator registering a side-effect step whose return value is ignored.
|
|
117
|
+
|
|
118
|
+
See `pyflowstep.tap`.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
name: The step name; defaults to the function name.
|
|
122
|
+
description: Overrides the function docstring (shown in the JSON schema).
|
|
123
|
+
processors (`((Any) -> Any) | Mapping[str | EllipsisType, (Any) -> Any] | None`):
|
|
124
|
+
How to transform arguments before the step is built: a callable for
|
|
125
|
+
every argument, or a mapping of parameter names to callables where
|
|
126
|
+
the key `...` covers the remaining arguments. `None` leaves them as-is.
|
|
127
|
+
hidden: Keep the step out of the registry lookups (`steps`, `[]`, `in`),
|
|
128
|
+
hence out of the compiler and the JSON schema. The returned
|
|
129
|
+
factory stays fully usable from Python.
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def decorator(fn: TapFn[T, P]) -> StepFactory[T, P]:
|
|
134
|
+
return self.register(
|
|
135
|
+
fn,
|
|
136
|
+
name=name,
|
|
137
|
+
description=description,
|
|
138
|
+
processors=processors,
|
|
139
|
+
hidden=hidden,
|
|
140
|
+
passthrough=True,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return decorator
|
|
144
|
+
|
|
145
|
+
def register[**P](
|
|
146
|
+
self,
|
|
147
|
+
fn: TapFn[T, P],
|
|
148
|
+
/,
|
|
149
|
+
*,
|
|
150
|
+
name: str | None = None,
|
|
151
|
+
description: str | None = None,
|
|
152
|
+
processors: (
|
|
153
|
+
Callable[[Any], Any] | Mapping[str | EllipsisType, Callable[[Any], Any]] | None
|
|
154
|
+
) = None,
|
|
155
|
+
hidden: bool = False,
|
|
156
|
+
passthrough: bool = False,
|
|
157
|
+
) -> StepFactory[T, P]:
|
|
158
|
+
"""Register `fn` as a step without decorator syntax and return its factory.
|
|
159
|
+
|
|
160
|
+
Useful for registering functions you do not own, or lambdas (with `name`).
|
|
161
|
+
The registered name is also the name shown in `repr` and error messages,
|
|
162
|
+
and processors run on every call of the returned factory, before the
|
|
163
|
+
arguments are bound to the step.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
fn: The step function; its first positional parameter receives the subject.
|
|
167
|
+
name: The step name; defaults to the function name.
|
|
168
|
+
description: Overrides the function docstring (shown in the JSON schema).
|
|
169
|
+
processors (`((Any) -> Any) | Mapping[str | EllipsisType, (Any) -> Any] | None`):
|
|
170
|
+
How to transform arguments before the step is built: a callable for
|
|
171
|
+
every argument, or a mapping of parameter names to callables where
|
|
172
|
+
the key `...` covers the remaining arguments. `None` leaves them as-is.
|
|
173
|
+
hidden: Keep the step out of the registry lookups (`steps`, `[]`, `in`),
|
|
174
|
+
hence out of the compiler and the JSON schema. The returned
|
|
175
|
+
factory stays fully usable from Python.
|
|
176
|
+
passthrough: Ignore the return value of `fn` and pass the subject on
|
|
177
|
+
unchanged, like `tap`.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
StepAlreadyRegisteredError: If the name is already taken.
|
|
181
|
+
InvalidStepNameError: If the name is not a valid Python identifier.
|
|
182
|
+
InvalidStepError: If `fn` does not accept the subject positionally.
|
|
183
|
+
InvalidProcessorsError: If `processors` is malformed or names an unknown
|
|
184
|
+
parameter.
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
step_name = validate_step_name(name or getattr(fn, "__name__", ""))
|
|
188
|
+
|
|
189
|
+
if step_name in self._steps:
|
|
190
|
+
raise StepAlreadyRegisteredError(step_name)
|
|
191
|
+
|
|
192
|
+
make = tap if passthrough else step
|
|
193
|
+
factory = _with_processors(make(_renamed(fn, step_name)), processors)
|
|
194
|
+
factory.__doc__ = description or fn.__doc__
|
|
195
|
+
|
|
196
|
+
self._steps[step_name] = factory
|
|
197
|
+
if hidden:
|
|
198
|
+
self._hidden.add(step_name)
|
|
199
|
+
|
|
200
|
+
return factory
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _renamed[F: Callable[..., Any]](fn: F, name: str) -> F:
|
|
204
|
+
"""Return `fn` itself, or a thin wrapper exposing it under `name`."""
|
|
205
|
+
if getattr(fn, "__name__", None) == name:
|
|
206
|
+
return fn
|
|
207
|
+
|
|
208
|
+
@wraps(fn)
|
|
209
|
+
def renamed(*args: Any, **kwargs: Any) -> Any:
|
|
210
|
+
return fn(*args, **kwargs)
|
|
211
|
+
|
|
212
|
+
renamed.__name__ = renamed.__qualname__ = name
|
|
213
|
+
return renamed # type: ignore[return-value]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _with_processors[T, **P](
|
|
217
|
+
factory: StepFactory[T, P],
|
|
218
|
+
processors: Callable[[Any], Any] | Mapping[str | EllipsisType, Callable[[Any], Any]] | None,
|
|
219
|
+
) -> StepFactory[T, P]:
|
|
220
|
+
"""Return a factory that processes its arguments before calling `factory`."""
|
|
221
|
+
if processors is None:
|
|
222
|
+
return factory
|
|
223
|
+
|
|
224
|
+
arguments_signature = signature(factory)
|
|
225
|
+
lookup = processor_lookup(processors, arguments_signature.parameters, factory.__name__)
|
|
226
|
+
|
|
227
|
+
@wraps(factory)
|
|
228
|
+
def processed(*args: P.args, **kwargs: P.kwargs) -> Flow[T]:
|
|
229
|
+
bound = bind_arguments(arguments_signature, factory.__name__, args, kwargs)
|
|
230
|
+
arguments = process_arguments(lookup, bound)
|
|
231
|
+
return factory(*arguments.args, **arguments.kwargs)
|
|
232
|
+
|
|
233
|
+
return processed
|
pyflowstep/steps.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Decorators that turn plain functions into step factories.
|
|
2
|
+
|
|
3
|
+
A *step function* takes the flow subject first, followed by its own arguments:
|
|
4
|
+
|
|
5
|
+
def click(page: Page, selector: str) -> Page: ...
|
|
6
|
+
|
|
7
|
+
Decorating it produces a *step factory*: calling the factory with the
|
|
8
|
+
remaining arguments returns a single-step `Flow`, ready to be composed.
|
|
9
|
+
|
|
10
|
+
click("button#submit") # -> Flow(click)
|
|
11
|
+
|
|
12
|
+
Naming steps and processing their arguments are registry concerns, see
|
|
13
|
+
`StepsRegistry`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from functools import wraps
|
|
18
|
+
from inspect import BoundArguments, Parameter, Signature, signature
|
|
19
|
+
from typing import Any, Concatenate
|
|
20
|
+
|
|
21
|
+
from .exceptions import InvalidStepError, to_argument_error
|
|
22
|
+
from .flow import Flow, action_name
|
|
23
|
+
|
|
24
|
+
type StepFn[T, **P] = Callable[Concatenate[T, P], T]
|
|
25
|
+
type TapFn[T, **P] = Callable[Concatenate[T, P], Any]
|
|
26
|
+
type StepFactory[T, **P] = Callable[P, Flow[T]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def step[T, **P](fn: StepFn[T, P], /) -> StepFactory[T, P]:
|
|
30
|
+
"""Turn a `(subject, *args, **kwargs) -> subject` function into a step factory.
|
|
31
|
+
|
|
32
|
+
Arguments are validated against the function signature as soon as the
|
|
33
|
+
factory is called, so mistakes surface while *building* a flow, not
|
|
34
|
+
halfway through running it.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
InvalidStepError: If `fn` does not accept the subject positionally.
|
|
38
|
+
ArgumentError: A subclass is raised by the factory for bad arguments.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
```python
|
|
42
|
+
>>> @step
|
|
43
|
+
... def add(total: int, amount: int) -> int:
|
|
44
|
+
... return total + amount
|
|
45
|
+
>>> @step
|
|
46
|
+
... def multiply(total: int, factor: int) -> int:
|
|
47
|
+
... return total * factor
|
|
48
|
+
>>> pipeline = add(2) >> multiply(10) >> add(amount=1)
|
|
49
|
+
>>> pipeline
|
|
50
|
+
Flow(add >> multiply >> add)
|
|
51
|
+
>>> pipeline(1)
|
|
52
|
+
31
|
|
53
|
+
>>> add()
|
|
54
|
+
Traceback (most recent call last):
|
|
55
|
+
...
|
|
56
|
+
pyflowstep.exceptions.MissingArgumentError: missing a required argument: 'amount' for step 'add'
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
return _make_step(fn, passthrough=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def tap[T, **P](fn: TapFn[T, P], /) -> StepFactory[T, P]:
|
|
65
|
+
"""Like `step`, for side-effect functions: the return value is ignored.
|
|
66
|
+
|
|
67
|
+
The subject passed in is handed, unchanged, to the next step. This removes
|
|
68
|
+
the `return page` boilerplate from steps that act on a mutable object.
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
```python
|
|
72
|
+
>>> log: list[str] = []
|
|
73
|
+
>>> @tap
|
|
74
|
+
... def record(value: int, label: str) -> None:
|
|
75
|
+
... log.append(f"{label}={value}")
|
|
76
|
+
>>> (record("before") >> Flow[int](lambda n: n * 10) >> record("after"))(4)
|
|
77
|
+
40
|
|
78
|
+
>>> log
|
|
79
|
+
['before=4', 'after=40']
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
return _make_step(fn, passthrough=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bind_arguments(
|
|
88
|
+
arguments_signature: Signature,
|
|
89
|
+
step_name: str,
|
|
90
|
+
args: tuple[Any, ...],
|
|
91
|
+
kwargs: dict[str, Any],
|
|
92
|
+
) -> BoundArguments:
|
|
93
|
+
"""Bind step arguments to a signature, raising the matching `ArgumentError`.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
```python
|
|
97
|
+
>>> from inspect import signature
|
|
98
|
+
>>> bind_arguments(signature(lambda selector: None), "click", ("#go",), {}).arguments
|
|
99
|
+
{'selector': '#go'}
|
|
100
|
+
>>> bind_arguments(signature(lambda selector: None), "click", (), {})
|
|
101
|
+
Traceback (most recent call last):
|
|
102
|
+
...
|
|
103
|
+
pyflowstep.exceptions.MissingArgumentError: missing a required argument: 'selector'...
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
return arguments_signature.bind(*args, **kwargs)
|
|
110
|
+
except TypeError as error:
|
|
111
|
+
raise to_argument_error(error, step_name) from error
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _make_step[T, **P](fn: TapFn[T, P], *, passthrough: bool) -> StepFactory[T, P]:
|
|
115
|
+
step_name = action_name(fn)
|
|
116
|
+
arguments_signature = _arguments_signature(fn, step_name)
|
|
117
|
+
|
|
118
|
+
@wraps(fn)
|
|
119
|
+
def factory(*args: P.args, **kwargs: P.kwargs) -> Flow[T]:
|
|
120
|
+
bound = bind_arguments(arguments_signature, step_name, args, kwargs)
|
|
121
|
+
return Flow(_make_action(fn, step_name, bound, passthrough=passthrough))
|
|
122
|
+
|
|
123
|
+
factory.__signature__ = arguments_signature # type: ignore[attr-defined]
|
|
124
|
+
return factory
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _arguments_signature(fn: Callable[..., Any], step_name: str) -> Signature:
|
|
128
|
+
"""Return the signature of `fn` without its leading subject parameter."""
|
|
129
|
+
try:
|
|
130
|
+
subject, *parameters = signature(fn).parameters.values()
|
|
131
|
+
except ValueError as error:
|
|
132
|
+
msg = f"Step '{step_name}' must accept the flow subject as its first positional parameter"
|
|
133
|
+
raise InvalidStepError(msg) from error
|
|
134
|
+
|
|
135
|
+
if subject.kind not in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD):
|
|
136
|
+
msg = (
|
|
137
|
+
f"Step '{step_name}' must accept the flow subject as its first positional parameter, "
|
|
138
|
+
f"got {subject.kind.description} parameter '{subject.name}'"
|
|
139
|
+
)
|
|
140
|
+
raise InvalidStepError(msg)
|
|
141
|
+
|
|
142
|
+
return Signature(parameters)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _make_action[T](
|
|
146
|
+
fn: Callable[..., Any],
|
|
147
|
+
step_name: str,
|
|
148
|
+
bound: BoundArguments,
|
|
149
|
+
*,
|
|
150
|
+
passthrough: bool,
|
|
151
|
+
) -> Callable[[T], T]:
|
|
152
|
+
def run(subject: T) -> T:
|
|
153
|
+
result = fn(subject, *bound.args, **bound.kwargs)
|
|
154
|
+
return subject if passthrough else result
|
|
155
|
+
|
|
156
|
+
run.__name__ = run.__qualname__ = step_name
|
|
157
|
+
run.__doc__ = fn.__doc__
|
|
158
|
+
return run
|
pyflowstep/validators.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from .exceptions import InvalidStepNameError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_step_name(name: str) -> str:
|
|
7
|
+
"""Return `name` unchanged if it is a valid ASCII Python identifier.
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
```python
|
|
11
|
+
>>> validate_step_name("add_milk")
|
|
12
|
+
'add_milk'
|
|
13
|
+
>>> validate_step_name("add-milk")
|
|
14
|
+
Traceback (most recent call last):
|
|
15
|
+
...
|
|
16
|
+
pyflowstep.exceptions.InvalidStepNameError: Invalid step name: 'add-milk', ...
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
if not isinstance(name, str) or not re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$").match(name):
|
|
22
|
+
msg = (
|
|
23
|
+
f"Invalid step name: {name!r}, "
|
|
24
|
+
"you should follow the python variable naming convention: "
|
|
25
|
+
"name must start with a letter and can only contain letters, numbers and underscores"
|
|
26
|
+
)
|
|
27
|
+
raise InvalidStepNameError(msg)
|
|
28
|
+
|
|
29
|
+
return name
|