Metadata-Version: 2.5
Name: velophex
Version: 0.2.0
Summary: VeloPhex Python SDK: author, run, pack and publish Python automations for the VeloPhex platform.
Project-URL: Homepage, https://velophex.com
Author: VeloPhex
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: ==3.12.*
Requires-Dist: httpx<0.29,>=0.27
Requires-Dist: pydantic<3,>=2.9
Requires-Dist: typer<0.16,>=0.12
Provides-Extra: agents
Requires-Dist: jsonschema<5,>=4.23; extra == 'agents'
Requires-Dist: langchain-core<2,>=1.6.6; extra == 'agents'
Requires-Dist: langchain-openai<2,>=1.6.7; extra == 'agents'
Requires-Dist: langgraph<1.3,>=1.2.12; extra == 'agents'
Provides-Extra: signing
Requires-Dist: cryptography<47,>=43; extra == 'signing'
Description-Content-Type: text/markdown

# VeloPhex.Python — the `velophex` SDK

The Python half of the VeloPhex hybrid platform: the library a Python automation is written
against, the worker the Runner executes it with, and the CLI that packs and publishes it.

A Python automation is an ordinary [uv](https://docs.astral.sh/uv/) project with a
`project.json` and one or more functions marked `@velophex.entrypoint`. `velophex pack` turns
it into the same signed `.nupkg` a Studio workflow produces, so the Orchestrator stores, places
and starts both the same way.

```python
import velophex
from velophex import runtime


@velophex.entrypoint(id="main", display_name="Total an invoice")
def run(pdf_path: str, pages: int = 1) -> dict[str, float]:
    """Total one invoice."""
    if not pdf_path.endswith(".pdf"):
        raise velophex.BusinessError("Only PDF invoices are accepted.", code="INVOICE_REJECTED")

    api_key = runtime.secret("ErpApiKey")
    return {"total": 42.5}
```

Files go through Orchestrator storage buckets, the twins of the workflow storage activities:
`runtime.upload_file`, `download_file`, `list_files` (prefix, `*.pdf;*.xlsx` patterns, recursive),
`delete_file`, `read_text` and `write_text`. A bucket is named; `default` always exists.

## Verifying this component

```powershell
cd VeloPhex.Python
uv sync                     # creates .venv and writes uv.lock (Python 3.12)
uv run pytest               # the whole suite
uv run ruff check           # lint
uv run ruff format --check  # formatting
```

`uv` picks the interpreter from `requires-python` (`>=3.12,<3.13`, ADR-P008). The contract
conformance tests read `VeloPhex.Platform.Contracts` from the sibling folder; if the components
are checked out apart, point at it:

```powershell
$env:VELOPHEX_CONTRACTS_DIR = "<path>\VeloPhex.Platform.Contracts"
uv run pytest
```

Tests that need it skip with a message rather than failing.

## What is where

| Path | What it is |
|---|---|
| `src/velophex/_json.py` | JSON that is byte-identical to `PlatformJson` in .NET. The escaping, the layout and the number literals are all part of the contract |
| `src/velophex/entrypoints.py` | `@entrypoint`; derives input/output JSON Schema from type hints with pydantic `TypeAdapter` |
| `src/velophex/errors.py` | `BusinessError` (a `Business` fault, never retried) and `VelophexError` |
| `src/velophex/contracts/` | `project.json`, package manifest v2, executor protocol messages, runtime kinds, the lexical rules — mirrors `VeloPhex.Platform.Contracts` |
| `src/velophex/worker/` | `python -m velophex.worker`: framing, the session state machine, the `logging` bridge and the handler loader |
| `src/velophex/runtime/` | `stop_requested()`, `context()` and the httpx client for `/api/v1/runtime/*` |
| `src/velophex/packaging.py` | The deterministic `.nupkg` builder, the twin of the engine's `AutomationPackageBuilder` |
| `src/velophex/cli/` | `velophex init \| run \| pack \| publish` (typer) |
| `src/velophex/agents/` | `velophex.agents.run`: the agent tool-calling loop (extra `velophex[agents]`); `testing.py` has the fake gateway and fake tools |
| `agent-runner/` | The `Agent.Runner` package every agent runs; see its README for how CI builds and signs it |
| `tests/data/sample_project/` | A Python project whose `project.json` was written by the **.NET** serializer |
| `tests/data/golden/` | The package the **.NET** `AutomationPackageBuilder` produced from that project |

## Quickstart: your first local run

The SDK is not on a public package index yet, so build its wheel once and point uv at it. Then
the steps after `init` are exactly what `velophex init` prints. This transcript was run in an
empty folder outside the repository with an empty uv cache, in Windows PowerShell 5.1:

```powershell
<checkout>\VeloPhex.Python\build\Build.ps1       # -> VeloPhex.Python\artifacts\dist
$env:UV_FIND_LINKS = "<checkout>\VeloPhex.Python\artifacts\dist"

uvx --python 3.12 --from velophex velophex init Hello.World
cd Hello.World
uv sync
echo '{"message": "Hi"}' > inputs.json
uv run velophex run main --inputs-file inputs.json
```

The last command logs `INFO hello_world.main: Hi` and prints `{"message": "Hi"}`. The
generated handler, `src/hello_world/main.py`, takes one input, `message`; `uv sync` writes
`uv.lock`, which every package must carry. `--inputs '{"message": "Hi"}'` works too where the
shell passes the quotes through (PowerShell 7.3+, bash); Windows PowerShell 5.1 strips them,
which is why the transcript uses a file. Exit codes: `0` success, `1` failed, `2` unusable
inputs, `3` a business rule; `--verbose` adds the traceback to an input error.

`init` found the SDK wheel through `UV_FIND_LINKS` and copied it into `wheels/`, so the same
project, packed and published as below, also restores and runs on a Robot with the Python
runtime - see "How a Robot gets the SDK".

## Publishing to an Orchestrator

From the project folder, with a personal access token that has `packages.create`:

```powershell
uv run velophex pack                  # -> artifacts\Hello.World.1.0.0.nupkg
$env:VELOPHEX_API_KEY = "vpx_pat_..."   # or --api-key-stdin, or be prompted on a terminal
uv run velophex publish artifacts\Hello.World.1.0.0.nupkg --orchestrator https://orchestrator.example.com --tenant <tenantId>
```

### How a Robot gets the SDK

A Robot restores the package's environment with `uv sync --frozen`, which installs from exactly
the sources `uv.lock` records and ignores any index or `--find-links` given at sync time. A lock
made against a wheel *folder* records that folder's absolute path, and it only restores where
the folder exists. So `init` **vendors** the SDK instead: it copies this SDK's own wheel into
the project's `wheels/` folder (from `--sdk-wheel`, or found in the `UV_FIND_LINKS` folders) and
writes

```toml
[tool.uv.sources]
velophex = { path = "wheels/velophex-<version>-py3-none-any.whl" }
```

`uv.lock` then records the wheel by a path relative to the project and by its SHA-256, and
`velophex pack` carries `wheels/` in the package like any other project file (hashed into the
manifest and signed). The Robot's `uv sync --frozen` restores the SDK from the unpacked package
itself, with no index and no Robot configuration, and it never asks any index for a package
named `velophex` - which is what closes the dependency-confusion gap on the unclaimed PyPI name
(PY-02). The SDK's own dependencies (pydantic, httpx, typer) still come from the Robot's index
(`Python:IndexUrl`, PyPI by default) or its wheelhouse (`Python:WheelhousePath`).

Commit `wheels/` with the project. To move a project to a newer SDK, replace the wheel, update
the path in `pyproject.toml` and run `uv lock`. When `init` finds no wheel it falls back to a
plain `velophex` dependency and warns that a Robot cannot restore the project until the SDK is
on an index it can reach.

Why not ship the wheel with the Robot instead: under `--frozen` the lock's recorded path wins,
so a wheel in the Robot's payload would only be used if every developer machine had it at the
same absolute path. Verified with uv 0.12.15; vendoring needs no Robot change at all.

## Building and publishing the SDK

```powershell
build\Build.ps1      # wheel + sdist into artifacts\dist, built twice and compared byte for byte, plus SHA256SUMS
build\Publish.ps1    # dry run: checks SHA256SUMS and runs `uv publish --dry-run`; uploads nothing
```

The version has one source, `__version__` in `src/velophex/__init__.py`; hatchling reads it.
Publishing is an outward action that needs the owner's approval: `Publish.ps1 -Publish`
uploads (`velophex` is on PyPI since 0.2.0), and refuses a public index if `pyproject.toml`
carries the `Private :: Do Not Upload` classifier (PyPI rejects that classifier too). Credentials come
from `UV_PUBLISH_TOKEN` or trusted publishing, never from a parameter.

The token is never a command-line option (it would land in shell history and the process
list); `--api-key` is refused with an error that says where to put it instead.

`pack` refreshes `project.json` from the decorated handlers before it builds (`--no-sync` to
skip), requires `uv.lock`, writes the manifest with the SHA-512 of every content file, and
computes `requiredRuntimes` as this project's runtime plus every `automationDependencies`
runtime — which is what the Orchestrator matches against a runner's reported runtimes.

`publish` is a `PUT {publicBaseUrl}/api/v1/tenants/{tenantId}/feed/v3/package` with the
package as multipart and `X-NuGet-ApiKey: vpx_pat_…`. A `409` is success only when the
feed answers `package_binary_duplicate` with a `sha256` equal to the local package's: these
exact bytes are already published. Any other `409` - different bytes under a published version -
fails, naming the package and version: a published version never changes, so increment the
version in `project.json` and pack again. Without a path, `publish` takes the one package in
`--output` (`artifacts`) and refuses to guess when there are several. The URL and tenant may come from
`VELOPHEX_ORCHESTRATOR_URL` and `VELOPHEX_TENANT_ID`; the key comes only from
`VELOPHEX_API_KEY`, stdin (`--api-key-stdin`) or a hidden prompt.

## How the worker runs an automation

The Runner prepares the environment with `uv sync --frozen --no-dev` and then starts
`<env>\Scripts\python.exe -m velophex.worker` (ADR-P008). Parent and worker speak the executor
protocol (ADR-P005) over the process's stdin and stdout:

1. The worker takes the session key out of `VELOPHEX_EXECUTOR_SESSION_KEY` and **removes the
   variable**, before any automation code is imported. It ignores `VELOPHEX_EXECUTOR_FEED_CREDENTIALS`
   (executor protocol §1): a Python run restores no NuGet packages, and the Runner never sets it for one.
2. The frame pipe moves off standard output to a private, non-inheritable descriptor, and fd 1,
   the Win32 standard output handle and `sys.stdout` all point at `stderr`. A `print()`, a child
   process (`os.system`, `subprocess`) or a `WriteFile` on the standard handle therefore cannot
   corrupt the frame stream, and a detached grandchild cannot keep the pipe open after the worker exits.
   The parent's pipe moves off standard input the same way, and fd 0, the Win32 standard input
   handle and `sys.stdin` become the null device: an automation or a child process that reads
   standard input gets end-of-file instead of consuming the parent's frames.
3. `hello` → `hello-ack` (runtime `python`, no capabilities: the environment is already
   prepared, and there is no Python debugger over this protocol yet).
4. `start` names a package root and an entry point id; `project.json` turns the id into
   `module:function`, the module is imported and the inputs are validated against the
   handler's signature before it is called.
5. While it runs, `logging` records become `log` messages and the parent's `stop` sets
   `velophex.runtime.stop_requested()`.
6. Exactly one terminal message: `result`, `canceled` or `fault`.

Fault mapping, which decides retries:

| Raised | Fault |
|---|---|
| `velophex.BusinessError` | `Business` / the error's `code`, never retried |
| inputs that do not fit the handler | `Application` / `INVALID_INPUT`, not retried |
| a package or handler that will not load | `Application` / `INVALID_PACKAGE` |
| anything else | `Application` / `UNHANDLED_EXCEPTION` |
| a return value with no JSON form (`datetime`, a set, NaN) | `Application` / `RESULT_NOT_SERIALIZABLE`, not retried |
| outputs over the frame limit (16 MiB) | `Application` / `RESULT_TOO_LARGE`, not retried |

The last two are checked before the terminal frame is written, so the parent always gets a
terminal message rather than a crash it would have to report (and retry) as
`EXECUTOR_CRASHED`. Both are entries in the shared `FaultCodes` table (Contracts 0.1.0-preview.4), so the workflow executor uses the same spellings.

A stop is cooperative: the flag is set and the handler is expected to notice. When the grace
period passes, an `async` handler's task is cancelled (it unwinds through `finally`); a
synchronous handler cannot be interrupted safely from outside, so the parent's kill is the
answer, and the worker logs a warning saying so. A handler that returns *after* a stop was
asked for ends the run as `canceled`, not `result`.

## What "the same package as .NET" means

`tests/test_pack_parity.py` compares what `velophex pack` builds against
`tests/data/golden/Sample.Automation.1.2.0.nupkg`, which the engine's
`AutomationPackageBuilder` produced from `tests/data/sample_project`. Parity is asserted on
the archive's *content*: the same entry names in the same order, the same fixed
`2000-01-01T00:00:00` timestamp, and the same bytes in every entry — `content/project.json`,
`velophex/package-manifest.json`, the `.nuspec` and `[Content_Types].xml`.

The *compressed* bytes are deliberately not compared. A .NET `ZipArchive` and Python's `zlib`
do not emit the same deflate stream, and nothing in the platform depends on it: what is hashed
and signed is the manifest and the files the manifest lists, never the archive's compression.

Regenerate the golden only for an intentional packaging change, by running the .NET builder
over the same fixture directory.

## The JSON, and why it is not `json.dumps`

The package manifest carries the SHA-512 of `content/project.json`, so a manifest written here
and a manifest written by Studio only verify against each other when both writers agree byte
for byte. `System.Text.Json` and Python's `json` do not agree, in three ways that all matter
and are all pinned by tests:

* The default .NET encoder escapes `"`, `&`, `'`, `+`, `<`, `>`, `` ` `` and everything
  outside printable ASCII as `\uXXXX` with **upper-case** hex. So `">=3.12,<3.13"` is written
  `">=3.12,<3.13"`.
* A `JsonElement` — an entry point's JSON Schema, say — is re-emitted with its **original
  number literals**: `1.0` stays `1.0`, `1e2` stays `1e2`.
* A `DateTimeOffset` goes through a typed fast path that never reaches the encoder, so the
  `+` of its offset stays a `+` where the same text in a plain string property would become
  `+`. The executor frame vectors pin exactly this.

## Agents: `velophex[agents]`

`velophex.agents.run(definition, inputs)` runs an agent definition (`agent-definition` v1,
`VeloPhex.Platform.Contracts/schemas/agent-definition.v1.schema.json`) and returns its validated
output. The extra brings LangGraph, `langchain-openai` and `jsonschema` from PyPI; LangGraph is
pinned to one minor and only `velophex/agents/_loop.py` imports it.

* **Model calls** go to the Orchestrator's LLM gateway, `POST <runtime>/ai/chat/completions`, in
  the OpenAI chat completions shape, non-streaming, with the job token and the agent's binding
  (`agent:default`) as the model. Outside a job, `VELOPHEX_AI_BASE_URL`, `VELOPHEX_AI_API_KEY`
  and `VELOPHEX_AI_MODEL` name any OpenAI-compatible endpoint; nothing is traced then.
* **Tools:** `process` runs a published automation **in this job** (`velophex.runtime.invoke_automation`,
  executor `sub-invoke`: same machine, same lease, no extra robot slot); `mcp`, `http`,
  `vector_search` and `action_task` go through the Runtime API. Every call carries an
  idempotency key, and tool and retrieved content reaches the model fenced as untrusted data.
* **Approvals** (`approval: before_call`) are bounded waits in the job: at most 900 s, and never
  past the run's own timeout. A rejected or expired approval means the call is not made.
* **Limits:** 12 model calls, 50 000 tokens and 15 minutes per run, temperature 0.2, unless the
  definition says otherwise. A limit ends the run with `agent_limit_exceeded`; an answer that
  fails the output schema gets one repair turn, then `agent_output_invalid`.
* **Faults keep their code.** A gateway refusal raises `AiBudgetExhaustedError`,
  `AiPolicyDeniedError`, `AiProviderUnavailableError` or `AiRequestInvalidError` (all
  `RuntimeApiError`), and the worker reports it under that code with the error envelope (cause,
  fix, request id, retry scope, docs URL), not as `UNHANDLED_EXCEPTION`. A run that fails after a
  tool call succeeded reports `effectsCommitted` and is never retried automatically; an unknown
  tool outcome is `tool_outcome_unknown`, reconciled by a person.
* **Testing:** `velophex.agents.testing.FakeChatServer` (a scripted gateway on 127.0.0.1),
  `FakeTools` and `RecordingTracer`; `tests/test_agents.py` shows the patterns.

`velophex run` and the worker shape outputs with the same rule (`normalize_outputs`): `None` is
`{}`, a scalar or list is `{"value": ...}`, and a value with no JSON form is
`RESULT_NOT_SERIALIZABLE` in both.

## Contract changes

`project.json`, the package manifest, the runner protocol and the executor protocol are
platform contracts. They change only through the `velophex-contract-change` skill, which
updates the engine, Studio, the Activities, the Orchestrator, the Runner and this SDK together.
Nothing in `src/velophex/contracts/` may drift on its own; the conformance tests against
`VeloPhex.Platform.Contracts/schemas/` are there to make drift fail loudly.
