Skip to content

feat(provider-tck): add the Python conformance suite for OpenFeature providers - #409

Draft
aepfli wants to merge 7 commits into
mainfrom
feat/provider-tck
Draft

feat(provider-tck): add the Python conformance suite for OpenFeature providers#409
aepfli wants to merge 7 commits into
mainfrom
feat/provider-tck

Conversation

@aepfli

@aepfli aepfli commented Aug 24, 2026

Copy link
Copy Markdown
Member

Closes #410
Part of open-feature/spec#417 — the cross-language tracking issue for the provider conformance suite. Java is the reference (java-sdk-contrib#1830); Go is in review (go-sdk-contrib#940).

This one was built and run locally, unlike the Go implementation: 56 passed, 7 skipped, 2 xfailed, with ruff and mypy --strict clean. Every design decision below was probed against pytest-bdd 8.1 rather than assumed.

What this is

A conformance suite any Python provider can adopt to verify it implements the provider contract — the Python implementation of Appendix F, running the same Gherkin scenarios, against the same canonical flag set, driven through the same control API as every other language's TCK. That shared basis is the point: "conformant" only means something if the question is identical everywhere.

It uses pytest-bdd, the same runner openfeature-provider-flagd and openfeature-flagd-api-testkit already depend on, so an adopting package gains no new test framework.

The adoption surface

One fixture and one call:

import pytest
from pytest_bdd import scenarios

from openfeature.contrib.tools.provider_tck import Capability, TckConfig, features_path


@pytest.fixture(scope="session")
def tck_config():
    control = MyBackendControl()
    return TckConfig(
        name="my-provider",
        control=control,
        new_provider=lambda: MyProvider(control.address),
        capabilities={Capability.EVENTS, Capability.OBJECT},
    )


scenarios(features_path())

No conftest.py, and nothing to import for the steps. The vocabulary ships as a pytest plugin registered through a pytest11 entry point. This is the nicest of the three implementations so far, and it is not an accident — pytest-bdd resolves steps through the fixture system, and fixtures from an installed plugin are visible to every test.

The feature files and canonical flag set are packaged with the distribution, so adopting needs no git submodule.

Four things I probed rather than assumed

Having a working toolchain this time, each of these was verified against pytest-bdd 8.1 before the design depended on it:

Question Answer
Does pytest-bdd turn Gherkin tags into markers, including dashed ones like @configuration-change? yes — getattr(pytest.mark, tag) handles them
Does pytest.skip() from an autouse fixture report skipped with the reason? yes, natively — no reporting machinery needed, unlike Go
Does scenarios() accept an absolute path into an installed package? yes
Do step definitions work from a plugin rather than the test module? yes — this is what removes the conftest.py

Two things that only showed up by running it, both now fixed and commented:

  • pytest-bdd creates markers without registering them, so every tag raised PytestUnknownMarkWarning — noise at best, a hard failure under -W error. The plugin registers them in pytest_configure.
  • The capability gate silently did nothing when guarded on request.fixturenames. pytest-bdd resolves a step's fixtures lazily as each step runs, so tck_config is not in fixturenames at setup time, and @unavailable scenarios ran against a config that never declared it. The gate now keys off the node's markers, which are on the item itself.

That second one is exactly the failure mode the suite exists to prevent — a gate that looks right and quietly passes everything — so it is pinned by a test.

Capabilities

A scenario whose capability was not declared is reported as skipped, with the reason — never as passed:

SKIPPED provider does not declare capability @stale.
        Declared: @events @object @strict-numeric-typing

Self-tests

Suite Subject Why
test_in_memory_conformance the SDK's InMemoryProvider reference adoption for a backend-less provider, and the Docker-free canary
test_controllable_conformance ControllableInMemoryProvider the only suite that exercises the configuration-change path — see finding 2
test_in_process_control InProcessControl pins what the Gherkin cannot assert about itself

There is no multi-provider suite because Python has no multi-provider — worth noting as its own gap.

Findings

1. A boolean satisfies an Integer request — and this is Python-only

boolean-flag evaluated through get_integer_details returns True, reason STATIC, no error code. The specification requires the code default and TYPE_MISMATCH.

type_map = {FlagType.INTEGER: int, ...}
if not isinstance(value, py_type):
    return TypeMismatchError(...)

bool is a subclass of int in Python, so isinstance(True, int) is True and the check passes. The application gets a value that behaves as 1, with nothing to indicate anything went wrong.

The identical scenario passes in Java and Go. No suite in another language could ever have caught this — which is a fair advertisement for the "multiple implementations" argument, and arrived on the suite's first real run.

Tracked as open-feature/python-sdk#619. The self-test marks that one row xfail(strict=True) with a pointer to the issue, so it stays visible in the report and fails the moment it starts passing, which forces the marker's removal when the SDK is fixed.

2. The in-memory provider cannot update its flag set

Appendix A requires it, and Python's copies its mapping in the constructor and exposes nothing to change it. Same class of gap as go-sdk#530, found independently in a second SDK.

Only half the machinery is missing — AbstractProvider already supplies emit_provider_configuration_changed — so ControllableInMemoryProvider here is a small subclass, not a reimplementation: every resolution decision is still the SDK's. It should port back as a method. Tracked as open-feature/python-sdk#620.

Verification

Check Result
pytest tests 56 passed, 7 skipped, 2 xfailed
ruff check (repo config) clean
mypy --strict clean, 13 source files
Assets byte-identical to spec#423 yes

Run in a clean venv against openfeature-sdk 0.8.4.

Known gaps

  • The assets are vendored, not submoduled. A follow-up will source them from the spec repo at build time, as openfeature-flagd-api-testkit already does for the flagd harness.
  • No HTTP control client yet — it arrives with the first containerised adopter (flagd or OFREP).
  • Evaluation context passthrough is unverifiable without an echo endpoint on the control API.
  • Caching, hooks and flag metadata are not covered.

Open questions

  1. Is tools/openfeature-provider-tck the right home, alongside the flagd testkit?
  2. The xfail(strict=True) for a known SDK deviation is a local answer to spec#417's open question 4 ("is a known-deviations concept needed?"). Does that shape look right before it becomes a pattern?
  3. Should ControllableInMemoryProvider live here at all, or should the SDK fix land first and this package depend on it?

…providers

A conformance suite any Python provider can adopt to verify it implements the
provider contract of the specification, and the Python implementation of the
cross-language suite defined in Appendix F. It runs the same Gherkin, the same
canonical flag set and the same control API as the Go and Java implementations.

It uses pytest-bdd, the runner the flagd provider and the flagd testkit already
use, so an adopting package gains no new test framework.

Adoption is one fixture and one call. The step definitions ship as a pytest
plugin registered through a pytest11 entry point, so there is no conftest.py to
write and nothing to import for the vocabulary - pytest-bdd resolves steps
through the fixture system, and fixtures from an installed plugin are visible
everywhere. The feature files and flag set are packaged with the distribution,
so adopting needs no git submodule.

Capability gating uses pytest.skip from an autouse fixture, so a scenario whose
capability was not declared is reported as skipped with the reason attached
rather than silently passing. The gate keys off the node's markers rather than
its requested fixtures: pytest-bdd resolves a step's fixtures lazily, so
tck_config is not in request.fixturenames at setup time, and guarding on that
silently disabled the gate.

Two self-test suites, plus unit tests for what the Gherkin cannot assert about
itself: the SDK's InMemoryProvider, and the TCK's own updatable one. The second
exists because the first cannot exercise the configuration-change path at all.

Findings, both confirmed by running the suite:

  * A boolean satisfies an Integer request. The client type-checks with
    isinstance(value, int) and bool subclasses int in Python, so boolean-flag
    requested as an Integer returns True with reason STATIC and no error code.
    This is Python-specific - the identical scenario passes in every other
    language - which is a fair argument for having more than one
    implementation. Tracked as open-feature/python-sdk#619, and marked
    xfail(strict=True) so it stays visible and un-hides itself once fixed.

  * InMemoryProvider cannot update its flag set, which Appendix A requires of
    an SDK in-memory provider. Only half the machinery is missing, since
    AbstractProvider already supplies emit_provider_configuration_changed, so
    ControllableInMemoryProvider is a small subclass rather than a
    reimplementation and should port back as a method. Tracked as
    open-feature/python-sdk#620.

Verified locally: 56 passed, 7 skipped, 2 xfailed; ruff and mypy --strict clean.

Part of open-feature/spec#417

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

aepfli added 2 commits August 24, 2026 13:57
Two things CI caught that local verification did not.

`ruff format` is a separate pre-commit hook from `ruff check`, and only the
latter was run locally. Nine files needed reformatting; the changes are
cosmetic line-wrapping only.

More importantly, the package was not being tested in CI at all. The build
matrix is gated on dorny/paths-filter and its filter list had no entry for
tools/openfeature-provider-tck, so no change under that path expanded the
matrix and the suite never ran. The locally reported 56 passed / 7 skipped /
2 xfailed was local-only. Adding the filter block, mirroring the one for
tools/openfeature-flagd-core, turns it on.

Verified after formatting: 56 passed, 7 skipped, 2 xfailed; ruff check and
mypy --strict still clean.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
`uv sync --frozen` in the build workflow validates the lockfile against the
manifests, and the previous commit added openfeature-provider-tck to the
workspace root's dependencies and [tool.uv.sources] without regenerating the
lock. That breaks the build job for *every* package, not just this one.

It was latent until now only because the paths-filter had no entry for this
package, so no build job ran at all. Enabling the filter in the previous commit
would have surfaced it as a red build.

The regeneration also picks up openfeature-provider-flagd 0.5.1 -> 0.5.2, which
the lock had missed when that release landed.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.41727% with 120 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.03%. Comparing base (92c5f49) to head (d6de5dc).

Files with missing lines Patch % Lines
...ure/contrib/tools/provider_tck/steps/flag_steps.py 72.97% 30 Missing ⚠️
...re/contrib/tools/provider_tck/steps/event_steps.py 61.19% 26 Missing ⚠️
...contrib/tools/provider_tck/steps/provider_steps.py 53.33% 21 Missing ⚠️
...c/openfeature/contrib/tools/provider_tck/values.py 76.78% 13 Missing ⚠️
...rc/openfeature/contrib/tools/provider_tck/state.py 84.93% 11 Missing ⚠️
...c/openfeature/contrib/tools/provider_tck/config.py 83.63% 9 Missing ⚠️
...openfeature/contrib/tools/provider_tck/__init__.py 76.47% 4 Missing ⚠️
...c/openfeature/contrib/tools/provider_tck/plugin.py 94.11% 2 Missing ⚠️
...enfeature/contrib/tools/provider_tck/capability.py 96.87% 1 Missing ⚠️
.../openfeature/contrib/tools/provider_tck/control.py 93.75% 1 Missing ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #409      +/-   ##
==========================================
- Coverage   95.64%   92.03%   -3.62%     
==========================================
  Files          24       59      +35     
  Lines        1057     2334    +1277     
==========================================
+ Hits         1011     2148    +1137     
- Misses         46      186     +140     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

TckConfig.ready_timeout was documented but never read by anything, so a
provider that hung while connecting would hang the whole pytest session with no
useful message, and the documented knob did nothing.

api.set_provider initialises synchronously and has no timeout of its own, so
the bound comes from running it on a worker thread and giving up on the result.
The worker is deliberately not cancelled -- Python cannot interrupt a thread
blocked in a socket call -- and is left to finish or die with the process,
which is acceptable because a timeout already means the scenario is failing.

A config field that claims to do something it does not is exactly the kind of
quiet untruth this suite exists to catch, so it is fixed rather than removed.

Verified: 56 passed, 7 skipped, 2 xfailed; ruff and mypy --strict clean.
Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…he tests

TckConfig.capabilities was annotated frozenset[Capability], but the README
tells adopters to write `capabilities={Capability.EVENTS, ...}` -- a set
literal. Anyone copying the documented example and running mypy got an
incompatible-argument error from the suite's own documentation. It is now
annotated Collection[Capability], which is what __post_init__ already accepted:
a set, a list or a generator all normalise to a frozenset on construction.

The reason this was invisible is the second half of the fix. mypy was
configured `files = "src"`, so the tests were never checked -- and the tests
are the reference adoption, the thing an adopting provider copies. They are now
in scope, which is what would have caught the annotation in the first place.

Verified: mypy clean over src and tests (17 files), ruff format and check
clean, 56 passed / 7 skipped / 2 xfailed.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck branch from a8e003a to 15a57bb Compare August 24, 2026 12:13
aepfli added 2 commits August 24, 2026 14:38
…dule

The feature files, the canonical flag set and the control-API document are
owned by open-feature/spec, not by this repository. Committing copies of them
here forks the definition of conformance -- the one thing this suite exists to
prevent -- and leaves no machine-checkable record of which spec revision the
copies came from.

Replace them with a git submodule at tools/openfeature-provider-tck/spec,
pinned at dfa16586 (spec#423), plus a build-time copy. The copies are
gitignored and carry a DO-NOT-EDIT marker, so the pin is now the only record
of the revision and the two cannot drift apart unnoticed.

An adopter installing this package still needs no submodule: the copies are
force-included into the wheel and the sdist, and the sdist excludes the
submodule itself so it carries the four assets rather than the whole spec
repository. Only a contributor to this package needs the submodule, and
`poe test` syncs it first.

This mirrors what openfeature-flagd-api-testkit already does for the flagd
test harness.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
lifecycle.feature was gated by @events, which was wrong in both directions.

An SDK dispatches PROVIDER_READY around initialize for any provider
(openfeature/provider/_registry.py), so a provider that declares @events
passes the readiness scenario without demonstrating anything -- a NoOpProvider
passes it identically. The gate made the scenario vacuous for exactly the
providers it admitted. Conversely a stateless provider such as OFREP has a
real initialisation to verify but no event stream of its own to declare
@events for, so the gate shut it out of a scenario it should be held to.

The spec revision pinned by the submodule retags the feature to @lifecycle and
adds the capability to Appendix F. Add the matching enum member; plugin.py
registers the marker by iterating the enum, so nothing else changes.

Neither in-memory self-test declares it. They have no backend to reach, so
their readiness scenario was passing vacuously too, and a skip with a reason
is the honest outcome. 54 passed, 9 skipped, 2 xfailed.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add tools/openfeature-provider-tck: a Python conformance suite for OpenFeature providers

1 participant