Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ jobs:
build_branch: build
build_main: build
artifact_path: dist
# No PyPI yet: publishing comes with the API provider, once Trusted
# Publishing is configured on pypi.org.
# PyPI is not published from here: publish.yml uploads this build's
# artifact after a push to main, because Trusted Publishing cannot
# name a reusable workflow from another repository.
45 changes: 45 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Publish

# PyPI's Trusted Publishing names the workflow that uploads, and it cannot be a
# reusable workflow from another repository. So the build stays in
# tehw0lf/workflows and this workflow only uploads what that build produced,
# via OIDC, without a token.
on:
workflow_run:
workflows: [ Build ]
types: [ completed ]
branches: [ main ]

jobs:
publish_to_pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
# A pull request from a fork whose branch is also called main would pass
# the branches filter; only a push to this repository publishes.
if: >-
github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.event == 'push'
&& github.event.workflow_run.head_repository.full_name == github.repository
timeout-minutes: 15
environment:
name: pypi
url: https://pypi.org/p/yaft
permissions:
actions: read
id-token: write
steps:
- name: Download the build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build
path: dist
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0

# Fails if the version is already on PyPI, which never replaces a file:
# a merge without a version bump should be loud, not skipped.
- name: Publish to PyPI
# Named explicitly: uv build also leaves a .gitignore in dist.
run: uv publish --trusted-publishing always dist/*.whl dist/*.tar.gz
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@ Java and Go ports are references for behaviour, not for API shape.
the boolean shape.
- `src/yaft/providers.py` — `FeatureProvider` protocol, local providers;
`LocalFeatureProvider.load` is the all-or-nothing refresh (R30)
- `src/yaft/api.py` — `APIFeatureProvider` over `urllib` (R26, R30, R32):
hash first, group only on change, hash recorded only after the group
loaded; `refresh()` raises `RefreshError`, `refresh_quietly()` logs. No
redirects; a deadline checked between `read1` calls bounds a trickling body
- `src/yaft/toggle.py` — `feature_toggle`, `set_provider`; functions per call,
classes once, empty shell for an off class without fallback (R14–R19)
- `tests/backend.py` — a local stand-in backend; the refresh cases of the
suite run through `APIFeatureProvider` against it
- `tests/conformance/` — the suite adapter; unknown case values and unknown
case-file format versions must fail, never be skipped
- `tests/test_*.py` — what the suite cannot see (generators, static/class
Expand All @@ -26,6 +32,9 @@ Java and Go ports are references for behaviour, not for API shape.

- No runtime dependencies.
- `requires-python = ">=3.11"`; CI runs the tests on 3.11 and 3.14.
- PyPI: `.github/workflows/publish.yml` uploads the `build` artifact of a
push to `main` via Trusted Publishing (environment `pypi`). PyPI never
replaces a file, so a merge without a version bump fails that job.
- Bump the patch version in `pyproject.toml` on every PR and run `uv lock`.
- Mutation-check new rules: `PYTHONDONTWRITEBYTECODE=1`, or a same-length edit
within the same second keeps running the stale `.pyc`.
Expand Down
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ typed, and has no runtime dependencies. Python 3.11 or later.

## Installation

Not on PyPI yet; that comes with the API provider. Until then:

```bash
pip install git+https://github.com/tehw0lf/yaft-python
pip install yaft
# or
uv add yaft
```

```python
Expand All @@ -41,6 +41,7 @@ set_provider(LocalFeatureProvider({"newCheckout": Feature(key="newCheckout", val
|---|---|---|
| `LocalFeatureProvider` | full `Feature` records | yes: `active_at`, `disabled_at` |
| `LocalBooleanProvider` | `{"myToggle": True}` | none, by design |
| `APIFeatureProvider` | one group of a YaFT backend | yes, evaluated locally |

Both read a JSON file with `from_file(path)`. `LocalFeatureProvider` takes a
YaFT backend response (`{"toggles": [...]}`, in either field spelling) or
Expand All @@ -57,6 +58,49 @@ A missing or unreadable file raises instead of starting with everything off.
nothing: a body that is not a toggle group raises `ValueError` and the old
data stays.

### From a YaFT backend

`APIFeatureProvider` loads one toggle group over HTTP, with `urllib` and
nothing else:

```python
import threading
import time

from yaft import APIFeatureProvider, set_provider

provider = APIFeatureProvider("https://yaft.example.com", "896ea308-382f-46b0-bc59-d93a28013633")
provider.refresh() # raises RefreshError if the backend cannot be reached
set_provider(provider)


def refresh_every_minute() -> None:
while True:
time.sleep(60)
provider.refresh_quietly() # logs a failure instead of raising it


threading.Thread(target=refresh_every_minute, daemon=True).start()
```

Nothing is fetched until the first `refresh()`; until then every feature is
off. `refresh()` asks `/collectionHash/{uuid}` first and fetches
`/features/{uuid}` only when the group changed. It returns `True` for new data
and `False` for none, and raises `RefreshError` when the backend is down or
answers with something that is not a toggle group. The previous data then
stays: an outage does not switch everything off. An empty group does, because
that is what deleting its last toggle looks like.

A toggle is found by its name (`"newCheckout"`) or its full key
(`"896ea308-…|newCheckout"`). Time bounds are evaluated locally, so a
scheduled toggle flips at its instant, not when the backend's cron job runs.
Keyword arguments: `timeout` (seconds per request, default 5),
`max_body_bytes` (default 1 MiB), `clock`, and `opener` for a custom
`urllib.request.OpenerDirector`, for example one with its own TLS context.
The default opener follows no redirects.

### Your own provider

Any object with an `is_enabled(key: str) -> bool` method is a provider, for
example one that reads environment variables.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "yaft"
version = "0.1.0"
version = "0.2.0"
description = "Yet another Feature Toggle: decorators for functions, methods and classes"
readme = "README.md"
license = "MIT"
Expand Down
3 changes: 3 additions & 0 deletions src/yaft/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def total(self, cart: list[int]) -> int: ...

from importlib.metadata import PackageNotFoundError, version

from .api import APIFeatureProvider, RefreshError
from .evaluate import Clock, evaluate, parse_timestamp, system_clock
from .mapping import normalise_booleans, normalise_collection, normalise_feature, normalise_group
from .model import Feature
Expand All @@ -26,12 +27,14 @@ def total(self, cart: list[int]) -> int: ...
__version__ = "0+unknown"

__all__ = [
"APIFeatureProvider",
"Clock",
"Feature",
"FeatureProvider",
"LocalBooleanProvider",
"LocalFeatureProvider",
"ProviderNotSetError",
"RefreshError",
"__version__",
"evaluate",
"feature_toggle",
Expand Down
205 changes: 205 additions & 0 deletions src/yaft/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""A provider that loads one toggle group from a YaFT backend (SPEC sections 5-7)."""

import json
import logging
import threading
import time
import urllib.error
import urllib.request
import uuid
from collections.abc import Mapping
from importlib.metadata import PackageNotFoundError, version
from typing import Any
from urllib.parse import urlsplit

from .evaluate import Clock, evaluate, system_clock
from .model import Feature
from .providers import LocalFeatureProvider

log = logging.getLogger("yaft")

# urllib's own "Python-urllib/3.x" is on the block lists of common bot
# filters: Cloudflare answers it with 403, so a backend behind Cloudflare
# looks like one that refuses every refresh.
try:
_USER_AGENT = f"yaft-python/{version('yaft')}"
except PackageNotFoundError: # pragma: no cover - only without an installed distribution
_USER_AGENT = "yaft-python"


class RefreshError(Exception):
"""A refresh failed; the provider still holds the data it had before."""


class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Turns a redirect into an error instead of following it.

The backend never redirects, so a redirect comes from something in front of
it -- a login page, a captive portal -- whose target is not a toggle group.
"""

def redirect_request(self, *args: Any, **kwargs: Any) -> None:
return None


class APIFeatureProvider:
"""A feature-shape provider over one toggle group of a YaFT backend.

:meth:`refresh` asks ``GET /collectionHash/{uuid}`` whether the group
changed and only then fetches ``GET /features/{uuid}``. Evaluation is local,
against ``clock`` (R26): a scheduled toggle flips at its exact instant, not
when the backend's cron job gets to it.

Nothing is fetched until the first refresh; until then every feature is
off. A failed refresh raises :class:`RefreshError` and keeps the previous
data, so a backend outage does not switch everything off (R30). Refreshing
on a schedule is left to the application, for example::

def refresh_every_minute() -> None:
while True:
time.sleep(60)
provider.refresh_quietly()

threading.Thread(target=refresh_every_minute, daemon=True).start()

Safe for use from several threads.
"""

def __init__(
self,
base_url: str,
group: str,
*,
timeout: float = 5.0,
max_body_bytes: int = 1 << 20,
clock: Clock = system_clock,
opener: urllib.request.OpenerDirector | None = None,
):
"""Prepares a provider for ``group`` on the backend at ``base_url``.

``base_url`` must be http or https, without query or fragment.
``timeout`` bounds each request, from connecting until the last byte of
the body; a read already waiting may overrun it by at most ``timeout``.
``max_body_bytes`` keeps a misbehaving endpoint from exhausting memory.
``opener`` replaces the default one, for example to add a TLS context;
the default follows no redirects and honours the proxy environment
variables, like everything built on :mod:`urllib.request`.
"""
parts = urlsplit(base_url)
if parts.scheme not in ("http", "https") or not parts.netloc:
raise ValueError(f"base URL must be http or https: {base_url!r}")
if parts.query or parts.fragment:
raise ValueError(f"base URL must not carry a query or fragment: {base_url!r}")
# The UUID goes into the request path, so only the canonical 8-4-4-4-12
# form is accepted. uuid.UUID alone would also take braces, a "urn:"
# prefix or no hyphens.
try:
canonical = str(uuid.UUID(group))
except ValueError:
canonical = None
if canonical is None or canonical != group.lower():
raise ValueError(f"group is not a UUID: {group!r}")
if timeout <= 0 or max_body_bytes <= 0:
raise ValueError("timeout and max_body_bytes must be positive")

root = base_url.rstrip("/")
self._features_url = f"{root}/features/{canonical}"
self._hash_url = f"{root}/collectionHash/{canonical}"
self._key_prefix = f"{canonical}|"
self._timeout = timeout
self._max_body_bytes = max_body_bytes
self._opener = opener or urllib.request.build_opener(_NoRedirect)
self._clock = clock
self._local = LocalFeatureProvider(clock=clock)
self._lock = threading.Lock()
self._hash = ""

@property
def data(self) -> Mapping[str, Feature]:
"""The features of the last successful refresh by key, read-only."""
return self._local.data

def refresh(self) -> bool:
"""Fetches the group if it changed since the last successful refresh.

Returns ``True`` if new data was loaded and ``False`` if the group is
unchanged. Raises :class:`RefreshError` if the backend cannot be reached
or answers with something that is not a toggle group (R32); the
previous data then stays in place.
"""
with self._lock:
hash_ = _hash_of(self._get(self._hash_url))
if hash_ is None:
raise RefreshError(f"GET {self._hash_url} sent no collectionHash")
if hash_ == self._hash:
return False
try:
self._local.load(self._get(self._features_url))
except ValueError as error:
raise RefreshError(f"GET {self._features_url}: {error}") from error
# Recorded only after the group loaded, so a failed fetch is
# retried (R30).
self._hash = hash_
return True

def refresh_quietly(self) -> bool:
""":meth:`refresh` for a scheduler: logs a failure instead of raising it."""
try:
return self.refresh()
except RefreshError as error:
log.warning("refresh failed; keeping the previous data: %s", error)
return False

def is_enabled(self, key: str) -> bool:
"""Answers for a toggle of this group, by its name or its ``uuid|name`` key.

The key is looked up as given first, then as a name within the group,
so a toggle can be named without knowing the group's UUID.
"""
data = self._local.data
feature = data.get(key)
if feature is None and not key.startswith(self._key_prefix):
feature = data.get(self._key_prefix + key)
return evaluate(feature, self._clock())

def _get(self, url: str) -> object:
request = urllib.request.Request(
url, headers={"Accept": "application/json", "User-Agent": _USER_AGENT}
)
deadline = time.monotonic() + self._timeout
try:
with self._opener.open(request, timeout=self._timeout) as response:
if response.status != 200:
raise RefreshError(f"GET {url} answered {response.status}")
body = bytearray()
# read1 returns after one read from the socket, so the deadline
# is checked while a slow server trickles the body in; read(n)
# would wait for all n bytes.
while chunk := response.read1(64 * 1024):
body += chunk
if len(body) > self._max_body_bytes:
raise RefreshError(f"GET {url} sent more than {self._max_body_bytes} bytes")
if time.monotonic() > deadline:
raise RefreshError(f"GET {url} took longer than {self._timeout} s")
except urllib.error.HTTPError as error:
# Raised for 3xx (no redirects) and every 4xx and 5xx.
error.close()
raise RefreshError(f"GET {url} answered {error.code}") from error
except (OSError, ValueError) as error:
# URLError, timeouts, refused connections and malformed responses
# are all OSError; http.client raises some ValueErrors of its own.
raise RefreshError(f"GET {url}: {error}") from error
try:
return json.loads(body)
except (ValueError, RecursionError) as error:
# RecursionError: a body of nothing but "[" nests deeper than the
# parser recurses, well within max_body_bytes.
raise RefreshError(f"GET {url} sent a body that does not parse: {error}") from error


def _hash_of(response: object) -> str | None:
"""Reads the hash by presence, as the other ports do: collectionHash, else value."""
if not isinstance(response, Mapping):
return None
value = response["collectionHash"] if "collectionHash" in response else response.get("value")
return value if isinstance(value, str) and value != "" else None
Loading
Loading