Metadata-Version: 2.4
Name: cru-flags
Version: 0.1.2
Summary: Official Python client for Cru's pipeline feature-flag service.
Project-URL: Homepage, https://github.com/CruGlobal/cru-flags-python#readme
Project-URL: Repository, https://github.com/CruGlobal/cru-flags-python
Project-URL: Issues, https://github.com/CruGlobal/cru-flags-python/issues
Project-URL: Changelog, https://github.com/CruGlobal/cru-flags-python/blob/main/CHANGELOG.md
Author: Cru
License-Expression: BSD-3-Clause
License-File: LICENSE
Keywords: cru,feature-flags,feature-toggles,flags,pipeline
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: mypy>=1.14; extra == 'dev'
Requires-Dist: pytest>=8.3; extra == 'dev'
Requires-Dist: ruff>=0.9; extra == 'dev'
Description-Content-Type: text/markdown

# cru-flags

[![PyPI](https://img.shields.io/pypi/v/cru-flags.svg)](https://pypi.org/project/cru-flags/)
[![license](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE)

> **Status: AI-generated, not actively maintained.** This library was
> authored primarily by an AI assistant against the specification in
> [`docs/design.md`](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`](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:

```python
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

```sh
pip install cru-flags
```

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

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

`<environment>` is `release-candidate` or `production`.

---

## Quickstart

### The 99% path

```python
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

```python
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

```python
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)

```python
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,  # per-request socket timeout
    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)

```sh
export CRU_FLAGS_REFRESH_MODE=on-demand
```

```python
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:

```python
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`](docs/design.md).

---

## Local development

This repo pins the exact Python version in [`.tool-versions`](.tool-versions)
(read by [`asdf`](https://asdf-vm.com/) locally and by CI, so the two cannot
drift) and uses [`uv`](https://docs.astral.sh/uv/) for the virtualenv:

```sh
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:

```sh
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`](CONTRIBUTING.md) for the workflow and
[`docs/design.md`](docs/design.md) for the design rationale.

---

## Releasing

Releases are automated. [release-please](https://github.com/googleapis/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](LICENSE).
