API cheatsheet¶
A flat, greppable reference of firm's public API — return types are shown in # -> comments, and
keyword-only parameters are simply listed by name. See the Cookbook for worked,
runnable examples.
firm.queue¶
import firm.queue as bq
from firm.queue import current_runtime
# bq.configure(database_url, ...) -> Runtime (process-global). All but database_url are keyword-only;
# pass engine=<your SQLAlchemy engine> instead of a URL to share your app's engine/pool:
bq.configure("postgresql://localhost/app", busy_timeout_ms=5000,
pool_size=20, max_overflow=40,
default_queue="default", preserve_finished_jobs=True)
current_runtime() # -> Runtime
@bq.job(queue="default", priority=0, attempts=1, backoff=3.0, concurrency=None)
def my_job(x): # the function stays directly callable
...
my_job.enqueue(x) # -> int | None (job id, or None if discarded)
my_job.enqueue_at(when, x) # when: datetime
my_job.enqueue_in(delta, x) # delta: timedelta
Concurrency (the concurrency= dict on @bq.job):
concurrency = {
"key": lambda *a, **k: a[0], # the variable part of the concurrency key
"to": 1, # max simultaneous executions per key
"duration": 300, # seconds before the semaphore expires (failsafe)
"group": None, # share a key namespace across jobs (default: this job)
"on_conflict": "block", # "block" (queue it) or "discard" (drop it)
}
Management (from firm.queue import queues), each takes a Runtime:
from firm.queue import queues
queues.all_queues(rt) # -> list[str]
queues.size(rt, q) # -> int
queues.pause(rt, q)
queues.resume(rt, q)
queues.is_paused(rt, q) # -> bool
queues.clear(rt, q) # -> int (jobs removed)
queues.latency(rt, q) # -> float (seconds since the oldest ready job)
Maintenance (from firm.queue import maintenance):
from datetime import timedelta
from firm.queue import maintenance
maintenance.retry_failed(rt, job_id) # -> bool
maintenance.retry_all_failed(rt) # -> int
maintenance.clear_finished(rt, older_than=timedelta(days=7), batch_size=500) # -> int
Running workers:
# CLI (production):
# firm-queue start --import myapp.jobs [--mode fork|thread] [--threads N] [--queues a,b]
from firm.queue import current_runtime
from firm.queue.supervisor import (ThreadSupervisor, ForkSupervisor, SupervisorConfig,
WorkerConfig, DispatcherConfig)
config = SupervisorConfig(workers=[WorkerConfig(queues=("*",), threads=3)],
dispatchers=[DispatcherConfig()], recurring=[])
sup = ThreadSupervisor(current_runtime(), config) # in-process; .start() is non-blocking
sup.start()
sup.stop() # or: with ThreadSupervisor(rt, config): ...
ForkSupervisor(current_runtime(), config).start() # production default (POSIX); blocks
Single steps (no long-lived process):
from firm.queue.worker import run_ready
from firm.queue.dispatcher import dispatch_once, run_maintenance
run_ready(rt, queues=("*",), limit=10) # -> int (claim + run one batch of up to `limit`)
dispatch_once(rt) # -> int (promote due scheduled jobs)
run_maintenance(rt) # -> int (release blocked jobs with capacity)
Recurring (from firm.queue.scheduler import RecurringTask, Scheduler):
from firm.queue.scheduler import RecurringTask, Scheduler
# 5-field cron; pass a list as SupervisorConfig(recurring=[...]) to start a scheduler automatically
task = RecurringTask(key="nightly", schedule="0 3 * * *", job=my_job, args=(), kwargs={})
Scheduler(rt, [task]).tick() # manual: enqueue anything due this period
Read queries (for custom dashboards) — each takes a live SQLAlchemy Connection and returns
plain dicts; this is what the firm-ui dashboard is built on:
from firm.queue import queries
queries.STATES # the valid `state` values, in display order
queries.state_counts(conn, queue=None) # -> dict[str, int] (per state, plus total)
queries.queue_rows(conn, now) # -> list[dict] (name, size, latency, paused)
queries.queue_names(conn) # -> list[str] (queues with ready jobs)
queries.queue_size(conn, queue) # -> int
queries.queue_latency(conn, queue, now) # -> float (seconds, 0.0 if empty)
queries.jobs_by_state(conn, state, limit=50, offset=0, queue=None) # -> list[dict]
queries.job_detail(conn, job_id) # -> dict | None
queries.processes(conn, now, alive_threshold=300.0) # -> list[dict] (+ age, alive)
queries.recurring(conn) # -> list[dict]
# a negative limit/offset, or an unknown state, raises ValueError.
firm.cache¶
from firm.cache import Cache, JSONCoder, MsgpackCoder, PickleCoder
# database_url is positional; everything after it is keyword-only (defaults shown):
cache = Cache("sqlite:///cache.db", engine=None, coder=None, encrypt_key=None,
max_age=1209600.0, max_size=268435456, max_entries=None, # 2 weeks / 256 MiB / off
expiry_batch_size=100, max_key_bytesize=1024, size_estimate_samples=10000,
create_schema=True, auto_expire=True, background_expiry=False, expiry_interval=60.0)
cache.get(key) # -> Any | None
cache.set(key, value)
cache.fetch(key, default_or_callable) # -> Any (compute + store on a miss)
cache.delete(key) # -> bool
cache.exist(key) # -> bool
cache.get_multi(keys) # -> dict
cache.set_multi(mapping)
cache.increment(key, by=1) # -> int
cache.decrement(key, by=1) # -> int
cache.clear()
cache.close() # or: with Cache(...) as cache: ...
# keys are str|bytes; values go through the coder (JSONCoder by default; MsgpackCoder for
# compact binary rows, PickleCoder for arbitrary Python objects — see cache/encryption-and-coders).
# encrypt_key=<Fernet key> encrypts values at rest (needs the [encryption] extra).
Read queries (for custom dashboards), Connection in / dicts out:
from firm.cache import queries
queries.cache_stats(conn) # -> {"entries": int, "estimated_size": int}
queries.cache_recent(conn, limit=50, offset=0) # -> list[dict] (id, key, byte_size, created_at)
# `key` is raw bytes (a cache key is arbitrary bytes — decode it yourself for display);
# a negative limit/offset raises ValueError.
firm.channel¶
from firm.channel import Channel
channel = Channel("sqlite:///cable.db", engine=None, polling_interval=0.1,
message_retention=86400.0, auto_trim=True, trim_batch_size=100,
create_schema=True)
channel.broadcast(name, payload) # name/payload: str|bytes (str payloads are UTF-8 encoded)
channel.subscribe(name, callback) # callback(payload: bytes); only future messages; per-process
channel.unsubscribe(name, callback)
channel.trim() # -> int
channel.close() # or: with Channel(...) as channel: ...
Read queries (for custom dashboards), Connection in / dicts out:
from firm.channel import queries
queries.channel_stats(conn) # -> {"messages": int, "channels": int}
queries.channel_top(conn, limit=25, offset=0) # -> list[dict] (channel, count, last)
queries.channel_recent(conn, limit=50, offset=0) # -> list[dict] (id, channel, payload, created_at)
# `channel` and `payload` are raw bytes (decode them yourself for display);
# a negative limit/offset raises ValueError.
firm.audit¶
from firm.audit import AuditLog, Ref, record
# database_url is positional; everything after it is keyword-only (defaults shown):
audit = AuditLog("sqlite:///audit.db", engine=None, create_schema=True,
max_age=None, background_retention=False, retention_interval=3600.0)
audit.record(action, subject=None, actor=None, data=None, changes=None,
correlation_id=None, context=None, conn=None)
audit.history(subject=None, subject_type=None, subject_id=None,
actor=None, actor_type=None, actor_id=None, action=None,
correlation_id=None, since=None, limit=100) # -> list[dict]
# subject_type=/subject_id= filter independently (either alone, or both); same for actor_*.
# pass either subject= or subject_type=/subject_id= for a field, never both (raises ValueError).
# a bare-string filter (actor="cron") filters by type only.
audit.close() # or: with AuditLog(...) as audit: ...
# shared-DB, same-transaction (atomic with the business change):
record(conn, action, subject=None, actor=None, data=None, changes=None,
correlation_id=None, context=None)
# subject/actor accept the same forms — type and id are each optional:
# invoice domain object with `.id` -> (ClassName, id)
# ("Invoice", 42) tuple; either half may be None (empty/None id -> NULL)
# "cron" bare string -> a role/kind label, stored as the type (no id)
# Ref("User", 7, name="…") explicit, with an optional human-readable display name
# None no actor/subject (a system event)
# An object may define __firm_audit_ref__(self) -> Ref to customize its audit identity.
# Each event dict from history()/get() carries subject_label/actor_label (the display names).
# data/changes/context: dicts stored as JSON text (not native JSON/JSONB).
Read queries (for custom dashboards), Connection in / dicts out — paginated search where
history() is a plain filtered read, plus the tamper-evidence status:
from firm.audit import queries
queries.audit_stats(conn) # -> {"events": int, "actions": int, "last_event_at": dt|None}
queries.audit_count(conn, action=None, subject=None, subject_type=None, subject_id=None,
actor=None, actor_type=None, actor_id=None, correlation_id=None) # -> int
queries.audit_search(conn, **same_filters, sort="created_at", dir="desc",
limit=25, offset=0) # -> list[dict] (the history() shape + row_mac)
queries.audit_detail(conn, event_id) # -> dict | None
queries.SORT_COLUMNS, queries.DEFAULT_SORT # the sortable columns and the default
# filters behave exactly as in history() (a bare string filters by type only, and passing both
# forms for one field raises ValueError); an unknown sort/dir falls back to the default, and a
# negative limit/offset raises ValueError.
# tamper-evidence (only meaningful when a key is configured; see audit/tamper-evidence):
queries.verify_status_row(conn) # -> dict | None (the verifier's status row)
queries.integrity_config(conn, key_configured=True) # -> IntegrityConfig
queries.integrity_state(status, config, now=None,
verify_max_age=..., anchor_max_age=...) # -> IntegrityState (pure)
queries.row_integrity_context(conn) # -> dict (the per-row signals, gathered once)
queries.row_status(row, ctx) # -> "sealed"|"unsealed"|"unprotected"|"tampered"|... | None
# IntegrityState carries state, tone (ok/warn/danger/neutral), escalate, and cause tokens.
firm.queue.contrib (optional — [flask] / [fastapi] extras)¶
# FastAPI: app = FastAPI(lifespan=lifespan(database_url=..., embed_workers=False,
# queues=("*",), threads=3))
from firm.queue.contrib.fastapi import lifespan
# Flask: FirmQueue(app, database_url=None, embed_workers=False, queues=("*",), threads=3)
# reads app.config/env FIRM_QUEUE_DATABASE_URL, then FIRM_DATABASE_URL;
# registers `flask firm-queue worker`
from firm.queue.contrib.flask import FirmQueue
from firm.queue.contrib.sqlalchemy import enqueue_after_commit
enqueue_after_commit(session, my_job, x) # enqueues iff the session commits
firm.ui (optional — the firm-ui package)¶
from firm.ui import BasicAuth, build_dashboard, serve, static_dir
dash = build_dashboard(database_url=None, queue_url=None, cache_url=None,
channel_url=None, audit_url=None) # -> Dashboard (close() when done)
serve(dash, host="127.0.0.1", port=8787, authenticator=None, channel_trim_retention=None)
# the dashboard without a transport: handle(UIRequest) -> UIResponse
from firm.ui import DashboardApp, Headers, UIRequest, UIResponse
app = DashboardApp(dash, authenticator=None, channel_trim_retention=None, static_url=None)
app.handle(UIRequest("GET", "/jobs", query="state=failed", headers=Headers(...),
body=b"", peer="", prefix="/firm", host="app.example"))
static_dir() # -> Path (the stylesheet's directory, to serve it from your own pipeline)
Mounts (each behind its own extra — [django] / [flask] / [fastapi]). Every one needs either
authenticator= or host_auth=True; both unset raises ValueError:
from firm.ui.contrib.django import dashboard_urls, dashboard_view # decorator=…, host_auth=…
from firm.ui.contrib.flask import blueprint # -> Blueprint
from firm.ui.contrib.fastapi import router # -> APIRouter
Command-line tools¶
firm-queue start|work|drain|dispatch|maintenance --database-url ... --import myapp.jobs
firm-cache stats|clear|trim --database-url ...
firm-channel stats|trim --database-url ...
firm-audit stats|history|prune --database-url ...
firm-ui --database-url ... [--queue-url ... --cache-url ... --channel-url ... --audit-url ...] [--host --port]
Database URLs come from --database-url or the FIRM_*_DATABASE_URL env vars. Bare
postgresql:// / mysql:// URLs auto-normalize to the shipped drivers ([postgres] / [mysql]).