Metadata-Version: 2.5
Name: larex-action-sdk
Version: 0.13.0
Summary: Framework-neutral Python SDK for building LAREX Action processors.
Project-URL: Homepage, https://github.com/OCR4all/larex-action-sdk
Project-URL: Repository, https://github.com/OCR4all/larex-action-sdk
Project-URL: Issues, https://github.com/OCR4all/larex-action-sdk/issues
Author: OCR4all
License-Expression: MIT
License-File: LICENSE
Keywords: actions,larex,ocr,processors
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx<1,>=0.28
Requires-Dist: pydantic<3,>=2.10
Provides-Extra: fastapi
Requires-Dist: fastapi<1,>=0.115; extra == 'fastapi'
Description-Content-Type: text/markdown

# LAREX Action SDK

> This SDK is work in progress. The public API can still change before LAREX
> Actions and the SDK are considered stable.

Framework-neutral Python SDK for building [LAREX](https://github.com/OCR4all/larex)
Action processors with signed dispatch verification, typed payloads, and cooperative
run cancellation.

The core package verifies LAREX dispatch requests, parses typed run/input payloads,
sends heartbeats, downloads selected files, uploads result manifests, and helps
processors acknowledge cancellation cleanly. FastAPI support is available as an
optional convenience extra.

## Installation

```bash
uv add "larex-action-sdk[fastapi]"
```

For framework-neutral usage only:

```bash
uv add larex-action-sdk
```

## FastAPI Processor

```python
import os

from larex_actions import ActionContext
from larex_actions.fastapi import create_larex_action_app


async def process(ctx: ActionContext) -> None:
    action_input = await ctx.pull_input()
    for page in action_input.pages:
        async with ctx.step(f"Processing {page.name}", progress_percent=25):
            await ctx.check_cancelled()
            results = ctx.result_builder()
            if page.xml:
                xml_bytes = await ctx.download_bytes(page.xml[0])
                results.add_xml_bytes(
                    page_id=page.id,
                    content=xml_bytes,
                    file_name=f"{page.name}-processed.xml",
                )
            await ctx.submit_page_results(page.id, results, f"Finished {page.name}")

    await ctx.complete(message="Done")


app = create_larex_action_app(
    processor_id="my-processor",
    dispatch_secret=os.environ["LAREX_DISPATCH_HMAC_SECRET"],
    handler=process,
    max_concurrent_runs=1,
)
```

Incremental page submissions require LAREX to advertise
`capabilities.incrementalPageResults`. The SDK refuses the submission when an
older server does not advertise it. Existing processors can continue to call
`await ctx.complete(results, "Done")` once with a bulk result.

## Custom File Results

LAREX servers that advertise `capabilities.customFileResults` accept arbitrary
durable files. Check the capability before doing expensive postprocessing and
add project-level bytes or paths to a result builder:

```python
if not ctx.capabilities.custom_file_results:
    raise RuntimeError("This LAREX server does not support custom file results")

results = ctx.result_builder()
results.add_file_bytes(
    content=ner_jsonl,
    file_name="named-entities.jsonl",
    mime_type="application/x-ndjson",
)
results.add_file_path(
    report_path,
    file_name="report.txt",
    mime_type="text/plain",
)
await ctx.complete(results, "Postprocessing complete")
```

Omit `page_id` for project-level files. Pass `page_id=page.id` when associating a
file with a page. Incremental page submissions require every file—including a
custom file—to carry the same page ID as the submission. The client raises
`CustomFileResultsUnsupported` before uploading if the server did not advertise
support.

`max_concurrent_runs` bounds simultaneous in-process handlers for CPU/GPU-heavy
processors. Additional signed dispatches remain accepted and wait for a slot.
`/ready` returns `503` while every slot is occupied; `/health` remains a liveness
endpoint. For crash-durable queuing, run the handler in an external worker system
instead of relying on FastAPI background tasks.

Result callbacks retry connection failures and transient HTTP responses (`408`,
`429`, `502`, `503`, and `504`) automatically. Path-based files are reopened for
every attempt. The defaults are four attempts with exponential backoff and jitter;
processors can tune `result_max_attempts`, `result_retry_backoff`, and
`result_retry_max_backoff` on `ActionClient` or `ActionClient.from_dispatch(...)`.
If LAREX rejects a result, `ResultSubmissionError` includes a bounded, sanitized
response detail in its message so processor logs show the actual import or
validation failure. It remains an `httpx.HTTPStatusError` subtype for compatibility.

## SDK Transport Logging

The SDK uses Python's standard `logging` module for optional transport diagnostics.
Records are emitted at `DEBUG` on the `larex_actions.transport` logger, so normal
processor output remains unchanged until the application enables that logger. The
library does not configure the root logger or install handlers.

When the processor already configures a root handler, including many application
logging setups, enabling the named logger is enough:

```python
import logging

logging.getLogger("larex_actions.transport").setLevel(logging.DEBUG)
```

For a Uvicorn or Docker process where the root logger has no handler, attach a
standard stream handler in the processor's own startup code. `StreamHandler` writes
to stderr by default, which Docker and normal process supervisors collect alongside
stdout:

```python
import logging

sdk_transport_logger = logging.getLogger("larex_actions.transport")
sdk_transport_logger.setLevel(logging.DEBUG)
sdk_transport_logger.addHandler(logging.StreamHandler())
sdk_transport_logger.propagate = False
```

Transport records identify operations such as input pulls, heartbeats, downloads,
page-result submissions, completion uploads, retries, and failures. They include
safe metadata when available—for example run/page IDs, result status and file type
counts, HTTP status, duration, and retry attempt. They never include authorization
headers, request or response bodies, file contents, or callback/download URLs.

This is local SDK transport logging only. Enabling it does not send extra `log=`
heartbeats or otherwise add requests to LAREX. It can show when an SDK call
succeeds, fails, or is retried, but it cannot determine what an Action does
internally between calls. Use Action-specific application logging for semantic
progress, or call `ctx.heartbeat(...)`/`ctx.log(...)` when that progress should
also be reported to LAREX.

The FastAPI adapter always exposes `/dispatch`, `/preflight`, and `/health`.
`/preflight` accepts only a valid signed LAREX request and reports the processor
identity, protocol version, and configured capabilities. Set
`LAREX_ACTION_ROUTE_PREFIXES` to also expose prefixed routes when a reverse
proxy keeps an external path prefix:

```bash
LAREX_ACTION_ROUTE_PREFIXES=/kraken,/ocr
```

With that setting, the same processor also accepts `/kraken/dispatch`,
`/kraken/preflight`, `/kraken/health`, and the equivalent `/ocr/*` routes. LAREX
must sign and call the same path the processor receives; do not strip the prefix
in the reverse proxy before the request reaches the processor.

Capabilities default to both SDK-supported result features. Override them when a
processor intentionally implements a smaller surface:

```python
app = create_larex_action_app(
    processor_id="my-processor",
    dispatch_secret=secret,
    handler=process,
    processor_capabilities={"incrementalPageResults": True, "customFileResults": False},
)
```

## Target-Aware Runs

LAREX can dispatch page, region, and textline targeted runs. The SDK exposes the
requested target on both dispatch and pulled input payloads:

```python
payload_target = ctx.payload.target
action_input = await ctx.pull_input()
input_target = action_input.target
```

Processors still receive full page files according to the Action YAML inputs.
Target metadata contains selected region/textline ids only. LAREX sends full page
images/XML and lets processors resolve geometry from PAGE XML, including whether
to crop, mask, pad, deskew, or process the full image.

Input definitions can declare whether each file type is unavailable, optional,
or required, including target-specific requirements:

```yaml
inputs:
  images:
    level: required
  xml:
    level: optional
    requiredForTargets:
      - REGION
```

LAREX resolves that contract for the selected target and exposes it on both
`ctx.payload.input_requirements` and `action_input.input_requirements`. Legacy
boolean definitions remain compatible (`true` is optional and `false` is none).
Pages missing a required input are excluded before dispatch.

Processors return normal PAGE XML via `ResultBuilder.add_xml_bytes(...)` or
`add_xml_path(...)`. For region or textline targeted runs, LAREX imports only the
selected target scope from the returned PAGE XML.

## Framework-Neutral Dispatch Verification

```python
from larex_actions import DispatchVerifier

payload = DispatchVerifier(
    processor_id="my-processor",
    dispatch_secret=secret,
).verify(
    method=request_method,
    path_and_query=request_path_and_query,
    headers=request_headers,
    body=request_body,
)
```

You can then pass `payload.model_dump(mode="json", by_alias=True)` to your own
queue/worker system and use `ActionClient.from_dispatch(payload)` in async workers.

## Cooperative Cancellation

LAREX cancellation is cooperative. The processor keeps polling the heartbeat
endpoint and LAREX responds with `cancelRequested: true` when the run should stop.

- Use `await ctx.check_cancelled()` at safe interruption points.
- `ctx.check_cancelled()` performs a heartbeat request, so avoid calling it in a
  hot inner loop without pacing.
- `await ctx.heartbeat(..., raise_on_cancel=True)` also raises `ActionCancelled`
  when a cancellation is pending.
- `await ctx.run_subprocess(...)` polls for cancellation while a child process is
  running, sends a final `status="cancelled"` heartbeat, and terminates the child
  process gracefully before escalating to `kill`.
- Once cancellation has been requested, the SDK refuses result uploads and
  acknowledges cancellation instead.

## Security

- Dispatch requests are verified with the `X-LAREX-Action-*` HMAC headers.
- Timestamps and nonces are checked to reduce replay risk.
- The FastAPI adapter rejects dispatch bodies larger than `max_dispatch_body_bytes`.
- Per-run bearer secrets and dispatch HMAC secrets are never included in model reprs.
- Processor YAML must still declare the inputs and outputs LAREX should expose or accept.

## Development

```bash
uv sync --all-extras
uv run ruff format .
uv run ruff check .
uv run pyright
uv run pytest
uv build
```

Releases are automated by the `python-semantic-release` workflow. Conventional
commits on `main` determine the next version (`feat` → minor, `fix`/`perf` →
patch, and breaking changes → major). The workflow verifies the package, updates
`pyproject.toml` and `uv.lock`, creates the release commit/tag and GitHub release.
Run it manually from the Actions tab when you need to force a bump level or
create a prerelease.

Configure a repository secret named `RELEASE_TOKEN` with a GitHub token that has
Contents read/write access. A PAT is required so the generated
tag/release triggers the existing PyPI publication workflow. Release candidate
tags containing `rc` publish to TestPyPI; published stable releases publish to
PyPI.
