Service Blueprint — Guide for New Python Services¶
Purpose: This document defines the mandatory structure, patterns, and shared components for every Python-based service in the Silvasonic project. New services MUST follow this blueprint to ensure full consistency with
controllerandrecorder.
1. Directory Layout¶
Every service lives in services/<name>/ and must follow this exact tree:
services/<name>/
├── Containerfile
├── README.md
├── pyproject.toml
├── src/
│ └── silvasonic/
│ └── <name>/
│ ├── __init__.py
│ ├── __main__.py
│ └── py.typed
└── tests/
├── integration/
└── unit/
└── test_<name>.py
| File | Purpose |
|---|---|
__init__.py |
Package docstring only: """Silvasonic <Name> Service Package.""" |
__main__.py |
Service entry point — async lifecycle (see §3) |
py.typed |
PEP 561 marker — enables downstream type checking |
Containerfile |
Container build recipe (see §5) |
README.md |
Service-specific documentation |
tests/unit/test_<name>.py |
Unit tests with 100% coverage target (see §7) |
2. pyproject.toml — Package Definition¶
Every service uses hatchling as build backend and declares silvasonic-core as
its only workspace dependency:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "silvasonic-<name>"
description = "<one-line description>"
readme = "README.md"
requires-python = ">=3.13"
dependencies = ["silvasonic-core"]
dynamic = ["version"]
[tool.hatch.version]
path = "https://github.com/kyellsen/silvasonic/blob/main/packages/core/src/silvasonic/core/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/silvasonic"]
Workspace Registration¶
The new service must be registered in the root pyproject.toml:
[project] dependencies— add"silvasonic-<name>"[tool.uv.sources]— addsilvasonic-<name> = { workspace = true }[tool.uv.workspace] membersalready auto-discovers via"services/*"
3. Service Lifecycle (__main__.py)¶
Every service must subclass SilvaService (see ADR-0019):
from silvasonic.core.service import SilvaService
class MyService(SilvaService):
"""<Name> Service — <one-line description>."""
service_name = "<name>"
service_port = <PORT> # See port_allocation.md
async def run(self) -> None:
"""Service-specific logic — runs after all infrastructure is ready."""
self.health.update_status("main", True, "running")
while not self._shutdown_event.is_set():
self.health.touch()
try:
# Cyclic Transient I/O Guard (ADR-0030)
# Your DB / Network / Filesystem domain logic here
pass
except Exception as exc:
# Log error and soft-fail to avoid fatal crashes from transient outages
# E.g. logger.warning("service.db_cycle_failed", error=str(exc))
self.health.update_status("main", False, "database_unavailable")
await asyncio.sleep(5.0) # Use a backoff constant like _DB_RETRY_SLEEP_S
continue
await asyncio.sleep(1)
def get_extra_meta(self) -> dict[str, Any]:
"""Optional: add service-specific fields to heartbeat meta."""
return {"my_metric": 42}
if __name__ == "__main__":
MyService().start()
The SilvaService base class handles the full lifecycle automatically:
- Logging —
configure_logging()(structlog, Rich in dev / JSON in prod) - Health Server — HTTP
/healthyon:service_port(Podman probes) - Resource Collector — per-process CPU/memory/threads via
psutil - Redis Connection — best-effort via
get_redis_connection()(skipped if unavailable) - Heartbeat Loop — fire-and-forget, periodic (
SET+PUBLISHto Redis, interval: seeDEFAULT_HEARTBEAT_INTERVAL_Sinheartbeat.py) run()— your service logic (override this)- Graceful Shutdown — SIGTERM / SIGINT →
_shutdown_event.set()
[!IMPORTANT] Services MUST NOT call lifecycle methods directly. The base class calls them in the correct order during
start(). Only overriderun()and optionallyget_extra_meta().
4. Shared Components from silvasonic-core¶
Services MUST NOT reimplement any of the following. Import and use exclusively
from silvasonic.core:
| Module | Import | Purpose |
|---|---|---|
| Service | silvasonic.core.service.SilvaService |
Unified lifecycle base class (ADR-0019) |
| Heartbeat | silvasonic.core.heartbeat.HeartbeatPublisher |
Async fire-and-forget Redis heartbeats |
| Heartbeat | silvasonic.core.heartbeat.HeartbeatPayload |
Pydantic model for heartbeat JSON schema |
| Redis | silvasonic.core.redis.get_redis_connection |
Best-effort connect, returns None on failure |
| Logging | silvasonic.core.logging.configure_logging |
Structured logging (Rich in dev, JSON in prod) |
| Health | silvasonic.core.health.HealthMonitor |
Thread-safe singleton for component status |
| Health | silvasonic.core.health.start_health_server |
Background HTTP server on /healthy |
| Resources | silvasonic.core.resources.ResourceCollector |
Per-process CPU/memory/storage metrics |
| Resources | silvasonic.core.resources.HostResourceCollector |
Host-level metrics (Controller only) |
| Settings | silvasonic.core.settings.DatabaseSettings |
Pydantic-based config from env vars |
| Config Schemas | silvasonic.core.schemas.system_config.* |
Pydantic models for system_config JSONB blobs |
| Database | silvasonic.core.database.session.get_session |
Async SQLAlchemy session (context manager) |
| Database | silvasonic.core.database.session.get_db |
FastAPI dependency for DB sessions |
| Database | silvasonic.core.database.check.check_database_connection |
Health probe for DB connectivity |
| Models | silvasonic.core.database.models.* |
Shared SQLAlchemy ORM models |
5. Containerfile¶
All Python service Containerfiles follow this identical structure:
FROM python:3.13-slim-bookworm
WORKDIR /app
# 1. System dependencies (always include curl for healthcheck)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
# ... service-specific packages here ...
&& rm -rf /var/lib/apt/lists/*
# 2. UV installer (pinned version!)
COPY --from=ghcr.io/astral-sh/uv:0.10.3 /uv /uvx /bin/
# 3. Copy workspace files
COPY pyproject.toml uv.lock ./
COPY packages/ packages/
COPY services/<name>/ services/<name>/
# 4. Install with uv
RUN uv sync --frozen --no-dev --no-editable --package silvasonic-<name>
# 5. Environment
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
EXPOSE <PORT>
ENTRYPOINT ["python", "-m"]
CMD ["silvasonic.<name>"]
[!WARNING] UV version (
0.10.3) must match across all Containerfiles. When upgrading, update all services at once.
Mandatory Rules¶
- Base image:
python:3.13-slim-bookworm(mandatory for all services) - Build context: Always the repo root (
.), never the service directory curlis always required (healthcheck)packages/is always copied (containssilvasonic-core)PYTHONUNBUFFERED=1andPYTHONDONTWRITEBYTECODE=1are always set
6. Compose Integration¶
Silvasonic uses two distinct Compose patterns depending on the service tier:
Tier 1 (Infrastructure) — Auto-Started¶
Tier 1 services (Database, Controller, Processor, Redis, Web-Mock, Gateway) are
started automatically by podman-compose up. Add a new Tier 1 service block
following this pattern:
<name>:
container_name: silvasonic-<name>
build:
context: .
dockerfile: services/<name>/Containerfile
restart: unless-stopped
env_file: .env
environment:
SILVASONIC_DB_HOST: database
ports:
- "${SILVASONIC_<NAME>_PORT:-<PORT>}:<PORT>"
depends_on:
database:
condition: service_healthy
networks:
- silvasonic-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:<PORT>/healthy"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
Tier 2 (Application) — Managed Profile¶
Tier 2 containers (e.g., Recorder, BirdNET) are not auto-started by Compose.
They are managed dynamically by the Controller at runtime via podman-py
(see ADR-0013).
However, Tier 2 services MUST still be declared in compose.yml under
profiles: ["managed"]. This serves three purposes:
- Centralized build definitions —
just builduses Compose to build all images, including managed ones, from a single command. - Resource-limit templates —
mem_limit,cpus,oom_score_adj, volumes, and healthchecks serve as declarative reference for the Controller's runtime container specs. - Dev-override mounts —
compose.override.ymlcan providePYTHONPATHoverrides for hot-reload during development.
[!IMPORTANT] The
profiles: ["managed"]key ensures these containers are excluded frompodman-compose up. They exist incompose.ymlpurely as build targets and configuration templates — the Controller is the sole authority for starting, stopping, and configuring them at runtime.
<name>:
build:
context: .
dockerfile: services/<name>/Containerfile
restart: unless-stopped
profiles: ["managed"]
networks:
- silvasonic-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:<PORT>/healthy"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# Resource Limits & QoS (ADR-0020) — template values, enforced by Controller
mem_limit: <LIMIT>
cpus: <CPUS>
compose.override.yml (Development)¶
Add volume mounts for hot-reload (applies to both Tier 1 and Tier 2 services):
<name>:
environment:
PYTHONPATH: /app/develop/service:/app/develop/core
volumes:
- ./services/<name>/src:/app/develop/service:z
- ./packages/core/src:/app/develop/core:z
.env.example¶
Add the port variable:
See Port Allocation for port assignment rules.
7. Testing¶
Test File Structure¶
Tests reside in services/<name>/tests/unit/test_<name>.py and must:
- Use
@pytest.mark.uniton every class/function - Cover all code paths — 100% coverage target
- Follow the established test class structure:
"""Unit tests for silvasonic-<name> service — 100 % coverage."""
import asyncio
import os
import signal
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.mark.unit
class TestPackage:
"""Basic package-level tests."""
def test_package_importable(self) -> None:
"""Package is importable."""
import silvasonic.<name>
assert silvasonic.<name> is not None
@pytest.mark.unit
class TestMonitorSomething:
"""Tests for monitor coroutines."""
# Mock asyncio.sleep with CancelledError to test one loop iteration
@pytest.mark.unit
class TestMain:
"""Tests for the main() coroutine."""
# Test lifecycle wiring + signal handling
# Test __main__ guard with runpy
Running Tests¶
just test-unit # Fast, mocked, parallel (4 workers)
just test-int # Integration (Testcontainers, needs Podman)
just test-system # System lifecycle (real Podman + built images, no HW)
just test-hw-all # Hardware system tests (real USB mic required)
just test-smoke # Against running stack (just start first)
just test-all # All tests except hardware (Unit+Int+System+Smoke+E2E)
For full test marker documentation, see Testing Guide.
8. Naming Conventions Summary¶
| Aspect | Pattern | Example |
|---|---|---|
| Service directory | services/<name>/ |
services/analyzer/ |
| PyPI package name | silvasonic-<name> |
silvasonic-analyzer |
| Python import | silvasonic.<name> |
silvasonic.analyzer |
| Compose service | <name> |
analyzer |
| Container name | silvasonic-<name> |
silvasonic-analyzer |
| Port env var | SILVASONIC_<NAME>_PORT |
SILVASONIC_ANALYZER_PORT |
See also AGENTS.md §3 for the full naming policy.
9. Checklist for a New Service¶
Use this checklist when adding a new service:
- [ ]
services/<name>/directory with full layout (§1) - [ ]
pyproject.tomlwith hatchling +silvasonic-coredep (§2) - [ ] Root
pyproject.tomlupdated (dependency + source) (§2) - [ ]
__main__.pyfollows lifecycle pattern (§3) - [ ] Uses only shared
silvasonic.coremodules (§4) - [ ]
Containerfilefollows template exactly (§5) - [ ]
compose.ymlservice block added — Tier 1: auto-started / Tier 2:profiles: ["managed"](§6) - [ ]
compose.override.ymldev mounts added (§6) - [ ]
.env.exampleport variable added (Tier 1 only) (§6) - [ ]
docs/arch/port_allocation.mdupdated (Tier 1 only) (§6) - [ ] Unit tests at 100% coverage (§7)
- [ ]
just checkpasses (lint + type + tests) - [ ]
just cipasses (full CI pipeline incl. build + smoke)