Skip to main content

cru-flags

PyPI license

Status: AI-generated, not actively maintained. This library was authored primarily by an AI assistant against the specification in docs/design.md and is not on anyone's active roadmap. Dependabot keeps dependencies and security advisories up to date automatically (patch + minor bumps auto-merge; majors require manual review), but feature work, bug fixes, and other changes happen on a best-effort basis. Pull requests and issues are welcome — they may take time to be reviewed. See CONTRIBUTING.md for the contribution workflow.

The official Python client for Cru's pipeline feature-flag service. It reads one URL from the environment, polls it in the background, and answers flag lookups from memory:

from cru_flags import flags

if flags.enabled("checkout_v2"):
    ...

enabled() does no I/O, never blocks, and never raises — unknown flags, a missing CRU_FLAGS_URL, and an unreachable flag service all answer False. Zero runtime dependencies, Python 3.11+, fully typed.


Install

pip install cru-flags

Then set the flag document URL for the environment the process runs in — the pipeline injects this for deployed services:

export CRU_FLAGS_URL=https://deploys.cru.org/flags/<project>/<environment>

<environment> is release-candidate or production.


Quickstart

The 99% path

from cru_flags import flags

flags.enabled("pilot_banner")  # -> True / False, never raises

flags is a module-level client built from the environment. Importing it starts nothing; the background poller starts on your first lookup.

Waiting for the first fetch at startup

from cru_flags import flags

if not flags.ready(timeout=3.0):
    log.info("cru-flags: still warming up; flags default to off")

ready() blocks until the first fetch attempt completes — success or failure — and returns whether that happened within timeout. It returns False immediately when no CRU_FLAGS_URL is configured.

Inspecting the current document

import json

from cru_flags import flags

json.dumps(flags.snapshot())
# {"Project": "ararat", "Environment": "release-candidate", "Version": 3,
#  "NotifySlack": true, "Flags": {"pilot_banner": {"Enabled": true, ...}}}

snapshot() returns a plain, JSON-serializable deep copy of the last document received ({} before the first success) — handy on a /health endpoint.

Explicit construction (tests, DI, non-default tuning)

from cru_flags import Client

client = Client(
    url="https://deploys.cru.org/flags/ararat/production",
    poll_seconds=30.0,  # refresh interval, ±20% jitter
    fetch_timeout=2.0,  # wall-clock deadline for one refresh
    on_error=None,  # None -> warn on the "cru_flags" logger
    refresh_mode="background",  # or "on-demand"
)

client.enabled("pilot_banner")
client.close()  # stop refreshing (optional; the thread is a daemon)

url=None (the default) reads CRU_FLAGS_URL on first use. on_error is called only on health transitions — with the exception when polling starts failing, with None when it recovers — so a long outage logs once, not once per poll.

On-demand refresh (Cloud Run, Lambda, anything that freezes)

export CRU_FLAGS_REFRESH_MODE=on-demand
from cru_flags import Client

flags = Client(refresh_mode="on-demand")  # or just use the singleton + env var

On scale-to-zero runtimes a background timer either doesn't run or keeps the instance warm for nothing. refresh_mode="on-demand" starts no thread: the refresh happens on the thread that reads a flag, and only when the snapshot is poll_seconds or older.

  • At most one conditional GET (usually a 304) per poll_seconds per process, measured from the last attempt — so a dead flag service costs one failed request per interval, not one per read. Concurrent readers coalesce onto one fetch; reads in between are served from memory.
  • The trade: enabled() can block, for up to fetch_timeout, once per interval. Everything else — fail-static, last-known-good forever, never raising, transition-only logging — is unchanged.

The env var switches the module-level flags singleton without a code change; an explicit refresh_mode argument wins over it, and an unrecognised env value warns and keeps background polling.

refresh() does the same refresh explicitly — useful in middleware if you'd rather pay it once per request than inside whichever enabled() call happens to be first:

flags.refresh()  # -> bool: fresh? no-op if the snapshot is younger than poll_seconds
flags.refresh(force=True)  # fetch regardless (also works in background mode)

Public API

Entry point Purpose
flags Module-level Client() built from CRU_FLAGS_URL.
Client(url=None, poll_seconds=30.0, fetch_timeout=2.0, on_error=None, refresh_mode=None) Explicit client for tests, DI, or non-default tuning.
Client.enabled(name) bool — is this flag on? Never raises; never blocks in background mode.
Client.ready(timeout=None) bool — block until the first fetch attempt completes.
Client.snapshot() dict — JSON-serializable copy of the last document.
Client.refresh(force=False) bool — refresh on this thread; no-op while the snapshot is fresh.
Client.close() Stop refreshing.

Behavioural contract

The library is designed to be fail-static: it is allowed to be out of date, but never allowed to be slow, loud, or fatal. Precisely:

Situation Behaviour
CRU_FLAGS_URL unset (or empty, or not http/https) Inert: every flag False, no thread, no socket, no warnings.
Before the first successful fetch Every flag False.
Flag name unknown, or Enabled missing False.
Enabled is not literally true (e.g. "true", 1, null) False — a malformed document reads as off.
Steady state One GET per poll_seconds ±20% jitter, with If-None-Match; 304 keeps the current snapshot.
Steady state, refresh_mode="on-demand" No thread; at most one GET per poll_seconds, on the reading thread, coalesced across concurrent readers.
404 from the service "No document published yet" — empty snapshot, not an error, no warning.
400 / 5xx / timeout / DNS failure / malformed JSON Last-known-good snapshot stays in force indefinitely (no TTL, no expiry to False). One warning on the transition into failure, one on recovery.
Retries None within a poll; the next poll is the retry.
Process exit The poller is a daemon thread and never delays interpreter shutdown.
Threads enabled() is safe from any thread; snapshot updates are a single atomic swap of an immutable document.

Every row above is covered by a test. The reasoning behind the surprising ones — no TTL, 404-is-data, transition-only logging — is in docs/design.md.


Local development

This repo pins the exact Python version in .tool-versions (read by asdf locally and by CI, so the two cannot drift) and uses uv for the virtualenv:

asdf plugin add python   # one-time, if not already set up
asdf install
uv venv --python "$(awk '/^python /{print $2}' .tool-versions)"
uv pip install -e ".[dev]"
source .venv/bin/activate

ruff check . && ruff format --check .
mypy
pytest
python -m build

There is one networked check that CI deliberately does not run:

python scripts/verify_live.py

It fetches the real public document for ararat/release-candidate and asserts that it parses and that a second conditional request returns 304.

See CONTRIBUTING.md for the workflow and docs/design.md for the design rationale.


Releasing

Releases are automated. release-please watches Conventional Commits on main and maintains a release PR; merging it tags the version, publishes a GitHub Release, and triggers .github/workflows/release.yml, which builds the sdist + wheel and uploads them to PyPI via Trusted Publishing (OIDC — there is no PyPI token in this repository).

The very first publish works through PyPI's pending publisher mechanism: the cru-flags project does not exist on PyPI yet, so the pending publisher configured for this repository and the pypi environment creates it on the first successful upload. No manual twine upload is needed at any point.


License

BSD-3-Clause.

Metadata

Release files for cru-flags 0.1.3

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

Source distribution (sdist)

Source distribution for cru-flags 0.1.3
File Size Uploaded
cru_flags-0.1.3.tar.gz 16.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cru-flags 0.1.3
File Interpreter ABI Platform
cru_flags-0.1.3-py3-none-any.whl Python 3 none any Details

Total release size: 31.8 kB

Release files / cru_flags-0.1.3.tar.gz

Download URL cru_flags-0.1.3.tar.gz
Size 16.1 kB
Tags Source
SHA-256 checksum
How to use checksums
69566027093e5c9944191ffb2fbc9ab7decaf584bf796504a06e9e07afb0e1ad
BLAKE2b-256 checksum
How to use checksums
7f959ce247b2848a4c8a6ea2f9c4a57586fc720b338c011ec888d5706b5789de
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.

Transparency log

Release files / cru_flags-0.1.3-py3-none-any.whl

Download URL cru_flags-0.1.3-py3-none-any.whl
Size 15.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
876e4514c7d4e1bba31a2b51328189c2c0f3c5310a90be764cd2e399a66b2afd
BLAKE2b-256 checksum
How to use checksums
7eebfc72b78748c52fe80470fb29ee4e5c71f9a8b9922c89f50ab82335ac1384
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.3 This release

2 release files

0.1.2

2 release files

0.1.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