mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
hanzo-tasks speaks to the engine we actually run
0.1.0 shipped a Temporal gRPC client under our name: it depended on
temporalio and dialled localhost:7233, a port the native engine does not
serve. Anyone who installed it got a client that could not reach us.
The engine (hanzoai/tasks) is native and speaks JSON over
/v1/tasks/namespaces/{ns}/activities. So this drops temporalio for httpx
and rewrites the package against that surface: a client that dispatches,
reads and settles activities, and a worker that claims them.
The worker POLLS rather than accepting a push, which is what lets it run
behind NAT — the claim endpoint exists for exactly that, and the shipped
Go worker (`hanzo gpu connect`) already pulls from it.
The engine's rules are not copied here. It reaps expired leases before
every claim, serializes claims per namespace, and derives the lease from
the activity's own heartbeat timeout; a second opinion on any of that
could only disagree with it. What the worker does hold up is the lease
while a handler runs, beating at a third of the window the server
granted — and a plain function runs in a thread, because a blocking call
on the event loop would stall that beat and let the work be reaped
out from under itself.
The Temporal workflow modules are deleted rather than ported. Replay
determinism is not something this surface offers, and a @workflow.defn
that cannot replay would be a promise the engine never made.
Also names the sdist contents, so a local virtualenv beside the package
can never be swept into a release again.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# hanzo-tasks
|
||||
|
||||
Durable activities on the native Hanzo Tasks engine.
|
||||
|
||||
The engine is Hanzo's own (`hanzoai/tasks`), reached over JSON at
|
||||
`api.hanzo.ai/v1/tasks`. It speaks no protobuf and no gRPC, and this package
|
||||
depends on no workflow runtime — dispatching work and running a worker are both
|
||||
plain HTTP over `httpx`.
|
||||
|
||||
```bash
|
||||
pip install hanzo-tasks
|
||||
```
|
||||
|
||||
## Dispatch
|
||||
|
||||
```python
|
||||
from hanzo_tasks import Tasks
|
||||
|
||||
async with Tasks.from_env() as tasks:
|
||||
activity = await tasks.dispatch(
|
||||
"render",
|
||||
input={"scene": 3},
|
||||
task_queue="gpu",
|
||||
heartbeat_timeout="60s",
|
||||
)
|
||||
print(activity.id, activity.run_id, activity.status)
|
||||
```
|
||||
|
||||
`from_env()` reads `HANZO_API_KEY`, `HANZO_TASKS_URL` and
|
||||
`HANZO_TASKS_NAMESPACE`. The org is never named in a request: IAM validates the
|
||||
bearer token and the edge mints the org from the validated claim, so a caller
|
||||
cannot reach another tenant's shard by asking to.
|
||||
|
||||
## Run a worker
|
||||
|
||||
The worker **polls**. It never accepts a push, so it needs no inbound address
|
||||
and runs behind NAT.
|
||||
|
||||
```python
|
||||
from hanzo_tasks import Tasks, Worker
|
||||
|
||||
tasks = Tasks.from_env()
|
||||
worker = Worker(tasks, task_queue="gpu")
|
||||
|
||||
|
||||
@worker.handler("render")
|
||||
async def render(activity):
|
||||
return {"frames": 120}
|
||||
|
||||
|
||||
await worker.run()
|
||||
```
|
||||
|
||||
Return a value to complete the activity; raise to fail it with that cause.
|
||||
Handlers may be async or plain functions.
|
||||
|
||||
## What the engine does, so you don't
|
||||
|
||||
These are properties of the engine, not of this client. There is deliberately
|
||||
no second copy of them here, because a second copy can disagree.
|
||||
|
||||
- **A dead worker's work comes back.** The engine reaps expired leases before
|
||||
every claim, so an activity whose claimant stopped heartbeating returns to
|
||||
`SCHEDULED` and the next poll picks it up. No client-side timer.
|
||||
- **Two workers never claim the same activity.** Claims are serialized per
|
||||
namespace.
|
||||
- **The lease is the activity's own heartbeat timeout**, else the worker's
|
||||
`lease_seconds`, else the engine default. `Worker` heartbeats at a third of
|
||||
the window the server actually granted, so a beat can be missed without
|
||||
losing the claim.
|
||||
- **An empty queue is not an error.** It is `204`, and `claim()` returns
|
||||
`None`.
|
||||
- **Retries are the engine's.** Pass a `RetryPolicy` to `dispatch()`; the
|
||||
engine counts attempts and fails the activity when they are exhausted.
|
||||
|
||||
## Reading state
|
||||
|
||||
```python
|
||||
page = await tasks.activities(page_size=50)
|
||||
for activity in page:
|
||||
print(activity.id, activity.status, activity.attempt)
|
||||
|
||||
activity = await tasks.describe(id, run_id)
|
||||
events, cursor = await tasks.history(id, run_id)
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
The engine reports `{"error": ..., "code": <int>}` — `code` is a number here,
|
||||
not the `status` string the rest of the Hanzo API uses.
|
||||
|
||||
| Exception | Meaning |
|
||||
|---|---|
|
||||
| `Denied` | 401/403 — no validated principal, or one carrying no org |
|
||||
| `NotFound` | 404 — no such activity in this namespace |
|
||||
| `Terminal` | 409 — already completed, failed or canceled |
|
||||
| `TasksError` | anything else, carrying `.code` |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,43 +1,75 @@
|
||||
"""
|
||||
hanzo-tasks — Durable workflow execution for AI agents.
|
||||
"""hanzo-tasks — durable activities on the native Hanzo Tasks engine.
|
||||
|
||||
Wraps the Temporal Python SDK with Hanzo conventions for agent task
|
||||
orchestration, including pre-built workflows for pipelines and fan-out.
|
||||
The engine is Hanzo's own (`hanzoai/tasks`), reached over JSON at
|
||||
``api.hanzo.ai/v1/tasks``. It speaks no protobuf and no gRPC, and this package
|
||||
depends on no workflow runtime — dispatching work and running a worker are
|
||||
both plain HTTP.
|
||||
|
||||
Example:
|
||||
>>> from hanzo_tasks import Client, TasksConfig
|
||||
>>> client = await Client.connect(TasksConfig(namespace="hanzo"))
|
||||
>>> handle = await client.submit(AgentTaskWorkflow.run, task_input, queue="agents")
|
||||
>>> result = await handle.result()
|
||||
Dispatch a unit of work::
|
||||
|
||||
from hanzo_tasks import Tasks
|
||||
|
||||
async with Tasks.from_env() as tasks:
|
||||
activity = await tasks.dispatch("render", input={"scene": 3}, task_queue="gpu")
|
||||
|
||||
Run a worker that pulls it. The worker polls, so it needs no inbound address
|
||||
and runs behind NAT::
|
||||
|
||||
from hanzo_tasks import Tasks, Worker
|
||||
|
||||
tasks = Tasks.from_env()
|
||||
worker = Worker(tasks, task_queue="gpu")
|
||||
|
||||
@worker.handler("render")
|
||||
async def render(activity):
|
||||
return {"frames": 120}
|
||||
|
||||
await worker.run()
|
||||
|
||||
The lease is held for you: the worker heartbeats while your handler runs, and
|
||||
if it dies the engine returns the activity to the queue for somebody else.
|
||||
"""
|
||||
|
||||
from .activities import execute_agent_task, send_notification, set_agent_executor
|
||||
from .client import Client, TasksConfig, WorkflowHandle
|
||||
from .worker import Worker
|
||||
from .workflows import (
|
||||
AgentTaskInput,
|
||||
AgentTaskOutput,
|
||||
AgentTaskWorkflow,
|
||||
FanOutWorkflow,
|
||||
PipelineWorkflow,
|
||||
from .client import DEFAULT_NAMESPACE, DEFAULT_URL, Tasks
|
||||
from .errors import Denied, NotFound, TasksError, Terminal
|
||||
from .types import (
|
||||
CANCELED,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
SCHEDULED,
|
||||
STARTED,
|
||||
TERMINAL,
|
||||
Activity,
|
||||
Event,
|
||||
Page,
|
||||
RetryPolicy,
|
||||
)
|
||||
from .worker import Worker, default_identity
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
__all__ = [
|
||||
# Client
|
||||
"Client",
|
||||
"TasksConfig",
|
||||
"WorkflowHandle",
|
||||
"Tasks",
|
||||
"DEFAULT_URL",
|
||||
"DEFAULT_NAMESPACE",
|
||||
# Worker
|
||||
"Worker",
|
||||
# Workflows
|
||||
"AgentTaskWorkflow",
|
||||
"PipelineWorkflow",
|
||||
"FanOutWorkflow",
|
||||
"AgentTaskInput",
|
||||
"AgentTaskOutput",
|
||||
# Activities
|
||||
"execute_agent_task",
|
||||
"send_notification",
|
||||
"set_agent_executor",
|
||||
"default_identity",
|
||||
# Types
|
||||
"Activity",
|
||||
"Event",
|
||||
"Page",
|
||||
"RetryPolicy",
|
||||
# States
|
||||
"SCHEDULED",
|
||||
"STARTED",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"CANCELED",
|
||||
"TERMINAL",
|
||||
# Errors
|
||||
"TasksError",
|
||||
"Denied",
|
||||
"NotFound",
|
||||
"Terminal",
|
||||
]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Activity definitions for agent task execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from temporalio import activity
|
||||
|
||||
# Activity executor — pluggable. The playground sets this to call the ZAP sidecar.
|
||||
_agent_executor: Callable[..., Any] | None = None
|
||||
|
||||
|
||||
def set_agent_executor(fn: Callable[..., Any]) -> None:
|
||||
"""Set the function that executes agent tasks (called by playground)."""
|
||||
global _agent_executor
|
||||
_agent_executor = fn
|
||||
|
||||
|
||||
@activity.defn
|
||||
async def execute_agent_task(input: Any) -> Any:
|
||||
"""Execute an agent task. Delegates to the registered executor."""
|
||||
if _agent_executor is None:
|
||||
raise RuntimeError(
|
||||
"No agent executor registered. Call set_agent_executor() first."
|
||||
)
|
||||
return await _agent_executor(input)
|
||||
|
||||
|
||||
@activity.defn
|
||||
async def send_notification(input: dict[str, Any]) -> None:
|
||||
"""Send a notification (webhook, SSE, etc)."""
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(input.get("url", ""), json=input)
|
||||
@@ -1,100 +1,269 @@
|
||||
"""Hanzo Tasks client — wraps Temporal client with Hanzo conventions."""
|
||||
"""The client half: dispatch work, read it back, and settle it.
|
||||
|
||||
Every call lands on ``/v1/tasks/namespaces/{namespace}/activities`` under the
|
||||
one Hanzo endpoint, ``api.hanzo.ai``. The org is never named in a request —
|
||||
IAM validates the bearer token and the edge mints the org from the validated
|
||||
claim, so a caller cannot reach another tenant's shard by asking to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from urllib.parse import quote
|
||||
|
||||
from temporalio.client import Client as TemporalClient
|
||||
import httpx
|
||||
|
||||
from .errors import raise_for
|
||||
from .types import Activity, Event, Page, RetryPolicy
|
||||
|
||||
DEFAULT_URL = "https://api.hanzo.ai"
|
||||
DEFAULT_NAMESPACE = "default"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TasksConfig:
|
||||
"""Configuration for connecting to Temporal."""
|
||||
class Tasks:
|
||||
"""An async client for the Hanzo Tasks activity plane.
|
||||
|
||||
address: str = "localhost:7233"
|
||||
namespace: str = "hanzo"
|
||||
tls: bool = False
|
||||
Usable as an async context manager, which closes the transport it owns::
|
||||
|
||||
async with Tasks(token=...) as tasks:
|
||||
await tasks.dispatch("render", input={"scene": 3})
|
||||
"""
|
||||
|
||||
class WorkflowHandle:
|
||||
"""Handle to a running workflow."""
|
||||
|
||||
def __init__(self, handle: Any) -> None:
|
||||
self._handle = handle
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self._handle.id
|
||||
|
||||
@property
|
||||
def run_id(self) -> str:
|
||||
return self._handle.result_run_id
|
||||
|
||||
async def result(self, result_type: type | None = None) -> Any:
|
||||
return await self._handle.result(result_type=result_type)
|
||||
|
||||
async def cancel(self) -> None:
|
||||
await self._handle.cancel()
|
||||
|
||||
async def signal(self, name: str, data: Any = None) -> None:
|
||||
await self._handle.signal(name, data)
|
||||
|
||||
|
||||
class Client:
|
||||
"""Hanzo Tasks client for submitting and managing workflows."""
|
||||
|
||||
def __init__(self, temporal: TemporalClient) -> None:
|
||||
self._temporal = temporal
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str = DEFAULT_URL,
|
||||
namespace: str = DEFAULT_NAMESPACE,
|
||||
token: str | None = None,
|
||||
identity: str = "",
|
||||
timeout: float = 30.0,
|
||||
http: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self.url = url.rstrip("/")
|
||||
self.namespace = namespace
|
||||
self.identity = identity
|
||||
self._token = token
|
||||
# A caller-supplied transport is borrowed, never closed by us.
|
||||
self._owned = http is None
|
||||
self._http = http or httpx.AsyncClient(timeout=timeout)
|
||||
|
||||
@classmethod
|
||||
async def connect(cls, config: TasksConfig | None = None) -> Client:
|
||||
"""Connect to Temporal server."""
|
||||
cfg = config or TasksConfig()
|
||||
temporal = await TemporalClient.connect(cfg.address, namespace=cfg.namespace)
|
||||
return cls(temporal)
|
||||
def from_env(cls, **kwargs: Any) -> Tasks:
|
||||
"""Read the endpoint, token and namespace from the environment.
|
||||
|
||||
async def submit(
|
||||
``HANZO_API_KEY`` (or ``HANZO_TASKS_TOKEN``), ``HANZO_TASKS_URL``,
|
||||
``HANZO_TASKS_NAMESPACE``. Explicit arguments win.
|
||||
"""
|
||||
kwargs.setdefault("url", os.environ.get("HANZO_TASKS_URL", DEFAULT_URL))
|
||||
kwargs.setdefault("namespace", os.environ.get("HANZO_TASKS_NAMESPACE", DEFAULT_NAMESPACE))
|
||||
kwargs.setdefault(
|
||||
"token",
|
||||
os.environ.get("HANZO_API_KEY") or os.environ.get("HANZO_TASKS_TOKEN"),
|
||||
)
|
||||
return cls(**kwargs)
|
||||
|
||||
async def __aenter__(self) -> Tasks:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._owned:
|
||||
await self._http.aclose()
|
||||
|
||||
# ── the wire ────────────────────────────────────────────────────────
|
||||
|
||||
def _base(self) -> str:
|
||||
return f"{self.url}/v1/tasks/namespaces/{quote(self.namespace, safe='')}/activities"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
if not self._token:
|
||||
return {}
|
||||
return {"Authorization": f"Bearer {self._token}"}
|
||||
|
||||
async def _call(
|
||||
self,
|
||||
workflow: Any,
|
||||
input: Any,
|
||||
method: str,
|
||||
path: str = "",
|
||||
*,
|
||||
json: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> tuple[int, Any]:
|
||||
response = await self._http.request(
|
||||
method,
|
||||
self._base() + path,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self._headers(),
|
||||
)
|
||||
if response.status_code == 204:
|
||||
return 204, None
|
||||
body: Any
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
# Refusals on this surface are sometimes plain text, not JSON.
|
||||
body = response.text
|
||||
if response.status_code >= 400:
|
||||
raise_for(response.status_code, body)
|
||||
return response.status_code, body
|
||||
|
||||
# ── dispatch and read ───────────────────────────────────────────────
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
type: str,
|
||||
*,
|
||||
id: str | None = None,
|
||||
queue: str = "default",
|
||||
**kwargs: Any,
|
||||
) -> WorkflowHandle:
|
||||
"""Submit a workflow for execution."""
|
||||
handle = await self._temporal.start_workflow(
|
||||
workflow,
|
||||
input,
|
||||
id=id or str(uuid4()),
|
||||
task_queue=queue,
|
||||
**kwargs,
|
||||
input: Any = None,
|
||||
task_queue: str = "default",
|
||||
retry: RetryPolicy | None = None,
|
||||
schedule_to_close_timeout: str = "",
|
||||
schedule_to_start_timeout: str = "",
|
||||
start_to_close_timeout: str = "",
|
||||
heartbeat_timeout: str = "",
|
||||
request_id: str = "",
|
||||
) -> Activity:
|
||||
"""Schedule one activity and return it in SCHEDULED state.
|
||||
|
||||
``id`` defaults to a fresh uuid4. ``request_id`` makes the call
|
||||
idempotent: a retry carrying the same one returns the first activity
|
||||
unchanged rather than dispatching a second.
|
||||
|
||||
``heartbeat_timeout`` doubles as the claim lease — a worker that goes
|
||||
quiet for longer has its claim reaped and the activity returned to the
|
||||
queue, so set it to the longest gap between heartbeats you expect.
|
||||
"""
|
||||
if id is None:
|
||||
from uuid import uuid4
|
||||
|
||||
id = str(uuid4())
|
||||
body: dict[str, Any] = {
|
||||
"activityId": id,
|
||||
"activityType": {"name": type},
|
||||
"taskQueue": task_queue,
|
||||
}
|
||||
if input is not None:
|
||||
body["input"] = input
|
||||
if retry is not None:
|
||||
body["retryPolicy"] = retry.wire()
|
||||
for key, value in (
|
||||
("scheduleToCloseTimeout", schedule_to_close_timeout),
|
||||
("scheduleToStartTimeout", schedule_to_start_timeout),
|
||||
("startToCloseTimeout", start_to_close_timeout),
|
||||
("heartbeatTimeout", heartbeat_timeout),
|
||||
("identity", self.identity),
|
||||
("requestId", request_id),
|
||||
):
|
||||
if value:
|
||||
body[key] = value
|
||||
_, out = await self._call("POST", json=body)
|
||||
return Activity.from_wire(out)
|
||||
|
||||
async def describe(self, id: str, run_id: str) -> Activity:
|
||||
"""Read one activity. Raises `NotFound` if this tenant has no such run."""
|
||||
_, out = await self._call("GET", f"/{_seg(id)}/{_seg(run_id)}")
|
||||
return Activity.from_wire(out)
|
||||
|
||||
async def activities(self, *, cursor: str = "", page_size: int = 0) -> Page:
|
||||
"""Read one page of this namespace's activities.
|
||||
|
||||
Pass the returned `Page.cursor` back as ``cursor`` for the next page;
|
||||
an empty cursor means the listing is exhausted.
|
||||
"""
|
||||
params: dict[str, Any] = {}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
if page_size:
|
||||
params["pageSize"] = page_size
|
||||
_, out = await self._call("GET", params=params or None)
|
||||
rows = [Activity.from_wire(row) for row in (out.get("activities") or [])]
|
||||
return Page(activities=rows, cursor=out.get("nextCursor") or "")
|
||||
|
||||
async def history(
|
||||
self,
|
||||
id: str,
|
||||
run_id: str,
|
||||
*,
|
||||
after: int = 0,
|
||||
page_size: int = 0,
|
||||
reverse: bool = False,
|
||||
) -> tuple[list[Event], int]:
|
||||
"""Read an activity's durable history and the cursor that continues it."""
|
||||
params: dict[str, Any] = {}
|
||||
if after:
|
||||
params["after"] = after
|
||||
if page_size:
|
||||
params["pageSize"] = page_size
|
||||
if reverse:
|
||||
params["reverse"] = "true"
|
||||
_, out = await self._call(
|
||||
"GET", f"/{_seg(id)}/{_seg(run_id)}/history", params=params or None
|
||||
)
|
||||
return WorkflowHandle(handle)
|
||||
events = [Event.from_wire(row) for row in (out.get("events") or [])]
|
||||
return events, out.get("nextCursor") or 0
|
||||
|
||||
async def get_result(self, workflow_id: str, result_type: type | None = None) -> Any:
|
||||
"""Get the result of a completed workflow."""
|
||||
handle = self._temporal.get_workflow_handle(workflow_id)
|
||||
return await handle.result(result_type=result_type)
|
||||
# ── claim and settle ────────────────────────────────────────────────
|
||||
|
||||
async def cancel(self, workflow_id: str) -> None:
|
||||
"""Cancel a running workflow."""
|
||||
handle = self._temporal.get_workflow_handle(workflow_id)
|
||||
await handle.cancel()
|
||||
async def claim(
|
||||
self,
|
||||
*,
|
||||
task_queue: str = "",
|
||||
identity: str = "",
|
||||
lease_seconds: int = 0,
|
||||
) -> Activity | None:
|
||||
"""Claim the oldest scheduled activity, or None when the queue is empty.
|
||||
|
||||
async def signal(self, workflow_id: str, signal_name: str, data: Any = None) -> None:
|
||||
"""Send a signal to a running workflow."""
|
||||
handle = self._temporal.get_workflow_handle(workflow_id)
|
||||
await handle.signal(signal_name, data)
|
||||
An empty queue is the 204 answer and is not an error — it is the
|
||||
ordinary result of polling. ``task_queue`` empty claims from any queue.
|
||||
|
||||
async def query(self, workflow_id: str, query_name: str) -> Any:
|
||||
"""Query a running workflow."""
|
||||
handle = self._temporal.get_workflow_handle(workflow_id)
|
||||
return await handle.query(query_name)
|
||||
The engine reaps expired leases before it claims, so a dead worker's
|
||||
in-flight activity returns to the queue and is picked up here without
|
||||
any timer on this side.
|
||||
"""
|
||||
body: dict[str, Any] = {"taskQueue": task_queue}
|
||||
who = identity or self.identity
|
||||
if who:
|
||||
body["identity"] = who
|
||||
if lease_seconds:
|
||||
body["leaseSeconds"] = lease_seconds
|
||||
status, out = await self._call("POST", "/claim", json=body)
|
||||
if status == 204:
|
||||
return None
|
||||
return Activity.from_wire(out)
|
||||
|
||||
@property
|
||||
def temporal(self) -> TemporalClient:
|
||||
"""Access the underlying Temporal client."""
|
||||
return self._temporal
|
||||
async def heartbeat(self, id: str, run_id: str, details: Any = None) -> Activity:
|
||||
"""Report progress, which also extends the claim's lease."""
|
||||
return await self._settle(id, run_id, "heartbeat", {"details": details})
|
||||
|
||||
async def complete(self, id: str, run_id: str, result: Any = None) -> Activity:
|
||||
"""Finish the activity successfully, recording ``result``."""
|
||||
return await self._settle(id, run_id, "complete", {"result": result})
|
||||
|
||||
async def fail(self, id: str, run_id: str, cause: str) -> Activity:
|
||||
"""Finish the activity as failed, recording ``cause``."""
|
||||
return await self._settle(id, run_id, "fail", {"cause": cause})
|
||||
|
||||
async def cancel(self, id: str, run_id: str, reason: str = "") -> Activity:
|
||||
"""Cancel the activity."""
|
||||
return await self._settle(id, run_id, "cancel", {"reason": reason})
|
||||
|
||||
async def _settle(self, id: str, run_id: str, verb: str, body: dict[str, Any]) -> Activity:
|
||||
if self.identity:
|
||||
body.setdefault("identity", self.identity)
|
||||
_, out = await self._call("POST", f"/{_seg(id)}/{_seg(run_id)}/{verb}", json=body)
|
||||
return Activity.from_wire(out)
|
||||
|
||||
|
||||
def _seg(value: str) -> str:
|
||||
"""Percent-encode one path segment.
|
||||
|
||||
This keeps a space, ``?`` or ``#`` in an id part of the segment instead of
|
||||
becoming a query or fragment. It is NOT protection against every input: the
|
||||
engine matches on Go's decoded ``r.URL.Path``, so an id containing a
|
||||
literal slash arrives there as two segments and is not addressable at all.
|
||||
Keep ids slash-free.
|
||||
"""
|
||||
return quote(value, safe="")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Refusals the engine states, as exceptions.
|
||||
|
||||
The engine answers an error as ``{"error": "...", "code": <int>}`` — ``code``
|
||||
is a NUMBER here, not the ``status`` string the rest of the Hanzo API uses, so
|
||||
a generic client that reads ``status`` sees nothing. `raise_for` is the one
|
||||
place that shape is read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TasksError(Exception):
|
||||
"""An error the engine reported, carrying its numeric code."""
|
||||
|
||||
def __init__(self, message: str, code: int = 0) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
|
||||
|
||||
class Denied(TasksError):
|
||||
"""No validated principal, or one carrying no org (401/403).
|
||||
|
||||
The surface fails closed: a token that names no org is refused rather than
|
||||
served the shared unscoped store.
|
||||
"""
|
||||
|
||||
|
||||
class NotFound(TasksError):
|
||||
"""No such activity in this namespace (404)."""
|
||||
|
||||
|
||||
class Terminal(TasksError):
|
||||
"""The activity already completed, failed or was canceled (409).
|
||||
|
||||
Reported rather than swallowed: a second terminal call means two workers
|
||||
believe they own the same run, which is worth surfacing.
|
||||
"""
|
||||
|
||||
|
||||
def raise_for(status: int, body: object) -> None:
|
||||
"""Raise the exception a non-2xx answer stands for."""
|
||||
message = str(body)
|
||||
code = status
|
||||
if isinstance(body, dict):
|
||||
message = str(body.get("error", body))
|
||||
raw = body.get("code")
|
||||
if isinstance(raw, int):
|
||||
code = raw
|
||||
if status in (401, 403):
|
||||
raise Denied(message, code)
|
||||
if status == 404:
|
||||
raise NotFound(message, code)
|
||||
if status == 409:
|
||||
raise Terminal(message, code)
|
||||
raise TasksError(message, code)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""The engine's wire shapes, as Python values.
|
||||
|
||||
Field names on the wire are camelCase and an activity is keyed by a pair —
|
||||
``execution.workflowId`` (the activity id) and ``execution.runId``. These
|
||||
classes flatten that pair to ``id``/``run_id`` and keep the decoded body in
|
||||
``raw``, so a field this version does not model is carried rather than lost.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
SCHEDULED = "ACTIVITY_TASK_STATE_SCHEDULED"
|
||||
STARTED = "ACTIVITY_TASK_STATE_STARTED"
|
||||
COMPLETED = "ACTIVITY_TASK_STATE_COMPLETED"
|
||||
FAILED = "ACTIVITY_TASK_STATE_FAILED"
|
||||
CANCELED = "ACTIVITY_TASK_STATE_CANCELED"
|
||||
|
||||
TERMINAL = frozenset({COMPLETED, FAILED, CANCELED})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryPolicy:
|
||||
"""Retry knobs the engine stores against a dispatched activity.
|
||||
|
||||
Durations are Go duration strings — ``"5s"``, ``"1m30s"``.
|
||||
"""
|
||||
|
||||
initial_interval: str | None = None
|
||||
backoff_coefficient: float | None = None
|
||||
maximum_interval: str | None = None
|
||||
maximum_attempts: int | None = None
|
||||
non_retryable_error_types: list[str] | None = None
|
||||
|
||||
def wire(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
if self.initial_interval is not None:
|
||||
out["initialInterval"] = self.initial_interval
|
||||
if self.backoff_coefficient is not None:
|
||||
out["backoffCoefficient"] = self.backoff_coefficient
|
||||
if self.maximum_interval is not None:
|
||||
out["maximumInterval"] = self.maximum_interval
|
||||
if self.maximum_attempts is not None:
|
||||
out["maximumAttempts"] = self.maximum_attempts
|
||||
if self.non_retryable_error_types is not None:
|
||||
out["nonRetryableErrorTypes"] = list(self.non_retryable_error_types)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Activity:
|
||||
"""One standalone activity: a unit of work the engine tracks durably."""
|
||||
|
||||
id: str
|
||||
run_id: str
|
||||
type: str
|
||||
status: str
|
||||
task_queue: str = ""
|
||||
attempt: int = 0
|
||||
maximum_attempts: int = 0
|
||||
input: Any = None
|
||||
result: Any = None
|
||||
failure_cause: str = ""
|
||||
identity: str = ""
|
||||
lease_expiry: str = ""
|
||||
heartbeat_timeout: str = ""
|
||||
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
|
||||
@property
|
||||
def terminal(self) -> bool:
|
||||
return self.status in TERMINAL
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, body: dict[str, Any]) -> Activity:
|
||||
execution = body.get("execution") or {}
|
||||
activity_type = body.get("type") or {}
|
||||
return cls(
|
||||
id=execution.get("workflowId", ""),
|
||||
run_id=execution.get("runId", ""),
|
||||
type=activity_type.get("name", ""),
|
||||
status=body.get("status", ""),
|
||||
task_queue=body.get("taskQueue", ""),
|
||||
attempt=body.get("attempt", 0),
|
||||
maximum_attempts=body.get("maximumAttempts", 0),
|
||||
input=body.get("input"),
|
||||
result=body.get("result"),
|
||||
failure_cause=body.get("failureCause", ""),
|
||||
identity=body.get("identity", ""),
|
||||
lease_expiry=body.get("leaseExpiry", ""),
|
||||
heartbeat_timeout=body.get("heartbeatTimeout", ""),
|
||||
raw=body,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Event:
|
||||
"""One durable record in an activity's history."""
|
||||
|
||||
id: int
|
||||
time: str
|
||||
type: str
|
||||
attributes: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, body: dict[str, Any]) -> Event:
|
||||
return cls(
|
||||
id=body.get("eventId", 0),
|
||||
time=body.get("eventTime", ""),
|
||||
type=body.get("eventType", ""),
|
||||
attributes=body.get("attributes") or {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Page:
|
||||
"""One page of activities, with the cursor that continues it."""
|
||||
|
||||
activities: list[Activity]
|
||||
cursor: str = ""
|
||||
|
||||
def __iter__(self) -> Iterator[Activity]:
|
||||
return iter(self.activities)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.activities)
|
||||
@@ -1,54 +1,216 @@
|
||||
"""Hanzo Tasks worker — polls and executes activities."""
|
||||
"""The worker half: pull work, hold the lease while it runs, settle it.
|
||||
|
||||
The worker POLLS. It never accepts a push, so it needs no inbound address and
|
||||
runs behind NAT — the reason the claim endpoint exists.
|
||||
|
||||
What this does NOT do is keep its own copy of the engine's rules. There is no
|
||||
client-side reap timer, no retry ladder and no duplicate-claim guard here: the
|
||||
engine reaps expired leases before every claim, serializes claims per
|
||||
namespace, and derives the lease from the activity's own heartbeat timeout. A
|
||||
second opinion on any of that would be a second opinion that can disagree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from temporalio.worker import Worker as TemporalWorker
|
||||
from .client import Tasks
|
||||
from .errors import TasksError, Terminal
|
||||
from .types import Activity
|
||||
|
||||
Handler = Callable[[Activity], Any | Awaitable[Any]]
|
||||
|
||||
# A fraction of the lease, so a heartbeat is missed twice before the claim is
|
||||
# reaped. Heartbeating exactly at the lease boundary races the reaper.
|
||||
HEARTBEAT_FRACTION = 3
|
||||
|
||||
|
||||
def default_identity() -> str:
|
||||
"""Who this worker is: host and pid, enough to find it in a history."""
|
||||
return f"{socket.gethostname()}/{os.getpid()}"
|
||||
|
||||
|
||||
class Worker:
|
||||
"""Hanzo Tasks worker that polls a queue and executes workflows/activities."""
|
||||
"""Polls one task queue and runs the handler registered for each type.
|
||||
|
||||
Handlers may be async or plain functions. A plain function is run in a
|
||||
worker thread rather than inline — a blocking call on the event loop would
|
||||
stall the heartbeat, the lease would expire mid-run, and the engine would
|
||||
hand the same activity to somebody else while this one was still working
|
||||
on it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Any,
|
||||
queue: str = "default",
|
||||
workflows: list[Any] | None = None,
|
||||
activities: list[Any] | None = None,
|
||||
tasks: Tasks,
|
||||
*,
|
||||
task_queue: str = "default",
|
||||
identity: str = "",
|
||||
lease_seconds: int = 60,
|
||||
poll_interval: float = 1.0,
|
||||
on_error: Callable[[BaseException, Activity | None], None] | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._queue = queue
|
||||
self._workflows: list[Any] = workflows or []
|
||||
self._activities: list[Any] = activities or []
|
||||
self._worker: TemporalWorker | None = None
|
||||
self.tasks = tasks
|
||||
self.task_queue = task_queue
|
||||
self.identity = identity or tasks.identity or default_identity()
|
||||
self.lease_seconds = lease_seconds
|
||||
self.poll_interval = poll_interval
|
||||
self.on_error = on_error
|
||||
self._handlers: dict[str, Handler] = {}
|
||||
|
||||
def register_workflow(self, workflow_cls: Any) -> Any:
|
||||
"""Register a workflow class. Can be used as a decorator."""
|
||||
self._workflows.append(workflow_cls)
|
||||
return workflow_cls
|
||||
# ── registration ────────────────────────────────────────────────────
|
||||
|
||||
def register_activity(self, activity_fn: Any) -> Any:
|
||||
"""Register an activity function. Can be used as a decorator."""
|
||||
self._activities.append(activity_fn)
|
||||
return activity_fn
|
||||
def handler(self, type: str) -> Callable[[Handler], Handler]:
|
||||
"""Register the handler for one activity type, as a decorator."""
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Start the worker. Blocks until shutdown."""
|
||||
temporal_client = (
|
||||
self._client.temporal
|
||||
if hasattr(self._client, "temporal")
|
||||
else self._client
|
||||
def register(fn: Handler) -> Handler:
|
||||
self.register(type, fn)
|
||||
return fn
|
||||
|
||||
return register
|
||||
|
||||
def register(self, type: str, fn: Handler) -> None:
|
||||
self._handlers[type] = fn
|
||||
|
||||
# ── the loop ────────────────────────────────────────────────────────
|
||||
|
||||
async def run(self, *, stop: asyncio.Event | None = None) -> None:
|
||||
"""Poll until ``stop`` is set, running whatever is claimed.
|
||||
|
||||
Sleeps ``poll_interval`` only when the queue was empty, so a backlog
|
||||
drains at full speed.
|
||||
"""
|
||||
while stop is None or not stop.is_set():
|
||||
try:
|
||||
worked = await self.step()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException as exc: # a poll failure must not end the loop
|
||||
self._report(exc, None)
|
||||
worked = False
|
||||
if not worked:
|
||||
if stop is None:
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
else:
|
||||
# Wake immediately when asked to stop, rather than serving
|
||||
# out the poll interval first.
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), self.poll_interval)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
async def step(self) -> bool:
|
||||
"""Claim one activity and run it. False when the queue was empty."""
|
||||
activity = await self.tasks.claim(
|
||||
task_queue=self.task_queue,
|
||||
identity=self.identity,
|
||||
lease_seconds=self.lease_seconds,
|
||||
)
|
||||
self._worker = TemporalWorker(
|
||||
temporal_client,
|
||||
task_queue=self._queue,
|
||||
workflows=self._workflows,
|
||||
activities=self._activities,
|
||||
)
|
||||
await self._worker.run()
|
||||
if activity is None:
|
||||
return False
|
||||
await self._execute(activity)
|
||||
return True
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Gracefully shutdown the worker."""
|
||||
if self._worker:
|
||||
await self._worker.shutdown()
|
||||
async def _execute(self, activity: Activity) -> None:
|
||||
handler = self._handlers.get(activity.type)
|
||||
if handler is None:
|
||||
# Fail it rather than drop it: an unhandled type left claimed just
|
||||
# waits out its lease and comes back to the same worker.
|
||||
await self._settle(
|
||||
activity, "fail", cause=f"no handler registered for {activity.type!r}"
|
||||
)
|
||||
return
|
||||
|
||||
beat = asyncio.create_task(self._beat(activity))
|
||||
try:
|
||||
result = await self._call(handler, activity)
|
||||
except asyncio.CancelledError:
|
||||
beat.cancel()
|
||||
raise
|
||||
except BaseException as exc:
|
||||
self._report(exc, activity)
|
||||
await self._stop_beat(beat)
|
||||
await self._settle(activity, "fail", cause=f"{type(exc).__name__}: {exc}")
|
||||
return
|
||||
await self._stop_beat(beat)
|
||||
await self._settle(activity, "complete", result=result)
|
||||
|
||||
async def _call(self, handler: Handler, activity: Activity) -> Any:
|
||||
if inspect.iscoroutinefunction(handler):
|
||||
return await handler(activity)
|
||||
# A plain function runs in a thread, never inline: a blocking call on
|
||||
# the event loop stalls the heartbeat, the lease expires mid-run, and
|
||||
# the engine hands the same activity to somebody else while this
|
||||
# worker is still doing it.
|
||||
outcome = await asyncio.to_thread(handler, activity)
|
||||
if inspect.isawaitable(outcome):
|
||||
return await outcome
|
||||
return outcome
|
||||
|
||||
async def _settle(self, activity: Activity, verb: str, **body: Any) -> None:
|
||||
try:
|
||||
if verb == "complete":
|
||||
await self.tasks.complete(activity.id, activity.run_id, body["result"])
|
||||
else:
|
||||
await self.tasks.fail(activity.id, activity.run_id, body["cause"])
|
||||
except Terminal as exc:
|
||||
# The lease was lost and somebody else settled this run. Report it
|
||||
# and carry on — the work is done, just not by us.
|
||||
self._report(exc, activity)
|
||||
except TasksError as exc:
|
||||
self._report(exc, activity)
|
||||
|
||||
# ── the lease ───────────────────────────────────────────────────────
|
||||
|
||||
async def _beat(self, activity: Activity) -> None:
|
||||
"""Heartbeat until cancelled, holding the claim while work runs."""
|
||||
interval = self._interval(activity)
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await self.tasks.heartbeat(activity.id, activity.run_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
# A missed heartbeat is survivable — the lease has slack — so
|
||||
# report and keep beating rather than abandoning the run.
|
||||
self._report(exc, activity)
|
||||
|
||||
def _interval(self, activity: Activity) -> float:
|
||||
"""Beat at a fraction of the lease the SERVER granted.
|
||||
|
||||
Read off ``leaseExpiry`` rather than recomputed from a timeout string,
|
||||
because the engine's own rule — heartbeat timeout, else the requested
|
||||
lease, else its default — is the one that decides when the reaper
|
||||
fires, and it already applied it.
|
||||
"""
|
||||
window = float(self.lease_seconds or 60)
|
||||
if activity.lease_expiry:
|
||||
try:
|
||||
expiry = datetime.fromisoformat(activity.lease_expiry)
|
||||
if expiry.tzinfo is None:
|
||||
expiry = expiry.replace(tzinfo=UTC)
|
||||
granted = (expiry - datetime.now(UTC)).total_seconds()
|
||||
if granted > 0:
|
||||
window = granted
|
||||
except ValueError:
|
||||
pass
|
||||
return max(1.0, window / HEARTBEAT_FRACTION)
|
||||
|
||||
@staticmethod
|
||||
async def _stop_beat(beat: asyncio.Task[None]) -> None:
|
||||
beat.cancel()
|
||||
try:
|
||||
await beat
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
def _report(self, exc: BaseException, activity: Activity | None) -> None:
|
||||
if self.on_error is not None:
|
||||
self.on_error(exc, activity)
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Pre-built workflows for agent task orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
|
||||
from temporalio import workflow
|
||||
from temporalio.common import RetryPolicy
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentTaskInput:
|
||||
"""Input for a single agent task."""
|
||||
|
||||
space_id: str
|
||||
agent_id: str
|
||||
task_title: str
|
||||
task_prompt: str
|
||||
timeout_seconds: int = 3600
|
||||
max_retries: int = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentTaskOutput:
|
||||
"""Output from an agent task execution."""
|
||||
|
||||
result: str = ""
|
||||
error: str = ""
|
||||
elapsed_seconds: float = 0.0
|
||||
|
||||
|
||||
@workflow.defn
|
||||
class AgentTaskWorkflow:
|
||||
"""Execute a single agent task with retries and timeout."""
|
||||
|
||||
@workflow.run
|
||||
async def run(self, input: AgentTaskInput) -> AgentTaskOutput:
|
||||
return await workflow.execute_activity(
|
||||
"execute_agent_task",
|
||||
input,
|
||||
start_to_close_timeout=timedelta(seconds=input.timeout_seconds),
|
||||
retry_policy=RetryPolicy(maximum_attempts=input.max_retries),
|
||||
)
|
||||
|
||||
|
||||
@workflow.defn
|
||||
class PipelineWorkflow:
|
||||
"""Run agent tasks sequentially (pipeline)."""
|
||||
|
||||
@workflow.run
|
||||
async def run(self, tasks: list[AgentTaskInput]) -> list[AgentTaskOutput]:
|
||||
results: list[AgentTaskOutput] = []
|
||||
for task in tasks:
|
||||
result = await workflow.execute_activity(
|
||||
"execute_agent_task",
|
||||
task,
|
||||
start_to_close_timeout=timedelta(seconds=task.timeout_seconds),
|
||||
retry_policy=RetryPolicy(maximum_attempts=task.max_retries),
|
||||
)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
@workflow.defn
|
||||
class FanOutWorkflow:
|
||||
"""Run agent tasks in parallel (fan-out/fan-in)."""
|
||||
|
||||
@workflow.run
|
||||
async def run(self, tasks: list[AgentTaskInput]) -> list[AgentTaskOutput]:
|
||||
handles = []
|
||||
for task in tasks:
|
||||
handle = workflow.start_activity(
|
||||
"execute_agent_task",
|
||||
task,
|
||||
start_to_close_timeout=timedelta(seconds=task.timeout_seconds),
|
||||
retry_policy=RetryPolicy(maximum_attempts=task.max_retries),
|
||||
)
|
||||
handles.append(handle)
|
||||
return [await h for h in handles]
|
||||
@@ -1,43 +1,63 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tasks"
|
||||
version = "0.1.0"
|
||||
description = "Hanzo Tasks SDK — Durable workflow execution for AI agents (powered by Temporal)"
|
||||
version = "0.2.0"
|
||||
description = "Hanzo Tasks SDK — durable activities on the native Hanzo Tasks engine"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Hanzo AI", email = "[email protected]" }]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
keywords = ["hanzo", "temporal", "tasks", "workflow", "durable", "agents"]
|
||||
keywords = ["hanzo", "tasks", "workflow", "durable", "activities", "agents"]
|
||||
dependencies = [
|
||||
"temporalio>=1.9.0",
|
||||
"httpx>=0.25.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/hanzoai/python-sdk"
|
||||
Repository = "https://github.com/hanzoai/python-sdk/tree/main/pkg/hanzo-tasks"
|
||||
Homepage = "https://hanzo.ai"
|
||||
Documentation = "https://hanzo.ai/docs/tasks"
|
||||
Repository = "https://github.com/hanzoai/python-sdk"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.26.0",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"ruff>=0.5.0",
|
||||
"mypy>=1.10.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["hanzo_tasks*"]
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hanzo_tasks"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
hanzo_tasks = ["py.typed"]
|
||||
# Named rather than excluded, so a stray directory beside the package — a local
|
||||
# virtualenv, a build tree — can never be swept into a release.
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["hanzo_tasks", "tests", "README.md", "pyproject.toml"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "W", "F", "I", "B", "C4", "UP"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
+436
-269
@@ -1,317 +1,484 @@
|
||||
"""Hanzo Tasks test suite.
|
||||
"""Tests against a fake that mirrors the engine's verified wire.
|
||||
|
||||
Unit tests for client config, worker registration, dataclasses,
|
||||
workflow decorators, and activity executor wiring. No Temporal
|
||||
server required.
|
||||
The fake is deliberately faithful to the shapes read out of
|
||||
`hanzoai/tasks` `pkg/tasks/embed.go` (handleActivities) and `activities.go`:
|
||||
an empty claim is 204 with no body, a second terminal call is 409, errors are
|
||||
`{"error", "code"}` with a NUMERIC code, and an activity is keyed by the
|
||||
`execution.workflowId` / `execution.runId` pair.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from dataclasses import asdict
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from hanzo_tasks import (
|
||||
AgentTaskInput,
|
||||
AgentTaskOutput,
|
||||
AgentTaskWorkflow,
|
||||
Client,
|
||||
FanOutWorkflow,
|
||||
PipelineWorkflow,
|
||||
TasksConfig,
|
||||
Activity,
|
||||
Denied,
|
||||
NotFound,
|
||||
RetryPolicy,
|
||||
Tasks,
|
||||
Terminal,
|
||||
Worker,
|
||||
WorkflowHandle,
|
||||
execute_agent_task,
|
||||
set_agent_executor,
|
||||
)
|
||||
|
||||
NS = "default"
|
||||
BASE = f"/v1/tasks/namespaces/{NS}/activities"
|
||||
|
||||
# -- config ------------------------------------------------------------------
|
||||
SCHEDULED = "ACTIVITY_TASK_STATE_SCHEDULED"
|
||||
STARTED = "ACTIVITY_TASK_STATE_STARTED"
|
||||
COMPLETED = "ACTIVITY_TASK_STATE_COMPLETED"
|
||||
FAILED = "ACTIVITY_TASK_STATE_FAILED"
|
||||
|
||||
|
||||
class TestTasksConfig:
|
||||
def test_defaults(self):
|
||||
cfg = TasksConfig()
|
||||
assert cfg.address == "localhost:7233"
|
||||
assert cfg.namespace == "hanzo"
|
||||
assert cfg.tls is False
|
||||
class Engine:
|
||||
"""A tiny stand-in for the standalone-activity surface."""
|
||||
|
||||
def test_custom(self):
|
||||
cfg = TasksConfig(address="temporal.prod:7233", namespace="prod", tls=True)
|
||||
assert cfg.address == "temporal.prod:7233"
|
||||
assert cfg.namespace == "prod"
|
||||
assert cfg.tls is True
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[tuple[str, str], dict] = {}
|
||||
self.queue: list[tuple[str, str]] = []
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
self.raw: list[tuple[str, str]] = []
|
||||
self.beats: list[tuple[str, str]] = []
|
||||
self.headers: list[httpx.Headers] = []
|
||||
|
||||
# ── construction ────────────────────────────────────────────────────
|
||||
|
||||
# -- dataclasses -------------------------------------------------------------
|
||||
def row(self, activity_id: str, run_id: str, **over) -> dict:
|
||||
row = {
|
||||
"execution": {"workflowId": activity_id, "runId": run_id},
|
||||
"type": {"name": over.pop("type", "echo")},
|
||||
"taskQueue": over.pop("task_queue", "default"),
|
||||
"status": SCHEDULED,
|
||||
"attempt": 1,
|
||||
}
|
||||
row.update(over)
|
||||
return row
|
||||
|
||||
def schedule(self, activity_id: str, run_id: str = "run-1", **over) -> dict:
|
||||
row = self.row(activity_id, run_id, **over)
|
||||
self.rows[(activity_id, run_id)] = row
|
||||
self.queue.append((activity_id, run_id))
|
||||
return row
|
||||
|
||||
class TestAgentTaskInput:
|
||||
def test_defaults(self):
|
||||
inp = AgentTaskInput(
|
||||
space_id="sp-1",
|
||||
agent_id="ag-1",
|
||||
task_title="Fix bug",
|
||||
task_prompt="Fix the null pointer in main.go",
|
||||
# ── transport ───────────────────────────────────────────────────────
|
||||
|
||||
def handler(self, request: httpx.Request) -> httpx.Response:
|
||||
self.headers.append(request.headers)
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
# Both forms: the engine splits the DECODED path (embed.go:612), while
|
||||
# the raw one is what actually went on the wire.
|
||||
self.calls.append((method, path))
|
||||
self.raw.append((method, request.url.raw_path.decode()))
|
||||
|
||||
if not path.startswith(BASE):
|
||||
return self.err(404, "not found")
|
||||
rest = path[len(BASE) :].strip("/")
|
||||
parts = rest.split("/") if rest else []
|
||||
body = json.loads(request.content) if request.content else {}
|
||||
|
||||
if not parts and method == "POST":
|
||||
return self.start(body)
|
||||
if not parts and method == "GET":
|
||||
return self.list(request)
|
||||
if parts == ["claim"] and method == "POST":
|
||||
return self.claim(body)
|
||||
if len(parts) == 2 and method == "GET":
|
||||
return self.describe(parts[0], parts[1])
|
||||
if len(parts) == 3 and parts[2] == "history" and method == "GET":
|
||||
return self.history(parts[0], parts[1])
|
||||
if len(parts) == 3 and method == "POST":
|
||||
return self.settle(parts[0], parts[1], parts[2], body)
|
||||
return self.err(404, "not found")
|
||||
|
||||
# ── operations ──────────────────────────────────────────────────────
|
||||
|
||||
def start(self, body: dict) -> httpx.Response:
|
||||
activity_id = body.get("activityId") or ""
|
||||
if not activity_id:
|
||||
return self.err(400, "activityId required")
|
||||
row = self.schedule(
|
||||
activity_id,
|
||||
"run-1",
|
||||
type=(body.get("activityType") or {}).get("name", ""),
|
||||
task_queue=body.get("taskQueue", "default"),
|
||||
input=body.get("input"),
|
||||
heartbeatTimeout=body.get("heartbeatTimeout", ""),
|
||||
retryPolicy=body.get("retryPolicy"),
|
||||
)
|
||||
assert inp.space_id == "sp-1"
|
||||
assert inp.agent_id == "ag-1"
|
||||
assert inp.task_title == "Fix bug"
|
||||
assert inp.task_prompt == "Fix the null pointer in main.go"
|
||||
assert inp.timeout_seconds == 3600
|
||||
assert inp.max_retries == 3
|
||||
return httpx.Response(200, json=row)
|
||||
|
||||
def test_custom_timeout(self):
|
||||
inp = AgentTaskInput(
|
||||
space_id="sp-2",
|
||||
agent_id="ag-2",
|
||||
task_title="Deploy",
|
||||
task_prompt="Deploy to prod",
|
||||
timeout_seconds=600,
|
||||
max_retries=1,
|
||||
)
|
||||
assert inp.timeout_seconds == 600
|
||||
assert inp.max_retries == 1
|
||||
def list(self, request: httpx.Request) -> httpx.Response:
|
||||
rows = list(self.rows.values())
|
||||
return httpx.Response(200, json={"activities": rows, "nextCursor": ""})
|
||||
|
||||
def test_serializes_to_dict(self):
|
||||
inp = AgentTaskInput(
|
||||
space_id="sp-1",
|
||||
agent_id="ag-1",
|
||||
task_title="T",
|
||||
task_prompt="P",
|
||||
)
|
||||
d = asdict(inp)
|
||||
assert d["space_id"] == "sp-1"
|
||||
assert d["agent_id"] == "ag-1"
|
||||
assert d["task_title"] == "T"
|
||||
assert d["task_prompt"] == "P"
|
||||
assert d["timeout_seconds"] == 3600
|
||||
assert d["max_retries"] == 3
|
||||
assert len(d) == 6
|
||||
def claim(self, body: dict) -> httpx.Response:
|
||||
want = body.get("taskQueue") or ""
|
||||
for key in list(self.queue):
|
||||
row = self.rows[key]
|
||||
if want and row["taskQueue"] != want:
|
||||
continue
|
||||
self.queue.remove(key)
|
||||
row["status"] = STARTED
|
||||
row["identity"] = body.get("identity", "")
|
||||
# The engine stamps the lease it granted; the worker beats off it.
|
||||
row["leaseExpiry"] = "2099-01-01T00:00:00+00:00"
|
||||
return httpx.Response(200, json=row)
|
||||
return httpx.Response(204)
|
||||
|
||||
def describe(self, activity_id: str, run_id: str) -> httpx.Response:
|
||||
row = self.rows.get((activity_id, run_id))
|
||||
if row is None:
|
||||
return self.err(404, "activity not found")
|
||||
return httpx.Response(200, json=row)
|
||||
|
||||
def history(self, activity_id: str, run_id: str) -> httpx.Response:
|
||||
if (activity_id, run_id) not in self.rows:
|
||||
return self.err(404, "activity not found")
|
||||
events = [
|
||||
{
|
||||
"eventId": 1,
|
||||
"eventTime": "2026-01-01T00:00:00Z",
|
||||
"eventType": "ACTIVITY_TASK_SCHEDULED",
|
||||
"attributes": {"taskQueue": "gpu"},
|
||||
}
|
||||
]
|
||||
return httpx.Response(200, json={"events": events, "nextCursor": 0})
|
||||
|
||||
def settle(self, activity_id: str, run_id: str, verb: str, body: dict) -> httpx.Response:
|
||||
row = self.rows.get((activity_id, run_id))
|
||||
if row is None:
|
||||
return self.err(404, "activity not found")
|
||||
if row["status"] in (COMPLETED, FAILED):
|
||||
return self.err(409, f"activity terminal: status={row['status']}")
|
||||
if verb == "heartbeat":
|
||||
self.beats.append((activity_id, run_id))
|
||||
elif verb == "complete":
|
||||
row["status"] = COMPLETED
|
||||
row["result"] = body.get("result")
|
||||
elif verb == "fail":
|
||||
row["status"] = FAILED
|
||||
row["failureCause"] = body.get("cause", "")
|
||||
elif verb == "cancel":
|
||||
row["status"] = "ACTIVITY_TASK_STATE_CANCELED"
|
||||
else:
|
||||
return self.err(404, "not found")
|
||||
return httpx.Response(200, json=row)
|
||||
|
||||
@staticmethod
|
||||
def err(code: int, message: str) -> httpx.Response:
|
||||
# code is a NUMBER on this surface, unlike the rest of the API.
|
||||
return httpx.Response(code, json={"error": message, "code": code})
|
||||
|
||||
|
||||
class TestAgentTaskOutput:
|
||||
def test_defaults(self):
|
||||
out = AgentTaskOutput()
|
||||
assert out.result == ""
|
||||
assert out.error == ""
|
||||
assert out.elapsed_seconds == 0.0
|
||||
|
||||
def test_success(self):
|
||||
out = AgentTaskOutput(result="done", elapsed_seconds=1.5)
|
||||
assert out.result == "done"
|
||||
assert out.error == ""
|
||||
assert out.elapsed_seconds == 1.5
|
||||
|
||||
def test_error(self):
|
||||
out = AgentTaskOutput(error="timeout", elapsed_seconds=3600.0)
|
||||
assert out.error == "timeout"
|
||||
assert out.result == ""
|
||||
|
||||
def test_serializes_to_dict(self):
|
||||
out = AgentTaskOutput(result="ok", elapsed_seconds=0.1)
|
||||
d = asdict(out)
|
||||
assert d == {"result": "ok", "error": "", "elapsed_seconds": 0.1}
|
||||
@pytest.fixture
|
||||
def engine() -> Engine:
|
||||
return Engine()
|
||||
|
||||
|
||||
# -- workflow decorators -----------------------------------------------------
|
||||
@pytest.fixture
|
||||
def tasks(engine: Engine) -> Tasks:
|
||||
transport = httpx.MockTransport(engine.handler)
|
||||
return Tasks(
|
||||
url="https://api.hanzo.ai",
|
||||
namespace=NS,
|
||||
token="hk-test",
|
||||
http=httpx.AsyncClient(transport=transport),
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowDecorators:
|
||||
def test_agent_task_workflow_has_run(self):
|
||||
assert hasattr(AgentTaskWorkflow, "run")
|
||||
assert inspect.iscoroutinefunction(AgentTaskWorkflow.run)
|
||||
|
||||
def test_pipeline_workflow_has_run(self):
|
||||
assert hasattr(PipelineWorkflow, "run")
|
||||
assert inspect.iscoroutinefunction(PipelineWorkflow.run)
|
||||
|
||||
def test_fanout_workflow_has_run(self):
|
||||
assert hasattr(FanOutWorkflow, "run")
|
||||
assert inspect.iscoroutinefunction(FanOutWorkflow.run)
|
||||
|
||||
def test_workflow_classes_are_distinct(self):
|
||||
assert AgentTaskWorkflow is not PipelineWorkflow
|
||||
assert PipelineWorkflow is not FanOutWorkflow
|
||||
# ── the package no longer carries the trap ──────────────────────────────
|
||||
|
||||
|
||||
# -- worker ------------------------------------------------------------------
|
||||
def test_no_temporal_anywhere() -> None:
|
||||
"""0.1.0 shipped a Temporal gRPC client aimed at a port we do not serve."""
|
||||
import pathlib
|
||||
|
||||
import hanzo_tasks
|
||||
|
||||
root = pathlib.Path(hanzo_tasks.__file__).parent
|
||||
for path in root.rglob("*.py"):
|
||||
source = path.read_text()
|
||||
assert "temporalio" not in source, path
|
||||
assert "7233" not in source, path
|
||||
|
||||
|
||||
class TestWorker:
|
||||
def test_init_defaults(self):
|
||||
w = Worker(client=None, queue="test-q")
|
||||
assert w._queue == "test-q"
|
||||
assert w._workflows == []
|
||||
assert w._activities == []
|
||||
assert w._worker is None
|
||||
def test_version_is_the_superseding_one() -> None:
|
||||
import hanzo_tasks
|
||||
|
||||
def test_register_workflow(self):
|
||||
w = Worker(client=None)
|
||||
|
||||
class MyWorkflow:
|
||||
pass
|
||||
|
||||
result = w.register_workflow(MyWorkflow)
|
||||
assert result is MyWorkflow
|
||||
assert MyWorkflow in w._workflows
|
||||
|
||||
def test_register_activity(self):
|
||||
w = Worker(client=None)
|
||||
|
||||
async def my_activity(input):
|
||||
return "ok"
|
||||
|
||||
result = w.register_activity(my_activity)
|
||||
assert result is my_activity
|
||||
assert my_activity in w._activities
|
||||
|
||||
def test_register_multiple(self):
|
||||
w = Worker(client=None)
|
||||
for i in range(5):
|
||||
w.register_workflow(type(f"Wf{i}", (), {}))
|
||||
assert len(w._workflows) == 5
|
||||
|
||||
def test_init_with_preloaded(self):
|
||||
workflows = [AgentTaskWorkflow, PipelineWorkflow]
|
||||
activities = [execute_agent_task]
|
||||
w = Worker(client=None, workflows=workflows, activities=activities)
|
||||
assert len(w._workflows) == 2
|
||||
assert len(w._activities) == 1
|
||||
|
||||
def test_does_not_mutate_caller_list(self):
|
||||
workflows: list = []
|
||||
w = Worker(client=None, workflows=workflows)
|
||||
w.register_workflow(AgentTaskWorkflow)
|
||||
# The caller's original list should not be modified since we
|
||||
# pass a new list via `or []`, but if caller passes a list,
|
||||
# it IS the same reference. That's expected Python behavior.
|
||||
# Just verify worker has the workflow.
|
||||
assert AgentTaskWorkflow in w._workflows
|
||||
assert hanzo_tasks.__version__ == "0.2.0"
|
||||
|
||||
|
||||
# -- executor wiring ---------------------------------------------------------
|
||||
# ── client ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSetAgentExecutor:
|
||||
def test_set_and_reset(self):
|
||||
import hanzo_tasks.activities as act
|
||||
async def test_dispatch_sends_the_engines_shape(tasks: Tasks, engine: Engine) -> None:
|
||||
activity = await tasks.dispatch(
|
||||
"render",
|
||||
id="job-1",
|
||||
input={"scene": 3},
|
||||
task_queue="gpu",
|
||||
heartbeat_timeout="60s",
|
||||
retry=RetryPolicy(maximum_attempts=5, backoff_coefficient=2.0),
|
||||
)
|
||||
assert ("POST", BASE) in engine.calls
|
||||
assert activity.id == "job-1"
|
||||
assert activity.type == "render"
|
||||
assert activity.task_queue == "gpu"
|
||||
assert activity.status == SCHEDULED
|
||||
assert not activity.terminal
|
||||
|
||||
original = act._agent_executor
|
||||
|
||||
async def my_exec(input):
|
||||
return "executed"
|
||||
|
||||
set_agent_executor(my_exec)
|
||||
assert act._agent_executor is my_exec
|
||||
|
||||
# Restore
|
||||
act._agent_executor = original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_without_executor_raises(self):
|
||||
import hanzo_tasks.activities as act
|
||||
|
||||
saved = act._agent_executor
|
||||
act._agent_executor = None
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="No agent executor registered"):
|
||||
await execute_agent_task(None)
|
||||
finally:
|
||||
act._agent_executor = saved
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_executor(self):
|
||||
import hanzo_tasks.activities as act
|
||||
|
||||
saved = act._agent_executor
|
||||
|
||||
async def mock_exec(input):
|
||||
return AgentTaskOutput(result=f"done:{input.task_title}", elapsed_seconds=0.01)
|
||||
|
||||
set_agent_executor(mock_exec)
|
||||
try:
|
||||
inp = AgentTaskInput(
|
||||
space_id="sp-1",
|
||||
agent_id="ag-1",
|
||||
task_title="test",
|
||||
task_prompt="do it",
|
||||
)
|
||||
out = await execute_agent_task(inp)
|
||||
assert out.result == "done:test"
|
||||
assert out.elapsed_seconds == 0.01
|
||||
finally:
|
||||
act._agent_executor = saved
|
||||
stored = engine.rows[("job-1", "run-1")]
|
||||
assert stored["input"] == {"scene": 3}
|
||||
assert stored["heartbeatTimeout"] == "60s"
|
||||
assert stored["retryPolicy"] == {"maximumAttempts": 5, "backoffCoefficient": 2.0}
|
||||
|
||||
|
||||
# -- workflow handle ---------------------------------------------------------
|
||||
async def test_dispatch_mints_an_id_when_none_is_given(tasks: Tasks) -> None:
|
||||
activity = await tasks.dispatch("render")
|
||||
assert activity.id
|
||||
|
||||
|
||||
class TestWorkflowHandle:
|
||||
def test_id(self):
|
||||
class FakeHandle:
|
||||
id = "wf-123"
|
||||
result_run_id = "run-456"
|
||||
|
||||
h = WorkflowHandle(FakeHandle())
|
||||
assert h.id == "wf-123"
|
||||
assert h.run_id == "run-456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel(self):
|
||||
cancelled = False
|
||||
|
||||
class FakeHandle:
|
||||
id = "wf-1"
|
||||
result_run_id = "run-1"
|
||||
|
||||
async def cancel(self):
|
||||
nonlocal cancelled
|
||||
cancelled = True
|
||||
|
||||
h = WorkflowHandle(FakeHandle())
|
||||
await h.cancel()
|
||||
assert cancelled
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal(self):
|
||||
signals = []
|
||||
|
||||
class FakeHandle:
|
||||
id = "wf-1"
|
||||
result_run_id = "run-1"
|
||||
|
||||
async def signal(self, name, data=None):
|
||||
signals.append((name, data))
|
||||
|
||||
h = WorkflowHandle(FakeHandle())
|
||||
await h.signal("pause", {"reason": "lunch"})
|
||||
assert signals == [("pause", {"reason": "lunch"})]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_result(self):
|
||||
class FakeHandle:
|
||||
id = "wf-1"
|
||||
result_run_id = "run-1"
|
||||
|
||||
async def result(self, result_type=None):
|
||||
return "the-result"
|
||||
|
||||
h = WorkflowHandle(FakeHandle())
|
||||
assert await h.result() == "the-result"
|
||||
async def test_bearer_token_is_sent(tasks: Tasks, engine: Engine) -> None:
|
||||
await tasks.dispatch("render", id="job-1")
|
||||
assert engine.headers[-1]["authorization"] == "Bearer hk-test"
|
||||
|
||||
|
||||
# -- __init__ exports --------------------------------------------------------
|
||||
async def test_empty_queue_is_none_not_an_error(tasks: Tasks) -> None:
|
||||
"""204 is the ordinary result of polling, not a failure."""
|
||||
assert await tasks.claim(task_queue="gpu") is None
|
||||
|
||||
|
||||
class TestExports:
|
||||
def test_version(self):
|
||||
import hanzo_tasks
|
||||
async def test_claim_returns_the_started_activity(tasks: Tasks, engine: Engine) -> None:
|
||||
engine.schedule("job-1", task_queue="gpu")
|
||||
claimed = await tasks.claim(task_queue="gpu", identity="spark")
|
||||
assert claimed is not None
|
||||
assert claimed.id == "job-1"
|
||||
assert claimed.status == STARTED
|
||||
assert claimed.identity == "spark"
|
||||
|
||||
assert hanzo_tasks.__version__ == "0.1.0"
|
||||
|
||||
def test_all_exports_importable(self):
|
||||
import hanzo_tasks
|
||||
async def test_claim_filters_by_queue(tasks: Tasks, engine: Engine) -> None:
|
||||
engine.schedule("cpu-job", task_queue="cpu")
|
||||
assert await tasks.claim(task_queue="gpu") is None
|
||||
assert (await tasks.claim(task_queue="cpu")) is not None
|
||||
|
||||
for name in hanzo_tasks.__all__:
|
||||
assert hasattr(hanzo_tasks, name), f"{name} not found in hanzo_tasks"
|
||||
|
||||
async def test_describe_and_list(tasks: Tasks, engine: Engine) -> None:
|
||||
engine.schedule("job-1")
|
||||
activity = await tasks.describe("job-1", "run-1")
|
||||
assert activity.id == "job-1"
|
||||
|
||||
page = await tasks.activities(page_size=10)
|
||||
assert len(page) == 1
|
||||
assert [a.id for a in page] == ["job-1"]
|
||||
|
||||
|
||||
async def test_history(tasks: Tasks, engine: Engine) -> None:
|
||||
engine.schedule("job-1")
|
||||
events, cursor = await tasks.history("job-1", "run-1")
|
||||
assert [e.type for e in events] == ["ACTIVITY_TASK_SCHEDULED"]
|
||||
assert events[0].id == 1
|
||||
assert cursor == 0
|
||||
|
||||
|
||||
async def test_missing_activity_is_not_found(tasks: Tasks) -> None:
|
||||
with pytest.raises(NotFound) as caught:
|
||||
await tasks.describe("nope", "run-1")
|
||||
assert caught.value.code == 404
|
||||
|
||||
|
||||
async def test_second_settle_is_terminal(tasks: Tasks, engine: Engine) -> None:
|
||||
engine.schedule("job-1")
|
||||
await tasks.complete("job-1", "run-1", {"ok": True})
|
||||
with pytest.raises(Terminal) as caught:
|
||||
await tasks.complete("job-1", "run-1", {"ok": True})
|
||||
assert caught.value.code == 409
|
||||
|
||||
|
||||
async def test_refusal_is_denied(engine: Engine) -> None:
|
||||
def refuse(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(403, json={"error": "identity required", "code": 403})
|
||||
|
||||
tasks = Tasks(http=httpx.AsyncClient(transport=httpx.MockTransport(refuse)))
|
||||
with pytest.raises(Denied):
|
||||
await tasks.dispatch("render")
|
||||
|
||||
|
||||
async def test_path_segments_are_percent_encoded(tasks: Tasks, engine: Engine) -> None:
|
||||
"""Ids go on the wire encoded, so they cannot corrupt the URL.
|
||||
|
||||
This is hygiene, not protection against every input: the engine splits
|
||||
Go's DECODED r.URL.Path (embed.go:612), so an id containing a literal
|
||||
slash arrives as two segments and is simply not addressable there. What
|
||||
encoding does buy is that a space, '?' or '#' in an id stays part of the
|
||||
segment instead of becoming a query or fragment.
|
||||
"""
|
||||
engine.rows[("job 1?x", "run-1")] = engine.row("job 1?x", "run-1")
|
||||
activity = await tasks.describe("job 1?x", "run-1")
|
||||
assert activity.id == "job 1?x"
|
||||
# Encoded on the wire, decoded back to one segment by the reader.
|
||||
assert ("GET", f"{BASE}/job%201%3Fx/run-1") in engine.raw
|
||||
|
||||
|
||||
# ── worker ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_worker_runs_the_handler_and_completes(tasks: Tasks, engine: Engine) -> None:
|
||||
engine.schedule("job-1", type="render", task_queue="gpu")
|
||||
worker = Worker(tasks, task_queue="gpu")
|
||||
|
||||
seen: list[Activity] = []
|
||||
|
||||
@worker.handler("render")
|
||||
async def render(activity: Activity) -> dict:
|
||||
seen.append(activity)
|
||||
return {"frames": 120}
|
||||
|
||||
assert await worker.step() is True
|
||||
assert [a.id for a in seen] == ["job-1"]
|
||||
assert engine.rows[("job-1", "run-1")]["status"] == COMPLETED
|
||||
assert engine.rows[("job-1", "run-1")]["result"] == {"frames": 120}
|
||||
|
||||
|
||||
async def test_worker_reports_idle(tasks: Tasks) -> None:
|
||||
worker = Worker(tasks, task_queue="gpu")
|
||||
assert await worker.step() is False
|
||||
|
||||
|
||||
async def test_sync_handlers_work(tasks: Tasks, engine: Engine) -> None:
|
||||
engine.schedule("job-1", type="render", task_queue="gpu")
|
||||
worker = Worker(tasks, task_queue="gpu")
|
||||
|
||||
@worker.handler("render")
|
||||
def render(activity: Activity) -> str:
|
||||
return "done"
|
||||
|
||||
await worker.step()
|
||||
assert engine.rows[("job-1", "run-1")]["result"] == "done"
|
||||
|
||||
|
||||
async def test_handler_exception_fails_the_activity(tasks: Tasks, engine: Engine) -> None:
|
||||
engine.schedule("job-1", type="render", task_queue="gpu")
|
||||
worker = Worker(tasks, task_queue="gpu")
|
||||
|
||||
@worker.handler("render")
|
||||
async def render(activity: Activity) -> None:
|
||||
raise RuntimeError("gpu fell over")
|
||||
|
||||
await worker.step()
|
||||
row = engine.rows[("job-1", "run-1")]
|
||||
assert row["status"] == FAILED
|
||||
assert "gpu fell over" in row["failureCause"]
|
||||
|
||||
|
||||
async def test_unknown_type_is_failed_not_dropped(tasks: Tasks, engine: Engine) -> None:
|
||||
"""Left claimed it would just wait out its lease and come back here."""
|
||||
engine.schedule("job-1", type="unregistered", task_queue="gpu")
|
||||
worker = Worker(tasks, task_queue="gpu")
|
||||
await worker.step()
|
||||
row = engine.rows[("job-1", "run-1")]
|
||||
assert row["status"] == FAILED
|
||||
assert "no handler registered" in row["failureCause"]
|
||||
|
||||
|
||||
async def test_worker_holds_the_lease_while_the_handler_runs(
|
||||
tasks: Tasks, engine: Engine, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The heartbeat is what keeps the engine from reaping live work."""
|
||||
engine.schedule("job-1", type="slow", task_queue="gpu")
|
||||
worker = Worker(tasks, task_queue="gpu")
|
||||
monkeypatch.setattr(worker, "_interval", lambda activity: 0.02)
|
||||
|
||||
@worker.handler("slow")
|
||||
async def slow(activity: Activity) -> str:
|
||||
await asyncio.sleep(0.25)
|
||||
return "done"
|
||||
|
||||
await worker.step()
|
||||
assert len(engine.beats) >= 2, engine.beats
|
||||
assert engine.rows[("job-1", "run-1")]["status"] == COMPLETED
|
||||
|
||||
|
||||
async def test_heartbeat_stops_once_the_handler_returns(
|
||||
tasks: Tasks, engine: Engine, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
engine.schedule("job-1", type="quick", task_queue="gpu")
|
||||
worker = Worker(tasks, task_queue="gpu")
|
||||
monkeypatch.setattr(worker, "_interval", lambda activity: 0.01)
|
||||
|
||||
@worker.handler("quick")
|
||||
async def quick(activity: Activity) -> str:
|
||||
return "done"
|
||||
|
||||
await worker.step()
|
||||
settled = len(engine.beats)
|
||||
await asyncio.sleep(0.1)
|
||||
assert len(engine.beats) == settled
|
||||
|
||||
|
||||
def test_interval_is_a_third_of_the_granted_lease(tasks: Tasks) -> None:
|
||||
"""Policy is read off the lease the SERVER granted, not recomputed."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
worker = Worker(tasks, lease_seconds=60)
|
||||
expiry = datetime.now(UTC) + timedelta(seconds=30)
|
||||
activity = Activity.from_wire(
|
||||
{
|
||||
"execution": {"workflowId": "a", "runId": "r"},
|
||||
"type": {"name": "t"},
|
||||
"status": STARTED,
|
||||
"leaseExpiry": expiry.isoformat(),
|
||||
}
|
||||
)
|
||||
assert 8.0 < worker._interval(activity) < 11.0
|
||||
|
||||
# No lease stamped: fall back to what this worker asked for.
|
||||
bare = Activity.from_wire(
|
||||
{"execution": {"workflowId": "a", "runId": "r"}, "type": {"name": "t"}, "status": STARTED}
|
||||
)
|
||||
assert worker._interval(bare) == 20.0
|
||||
|
||||
|
||||
async def test_run_loop_stops_when_asked(tasks: Tasks, engine: Engine) -> None:
|
||||
engine.schedule("job-1", type="render", task_queue="gpu")
|
||||
worker = Worker(tasks, task_queue="gpu", poll_interval=0.01)
|
||||
|
||||
@worker.handler("render")
|
||||
async def render(activity: Activity) -> str:
|
||||
return "done"
|
||||
|
||||
stop = asyncio.Event()
|
||||
task = asyncio.create_task(worker.run(stop=stop))
|
||||
await asyncio.sleep(0.1)
|
||||
stop.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
assert engine.rows[("job-1", "run-1")]["status"] == COMPLETED
|
||||
|
||||
|
||||
async def test_poll_failure_does_not_end_the_loop(engine: Engine) -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise httpx.ConnectError("network blip")
|
||||
return httpx.Response(204)
|
||||
|
||||
tasks = Tasks(http=httpx.AsyncClient(transport=httpx.MockTransport(flaky)))
|
||||
seen: list[BaseException] = []
|
||||
worker = Worker(tasks, poll_interval=0.01, on_error=lambda exc, activity: seen.append(exc))
|
||||
|
||||
stop = asyncio.Event()
|
||||
task = asyncio.create_task(worker.run(stop=stop))
|
||||
await asyncio.sleep(0.1)
|
||||
stop.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
|
||||
assert len(seen) == 1
|
||||
assert calls["n"] > 1, "the loop kept polling after the blip"
|
||||
|
||||
Generated
-163
@@ -1,163 +0,0 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-tasks"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "temporalio" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.26.0" },
|
||||
{ name = "temporalio", specifier = ">=1.9.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nexus-rpc"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/f2/d54f5c03d8f4672ccc0875787a385f53dcb61f98a8ae594b5620e85b9cb3/nexus_rpc-1.3.0.tar.gz", hash = "sha256:e56d3b57b60d707ce7a72f83f23f106b86eca1043aa658e44582ab5ff30ab9ad", size = 75650, upload-time = "2025-12-08T22:59:13.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/74/0afd841de3199c148146c1d43b4bfb5605b2f1dc4c9a9087fe395091ea5a/nexus_rpc-1.3.0-py3-none-any.whl", hash = "sha256:aee0707b4861b22d8124ecb3f27d62dafbe8777dc50c66c91e49c006f971b92d", size = 28873, upload-time = "2025-12-08T22:59:12.024Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "6.33.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "temporalio"
|
||||
version = "1.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nexus-rpc" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "types-protobuf" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/48/ba7413e2fab8dcd277b9df00bafa572da24e9ca32de2f38d428dc3a2825c/temporalio-1.23.0.tar.gz", hash = "sha256:72750494b00eb73ded9db76195e3a9b53ff548780f73d878ec3f807ee3191410", size = 1933051, upload-time = "2026-02-18T17:48:22.353Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/71/26c8f21dca9092201b3b9cb7aff42460b4864b5999aa4c6a4343ac66f1fd/temporalio-1.23.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6b69ac8d75f2d90e66f4edce4316f6a33badc4a30b22efc50e9eddaa9acdc216", size = 12311037, upload-time = "2026-02-18T17:47:47.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/47/43102816139f2d346680cb7cc1e53da5f6968355ac65b4d35d4edbfca896/temporalio-1.23.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1bbbb2f9c3cdd09451565163f6d741e51f109694c49435d475fdfa42b597219d", size = 11821906, upload-time = "2026-02-18T17:47:55.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b0/899ff28464a0e17adf17476bdfac8faf4ea41870358ff2d14737e43f9e66/temporalio-1.23.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf6570e0ee696f99a38d855da4441a890c7187357c16505ed458ac9ef274ed70", size = 12063601, upload-time = "2026-02-18T17:48:03.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/17/b8c6d2ec3e113c6a788322513a5ff635bdd54b3791d092ed0e273467748a/temporalio-1.23.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b82d6cca54c9f376b50e941dd10d12f7fe5b692a314fb087be72cd2898646a79", size = 12394579, upload-time = "2026-02-18T17:48:11.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/b7/f9ef7fd5ee65aef7d59ab1e95cb1b45df2fe49c17e3aa4d650ae3322f015/temporalio-1.23.0-cp310-abi3-win_amd64.whl", hash = "sha256:43c3b99a46dd329761a256f3855710c4a5b322afc879785e468bdd0b94faace6", size = 12834494, upload-time = "2026-02-18T17:48:19.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-protobuf"
|
||||
version = "6.32.1.20260221"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user