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/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Compose functions into readable, reusable, JSON-definable flows.
|
|
2
|
+
|
|
3
|
+
`pyflowstep` is a functional replacement for the fluent-interface (method chaining)
|
|
4
|
+
pattern. Instead of adding chainable methods to a class, you write small step
|
|
5
|
+
functions and compose them with `>>`:
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
>>> from pyflowstep import step
|
|
9
|
+
>>> @step
|
|
10
|
+
... def add(total: int, amount: int) -> int:
|
|
11
|
+
... return total + amount
|
|
12
|
+
>>> @step
|
|
13
|
+
... def double(total: int) -> int:
|
|
14
|
+
... return total * 2
|
|
15
|
+
>>> (add(3) >> double() >> add(1))(0)
|
|
16
|
+
7
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Steps collected in a `StepsRegistry` can also be described as JSON
|
|
21
|
+
(`get_flow_json_schema`) and compiled from JSON (`FlowCompiler`).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .compilers import FlowCompiler, FlowDefinition, StepDict, validate_step_dict
|
|
25
|
+
from .exceptions import (
|
|
26
|
+
ArgumentError,
|
|
27
|
+
InvalidFlowDefinitionError,
|
|
28
|
+
InvalidProcessorsError,
|
|
29
|
+
InvalidStepError,
|
|
30
|
+
InvalidStepNameError,
|
|
31
|
+
MissingArgumentError,
|
|
32
|
+
MultipleValuesArgumentError,
|
|
33
|
+
PositionalOnlyArgumentError,
|
|
34
|
+
ProcessArgumentError,
|
|
35
|
+
PyflowstepError,
|
|
36
|
+
StepAlreadyRegisteredError,
|
|
37
|
+
StepDoesNotExistError,
|
|
38
|
+
TooManyArgumentsError,
|
|
39
|
+
UnexpectedKeywordArgumentError,
|
|
40
|
+
)
|
|
41
|
+
from .flow import Action, Flow, compose
|
|
42
|
+
from .json_schema import get_flow_json_schema, get_json_schema, get_step_json_schema
|
|
43
|
+
from .registry import StepsRegistry
|
|
44
|
+
from .steps import StepFactory, StepFn, TapFn, step, tap
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Action",
|
|
48
|
+
"ArgumentError",
|
|
49
|
+
"Flow",
|
|
50
|
+
"FlowCompiler",
|
|
51
|
+
"FlowDefinition",
|
|
52
|
+
"InvalidFlowDefinitionError",
|
|
53
|
+
"InvalidProcessorsError",
|
|
54
|
+
"InvalidStepError",
|
|
55
|
+
"InvalidStepNameError",
|
|
56
|
+
"MissingArgumentError",
|
|
57
|
+
"MultipleValuesArgumentError",
|
|
58
|
+
"PositionalOnlyArgumentError",
|
|
59
|
+
"ProcessArgumentError",
|
|
60
|
+
"PyflowstepError",
|
|
61
|
+
"StepAlreadyRegisteredError",
|
|
62
|
+
"StepDict",
|
|
63
|
+
"StepDoesNotExistError",
|
|
64
|
+
"StepFactory",
|
|
65
|
+
"StepFn",
|
|
66
|
+
"StepsRegistry",
|
|
67
|
+
"TapFn",
|
|
68
|
+
"TooManyArgumentsError",
|
|
69
|
+
"UnexpectedKeywordArgumentError",
|
|
70
|
+
"compose",
|
|
71
|
+
"get_flow_json_schema",
|
|
72
|
+
"get_json_schema",
|
|
73
|
+
"get_step_json_schema",
|
|
74
|
+
"step",
|
|
75
|
+
"tap",
|
|
76
|
+
"validate_step_dict",
|
|
77
|
+
]
|
pyflowstep/compilers.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Compile flow definitions (lists of step dictionaries, or JSON) into `Flow` objects."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
4
|
+
from typing import Any, NotRequired, TypedDict
|
|
5
|
+
|
|
6
|
+
from .exceptions import InvalidFlowDefinitionError, PyflowstepError, StepDoesNotExistError
|
|
7
|
+
from .flow import Flow, compose
|
|
8
|
+
|
|
9
|
+
_STEP_DICT_SHAPE = '{"name": str, "args"?: list, "kwargs"?: dict[str, Any]}'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StepDict(TypedDict):
|
|
13
|
+
"""The dictionary form of one step.
|
|
14
|
+
|
|
15
|
+
Only `name` is required; `args` defaults to `[]` and `kwargs` to `{}`.
|
|
16
|
+
|
|
17
|
+
{"name": "fill", "args": ["input[name=q]"], "kwargs": {"value": "pyflowstep"}}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
args: NotRequired[list[Any]]
|
|
22
|
+
kwargs: NotRequired[dict[str, Any]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
type FlowDefinition = Sequence[StepDict]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FlowCompiler[T]:
|
|
29
|
+
"""Compile a flow definition into a runnable `Flow`.
|
|
30
|
+
|
|
31
|
+
A flow definition is a list of `StepDict` objects, one per step, run in
|
|
32
|
+
order. Every problem is reported while compiling, before anything runs:
|
|
33
|
+
malformed dictionaries, unknown step names, bad arguments and failing
|
|
34
|
+
processors. Each error carries a note with its JSON path, e.g. `at $[2]`.
|
|
35
|
+
|
|
36
|
+
The compiler works on already-parsed data (lists and dicts), so where the
|
|
37
|
+
definition comes from (a JSON file, a database, an API) is up to you.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
steps: A mapping from step name to step factory, typically
|
|
41
|
+
`registry.steps`.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
```python
|
|
45
|
+
>>> from pyflowstep import step
|
|
46
|
+
>>> @step
|
|
47
|
+
... def add(total: int, amount: int) -> int:
|
|
48
|
+
... return total + amount
|
|
49
|
+
>>> @step
|
|
50
|
+
... def multiply(total: int, factor: int) -> int:
|
|
51
|
+
... return total * factor
|
|
52
|
+
>>> compiler = FlowCompiler[int]({"add": add, "multiply": multiply})
|
|
53
|
+
>>> flow = compiler.compile([
|
|
54
|
+
... {"name": "add", "args": [2]},
|
|
55
|
+
... {"name": "multiply", "kwargs": {"factor": 10}},
|
|
56
|
+
... ])
|
|
57
|
+
>>> flow
|
|
58
|
+
Flow(add >> multiply)
|
|
59
|
+
>>> flow(1)
|
|
60
|
+
30
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, steps: Mapping[str, Callable[..., Flow[T]]], /) -> None:
|
|
67
|
+
self._steps = steps
|
|
68
|
+
|
|
69
|
+
def compile(self, definition: FlowDefinition) -> Flow[T]:
|
|
70
|
+
"""Compile a list of step dictionaries into a single flat `Flow`."""
|
|
71
|
+
if isinstance(definition, str | bytes | Mapping) or not isinstance(definition, Sequence):
|
|
72
|
+
raise InvalidFlowDefinitionError(_invalid_definition_message(definition))
|
|
73
|
+
|
|
74
|
+
return compose(
|
|
75
|
+
*(self._compile_step(item, f"$[{index}]") for index, item in enumerate(definition)),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _compile_step(self, item: Any, path: str) -> Flow[T]:
|
|
79
|
+
try:
|
|
80
|
+
step_dict = validate_step_dict(item, path)
|
|
81
|
+
factory = self._find_step(step_dict["name"])
|
|
82
|
+
return factory(*step_dict.get("args", []), **step_dict.get("kwargs", {}))
|
|
83
|
+
except PyflowstepError as error:
|
|
84
|
+
error.add_note(f"at {path}")
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
def _find_step(self, name: str) -> Callable[..., Flow[T]]:
|
|
88
|
+
if name not in self._steps:
|
|
89
|
+
raise StepDoesNotExistError(name, self._steps.keys())
|
|
90
|
+
return self._steps[name]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_step_dict(item: Any, path: str = "$") -> StepDict:
|
|
94
|
+
"""Return `item` if it is a well-formed `StepDict`, raise otherwise.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
InvalidFlowDefinitionError: With a message explaining what is wrong at `path`.
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
```python
|
|
101
|
+
>>> validate_step_dict({"name": "click", "args": ["#go"]})
|
|
102
|
+
{'name': 'click', 'args': ['#go']}
|
|
103
|
+
>>> validate_step_dict({"name": "click", "selector": "#go"}, "$[0]")
|
|
104
|
+
Traceback (most recent call last):
|
|
105
|
+
...
|
|
106
|
+
pyflowstep.exceptions.InvalidFlowDefinitionError: Invalid step at $[0]: unknown keys ...
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
problem = _step_dict_problem(item)
|
|
112
|
+
|
|
113
|
+
if problem is not None:
|
|
114
|
+
msg = f"Invalid step at {path}: {problem}, expected {_STEP_DICT_SHAPE}, received {item!r}"
|
|
115
|
+
raise InvalidFlowDefinitionError(msg)
|
|
116
|
+
|
|
117
|
+
return item
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _step_dict_problem(item: Any) -> str | None:
|
|
121
|
+
"""Describe the first thing wrong with a step dictionary, or `None` if it is valid."""
|
|
122
|
+
if not isinstance(item, Mapping):
|
|
123
|
+
return "a step must be an object"
|
|
124
|
+
|
|
125
|
+
if unknown_keys := item.keys() - frozenset({"name", "args", "kwargs"}):
|
|
126
|
+
return f"unknown keys {sorted(map(str, unknown_keys))}"
|
|
127
|
+
|
|
128
|
+
if not isinstance(item.get("name"), str):
|
|
129
|
+
return "'name' is required and must be a string"
|
|
130
|
+
|
|
131
|
+
if not isinstance(item.get("args", []), list):
|
|
132
|
+
return "'args' must be a list"
|
|
133
|
+
|
|
134
|
+
if not isinstance(item.get("kwargs", {}), Mapping):
|
|
135
|
+
return "'kwargs' must be an object"
|
|
136
|
+
|
|
137
|
+
if not all(isinstance(key, str) for key in item.get("kwargs", {})):
|
|
138
|
+
return "'kwargs' keys must be strings"
|
|
139
|
+
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _invalid_definition_message(definition: Any) -> str:
|
|
144
|
+
return (
|
|
145
|
+
"Invalid flow definition at $: expected a list of steps like "
|
|
146
|
+
f"[{_STEP_DICT_SHAPE}, ...], received {definition!r}"
|
|
147
|
+
)
|
pyflowstep/exceptions.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Exceptions raised by pyflowstep.
|
|
2
|
+
|
|
3
|
+
Every exception derives from `PyflowstepError`, so callers can catch the whole
|
|
4
|
+
family at once. Argument errors additionally derive from `TypeError`, and
|
|
5
|
+
definition errors from `ValueError`, to stay compatible with code that expects
|
|
6
|
+
the built-in exception types.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PyflowstepError(Exception):
|
|
14
|
+
"""Base class for every exception raised by pyflowstep."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StepAlreadyRegisteredError(PyflowstepError):
|
|
18
|
+
"""Raised when a step name is registered twice in the same registry."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, name: str) -> None:
|
|
21
|
+
super().__init__(f"Step '{name}' is already registered.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StepDoesNotExistError(PyflowstepError, LookupError):
|
|
25
|
+
"""Raised when a step name cannot be found in a registry or a compiler."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, name: str, available_steps: Iterable[str] | None = None) -> None:
|
|
28
|
+
msg = f"Step '{name}' does not exist."
|
|
29
|
+
|
|
30
|
+
if available_steps is not None:
|
|
31
|
+
msg += f" Available steps: {', '.join(sorted(available_steps)) or '(none)'}"
|
|
32
|
+
|
|
33
|
+
super().__init__(msg)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidStepError(PyflowstepError, TypeError):
|
|
37
|
+
"""Raised when a function cannot be turned into a step.
|
|
38
|
+
|
|
39
|
+
A step function must accept the flow subject as its first positional
|
|
40
|
+
parameter, for example `def click(page: Page, selector: str) -> Page`.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InvalidProcessorsError(PyflowstepError, TypeError):
|
|
45
|
+
"""Raised when the `processors` option of a step is malformed.
|
|
46
|
+
|
|
47
|
+
It must be a callable, or a mapping of parameter names (or `...`) to
|
|
48
|
+
callables, and every name must be a parameter of the step.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InvalidStepNameError(PyflowstepError, ValueError):
|
|
53
|
+
"""Raised when a step name does not follow the Python identifier convention."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InvalidFlowDefinitionError(PyflowstepError, ValueError):
|
|
57
|
+
"""Raised when a flow definition (a list of step dictionaries) is malformed."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ArgumentError(PyflowstepError, TypeError):
|
|
61
|
+
"""Base class for errors caused by the arguments given to a step."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MissingArgumentError(ArgumentError):
|
|
65
|
+
"""Raised when a required step argument is missing."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TooManyArgumentsError(ArgumentError):
|
|
69
|
+
"""Raised when a step receives more positional arguments than it accepts."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class UnexpectedKeywordArgumentError(ArgumentError):
|
|
73
|
+
"""Raised when a step receives a keyword argument it does not declare."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MultipleValuesArgumentError(ArgumentError):
|
|
77
|
+
"""Raised when a step argument is given both positionally and by keyword."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PositionalOnlyArgumentError(ArgumentError):
|
|
81
|
+
"""Raised when a positional-only step argument is passed as a keyword."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ProcessArgumentError(ArgumentError):
|
|
85
|
+
"""Raised when a processor fails to transform a step argument."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_ARGUMENT_ERROR_PATTERNS: tuple[tuple[str, type[ArgumentError]], ...] = (
|
|
89
|
+
(r"positional.only.*passed as (a )?keyword", PositionalOnlyArgumentError),
|
|
90
|
+
(r"missing a required", MissingArgumentError),
|
|
91
|
+
(r"too many positional arguments", TooManyArgumentsError),
|
|
92
|
+
(r"got an unexpected keyword argument", UnexpectedKeywordArgumentError),
|
|
93
|
+
(r"multiple values for argument", MultipleValuesArgumentError),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def to_argument_error(error: TypeError, step_name: str) -> ArgumentError:
|
|
98
|
+
"""Translate a `Signature.bind` `TypeError` into the matching `ArgumentError`.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
```python
|
|
102
|
+
>>> error = to_argument_error(TypeError("too many positional arguments"), "click")
|
|
103
|
+
>>> type(error).__name__
|
|
104
|
+
'TooManyArgumentsError'
|
|
105
|
+
>>> str(error)
|
|
106
|
+
"too many positional arguments for step 'click'"
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
message = str(error)
|
|
112
|
+
error_type = next(
|
|
113
|
+
(error for pattern, error in _ARGUMENT_ERROR_PATTERNS if re.search(pattern, message)),
|
|
114
|
+
ArgumentError,
|
|
115
|
+
)
|
|
116
|
+
return error_type(f"{message} for step '{step_name}'")
|
pyflowstep/flow.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The `Flow` type: an immutable, composable pipeline of `T -> T` actions."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator
|
|
4
|
+
from functools import reduce
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
type Action[T] = Callable[[T], T]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Flow[T]:
|
|
11
|
+
"""An immutable sequence of actions applied, in order, to a single subject.
|
|
12
|
+
|
|
13
|
+
A flow is a callable `T -> T`. Calling it threads the subject through every
|
|
14
|
+
action: the output of one action is the input of the next. Flows compose
|
|
15
|
+
with `>>`, which always returns a *new* flow and never mutates its operands.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
*actions: The callables to run, in order. Each one takes the subject and
|
|
19
|
+
returns the (possibly new) subject. With no actions the flow is the
|
|
20
|
+
identity.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
```python
|
|
24
|
+
>>> increment = Flow[int](lambda n: n + 1)
|
|
25
|
+
>>> double = Flow[int](lambda n: n * 2)
|
|
26
|
+
>>> pipeline = increment >> double >> increment
|
|
27
|
+
>>> pipeline(3)
|
|
28
|
+
9
|
|
29
|
+
>>> len(pipeline)
|
|
30
|
+
3
|
|
31
|
+
>>> Flow[int]()(7) # an empty flow is the identity
|
|
32
|
+
7
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("_actions",)
|
|
39
|
+
|
|
40
|
+
def __init__(self, *actions: Action[T]) -> None:
|
|
41
|
+
self._actions = actions
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def actions(self) -> tuple[Action[T], ...]:
|
|
45
|
+
"""The actions of the flow, in execution order."""
|
|
46
|
+
return self._actions
|
|
47
|
+
|
|
48
|
+
def __call__(self, obj: T) -> T:
|
|
49
|
+
return reduce(lambda obj, action: action(obj), self._actions, obj)
|
|
50
|
+
|
|
51
|
+
def __rshift__(self, other: Action[T]) -> "Flow[T]":
|
|
52
|
+
if not callable(other):
|
|
53
|
+
return NotImplemented
|
|
54
|
+
return Flow(*self._actions, *_actions_of(other))
|
|
55
|
+
|
|
56
|
+
def __rrshift__(self, other: Action[T]) -> "Flow[T]":
|
|
57
|
+
if not callable(other):
|
|
58
|
+
return NotImplemented
|
|
59
|
+
return Flow(*_actions_of(other), *self._actions)
|
|
60
|
+
|
|
61
|
+
def __len__(self) -> int:
|
|
62
|
+
return len(self._actions)
|
|
63
|
+
|
|
64
|
+
def __iter__(self) -> Iterator[Action[T]]:
|
|
65
|
+
return iter(self._actions)
|
|
66
|
+
|
|
67
|
+
def __bool__(self) -> bool:
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
return f"Flow({' >> '.join(map(action_name, self._actions))})"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compose[T](*actions: Action[T]) -> Flow[T]:
|
|
75
|
+
"""Combine actions and flows into one flat `Flow`, left to right.
|
|
76
|
+
|
|
77
|
+
`compose(a, b, c)` is equivalent to `Flow() >> a >> b >> c`. Nested flows
|
|
78
|
+
are flattened, so the result lists every underlying step.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
```python
|
|
82
|
+
>>> add_one = Flow[int](lambda n: n + 1)
|
|
83
|
+
>>> square = lambda n: n * n
|
|
84
|
+
>>> compose(add_one, square, add_one)(2)
|
|
85
|
+
10
|
|
86
|
+
>>> compose()(5)
|
|
87
|
+
5
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
return reduce(Flow.__rshift__, actions, Flow[T]())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def action_name(action: Callable[..., Any]) -> str:
|
|
96
|
+
"""Return a human-readable name for an action, used by `repr` and error notes.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
```python
|
|
100
|
+
>>> action_name(len)
|
|
101
|
+
'len'
|
|
102
|
+
>>> action_name(lambda x: x)
|
|
103
|
+
'<lambda>'
|
|
104
|
+
>>> action_name(Flow(len, abs))
|
|
105
|
+
'Flow(len >> abs)'
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
if isinstance(action, Flow):
|
|
111
|
+
return repr(action)
|
|
112
|
+
return getattr(action, "__name__", None) or repr(action)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _actions_of[T](obj: Action[T] | Flow[T]) -> tuple[Action[T], ...]:
|
|
116
|
+
return obj.actions if isinstance(obj, Flow) else (obj,)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Generate JSON Schemas describing steps and whole flow definitions.
|
|
2
|
+
|
|
3
|
+
The schemas describe the exact shape accepted by `FlowCompiler`, so they can be
|
|
4
|
+
handed to a form builder, a validator, or an LLM that writes flows as JSON.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from datetime import date, datetime, time
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from inspect import Parameter, get_annotations, signature
|
|
12
|
+
from types import NoneType, UnionType
|
|
13
|
+
from typing import (
|
|
14
|
+
Annotated,
|
|
15
|
+
Any,
|
|
16
|
+
Literal,
|
|
17
|
+
NotRequired,
|
|
18
|
+
Required,
|
|
19
|
+
TypeAliasType,
|
|
20
|
+
Union,
|
|
21
|
+
get_args,
|
|
22
|
+
get_origin,
|
|
23
|
+
is_typeddict,
|
|
24
|
+
)
|
|
25
|
+
from uuid import UUID
|
|
26
|
+
|
|
27
|
+
JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
|
28
|
+
|
|
29
|
+
_SCALAR_SCHEMAS: dict[Any, dict[str, Any]] = {
|
|
30
|
+
str: {"type": "string"},
|
|
31
|
+
int: {"type": "integer"},
|
|
32
|
+
float: {"type": "number"},
|
|
33
|
+
Decimal: {"type": "number"},
|
|
34
|
+
bool: {"type": "boolean"},
|
|
35
|
+
None: {"type": "null"},
|
|
36
|
+
NoneType: {"type": "null"},
|
|
37
|
+
datetime: {"type": "string", "format": "date-time"},
|
|
38
|
+
date: {"type": "string", "format": "date"},
|
|
39
|
+
time: {"type": "string", "format": "time"},
|
|
40
|
+
UUID: {"type": "string", "format": "uuid"},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_ARRAY_TYPES = (list, tuple, set, frozenset)
|
|
44
|
+
_WRAPPER_ORIGINS = (Annotated, Required, NotRequired)
|
|
45
|
+
_POSITIONAL_KINDS = (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD)
|
|
46
|
+
_KEYWORD_KINDS = (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_json_schema(annotation: Any) -> dict[str, Any]:
|
|
50
|
+
"""Return the JSON Schema of a Python type annotation.
|
|
51
|
+
|
|
52
|
+
Unknown or unannotated types map to `{}`, which accepts any value.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
```python
|
|
56
|
+
>>> get_json_schema(int)
|
|
57
|
+
{'type': 'integer'}
|
|
58
|
+
>>> get_json_schema(list[str])
|
|
59
|
+
{'type': 'array', 'items': {'type': 'string'}}
|
|
60
|
+
>>> get_json_schema(Literal["oat", "soy"])
|
|
61
|
+
{'enum': ['oat', 'soy'], 'type': 'string'}
|
|
62
|
+
>>> get_json_schema(int | None)
|
|
63
|
+
{'anyOf': [{'type': 'integer'}, {'type': 'null'}]}
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
origin = get_origin(annotation)
|
|
69
|
+
|
|
70
|
+
if is_typeddict(annotation):
|
|
71
|
+
return _typeddict_schema(annotation)
|
|
72
|
+
|
|
73
|
+
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
|
74
|
+
return _enum_schema([member.value for member in annotation])
|
|
75
|
+
|
|
76
|
+
if isinstance(annotation, TypeAliasType):
|
|
77
|
+
return get_json_schema(annotation.__value__)
|
|
78
|
+
|
|
79
|
+
if origin in _WRAPPER_ORIGINS:
|
|
80
|
+
return get_json_schema(get_args(annotation)[0])
|
|
81
|
+
|
|
82
|
+
if origin is Literal:
|
|
83
|
+
return _enum_schema(list(get_args(annotation)))
|
|
84
|
+
|
|
85
|
+
if origin in (Union, UnionType):
|
|
86
|
+
return {"anyOf": [get_json_schema(arg) for arg in get_args(annotation)]}
|
|
87
|
+
|
|
88
|
+
if (origin or annotation) in _ARRAY_TYPES:
|
|
89
|
+
return _array_schema(get_args(annotation))
|
|
90
|
+
|
|
91
|
+
if (origin or annotation) is dict:
|
|
92
|
+
return _dict_schema(get_args(annotation))
|
|
93
|
+
|
|
94
|
+
return dict(_SCALAR_SCHEMAS.get(annotation, {}))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_step_json_schema(name: str, step: Callable[..., Any]) -> dict[str, Any]:
|
|
98
|
+
"""Return the JSON Schema of one step dictionary, e.g. `{"name": ..., "args": [...]}`.
|
|
99
|
+
|
|
100
|
+
`step` is a step factory (as returned by `step`, `tap` or a registry), whose
|
|
101
|
+
signature excludes the subject. Positional parameters are described under
|
|
102
|
+
`args` (via `prefixItems`), keyword-capable parameters under `kwargs`, and
|
|
103
|
+
the step docstring becomes the description.
|
|
104
|
+
|
|
105
|
+
Example:
|
|
106
|
+
```python
|
|
107
|
+
>>> from pyflowstep import step
|
|
108
|
+
>>> @step
|
|
109
|
+
... def fill(page, selector: str, value: str = "") -> object:
|
|
110
|
+
... '''Type a value into a field.'''
|
|
111
|
+
... return page
|
|
112
|
+
>>> schema = get_step_json_schema("fill", fill)
|
|
113
|
+
>>> schema["properties"]["name"]
|
|
114
|
+
{'const': 'fill'}
|
|
115
|
+
>>> schema["properties"]["args"]["prefixItems"]
|
|
116
|
+
[{'type': 'string'}, {'type': 'string', 'default': ''}]
|
|
117
|
+
>>> schema["description"]
|
|
118
|
+
'Type a value into a field.'
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
parameters = list(signature(step).parameters.values())
|
|
124
|
+
annotations = _resolved_annotations(step)
|
|
125
|
+
|
|
126
|
+
def parameter_schema(parameter: Parameter) -> dict[str, Any]:
|
|
127
|
+
schema = get_json_schema(annotations.get(parameter.name, Any))
|
|
128
|
+
if parameter.default is not Parameter.empty and _is_json_value(parameter.default):
|
|
129
|
+
schema["default"] = parameter.default
|
|
130
|
+
return schema
|
|
131
|
+
|
|
132
|
+
schema: dict[str, Any] = {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {
|
|
135
|
+
"name": {"const": name},
|
|
136
|
+
"args": _args_schema(parameters, parameter_schema),
|
|
137
|
+
"kwargs": _kwargs_schema(parameters, parameter_schema),
|
|
138
|
+
},
|
|
139
|
+
"required": ["name"],
|
|
140
|
+
"additionalProperties": False,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if step.__doc__:
|
|
144
|
+
schema["description"] = step.__doc__.strip()
|
|
145
|
+
|
|
146
|
+
return schema
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_flow_json_schema(steps: Mapping[str, Callable[..., Any]]) -> dict[str, Any]:
|
|
150
|
+
"""Return the JSON Schema of a whole flow definition: a list of step dictionaries.
|
|
151
|
+
|
|
152
|
+
Pass `registry.steps` (or any name-to-step mapping given to `FlowCompiler`).
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
```python
|
|
156
|
+
>>> from pyflowstep import step
|
|
157
|
+
>>> @step
|
|
158
|
+
... def wait(page, selector: str) -> object:
|
|
159
|
+
... return page
|
|
160
|
+
>>> schema = get_flow_json_schema({"wait": wait})
|
|
161
|
+
>>> schema["type"], len(schema["items"]["oneOf"])
|
|
162
|
+
('array', 1)
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
return {
|
|
168
|
+
"$schema": JSON_SCHEMA_DIALECT,
|
|
169
|
+
"type": "array",
|
|
170
|
+
"items": {"oneOf": [get_step_json_schema(name, step) for name, step in steps.items()]},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _args_schema(
|
|
175
|
+
parameters: list[Parameter],
|
|
176
|
+
parameter_schema: Callable[[Parameter], dict[str, Any]],
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
positional = [parameter for parameter in parameters if parameter.kind in _POSITIONAL_KINDS]
|
|
179
|
+
var_positional = next(
|
|
180
|
+
(parameter for parameter in parameters if parameter.kind is Parameter.VAR_POSITIONAL),
|
|
181
|
+
None,
|
|
182
|
+
)
|
|
183
|
+
return {
|
|
184
|
+
"type": "array",
|
|
185
|
+
"prefixItems": [parameter_schema(parameter) for parameter in positional],
|
|
186
|
+
"items": parameter_schema(var_positional) if var_positional else False,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _kwargs_schema(
|
|
191
|
+
parameters: list[Parameter],
|
|
192
|
+
parameter_schema: Callable[[Parameter], dict[str, Any]],
|
|
193
|
+
) -> dict[str, Any]:
|
|
194
|
+
keyword = [parameter for parameter in parameters if parameter.kind in _KEYWORD_KINDS]
|
|
195
|
+
var_keyword = next(
|
|
196
|
+
(parameter for parameter in parameters if parameter.kind is Parameter.VAR_KEYWORD),
|
|
197
|
+
None,
|
|
198
|
+
)
|
|
199
|
+
return {
|
|
200
|
+
"type": "object",
|
|
201
|
+
"properties": {parameter.name: parameter_schema(parameter) for parameter in keyword},
|
|
202
|
+
"required": [
|
|
203
|
+
parameter.name
|
|
204
|
+
for parameter in keyword
|
|
205
|
+
if parameter.kind is Parameter.KEYWORD_ONLY and parameter.default is Parameter.empty
|
|
206
|
+
],
|
|
207
|
+
"additionalProperties": parameter_schema(var_keyword) if var_keyword else False,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _enum_schema(values: list[Any]) -> dict[str, Any]:
|
|
212
|
+
schema: dict[str, Any] = {"enum": values}
|
|
213
|
+
if values and all(isinstance(value, str) for value in values):
|
|
214
|
+
schema["type"] = "string"
|
|
215
|
+
return schema
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _array_schema(args: tuple[Any, ...]) -> dict[str, Any]:
|
|
219
|
+
return {"type": "array", "items": get_json_schema(args[0]) if args else {}}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _dict_schema(args: tuple[Any, ...]) -> dict[str, Any]:
|
|
223
|
+
return {"type": "object", "additionalProperties": get_json_schema(args[1]) if args else {}}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _typeddict_schema(annotation: Any) -> dict[str, Any]:
|
|
227
|
+
annotations = get_annotations(annotation)
|
|
228
|
+
return {
|
|
229
|
+
"type": "object",
|
|
230
|
+
"properties": {name: get_json_schema(typ) for name, typ in annotations.items()},
|
|
231
|
+
"required": [name for name in annotations if name in annotation.__required_keys__],
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _resolved_annotations(fn: Callable[..., Any]) -> dict[str, Any]:
|
|
236
|
+
"""Evaluate string annotations when possible, fall back to the raw ones."""
|
|
237
|
+
try:
|
|
238
|
+
return get_annotations(fn, eval_str=True)
|
|
239
|
+
except NameError:
|
|
240
|
+
return get_annotations(fn)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _is_json_value(value: Any) -> bool:
|
|
244
|
+
return value is None or isinstance(value, str | int | float | bool | list | dict)
|