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.
@@ -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
@@ -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