Skip to main content

common-python-utils

Common Python utilities shared across Kaiano's projects.

Install name: miniapppolis-common-utils (PyPI)  ·  Import namespace: mini_app_polis

The distribution and the import namespace differ on purpose: the published name is prefixed because the registry is public, while the import namespace is the one every consumer already writes. Install miniapppolis-common-utils, import mini_app_polis (for example, from mini_app_polis.google import GoogleAPI).


Installation

Declare a version range in your pyproject.toml and let the lockfile carry the exact pin:

[project]
dependencies = [
  "miniapppolis-common-utils>=5.0,<6",
]

Then uv lock. To move to a newer release, uv lock --upgrade-package miniapppolis-common-utils — no edit to this file.

To use the LLM module, add the llm extra:

dependencies = [
  "miniapppolis-common-utils[llm]>=5.0,<6",
]

Modules

Module Import Description
activity.py from mini_app_polis import activity A FastAPI service's Discord feed: committed data changes, faults, announce-on-commit, background faults (activity extra)
api/ from mini_app_polis.api import KaianoApiClient HTTP client for internal FastAPI services
api/contract/ from mini_app_polis.api.contract import IngestSet, ENDPOINTS Typed request/response models for the Kaiano API endpoints the fleet calls
asana/ from mini_app_polis.asana import AsanaClient Asana task creation with external-id idempotency
config.py from mini_app_polis import config Env-var driven shared config (Spotify, Google, VDJ)
deadline.py from mini_app_polis.deadline import run_deadline A run deadline the library's retry loops respect: no attempt starts, and no backoff is waited out, past it; without_run_deadline() lifts it for cleanup
discord.py from mini_app_polis import discord Discord webhook transport: named channels, rate-limit cooldowns, environment labels, never raises
doppler.py from mini_app_polis.doppler import DopplerClient Reads and writes secrets in one Doppler config (API or CLI), and the check-doppler-keys console script
google/ from mini_app_polis.google import GoogleAPI Drive + Sheets facade
llm/ from mini_app_polis.llm import build_llm, LLMMessage OpenAI + Anthropic clients (optional extra)
mp3/ from mini_app_polis.mp3 import ... AcoustID identification, tagging, renaming
music/ from mini_app_polis.music import normalize_for_matching Music data normalization utilities
spotify/ from mini_app_polis.spotify import SpotifyAPI Spotipy wrapper; raises SpotifyTokenExpired when Spotify refuses the refresh token (invalid_grant, not retried); every request, token refresh included, is bounded by SPOTIFY_REQUEST_TIMEOUT_SECONDS
timing.py from mini_app_polis import timing Per-invocation working vs waiting time, by service, as one CloudWatch line
vdj/ from mini_app_polis.vdj.m3u import ParseFacade VirtualDJ M3U parsing

Usage

KaianoApiClient

from mini_app_polis.api import KaianoApiClient

# Set KAIANO_API_BASE_URL (KAIANO_API_BASE_URL_DEV outside production)
# and this machine's own key (deejay-cog -> DEEJAY_COG_API_KEY) in the
# environment. The machine name is what selects the key variable, and the
# key is what names this caller to the API. Without it the client falls
# back to KAIANO_API_KEY; with neither set, every call raises
# KaianoApiError before any request is sent.
client = KaianoApiClient.from_env("deejay-cog")
result = client.post("/sets", {"name": "My Set"})

For the endpoints in mini_app_polis.api.contract.ENDPOINTS, use the typed methods. They take the request model the API validates against and return the envelope's data as the response model. get() and post() still work for anything outside the catalog.

from mini_app_polis.api.contract import TranscriptionRunRequest

accepted = client.request_transcription_run(
    TranscriptionRunRequest(mode="wcs-transcripts", drive_file_id=file_id)
)
if accepted.deduplicated:
    ...

The API imports these same models for its routes, so the contract has a single definition. A response that doesn't match raises pydantic's ValidationError, not KaianoApiError. Response models never forbid extra fields, so a field the API adds doesn't break a cog pinned to an older release. A test enforces this.

Contract suite. contract/ checks the catalog against the deployed development API (TEST-016). It calls every endpoint through the typed methods and fails if an endpoint is neither exercised nor listed in contract/harness.py's NOT_EXERCISED. It writes data, so it refuses to run unless it has a dev_ key and a non-production host, and it stops if the API doesn't recognise the key as contract-suite (CD-033). It runs from .github/workflows/contract.yml on the promotion pull request to main, where it gates the release, and when the API's dev deploy dispatches dev-api-deployed. Locally:

CONTRACT_API_URL=https://dev-api.kaianolevine.com CONTRACT_SUITE_API_KEY=dev_… \
  uv run pytest contract --no-cov

AsanaClient

from datetime import date

from mini_app_polis.asana import AsanaClient, AsanaTaskInput, link, rich_text_body

# Set ASANA_ACCESS_TOKEN (personal access token) and, if you resolve tag
# names, ASANA_WORKSPACE_ID in the environment.
client = AsanaClient.from_env()

external_id = "voicenote.1AbCdEf"

# Idempotency is a single lookup, not a list-and-scan: Asana stores an
# app-scoped `external` object on each task and lets you address the task
# by it. Completed tasks are found too, so a triaged item is never
# recreated.
if client.find_task_by_external_id(external_id) is None:
    client.create_task(
        AsanaTaskInput(
            name="Send the report",
            html_notes=rich_text_body(
                "Because it is due Friday.",
                link("https://example.com/source", "Source"),
            ),
            project_gid="1218223550488548",
            section_gid="1218337761864701",
            assignee="me",
            due_on=date.today(),
            tag_gids=(client.find_or_create_tag("review"),),
            external_id=external_id,
        )
    )

Task bodies are Asana rich text, not markdown, and Asana returns 400 on malformed markup. Compose them with rich_text_body / link / escape_rich_text rather than by hand — transcripts and model output contain < and & often enough that escaping is a correctness concern.

LLM (requires llm extra)

from mini_app_polis.llm import build_llm, LLMMessage

llm = build_llm(provider="anthropic", model="claude-3-5-sonnet-20241022")
result = llm.generate_json(
    messages=[
        LLMMessage(role="system", content="Return JSON only."),
        LLMMessage(role="user", content="Extract the artist and title."),
    ],
    json_schema={
        "type": "object",
        "properties": {
            "artist": {"type": "string"},
            "title": {"type": "string"},
        },
        "required": ["artist", "title"],
    },
)
print(result.output_json)  # {"artist": "...", "title": "..."}

Discord

from mini_app_polis import discord

await discord.send_message(
    discord.CHANNEL_ERRORS,
    content="Nightly export failed",
    context="export",  # names the producer in the log line
)

Four channels — default, errors, activity, runs — each read from its own DISCORD_WEBHOOK_URL_<CHANNEL> and falling back to DISCORD_WEBHOOK_URL when that is unset, so a channel can exist in code before it exists in Discord. Outside production, content and embed titles are prefixed [DEVELOPMENT], and mentions are off (allowed_mentions pings nobody), so text a person typed cannot ping @everyone; send_payload posts a body exactly as given instead. Every call returns whether Discord accepted the message and never raises: failures are logged and, if the process has initialised sentry_sdk, reported there. Sends to one webhook go out one at a time, in order, paced by the bucket Discord reports on every answer; a 429 holds that webhook (or all of them, for a global or Cloudflare limit) and the message is sent again when the hold ends, rather than posting through it. A send waits at most MAX_WAIT_SECS (10 s; max_wait= to change it) — a burst larger than that loses its tail, a Cloudflare 1015 drops what is sent during it — and the first drop of an episode is reported to Sentry. webhook_url(..., source=...) and the source= argument on the send functions read the variables from a mapping instead of the environment. post_webhook is the rate-limit-aware POST underneath, for payloads this module does not shape (GitHub events forwarded to the webhook's /github suffix). See the module docstring for the rest.

Activity feed (requires activity extra)

Committed data changes to activity, faults to errors, for a FastAPI service on SQLAlchemy 2. Needs the activity extra and a release that has these modules: >=5.0 is not enough, since earlier 5.x releases lack them. Use the minor release that introduced mini_app_polis.activity (and mini_app_polis.discord) as the lower bound:

dependencies = ["miniapppolis-common-utils[activity]>=<that release>,<6"]
from mini_app_polis import activity, discord

config = activity.ActivityConfig(
    service="api-deejaytools",                 # shown in every footer
    suppressed_tables={"identity_audit_events"},
    excluded_paths=("/health", "/version"),
    # is_machine=lambda request: request.state.caller_kind == "machine",
)
# After the middleware whose status it should see, so it wraps them.
app.middleware("http")(activity.activity_middleware(config))

Importing the module registers the session listeners. One message per request that committed something (rolled-back work reports nothing; bulk statements are counted as statements), and one per 5xx, unhandled exception, or — when is_machine is given — 4xx to a machine caller. The actor is request.state.caller unless caller= says otherwise. A fault names the exception's type and Sentry id, never its message. Titles carry the environment prefix only with ActivityConfig(label=True) (and report_fault(..., label=True)); the footer always names the environment. A handler, or a pure ASGI error middleware holding only the scope, can attach a reason with activity.record_fault_detail(request_or_scope, "...").

Two producers for what a request tally cannot say:

# Posted when this session's transaction commits; never on rollback.
# Works in a request and in a background job with its own session.
# `title` is user-typed: mentions are off, so an "@everyone" in it stays
# text, but escape Discord markdown in it yourself if that matters.
activity.announce_on_commit(
    session, discord.CHANNEL_ACTIVITY, content=f"Song added: {title}"
)
await session.commit()

# A scheduler tick or job runner with no request around it.
except Exception as exc:
    await activity.report_fault("scheduler · drive_jobs", exc, service="api-deejaytools")

Delivery is fire-and-forget; await activity.wait_for_deliveries() lets tests (and a shutdown) wait for it. See the module docstring for what the listeners cannot see.

Run deadline

from mini_app_polis.deadline import RunDeadlineReached, run_deadline

# A Lambda handler: stop 30 s before the hard ceiling.
budget_s = context.get_remaining_time_in_millis() / 1000 - 30
try:
    with run_deadline(budget_s):
        run()
except RunDeadlineReached:
    ...  # stop cleanly; report a partial run

Inside the block, the retry loops this library owns — every Google Drive and Sheets call, DriveFacade.copy_file's wait for a new file to appear, and Spotify's per-call retries — check the deadline between attempts. An attempt is not started once it has passed (RunDeadlineReached, a TimeoutError, chained from the previous error), and a backoff that would end at or past it is skipped, re-raising the error that prompted it. An attempt already in flight is not interrupted, so leave room for one call's own timeout. Nesting keeps the earlier deadline; remaining_seconds() returns None when none is set, and without one nothing changes.

Cleanup that must happen because the run is out of time — rolling back a half-finished write — would be refused by the deadline that triggered it. without_run_deadline() clears the deadline for its block and restores it on exit, so that cleanup gets the ordinary retries:

from mini_app_polis.deadline import without_run_deadline

with without_run_deadline():
    drive.delete_file(half_written_id)

Keep such blocks short: they spend the headroom between the deadline and the hard ceiling. A run_deadline nested inside starts afresh and can bound the cleanup itself.

The deadline is a contextvars value: asyncio.to_thread and new asyncio tasks inherit it, a plain threading.Thread does not. See the module docstring.

Logger

from mini_app_polis import logger

logger.info("Starting pipeline")
logger.error("Something went wrong: %s", err)

# Or get the logger instance directly
log = logger.get_logger()

Set LOGGING_LEVEL=INFO (or DEBUG/WARNING/ERROR) in your environment.

Timing

from mini_app_polis import timing

with timing.invocation(cog="deejay", mode=mode):
    run()

When the block ends, one JSON line goes to stdout: wall and CPU time, the idle share, and the waiting split by service (google, spotify, anthropic, api, sleep, …). CloudWatch Logs Insights reads it as fields (timing.idle_pct, timing.wait.anthropic.ms). See the module docstring for how calls are attributed.


Development

Prerequisites

  • uv — Python package manager
curl -LsSf https://astral.sh/uv/install.sh | sh

First-time setup

Clone the repo and run these commands once in order:

# 1. Install all dependencies including dev and optional extras
uv sync --all-extras

# 2. Install pre-commit hooks into git
uv run pre-commit install

That's it. Pre-commit will now run automatically on every git commit.

Daily workflow

# Run tests
uv run pytest

# Run tests with coverage detail
uv run pytest --cov=mini_app_polis --cov-report=term-missing

# Lint (auto-fix where possible)
uv run ruff check src/ tests/ --fix

# Format
uv run ruff format src/ tests/

# Type check
uv run mypy src/

# Run all pre-commit hooks manually against all files
uv run pre-commit run --all-files

What pre-commit does

On every git commit, the following run automatically:

  • ruff — lints and auto-fixes what it can
  • ruff-format — formats code
  • python-check-mock-methods — catches incorrect mock usage
  • python-use-type-annotations — flags old-style type comments
  • gitleaks — blocks commits that contain secrets

If any hook fails, the commit is blocked. Ruff will auto-fix in place — just git add . and re-commit.

Environment variables

Tests need no secrets: uv run pytest runs anywhere.

Everything else reads its settings from the process environment, supplied by Doppler. Nothing reads a .env file. Once per machine, then once per clone:

brew install gnupg dopplerhq/cli/doppler
doppler login
doppler setup        # reads doppler.yaml: mini-app-polis-ecosystem / dev

Then run anything that needs secrets under it — the contract suite, say:

doppler run -- uv run pytest contract --no-cov

Local runs use the dev config only. .env.example lists the names this library reads; the values live in Doppler. Uncommented names are required, commented ones optional. Every repo that depends on this library gets the check-doppler-keys console script, which lists any required name that dev is missing (names only, never values):

uv run check-doppler-keys

Key variables:

Variable Used by Required for
KAIANO_API_BASE_URL KaianoApiClient Calling internal FastAPI services, in production. Every other environment reads KAIANO_API_BASE_URL_DEV, with no fallback between the two
<MACHINE_NAME>_API_KEY KaianoApiClient This machine's own named key, e.g. DEEJAY_COG_API_KEY. Derived from the machine name by machine_key_env_var()
KAIANO_API_KEY KaianoApiClient Unnamed fallback for a caller that declares no machine name. Authenticates, but its writes are unattributable
ASANA_ACCESS_TOKEN AsanaClient Asana personal access token. Read per request, so rotation needs no restart
ASANA_WORKSPACE_ID AsanaClient Workspace gid. Required only by find_or_create_tag(), since tags are workspace-scoped objects
LOGGING_LEVEL logger Log verbosity (INFO default). Importing logger calls basicConfig, so this reaches every library in the process. Loggers known to print credentials (websockets, httpx, httpcore, …) are held at INFO regardless — raising this to DEBUG will not print your secrets
GOOGLE_CREDENTIALS_JSON GoogleAPI Google Drive + Sheets access
SPOTIPY_CLIENT_ID SpotifyAPI Spotify operations
SPOTIPY_CLIENT_SECRET SpotifyAPI Spotify operations
SPOTIPY_REFRESH_TOKEN SpotifyAPI Spotify operations
DISCORD_WEBHOOK_URL discord, activity Fallback webhook for any channel whose own variable is unset
DISCORD_WEBHOOK_URL_DEFAULT / _ERRORS / _ACTIVITY / _RUNS discord, activity One webhook per channel. Optional; each falls back to DISCORD_WEBHOOK_URL
ANTHROPIC_API_KEY llm extra Anthropic LLM calls
OPENAI_API_KEY llm extra OpenAI LLM calls
DOPPLER_TOKEN / DOPPLER_PROJECT / DOPPLER_CONFIG DopplerClient.from_env() Writing a secret back to Doppler from a service. The token is a service token with write access to that one config; Doppler's syncs inject the other two

Releasing

Releases are automated via semantic-release on push to main.

Commit format Bump Example result
fix: ... patch v1.0.0 -> v1.0.1
feat: ... minor v1.0.0 -> v1.1.0
feat!: ... major v1.0.0 -> v2.0.0

Important: Semantic-release only recognizes Conventional Commits format. These will NOT trigger a release:

  • breaking change: ... - unrecognized type
  • feat: breaking change ... - the word "breaking" in the message does not count
  • Free-form messages with no type prefix

For major bumps, prefer the BREAKING CHANGE footer in the commit body as it is more reliably parsed than feat!:

feat: your message here

BREAKING CHANGE: description of what changed and why it breaks

The footer format (BREAKING CHANGE: in the body) is the more battle-tested path across different versions of semantic-release. feat! should work per spec but has been known to behave inconsistently depending on plugin versions. Document both and lean on the footer.


Import Namespace

Use mini_app_polis as the import namespace across all modules (for example, from mini_app_polis.api import KaianoApiClient).

Metadata

Release files for miniapppolis-common-utils 6.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for miniapppolis-common-utils 6.3.0
File Size Uploaded
miniapppolis_common_utils-6.3.0.tar.gz 389.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for miniapppolis-common-utils 6.3.0
File Interpreter ABI Platform
miniapppolis_common_utils-6.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 557.3 kB

Release files / miniapppolis_common_utils-6.3.0.tar.gz

Download URL miniapppolis_common_utils-6.3.0.tar.gz
Size 389.3 kB
Tags Source
SHA-256 checksum
How to use checksums
7f66c19f869b21cdc435380914ba801dc89bcd0ee857094fd14b5ee82397bc35
BLAKE2b-256 checksum
How to use checksums
7acbc2cc75e7d72946c6e52b2d6a37db53cd03875f9894a8d3b92d4da8b0709c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / miniapppolis_common_utils-6.3.0-py3-none-any.whl

Download URL miniapppolis_common_utils-6.3.0-py3-none-any.whl
Size 168.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5793be44ad46a4b6288e735659aa6febb87cc56ef5519b395fc291aa27f73484
BLAKE2b-256 checksum
How to use checksums
54692a7b24496430a2566bc8ba8c8731e4530da929b2aa50eccefbec7b0c18d9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

6.4.0

2 release files

This release

6.3.0 This release

2 release files

6.2.0

2 release files

6.1.1

2 release files

6.1.0

2 release files

6.0.1

2 release files

6.0.0

2 release files

5.25.5

2 release files

5.25.4

2 release files

5.25.3

2 release files

5.25.2

2 release files

5.25.1

2 release files

5.17.0

2 release files

5.16.0

2 release files

5.15.4

2 release files

5.15.3

2 release files

5.15.2

2 release files

5.15.1

2 release files

5.15.0

2 release files

5.14.1

2 release files

5.14.0

2 release files

5.13.2

2 release files

5.13.1

2 release files

5.13.0

2 release files

5.12.1

2 release files

5.12.0

2 release files

5.11.0

2 release files

5.10.2

2 release files

5.10.1

2 release files

5.10.0

2 release files

5.9.1

2 release files

5.9.0

2 release files

5.8.0

2 release files

5.7.1

2 release files

5.7.0

2 release files

5.6.0

2 release files

5.5.1

2 release files

5.5.0

2 release files

5.4.0

2 release files

5.3.2

2 release files

5.3.1

2 release files

5.3.0

2 release files

5.2.0

2 release files

5.1.3

2 release files

5.1.2

2 release files

5.1.1

2 release files

5.1.0

2 release files

5.0.2

2 release files

5.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page