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 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
+ ]
@@ -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
+ )
@@ -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)