Repository navigation
feat: core, local providers and decorator, conformance suite 4.0.0 green #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| name: Build | ||
|
|
||
| on: | ||
| push: | ||
| branches: [ main ] | ||
| pull_request: | ||
| branches: [ main ] | ||
|
|
||
| jobs: | ||
| build_and_publish: | ||
| uses: tehw0lf/workflows/.github/workflows/build-test-publish.yml@main | ||
| permissions: | ||
| id-token: write | ||
| attestations: write | ||
| actions: write | ||
| contents: write | ||
| packages: write | ||
| security-events: write | ||
| pull-requests: write | ||
| with: | ||
| tool: uv | ||
| install: sync | ||
| lint: run ruff check && uv run ruff format --check && uv run mypy | ||
| # Fetches the conformance suite pinned in conformance.lock, then runs | ||
| # every test on the oldest and the newest supported Python: the | ||
| # orchestrator installs one version, so 3.11 would otherwise go | ||
| # untested and a newer-only construct would ship unnoticed. | ||
| test: run ./scripts/fetch-conformance.sh && uv run --python 3.11 pytest && uv run --python 3.14 pytest | ||
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| # CLAUDE.md | ||
|
|
||
| Python port of YaFT (PyPI `yaft`, import `yaft`). The normative rules are | ||
| `yaft-conformance/SPEC.md` (fetched to `tests/suite/SPEC.md`); the TypeScript, | ||
| Java and Go ports are references for behaviour, not for API shape. | ||
|
|
||
| ## Layout | ||
|
|
||
| - `src/yaft/model.py` — `Feature` (frozen dataclass, snake_case fields) | ||
| - `src/yaft/evaluate.py` — `Clock`, `evaluate`, `parse_timestamp` (R3–R13, | ||
| R27, R28). `datetime` does the calendar checks; only the offset minutes are | ||
| checked by hand. The pattern needs `re.ASCII` and `fullmatch`. | ||
| - `src/yaft/mapping.py` — response normalisation (R22–R25, R29, R30). A key or | ||
| value that is not a string is not set: no coercion, JSON booleans belong in | ||
| the boolean shape. | ||
| - `src/yaft/providers.py` — `FeatureProvider` protocol, local providers; | ||
| `LocalFeatureProvider.load` is the all-or-nothing refresh (R30) | ||
| - `src/yaft/toggle.py` — `feature_toggle`, `set_provider`; functions per call, | ||
| classes once, empty shell for an off class without fallback (R14–R19) | ||
| - `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 | ||
| methods, the shell, Python's parsing traps) | ||
|
|
||
| ## Constraints | ||
|
|
||
| - No runtime dependencies. | ||
| - `requires-python = ">=3.11"`; CI runs the tests on 3.11 and 3.14. | ||
| - 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`. | ||
|
|
||
| ## Pre-commit validation | ||
|
|
||
| ```bash | ||
| uv sync && uv run ruff check && uv run ruff format --check && uv run mypy \ | ||
| && ./scripts/fetch-conformance.sh && uv run --python 3.11 pytest \ | ||
| && uv run --python 3.14 pytest && uv build | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,160 @@ | ||
| <p align="center"><img src="logo.svg" alt="YaFT" width="120"></p> | ||
| # YaFT for Python | ||
|
|
||
| # yaft-python | ||
| <div align="center"> | ||
| <img src="./logo.svg" alt="YaFT Logo" width="140"> | ||
| </div> | ||
|
|
||
| YaFT (Yet another Feature Toggle) for Python: decorators for functions, | ||
| methods and classes, evaluated by the same rules as every other YaFT port and | ||
| checked against the shared | ||
| [conformance suite](https://github.com/tehw0lf/yaft-conformance). | ||
| --- | ||
|
|
||
| Work in progress. | ||
| Feature toggles for functions, methods and classes in Python, following the | ||
| same rules as [`@tehw0lf/yaft`](https://github.com/tehw0lf/yaft-ts), | ||
| [`de.tehwolf:yaft`](https://github.com/tehw0lf/yaft-java) and | ||
| [`yaft-go`](https://github.com/tehw0lf/yaft-go). It passes every case of | ||
| [yaft-conformance](https://github.com/tehw0lf/yaft-conformance), is fully | ||
| 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 | ||
| ``` | ||
|
|
||
| ```python | ||
| from yaft import feature_toggle, set_provider | ||
| ``` | ||
|
|
||
| ## Providers | ||
|
|
||
| Set one provider at startup: | ||
|
|
||
| ```python | ||
| from yaft import Feature, LocalFeatureProvider, set_provider | ||
|
|
||
| set_provider(LocalFeatureProvider({"newCheckout": Feature(key="newCheckout", value="true")})) | ||
| ``` | ||
|
|
||
| | Provider | Data | Time bounds | | ||
| |---|---|---| | ||
| | `LocalFeatureProvider` | full `Feature` records | yes: `active_at`, `disabled_at` | | ||
| | `LocalBooleanProvider` | `{"myToggle": True}` | none, by design | | ||
|
|
||
| Both read a JSON file with `from_file(path)`. `LocalFeatureProvider` takes a | ||
| YaFT backend response (`{"toggles": [...]}`, in either field spelling) or | ||
| features keyed by name, the format yaft-ts reads: | ||
|
|
||
| ```json | ||
| { | ||
| "newCheckout": { "value": "true", "activeAt": "2026-10-01T00:00:00Z", "disabledAt": "" } | ||
| } | ||
| ``` | ||
|
|
||
| A missing or unreadable file raises instead of starting with everything off. | ||
| `load(response)` replaces the data with a newer backend response, all or | ||
| nothing: a body that is not a toggle group raises `ValueError` and the old | ||
| data stays. | ||
|
|
||
| Any object with an `is_enabled(key: str) -> bool` method is a provider, for | ||
| example one that reads environment variables. | ||
|
|
||
| ### Evaluation rules | ||
|
|
||
| A feature is on when all of these hold: | ||
|
|
||
| - `value` is exactly `"true"`. `"True"`, `"1"` and `""` are off. | ||
| - `active_at` has been reached, if set. At exactly `active_at` it is on. | ||
| - `disabled_at` has not been reached, if set. At exactly `disabled_at` it is off. | ||
|
|
||
| A missing feature is off. Only RFC 3339 with an offset is a valid bound | ||
| (`2026-09-18T15:00:00Z`, `2026-09-18T17:00:00+02:00`); an unset or invalid one | ||
| is ignored with a warning on the `yaft` logger, never raised. The clock is a | ||
| parameter, so tests can pin the time: | ||
|
|
||
| ```python | ||
| from datetime import UTC, datetime | ||
|
|
||
| provider = LocalFeatureProvider(data, clock=lambda: datetime(2026, 9, 18, 12, tzinfo=UTC)) | ||
| ``` | ||
|
|
||
| The boolean shape has no time logic, and only the boolean `True` is on. A | ||
| value that is not a boolean, such as the string `"true"`, is dropped when the | ||
| data is loaded. | ||
|
|
||
| ## Toggling code | ||
|
|
||
| ### Functions and methods: evaluated on every call | ||
|
|
||
| ```python | ||
| class Checkout: | ||
| def classic_total(self, cart: list[int]) -> int: | ||
| return sum(cart) | ||
|
|
||
| @feature_toggle("newPricing", fallback=classic_total) | ||
| def total(self, cart: list[int]) -> int: | ||
| return sum(cart) - self.discount(cart) | ||
| ``` | ||
|
|
||
| Off, the fallback runs with the same arguments and the same `self`. Without a | ||
| fallback, an off call returns *nothing*: | ||
|
|
||
| | Decorated | Off, without a fallback | | ||
| |---|---| | ||
| | function or method | `None` | | ||
| | `async def` | a coroutine that resolves to `None`, so `await` still works | | ||
| | generator | an empty iterator, so a `for` loop still works | | ||
| | async generator | an empty async iterator | | ||
|
|
||
| A synchronous fallback on an `async def` is fine; its result is returned from | ||
| the coroutine. `staticmethod` and `classmethod` can be decorated too, with | ||
| `@feature_toggle` on the outside. | ||
|
|
||
| ### Classes: decided once | ||
|
|
||
| ```python | ||
| @feature_toggle("newCheckout", fallback=ClassicCheckout) | ||
| class NewCheckout: ... | ||
| ``` | ||
|
|
||
| The toggle is read **once**, when the class is decorated; changing it later | ||
| does not swap the class. Without a fallback, an off class becomes an *empty | ||
| shell*: it takes any constructor arguments, and every method, inherited ones | ||
| included, returns nothing of its own kind (see the table above). Properties | ||
| read as `None`. `__call__` and the context manager methods are kept; other | ||
| dunder methods and data attributes are not. | ||
|
|
||
| ### When a toggle is read | ||
|
|
||
| | | Evaluated | | ||
| |---|---| | ||
| | class | **once**, when decorated | | ||
| | function or method | on **every call** | | ||
| | `async def`, generator | on every call, when the coroutine or generator starts | | ||
|
|
||
| ### Fail fast | ||
|
|
||
| `feature_toggle` raises `ProviderNotSetError` ("FeatureToggleProvider not | ||
| set") when it is applied, not on the first call, if no provider is set. A | ||
| fallback of the wrong kind, such as a class for a function, raises `TypeError` | ||
| at the same point. A misconfiguration shows at startup. | ||
|
|
||
| ## Conformance | ||
|
|
||
| `conformance.lock` pins a release of the suite by version and checksum. | ||
| `scripts/fetch-conformance.sh` fetches and verifies it into `tests/suite`, and | ||
| `pytest` then runs every case next to this package's own tests. | ||
|
|
||
| ## Development | ||
|
|
||
| ```bash | ||
| uv sync | ||
| uv run ruff check && uv run ruff format --check && uv run mypy | ||
| ./scripts/fetch-conformance.sh && uv run --python 3.11 pytest && uv run --python 3.14 pytest | ||
| uv build | ||
| ``` | ||
|
|
||
| ## License | ||
|
|
||
| MIT |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| version=v5.0.0 | ||
| sha256=47b6adaac579aa7963f1fe1679676445c09b7f74c6812136ee35eeeeb4a7f3bc |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| [project] | ||
| name = "yaft" | ||
| version = "0.1.0" | ||
| description = "Yet another Feature Toggle: decorators for functions, methods and classes" | ||
| readme = "README.md" | ||
| license = "MIT" | ||
| license-files = ["LICENSE"] | ||
| authors = [{ name = "tehw0lf", email = "tehwolf@protonmail.com" }] | ||
| requires-python = ">=3.11" | ||
| # No runtime dependencies, on purpose: a feature toggle library should not pull | ||
| # a dependency tree into every application that uses it. | ||
| dependencies = [] | ||
| keywords = ["feature", "toggle", "feature-toggle", "feature-flag", "yaft"] | ||
| classifiers = [ | ||
| "Development Status :: 4 - Beta", | ||
| "Intended Audience :: Developers", | ||
| "Programming Language :: Python :: 3", | ||
| "Programming Language :: Python :: 3 :: Only", | ||
| "Programming Language :: Python :: 3.11", | ||
| "Programming Language :: Python :: 3.12", | ||
| "Programming Language :: Python :: 3.13", | ||
| "Programming Language :: Python :: 3.14", | ||
| "Typing :: Typed", | ||
| ] | ||
|
|
||
| [project.urls] | ||
| Homepage = "https://github.com/tehw0lf/yaft-python" | ||
| Repository = "https://github.com/tehw0lf/yaft-python" | ||
| Issues = "https://github.com/tehw0lf/yaft-python/issues" | ||
|
|
||
| [dependency-groups] | ||
| dev = ["mypy", "pytest", "ruff"] | ||
|
|
||
| [build-system] | ||
| requires = ["hatchling"] | ||
| build-backend = "hatchling.build" | ||
|
|
||
| [tool.ruff] | ||
| target-version = "py311" | ||
| line-length = 100 | ||
|
|
||
| [tool.ruff.lint] | ||
| select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "PT"] | ||
|
|
||
| [tool.mypy] | ||
| strict = true | ||
| python_version = "3.11" | ||
| files = ["src", "tests"] | ||
|
|
||
| [tool.pytest.ini_options] | ||
| testpaths = ["tests"] | ||
| addopts = "-ra --strict-markers" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| #!/usr/bin/env bash | ||
| # | ||
| # Fetches the YaFT conformance suite pinned in conformance.lock. | ||
| # | ||
| # Copy this into a port, next to a conformance.lock of the form: | ||
| # | ||
| # version=v1.0.0 | ||
| # sha256=<checksum of cases.tar.gz> | ||
| # | ||
| # The checksum is not optional. A Git tag can be moved; verifying the asset is | ||
| # what stops a port's tests from changing without a diff. | ||
| # | ||
| # Usage: scripts/fetch-conformance.sh [target-dir] (default: tests/suite) | ||
| # | ||
| # Set the default to wherever the port's adapter reads the cases from. Ports | ||
| # differ -- yaft-ts uses src/test/conformance -- and a default that does not | ||
| # match leaves the next test run reporting missing cases. | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| REPO="tehw0lf/yaft-conformance" | ||
| LOCK="${LOCK:-conformance.lock}" | ||
| TARGET="${1:-tests/suite}" | ||
|
|
||
| if [[ ! -f "$LOCK" ]]; then | ||
| echo "error: $LOCK not found; run from the port's root" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| # || true: without it, a missing line makes grep fail, and set -e would end | ||
| # the script before the message below could say why. | ||
| version="$(grep -E '^version=' "$LOCK" | cut -d= -f2- || true)" | ||
| expected="$(grep -E '^sha256=' "$LOCK" | cut -d= -f2- || true)" | ||
|
|
||
| if [[ -z "$version" || -z "$expected" ]]; then | ||
| echo "error: $LOCK needs both version= and sha256=" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| tmp="$(mktemp -d)" | ||
| trap 'rm -rf "$tmp"' EXIT | ||
|
|
||
| url="https://github.com/${REPO}/releases/download/${version}/cases.tar.gz" | ||
| echo "fetching conformance suite ${version}" | ||
| curl --fail --location --silent --show-error --output "$tmp/cases.tar.gz" "$url" | ||
|
|
||
| # sha256sum is GNU coreutils and is not present on macOS; shasum ships with | ||
| # both. Preferring sha256sum keeps Linux CI on the faster binary. | ||
| if command -v sha256sum >/dev/null 2>&1; then | ||
| actual="$(sha256sum "$tmp/cases.tar.gz" | cut -d' ' -f1)" | ||
| elif command -v shasum >/dev/null 2>&1; then | ||
| actual="$(shasum -a 256 "$tmp/cases.tar.gz" | cut -d' ' -f1)" | ||
| else | ||
| echo "error: neither sha256sum nor shasum found; cannot verify the download" >&2 | ||
| exit 1 | ||
| fi | ||
| if [[ "$actual" != "$expected" ]]; then | ||
| echo "error: checksum mismatch for cases.tar.gz" >&2 | ||
| echo " expected $expected" >&2 | ||
| echo " actual $actual" >&2 | ||
| echo "The tag may have been moved. Do not update the lock without reading the diff." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| rm -rf "$TARGET" | ||
| mkdir -p "$TARGET" | ||
| tar --extract --gzip --file "$tmp/cases.tar.gz" --directory "$TARGET" | ||
|
|
||
| echo "conformance suite ${version} unpacked into ${TARGET}" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Pin the privileged reusable workflow to a reviewed commit.
This job loads
tehw0lf/workflowsfrom the mutablemainbranch and grants the called workflow the write permissions on Lines 12–19. A later change to that branch can run with those permissions without a change to this repository. Pin the reusable workflow to a full commit SHA, and limit the caller’s permissions to those its jobs require. (docs.github.com)🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declined, by a standing decision for all YaFT ports:
tehw0lf/workflowsare the owner's own reusable workflows, protected by a ruleset onmain, and callers stay on@mainon purpose. Build and publish rights are already separated: every sub-job of the orchestrator gets only the permissions and secrets it needs, and this caller passes no secrets at all (OIDC only).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tehw0lf, thanks for the guidance. I understand that YaFT ports usetehw0lf/workflows@mainby design and that a ruleset protectsmain. I will not press for a commit pin here. I have not independently verified the sub-job permissions.✏️ Learnings added
You are interacting with an AI system.