A lightweight Python package providing boilerplate for writing Attune actions and sensors.
SDK 0.3.0 is generated for and compatible with the Attune OpenAPI 0.3.0 contract.
pip install attune-sdk # includes API client, httpx, attrs
pip install attune-sdk[sensor] # adds websocket-client for managed sensors
pip install attune-sdk[dev] # adds pytest + openapi-python-clientActions receive parameters as JSON on stdin and output results as JSON on stdout. This package handles all of that:
#!/usr/bin/env python3
import attune
def main(name: str, count: int = 1):
return {"greeting": f"Hello, {name}!" * count}
attune.run_action(main)Your function declares whatever parameters it needs as keyword arguments —
they're matched from the JSON input. Extra parameters not in your signature
are silently dropped (unless you add **kwargs).
The context is a module-level singleton available anywhere after import:
import attune
def main(url: str, method: str = "GET"):
if attune.context.has_api_token:
from attune.api_client.api.packs import list_packs
packs = list_packs.sync(client=attune.context.client)
# ...
return {"action": attune.context.action_ref, "exec_id": attune.context.execution_id}
attune.run_action(main)Use attune.artifacts for action-owned artifacts with context-aware defaults.
The helpers default scope="action", owner=attune.context.action_ref,
created_by=attune.context.action_ref, execution=attune.context.execution_id
(when present), and visibility="private".
import attune
def main():
allocation = attune.artifacts.allocate_file_version(
"python_example.demo.log",
name="Execution Log",
)
allocation.write_text("hello\n")
progress = attune.artifacts.create_progress("python_example.demo.progress")
progress.append({"message": "started", "percent": 10})
return {
"artifact_id": allocation.artifact_id,
"version_id": allocation.version_id,
"file_path": allocation.file_path,
"path": str(allocation.absolute_path),
}
attune.run_action(main)allocate_file_version() uses Attune's upsert-and-allocate API, returns the
local absolute path under ATTUNE_ARTIFACTS_DIR, and creates parent
directories for you. create_progress() creates or reuses a progress artifact
and append() streams entries without silent failure paths.
The SDK includes a fully typed, auto-generated OpenAPI client. The easiest way
to use it is via attune.context.client which is a lazily constructed
AuthenticatedClient using the execution-scoped token:
import attune
from attune.api_client.api.packs import list_packs
from attune.api_client.api.executions import get_execution
# Sync — use endpoint_name.sync(client=...)
packs = list_packs.sync(client=attune.context.client)
if packs is None:
raise RuntimeError("Unable to list packs")
pack_data = packs.to_dict()
# Async — use endpoint_name.asyncio(client=...) with the same client instance
packs = await list_packs.asyncio(client=attune.context.client)The same client instance handles both sync and async calls — no separate async client needed. Each endpoint module exposes four functions:
| Function | Returns |
|---|---|
sync(client=...) |
Parsed response model (or None) |
sync_detailed(client=...) |
Response[T] with status, headers, content |
asyncio(client=...) |
Parsed response model (async) |
asyncio_detailed(client=...) |
Response[T] (async) |
Generated endpoint helpers may return None for documented error responses.
Check the result before accessing fields. Generated response and model objects
provide to_dict() when a plain JSON-compatible dictionary is needed.
Constructing a client manually (e.g., outside an execution):
from attune.api_client import AuthenticatedClient
client = AuthenticatedClient(base_url="http://localhost:8080", token="your-token")All API modules live under attune.api_client.api.<domain> and all request/response
models under attune.api_client.models.
Actions can use the typed API client with their execution-scoped credentials for common platform operations.
import attune
from attune.api_client.api.queues import enqueue_queue_item
from attune.api_client.models.enqueue_work_queue_item_request import EnqueueWorkQueueItemRequest
from attune.api_client.models.enqueue_work_queue_item_request_payload import (
EnqueueWorkQueueItemRequestPayload,
)
def main(order_id: str):
enqueue_queue_item.sync(
"acme.orders",
client=attune.context.client,
body=EnqueueWorkQueueItemRequest(
item_key=f"order-{order_id}",
priority=10,
payload=EnqueueWorkQueueItemRequestPayload.from_dict(
{"order_id": order_id}
),
),
)
return {"queued_order_id": order_id}
attune.run_action(main)Use the bulk endpoint to enqueue all items in one request. Give each item a stable key so it can be identified across submissions:
import attune
from attune.api_client.api.queues import bulk_enqueue_queue_items
from attune.api_client.models.bulk_enqueue_work_queue_items_request import (
BulkEnqueueWorkQueueItemsRequest,
)
from attune.api_client.models.enqueue_work_queue_item_request import EnqueueWorkQueueItemRequest
from attune.api_client.models.enqueue_work_queue_item_request_payload import (
EnqueueWorkQueueItemRequestPayload,
)
def main(order_ids: list[str]):
result = bulk_enqueue_queue_items.sync(
"acme.orders",
client=attune.context.client,
body=BulkEnqueueWorkQueueItemsRequest(
items=[
EnqueueWorkQueueItemRequest(
item_key=f"order-{order_id}",
payload=EnqueueWorkQueueItemRequestPayload.from_dict(
{"order_id": order_id}
),
)
for order_id in order_ids
],
)
)
if result is None:
raise RuntimeError("Bulk queue enqueue failed")
return {
"created_count": result.data.created_count,
"updated_count": result.data.updated_count,
}
attune.run_action(main)import attune
from attune.api_client.api.events import create_event
from attune.api_client.models.create_event_request import CreateEventRequest
from attune.api_client.models.create_event_request_payload_type_0 import (
CreateEventRequestPayloadType0,
)
def main(deployment_id: str, environment: str):
create_event.sync(
client=attune.context.client,
body=CreateEventRequest(
trigger_ref="acme.deployment.completed",
payload=CreateEventRequestPayloadType0.from_dict(
{"deployment_id": deployment_id, "environment": environment}
),
),
)
return {"emitted_event": "acme.deployment.completed"}
attune.run_action(main)Pass the file_path returned by allocate_file_version() to a downstream
action. It is relative to the shared artifact volume, so resolve and validate
it before reading:
import attune
def main(file_path: str):
artifacts_dir = attune.context.artifacts_dir
if artifacts_dir is None:
raise RuntimeError("ATTUNE_ARTIFACTS_DIR is required to read file artifacts")
root = artifacts_dir.resolve()
artifact_path = (root / file_path).resolve()
if not artifact_path.is_relative_to(root):
raise ValueError("file_path must remain within ATTUNE_ARTIFACTS_DIR")
return {"contents": artifact_path.read_text(encoding="utf-8")}
attune.run_action(main)Sensors are long-running processes that emit events. The SDK provides rule lifecycle management, signal handling (SIGINT/SIGTERM), and notifier WebSocket lifecycle delivery out of the box.
The sensor context is a module-level singleton, accessible anywhere:
import attune
# attune.sensor_context is available at import time
print(attune.sensor_context.sensor_ref)
print(attune.sensor_context.api_url)
print(attune.sensor_context.config) # caller-supplied ATTUNE_SENSOR_CONFIG_* varsThe current managed sensor service rotates credentials with a controlled
process restart and injects the replacement ATTUNE_API_TOKEN into the new
process. The SDK also supports an externally managed token-state file for
nonstandard runtimes; see
docs/managed-sensor-token-rotation-contract.md.
Persisted sensor config is not injected into managed child processes.
Use each rule's trigger_params for managed configuration, or retrieve other
configuration from an explicitly authorized source. sensor_context.config
only exposes ATTUNE_SENSOR_CONFIG_* variables supplied by the caller.
One polling thread per active rule:
#!/usr/bin/env python3
import attune
class TemperatureSensor(attune.PollingSensor):
def setup(self):
self.interval = 5.0 # default interval (overridable per-rule)
def poll(self, rule):
device = rule.trigger_params.get("device", "/dev/temp0")
temp = read_temperature(device)
if temp > 100:
self.emit({"temperature": temp, "alert": True}, rule=rule)
attune.run_sensor(TemperatureSensor)One asyncio task per active rule (ideal for I/O-bound checks):
#!/usr/bin/env python3
import attune
class ApiSensor(attune.AsyncPollingSensor):
async def setup(self):
import httpx
self.http = httpx.AsyncClient()
self.interval = 10.0
async def poll(self, rule):
url = rule.trigger_params["url"]
resp = await self.http.get(url)
if resp.status_code >= 500:
await self.async_emit({"url": url, "status": resp.status_code}, rule=rule)
async def cleanup(self):
await self.http.aclose()
attune.run_sensor(ApiSensor)For non-polling sensors, override run():
import attune
class FileTailSensor(attune.Sensor):
def run(self):
import time
path = attune.sensor_context.config.get("watch_path", "/var/log/app.log")
with open(path) as f:
f.seek(0, 2) # seek to end
while not self.is_shutting_down:
line = f.readline()
if line:
self.emit({"line": line.strip()})
else:
time.sleep(0.5)
attune.run_sensor(FileTailSensor)All sensor classes support rule lifecycle hooks that fire when the platform
creates, enables, disables, deletes, or updates a rule. Managed sensors
bootstrap from ATTUNE_SENSOR_TRIGGERS and then receive live lifecycle deltas
from the notifier WebSocket using Authorization: Bearer <ATTUNE_API_TOKEN>
and trigger_ref:<ref> subscriptions:
class StatefulSensor(attune.PollingSensor):
def on_rule_created(self, rule):
"""New rule activated — allocate per-rule resources."""
self.logger.info("Rule created: %s", rule.rule_ref, extra={"params": rule.trigger_params})
def on_rule_enabled(self, rule):
"""Previously disabled rule re-enabled."""
def on_rule_disabled(self, rule):
"""Rule disabled — pause per-rule work."""
def on_rule_deleted(self, rule):
"""Rule permanently removed — free resources."""
def on_rule_updated(self, rule, old_params):
"""Rule parameters changed — adapt."""
self.logger.info("Rule updated: %s → %s", old_params, rule.trigger_params)PollingSensor and AsyncPollingSensor automatically start/stop per-rule
poll threads/tasks in response to these hooks. Override them to add custom
behavior (call super() to keep the auto-management).
Passing rule=rule to emit() or async_emit() targets that numeric rule by
default using trigger_instance_id="rule_<id>". Pass target_rule=False only
for an intentional broadcast to every eligible enabled rule for the trigger.
The managed sensor service currently rotates tokens by restarting the sensor
process. It does not inject ATTUNE_SENSOR_TOKEN_STATE_PATH. If another runtime
explicitly provides that path, the SDK reads the JSON file on demand. Expected
fields:
{
"token": "eyJ...",
"expires_at": "2026-12-31T00:00:00Z"
}Compatibility aliases are also accepted: api_token and token_expires_at.
If the state file is unavailable, the SDK falls back to the startup
ATTUNE_API_TOKEN value when present; otherwise token reads fail clearly.
For Python token-source and failure behavior, see:
docs/managed-sensor-token-rotation-contract.md.
| Variable | Description |
|---|---|
ATTUNE_ACTION |
Action reference (e.g., mypack.deploy) |
ATTUNE_PACK_REF |
Pack reference |
ATTUNE_EXEC_ID |
Execution database ID |
ATTUNE_API_URL |
API base URL |
ATTUNE_API_TOKEN |
Execution-scoped API token (optional) |
ATTUNE_ARTIFACTS_DIR |
Shared artifact volume path |
ATTUNE_RUNTIME_ENVS_DIR |
Runtime environments root |
ATTUNE_RULE |
Rule reference (if rule-triggered) |
ATTUNE_TRIGGER |
Trigger reference (if event-triggered) |
| Variable | Description |
|---|---|
ATTUNE_SENSOR_REF |
Sensor reference |
ATTUNE_SENSOR_ID |
Sensor database ID |
ATTUNE_API_URL |
API base URL |
ATTUNE_API_TOKEN |
Sensor-scoped API token |
ATTUNE_PACK_REF |
Pack reference |
ATTUNE_ARTIFACTS_DIR |
Shared artifact volume path |
ATTUNE_API_TOKEN_EXPIRES_AT |
Optional ISO-8601/epoch expiry metadata for the fallback token |
ATTUNE_SENSOR_TOKEN_EXPIRES_AT |
Optional legacy expiry metadata fallback |
ATTUNE_SENSOR_TOKEN_STATE_PATH |
Optional externally managed token state file; not injected by the managed service |
ATTUNE_SENSOR_TOKEN_RECONNECT_WINDOW_SECONDS |
Optional pre-expiry reconnect window (default 30) |
ATTUNE_NOTIFIER_WS_URL |
Notifier WebSocket URL; remote endpoints require wss:// by default |
ATTUNE_ALLOW_INSECURE_NOTIFIER_WS |
Allow non-loopback ws:// only on an explicitly trusted development network |
ATTUNE_SENSOR_TRIGGERS |
Bootstrap JSON array of managed rule bindings |
ATTUNE_SENSOR_TRIGGER_TYPES |
JSON array (or comma-separated fallback) of all trigger refs to subscribe to |
ATTUNE_LOG_LEVEL |
Log verbosity |
ATTUNE_LOG_FORMAT |
Managed runtime log format |
ATTUNE_SENSOR_CONFIG_* |
Caller-supplied values exposed by sensor_context.config; persisted sensor config is not injected |
ATTUNE_SENSOR_TRIGGERS is a startup snapshot. Ordinary rule changes arrive
in-process through notifier lifecycle envelopes and do not restart the sensor.
If ATTUNE_SENSOR_TRIGGER_TYPES is absent, the SDK derives subscriptions from
the bootstrap snapshot.
cd python-attune-sdk
pip install -e ".[dev]"
pytestThe generated client is committed to the repo so users get it out of the box. To update it after API changes:
# With a running API:
./scripts/generate-client.sh
# Or from a spec file:
./scripts/generate-client.sh /path/to/openapi.jsonRequires openapi-python-client==0.29.0 (included in [dev] extras). Generation
is staged in a temporary directory, so a failed generation leaves the committed
client intact.
The contract tests use the checked operation-inventory snapshot by default. To compare every generated operation directly with a candidate spec, run:
ATTUNE_OPENAPI_PATH=/path/to/openapi.json pytest tests/test_api_client.py