velophex 0.2.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.
Files changed (43) hide show
  1. velophex/__init__.py +38 -0
  2. velophex/_json.py +297 -0
  3. velophex/agents/__init__.py +130 -0
  4. velophex/agents/_errors.py +75 -0
  5. velophex/agents/_loop.py +436 -0
  6. velophex/agents/_tools.py +168 -0
  7. velophex/agents/_tracing.py +87 -0
  8. velophex/agents/testing.py +249 -0
  9. velophex/cli/__init__.py +5 -0
  10. velophex/cli/main.py +548 -0
  11. velophex/cli/publish.py +152 -0
  12. velophex/cli/sync.py +90 -0
  13. velophex/cli/templates.py +126 -0
  14. velophex/contracts/__init__.py +103 -0
  15. velophex/contracts/_model.py +26 -0
  16. velophex/contracts/agents.py +110 -0
  17. velophex/contracts/diagnostics.py +157 -0
  18. velophex/contracts/executor.py +1921 -0
  19. velophex/contracts/package.py +391 -0
  20. velophex/contracts/project.py +1008 -0
  21. velophex/contracts/runtimes.py +226 -0
  22. velophex/contracts/signin.py +53 -0
  23. velophex/contracts/validation.py +158 -0
  24. velophex/entrypoints.py +329 -0
  25. velophex/errors.py +44 -0
  26. velophex/packaging.py +465 -0
  27. velophex/py.typed +0 -0
  28. velophex/runtime/__init__.py +471 -0
  29. velophex/runtime/_session.py +192 -0
  30. velophex/runtime/client.py +957 -0
  31. velophex/runtime/models.py +326 -0
  32. velophex/signing.py +425 -0
  33. velophex/worker/__init__.py +27 -0
  34. velophex/worker/__main__.py +118 -0
  35. velophex/worker/framing.py +248 -0
  36. velophex/worker/loader.py +105 -0
  37. velophex/worker/outbox.py +147 -0
  38. velophex/worker/session.py +519 -0
  39. velophex-0.2.0.dist-info/METADATA +313 -0
  40. velophex-0.2.0.dist-info/RECORD +43 -0
  41. velophex-0.2.0.dist-info/WHEEL +4 -0
  42. velophex-0.2.0.dist-info/entry_points.txt +2 -0
  43. velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,329 @@
1
+ """The ``@velophex.entrypoint`` decorator and the JSON Schemas it derives from type hints.
2
+
3
+ An entry point's input and output schema is what the Orchestrator validates job inputs
4
+ against and what the start form in the web UI is generated from (ADR-P002), so the schema has
5
+ to come from something a developer cannot forget to update. It is derived from the handler's
6
+ signature with pydantic's ``TypeAdapter``: parameters become properties, parameters without a
7
+ default become ``required``, and the return annotation becomes the output schema.
8
+
9
+ >>> from velophex import entrypoint
10
+ >>>
11
+ >>> @entrypoint(id="main", description="Extract the totals of one invoice.")
12
+ ... def run(pdf_path: str, pages: int = 1) -> dict[str, float]:
13
+ ... ...
14
+
15
+ ``velophex pack`` reads the schemas out of the decorated functions; the worker validates the
16
+ ``start`` inputs against the same adapter before it calls the handler, so a bad input is a
17
+ ``Business``-free ``Application`` fault with a pointer rather than a ``TypeError`` halfway
18
+ through the automation.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import inspect
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, replace
26
+ from typing import Any, Final, get_type_hints
27
+
28
+ from pydantic import TypeAdapter, ValidationError
29
+
30
+ from velophex import _json
31
+
32
+ __all__ = [
33
+ "ENTRYPOINT_ATTRIBUTE",
34
+ "EntryPointDefinition",
35
+ "EntryPointInputError",
36
+ "entrypoint",
37
+ "entrypoint_definition",
38
+ "is_entrypoint",
39
+ "normalize_outputs",
40
+ ]
41
+
42
+ #: Attribute the decorator puts on the function; ``velophex pack`` and the worker read it.
43
+ ENTRYPOINT_ATTRIBUTE: Final = "__velophex_entrypoint__"
44
+
45
+ _EMPTY_OBJECT_SCHEMA: Final[dict[str, Any]] = {"type": "object", "properties": {}, "additionalProperties": False}
46
+
47
+
48
+ class EntryPointInputError(ValueError):
49
+ """The ``start`` inputs do not fit the handler's signature.
50
+
51
+ :attr:`pointers` lists a JSON pointer per offending member, which is what the Orchestrator
52
+ puts into its ``422`` problem details.
53
+ """
54
+
55
+ def __init__(self, message: str, pointers: list[str]) -> None:
56
+ super().__init__(message)
57
+ self.pointers = pointers
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class EntryPointDefinition:
62
+ """Everything the SDK knows about one decorated handler."""
63
+
64
+ id: str
65
+ function: Callable[..., Any]
66
+ display_name: str | None
67
+ description: str | None
68
+ input_schema: dict[str, Any]
69
+ output_schema: dict[str, Any] | None
70
+ is_async: bool
71
+ #: Adapters for the individual parameters, used to coerce and validate start inputs.
72
+ parameters: dict[str, TypeAdapter[Any]]
73
+ required: tuple[str, ...]
74
+
75
+ @property
76
+ def handler(self) -> str:
77
+ """The ``module:function`` reference that goes into ``project.json``."""
78
+ return f"{self.function.__module__}:{self.function.__name__}"
79
+
80
+ def bind(self, inputs: Any) -> dict[str, Any]:
81
+ """Validate and coerce *inputs* into keyword arguments for the handler.
82
+
83
+ Raises :class:`EntryPointInputError` when a required input is missing, an input is
84
+ unknown or a value does not fit its annotation.
85
+ """
86
+ values = inputs if inputs is not None else {}
87
+
88
+ if not isinstance(values, dict):
89
+ raise EntryPointInputError("The inputs must be a JSON object.", ["$"])
90
+
91
+ pointers = [f"$.{name}" for name in self.required if name not in values]
92
+ unknown = [f"$.{name}" for name in values if name not in self.parameters]
93
+
94
+ if pointers or unknown:
95
+ missing = ", ".join(pointer.removeprefix("$.") for pointer in pointers)
96
+ extra = ", ".join(pointer.removeprefix("$.") for pointer in unknown)
97
+ parts = [text for text in (f"missing: {missing}" if missing else "", f"unknown: {extra}" if extra else "")]
98
+ raise EntryPointInputError(
99
+ f"The inputs do not fit '{self.id}' ({'; '.join(part for part in parts if part)}).",
100
+ pointers + unknown,
101
+ )
102
+
103
+ bound: dict[str, Any] = {}
104
+
105
+ for name, value in values.items():
106
+ try:
107
+ bound[name] = self.parameters[name].validate_python(value)
108
+ except ValidationError as error:
109
+ # str(error) echoes the rejected value (input_value=...), and this message becomes
110
+ # the stored fault. Inputs carry secrets, so only the path and the reason are kept
111
+ # (PY-04); `from None` keeps the value out of a chained traceback too.
112
+ reasons = "; ".join(
113
+ f"{'.'.join(str(part) for part in (name, *detail['loc']))}: {detail['msg']}"
114
+ for detail in error.errors(include_input=False, include_url=False, include_context=False)
115
+ )
116
+ raise EntryPointInputError(f"Input '{name}' is not valid: {reasons}.", [f"$.{name}"]) from None
117
+
118
+ return bound
119
+
120
+
121
+ def entrypoint[Handler: Callable[..., Any]](
122
+ function: Handler | None = None,
123
+ *,
124
+ id: str | None = None,
125
+ display_name: str | None = None,
126
+ description: str | None = None,
127
+ input_schema: dict[str, Any] | None = None,
128
+ output_schema: dict[str, Any] | None = None,
129
+ ) -> Handler | Callable[[Handler], Handler]:
130
+ """Mark *function* as an entry point of the automation.
131
+
132
+ The decorator returns the function unchanged, so a handler stays an ordinary callable
133
+ that tests can call directly. It only attaches the definition the CLI and the worker read.
134
+
135
+ Args:
136
+ function: The handler, when the decorator is used without parentheses.
137
+ id: The entry point id; defaults to the function name. It must match
138
+ ``^[A-Za-z][A-Za-z0-9_.-]{0,63}$``.
139
+ display_name: A label for Studio and the web UI.
140
+ description: Free text; defaults to the function's docstring summary.
141
+ input_schema: The input schema, declared rather than derived from the signature.
142
+ ``velophex pack`` writes it to ``project.json`` as given, so a schema another
143
+ document owns (an agent definition) is never rewritten from type hints.
144
+ output_schema: The output schema, declared the same way.
145
+ """
146
+
147
+ def decorate(target: Handler) -> Handler:
148
+ definition = _describe(target, id, display_name, description)
149
+
150
+ if input_schema is not None or output_schema is not None:
151
+ definition = replace(
152
+ definition,
153
+ input_schema=_plain(input_schema) if input_schema is not None else definition.input_schema,
154
+ output_schema=_plain(output_schema) if output_schema is not None else definition.output_schema,
155
+ )
156
+
157
+ setattr(target, ENTRYPOINT_ATTRIBUTE, definition)
158
+ return target
159
+
160
+ return decorate if function is None else decorate(function)
161
+
162
+
163
+ def normalize_outputs(value: Any) -> Any:
164
+ """Shape a handler's return value into the JSON object a run's result carries.
165
+
166
+ One rule for every place a handler runs, so ``velophex run`` prints what the worker sends:
167
+ ``None`` is ``{}``, a pydantic model is its JSON dump, an object is itself, and a scalar or a
168
+ list is wrapped as ``{"value": ...}``, matching the output schema the decorator derives. A
169
+ value with no JSON form (a ``datetime``, NaN) is left for the writer to refuse, which both
170
+ callers report as ``RESULT_NOT_SERIALIZABLE``.
171
+ """
172
+ if value is None:
173
+ return {}
174
+
175
+ if isinstance(value, dict):
176
+ return value
177
+
178
+ if hasattr(value, "model_dump"):
179
+ return value.model_dump(mode="json", by_alias=True)
180
+
181
+ return {"value": value}
182
+
183
+
184
+ def is_entrypoint(value: object) -> bool:
185
+ """True when *value* is a function decorated with :func:`entrypoint`."""
186
+ return getattr(value, ENTRYPOINT_ATTRIBUTE, None) is not None
187
+
188
+
189
+ def entrypoint_definition(value: object) -> EntryPointDefinition:
190
+ """The definition attached to a decorated handler."""
191
+ definition = getattr(value, ENTRYPOINT_ATTRIBUTE, None)
192
+
193
+ if not isinstance(definition, EntryPointDefinition):
194
+ raise TypeError(f"{value!r} is not decorated with @velophex.entrypoint.")
195
+
196
+ return definition
197
+
198
+
199
+ def _describe(
200
+ target: Callable[..., Any],
201
+ entry_id: str | None,
202
+ display_name: str | None,
203
+ description: str | None,
204
+ ) -> EntryPointDefinition:
205
+ signature = inspect.signature(target)
206
+ hints = get_type_hints(target)
207
+
208
+ parameters: dict[str, TypeAdapter[Any]] = {}
209
+ required: list[str] = []
210
+
211
+ for name, parameter in signature.parameters.items():
212
+ if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
213
+ raise TypeError(f"'{target.__name__}' cannot be an entry point: *args and **kwargs have no schema.")
214
+
215
+ parameters[name] = TypeAdapter(hints.get(name, Any))
216
+
217
+ if parameter.default is inspect.Parameter.empty:
218
+ required.append(name)
219
+
220
+ properties, definitions = _parameter_schemas(parameters)
221
+
222
+ for name, parameter in signature.parameters.items():
223
+ if parameter.default is not inspect.Parameter.empty:
224
+ properties[name]["default"] = parameter.default
225
+
226
+ input_schema: dict[str, Any] = {"type": "object", "properties": properties, "additionalProperties": False}
227
+
228
+ if required:
229
+ input_schema["required"] = required
230
+
231
+ if definitions:
232
+ # One shared $defs at the document root, where every "#/$defs/..." reference in the
233
+ # properties points. Per-parameter schemas used to keep their own $defs nested under
234
+ # "properties", which left every reference to a nested model unresolvable.
235
+ input_schema["$defs"] = definitions
236
+
237
+ return EntryPointDefinition(
238
+ id=entry_id or target.__name__,
239
+ function=target,
240
+ display_name=display_name,
241
+ description=description or _summary(target),
242
+ input_schema=input_schema,
243
+ output_schema=_output_schema(hints.get("return", inspect.Signature.empty)),
244
+ is_async=inspect.iscoroutinefunction(target),
245
+ parameters=parameters,
246
+ required=tuple(required),
247
+ )
248
+
249
+
250
+ def _output_schema(annotation: Any) -> dict[str, Any] | None:
251
+ if annotation is inspect.Signature.empty or annotation is None or annotation is type(None):
252
+ # A handler that returns nothing still has an output contract: the empty object.
253
+ return dict(_EMPTY_OBJECT_SCHEMA)
254
+
255
+ schema = _json_schema(TypeAdapter(annotation))
256
+
257
+ # Outputs travel as the JSON object of a `result` message, so a handler that returns a
258
+ # scalar is described as an object with a single `value` member.
259
+ if schema.get("type") != "object" and not _refers_to_an_object(schema):
260
+ # The wrapped schema's $defs move up to the new root: its references are
261
+ # root-relative ("#/$defs/..."), so left under "properties.value" they resolve to
262
+ # nothing (a list of models, or a recursive model whose root is a $ref).
263
+ definitions = schema.pop("$defs", None)
264
+ wrapped: dict[str, Any] = {"type": "object", "properties": {"value": schema}, "required": ["value"]}
265
+
266
+ if definitions:
267
+ wrapped["$defs"] = definitions
268
+
269
+ return wrapped
270
+
271
+ return schema
272
+
273
+
274
+ def _refers_to_an_object(schema: dict[str, Any]) -> bool:
275
+ """True for ``{"$ref": "#/$defs/X", "$defs": {...}}`` where ``X`` is an object.
276
+
277
+ pydantic describes a recursive model that way. The handler still returns the model, which
278
+ the worker sends as its own JSON object, so it must not be wrapped in ``{"value": ...}``.
279
+ """
280
+ reference = schema.get("$ref")
281
+
282
+ if not isinstance(reference, str) or not reference.startswith("#/$defs/"):
283
+ return False
284
+
285
+ target = (schema.get("$defs") or {}).get(reference.removeprefix("#/$defs/"))
286
+ return isinstance(target, dict) and target.get("type") == "object"
287
+
288
+
289
+ _REF_TEMPLATE: Final = "#/$defs/{model}"
290
+
291
+
292
+ def _json_schema(adapter: TypeAdapter[Any]) -> dict[str, Any]:
293
+ # `serialization` mode keeps the schema aligned with what the handler actually returns;
294
+ # for inputs the two modes agree for every type the protocol can carry.
295
+ schema = adapter.json_schema(ref_template=_REF_TEMPLATE)
296
+
297
+ # Round-trip through the platform reader so the schema carries only plain JSON values and
298
+ # can be embedded in project.json without surprises.
299
+ return _plain(schema)
300
+
301
+
302
+ def _parameter_schemas(parameters: dict[str, TypeAdapter[Any]]) -> tuple[dict[str, Any], dict[str, Any]]:
303
+ """The schema of every parameter, and the definitions they share.
304
+
305
+ All parameters are generated in one pass so pydantic names the definitions once for the
306
+ whole document: two parameters of the same model share one entry, and two different
307
+ models that happen to have the same name get distinct ones instead of overwriting each
308
+ other. The references it writes are ``#/$defs/<name>``, so the definitions belong at the
309
+ root of the input schema.
310
+ """
311
+ if not parameters:
312
+ return {}, {}
313
+
314
+ schemas, definitions = TypeAdapter.json_schemas(
315
+ [(name, "validation", adapter) for name, adapter in parameters.items()],
316
+ ref_template=_REF_TEMPLATE,
317
+ )
318
+
319
+ properties = {name: _plain(schemas[(name, "validation")]) for name in parameters}
320
+ return properties, _plain(definitions.get("$defs") or {})
321
+
322
+
323
+ def _plain(schema: Any) -> Any:
324
+ return _json.loads(_json.encode_wire(schema))
325
+
326
+
327
+ def _summary(target: Callable[..., Any]) -> str | None:
328
+ doc = inspect.getdoc(target)
329
+ return doc.split("\n", 1)[0].strip() if doc else None
velophex/errors.py ADDED
@@ -0,0 +1,44 @@
1
+ """The exceptions a VeloPhex Python automation raises and the SDK's own failure type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from velophex.contracts.executor import FaultInfo
9
+
10
+ __all__ = ["BusinessError", "VelophexError"]
11
+
12
+
13
+ class VelophexError(Exception):
14
+ """Base class for every error the SDK raises itself.
15
+
16
+ Automation code normally sees subclasses only; the worker turns anything else into an
17
+ ``Application`` fault.
18
+ """
19
+
20
+ def to_fault(self) -> FaultInfo | None:
21
+ """The fault the worker reports for this error, or ``None`` for ``UNHANDLED_EXCEPTION``.
22
+
23
+ A subclass that knows its code (a Runtime API refusal, an agent guardrail) overrides
24
+ this, so the code reaches the job instead of collapsing into ``UNHANDLED_EXCEPTION``.
25
+ """
26
+ return None
27
+
28
+
29
+ class BusinessError(VelophexError):
30
+ """A business rule the automation cannot satisfy — an expected, final outcome.
31
+
32
+ The worker reports it as ``Fault(kind="Business")``, which the Orchestrator never
33
+ retries (ADR-P004): a rejected invoice does not become acceptable on a second run. Use
34
+ an ordinary exception for anything that a retry could fix.
35
+
36
+ Example:
37
+ >>> raise BusinessError("Total is negative.", code="INVOICE_REJECTED")
38
+ """
39
+
40
+ def __init__(self, message: str, *, code: str = "BUSINESS_RULE_VIOLATED", detail: str | None = None) -> None:
41
+ super().__init__(message)
42
+ self.message = message
43
+ self.code = code
44
+ self.detail = detail