Skip to content

Commit 89c6009

Browse files
committed
feat(provider-tck): emit a machine-readable conformance report
Setting PROVIDER_TCK_REPORT_DIR makes each suite write its run to <dir>/<name>.json against the report schema in the specification repository (open-feature/spec#425, part of open-feature/spec#424). Unset means no report, which is the default and is not an error. An environment variable rather than a TckConfig field, so that emitting a report is a property of the run and not of the code: CI sets it, a local run does not, and no adopter changes a line to publish one. Several suites in one pytest session each write their own file, so flagd's two resolvers would not collide. The load-bearing part is the per-scenario list. Appendix F requires that a scenario skipped for an undeclared capability is reported as skipped with the reason and never as passed, and nothing downstream can check that against a summary line. Recording every scenario's outcome individually makes the rule checkable by the consumer instead of dependent on the runner. It is also required to be complete, because a document that quietly dropped what it skipped would satisfy the letter of the rule and still mislead whoever read it. pytest, unlike godog, reports a skip honestly -- so the interesting divergence here is elsewhere. The one scenario the Python SDK cannot satisfy is marked xfail, so the run finishes green; the provider still did not satisfy it, and the document says failed with the reason. An expected failure is a recorded deviation, not an excused one. Scenarios are therefore enumerated at collection and resolved at the end of the session rather than as fixtures run, which is also what keeps a scenario skipped by a marker -- whose fixtures never run at all -- from vanishing from the document. Identity comes from spec_revision.json, generated by hatch_build_sync.py beside the copied assets and force-included into the wheel. It has to be captured at build time: the submodule that knows the answer is not in the distribution, so an installed copy has nothing left to ask. A build that cannot reach git -- an unpacked sdist -- warns and records "unknown" rather than inventing a commit. Both the commit and the tree hash are recorded, the tree because it identifies the assets alone: unchanged by unrelated edits elsewhere in the specification, so two runs of identical assets agree even when pinned to different commits, and checkable because `git rev-parse <commit>:specification/assets/provider-tck` reproduces it. Two smaller decisions. The provider is identified by the name it reports through its own metadata, with TckConfig.name recorded as the configuration, because TckConfig.name is chosen to read well in a failure message -- "flagd-rpc" -- and a provider with two materially different modes produces two reports that are not interchangeable. And how the backend was driven is read off an optional control_api property rather than added to the BackendControl protocol, so that adding it leaves every existing control complete and one that stays quiet simply omits the field. The tests assert the two properties a consumer is entitled to assume -- that no scenario the capability gate stopped is ever reported as passed, and that every collected scenario appears exactly once, counted against pytest's own collection rather than against a number written down beside it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
1 parent 51b6d3d commit 89c6009

15 files changed

Lines changed: 1534 additions & 9 deletions

File tree

tools/openfeature-provider-tck/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@
55
src/openfeature/contrib/tools/provider_tck/features/
66
src/openfeature/contrib/tools/provider_tck/flag_data/
77
src/openfeature/contrib/tools/provider_tck/control-api.yaml
8+
# Generated alongside them, from the submodule pin, so a conformance report can
9+
# name the spec revision it ran against.
10+
src/openfeature/contrib/tools/provider_tck/spec_revision.json

tools/openfeature-provider-tck/README.md

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,19 +218,88 @@ is recorded by the pin and nowhere else, so the two cannot drift apart unnoticed
218218

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

221+
## Conformance reports
222+
223+
Set `PROVIDER_TCK_REPORT_DIR` and each suite writes a machine-readable record of its run to
224+
`<dir>/<name>.json`, conforming to the [report schema][report-schema] in the specification.
225+
226+
```console
227+
$ PROVIDER_TCK_REPORT_DIR=./reports pytest
228+
provider-tck [in-memory]: report written to reports/in-memory.json (1 failed, 5 not-declared, 23 passed)
229+
230+
$ jq '.scenarios | group_by(.outcome) | map({(.[0].outcome): length}) | add' reports/in-memory.json
231+
{
232+
"failed": 1,
233+
"not-declared": 5,
234+
"passed": 23
235+
}
236+
```
237+
238+
It is an environment variable rather than a `TckConfig` field so that emitting a report is a property
239+
of the *run* and not of the code: CI sets it, a developer running the suite locally does not, and no
240+
adopter changes a line to publish one. Unset means no report, which is not an error. Several suites
241+
in one pytest session each write their own file, so flagd's two resolvers would not collide.
242+
243+
### Why every scenario is listed
244+
245+
Appendix F requires that a scenario skipped for an undeclared capability is reported as skipped
246+
**with the reason** and never as passed. A consumer cannot check that against a summary line, so the
247+
report records the outcome of *every* scenario individually — and is required to be complete, because
248+
a document that quietly dropped what it skipped would satisfy the letter of the rule and still
249+
mislead whoever read it.
250+
251+
Which also means the report is not a transcription of pytest's summary. The run above finishes green:
252+
the one scenario the Python SDK cannot satisfy is marked `xfail` (finding 1), so pytest counts it as
253+
expected and exits zero. The provider still did not satisfy it, and the document says `failed` with
254+
the reason — an expected failure is a recorded deviation, not an excused one.
255+
256+
Four outcomes rather than two, because "did not run" is not one thing:
257+
258+
| Outcome | Means |
259+
| --- | --- |
260+
| `passed` | the scenario ran and passed |
261+
| `failed` | the scenario ran and failed, including a known deviation marked `xfail` |
262+
| `not-declared` | skipped because the provider did not declare a capability the scenario is tagged with |
263+
| `not-applicable` | skipped for any other reason — a marker an adopter applied, a step calling `pytest.skip` |
264+
265+
### What identifies a report
266+
267+
`tck.specRevision` and `tck.assetsTree` come from `spec_revision.json`, which `hatch_build_sync.py`
268+
generates from the submodule alongside the copied assets. It has to be captured at build time: the
269+
submodule is not in the wheel, so an installed copy has nothing left to ask. A build that cannot
270+
reach git — an unpacked sdist, say — warns and records `unknown` rather than inventing a commit.
271+
272+
The tree hash is carried as well as the commit because it identifies the assets alone. It is
273+
unchanged by unrelated edits elsewhere in the specification, so two runs that executed identical
274+
assets report the same value even when pinned to different commits — and it is checkable, since
275+
`git rev-parse <specRevision>:specification/assets/provider-tck` must reproduce it.
276+
277+
`provider.name` is what the provider reports through its own metadata, not `TckConfig.name`.
278+
`TckConfig.name` is chosen to read well in a failure message — `flagd-rpc` — which makes it the
279+
*configuration*, and it is reported as such. One provider with two materially different modes
280+
produces two reports that are not interchangeable.
281+
282+
`backend.controlApi` is read off an optional `control_api` property on your `BackendControl`,
283+
returning `"http"` or `"in-process"`. It is not a member of the protocol: adding one would make every
284+
existing control incomplete for the sake of one string, and a control that stays quiet simply omits
285+
the field.
286+
221287
## The self-tests
222288

223289
| Suite | Subject | Why |
224290
| --- | --- | --- |
225291
| `test_in_memory_conformance` | the SDK's `InMemoryProvider` | reference adoption for a backend-less provider |
226292
| `test_controllable_conformance` | `ControllableInMemoryProvider` | the only suite that exercises the configuration-change path — see finding 2 |
227293
| `test_in_process_control` | `InProcessControl` | pins what the Gherkin cannot assert about itself |
294+
| `test_report` | the conformance report | checks the two properties a consumer is entitled to assume |
228295

229296
```
230-
54 passed, 9 skipped, 2 xfailed
297+
78 passed, 9 skipped, 2 xfailed
231298
```
232299

233-
No Docker, no network, under a second.
300+
No Docker and no network. The conformance suites take under a second; `test_report` takes most of a
301+
minute, because the properties it checks are properties of a whole pytest session and it runs four of
302+
them in subprocesses to check them.
234303

235304
Neither in-memory suite declares `@lifecycle`, so the three lifecycle scenarios are skipped in both.
236305
That is the point: with no backend to reach, they would pass without testing anything — which is
@@ -242,7 +311,14 @@ what they did while the feature was gated on `@events`.
242311
cannot assert one *reached* the backend. That needs an echo operation on the control API.
243312
- **No HTTP control client yet.** It arrives with the first containerised adopter.
244313
- **Caching, hooks and flag metadata** are not covered.
245-
314+
- **A report cannot name a Scenario Outline row portably.** Every row of an outline shares one
315+
scenario name, and the report schema has nowhere to put the row, so several entries would be
316+
indistinguishable — including, here, one that differs in outcome from its siblings. This
317+
implementation qualifies the name with pytest's example id (`... [boolean-flag-Integer-1]`), which
318+
is unambiguous but is not what another language would produce for the same row. Raised on
319+
[open-feature/spec#424](https://github.com/open-feature/spec/issues/424).
320+
321+
[report-schema]: https://github.com/open-feature/spec/blob/main/specification/assets/provider-tck/report/conformance-report.schema.json
246322
[appendix-a]: https://github.com/open-feature/spec/blob/main/specification/appendix-a-included-utilities.md
247323
[appendix-f]: https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md
248324
[spec]: https://github.com/open-feature/spec

tools/openfeature-provider-tck/hatch_build.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,28 @@
1818
# the single definition of what gets copied where -- would not be importable.
1919
sys.path.insert(0, str(Path(__file__).parent))
2020

21-
from hatch_build_sync import FILES, PACKAGE_REL, SPEC_ASSETS, TREES, sync
21+
from hatch_build_sync import (
22+
FILES,
23+
PACKAGE_REL,
24+
REVISION_FILE,
25+
SPEC_ASSETS,
26+
TREES,
27+
sync,
28+
)
2229

2330

2431
class SpecAssetsCopyHook(BuildHookInterface):
2532
PLUGIN_NAME = "spec-assets-copy"
2633

2734
def initialize(self, version: str, build_data: dict) -> None:
2835
root = Path(self.root)
29-
copies = [root / PACKAGE_REL / dest for _, dest in TREES + FILES]
36+
# The generated revision file travels with the assets it describes. It
37+
# has to be built here rather than read at run time, because the
38+
# submodule that knows the answer is not in the wheel and a conformance
39+
# report has to name the revision it ran against.
40+
copies = [root / PACKAGE_REL / dest for _, dest in TREES + FILES] + [
41+
root / PACKAGE_REL / REVISION_FILE
42+
]
3043

3144
# Building from a checkout: refresh from the submodule, so what ships is
3245
# always the revision the pin names. Building from an sdist: there is no

tools/openfeature-provider-tck/hatch_build_sync.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@
1010
needs no submodule: the copies are inside the distribution.
1111
"""
1212

13+
import json
1314
import shutil
15+
import subprocess
16+
import warnings
1417
from pathlib import Path
1518

1619
ROOT = Path(__file__).parent
17-
SPEC_ASSETS = (ROOT / "spec/specification/assets/provider-tck").resolve()
20+
SPEC_ROOT = (ROOT / "spec").resolve()
21+
ASSETS_PATH_IN_SPEC = "specification/assets/provider-tck"
22+
SPEC_ASSETS = (SPEC_ROOT / ASSETS_PATH_IN_SPEC).resolve()
1823
PACKAGE_REL = Path("src/openfeature/contrib/tools/provider_tck")
1924
DEST_BASE = ROOT / PACKAGE_REL
2025

@@ -28,6 +33,27 @@
2833
TREES = [("gherkin", "features"), ("flags", "flag_data")]
2934
FILES = [("openapi/control-api.yaml", "control-api.yaml")]
3035

36+
REVISION_FILE = "spec_revision.json"
37+
"""Which revision of the specification the copied assets came from.
38+
39+
Recorded at build time because the answer is only available at build time: the
40+
submodule that holds it is not in the wheel, and a conformance report that cannot
41+
name the revision it ran against cannot be compared with another. It is generated
42+
by the same command that copies the assets, which is what keeps the two from
43+
disagreeing.
44+
45+
Not committed, for the same reason the assets are not: the submodule pin is the
46+
single record of which revision this package targets.
47+
"""
48+
49+
UNKNOWN_REVISION = "unknown"
50+
"""Seven characters, the minimum the report schema accepts.
51+
52+
A build that cannot reach git says it does not know rather than inventing a
53+
commit, and still produces a document that validates. Which happens for real:
54+
building from a source tarball has no ``.git`` to ask.
55+
"""
56+
3157

3258
def sync() -> None:
3359
if not SPEC_ASSETS.exists():
@@ -51,6 +77,50 @@ def sync() -> None:
5177
dest.unlink()
5278
shutil.copy2(SPEC_ASSETS / src_name, dest)
5379

80+
write_revision()
81+
82+
83+
def write_revision() -> None:
84+
"""Record the spec commit and the asset tree these copies came from.
85+
86+
The tree hash is carried as well as the commit because it identifies the
87+
assets alone: it does not change when an unrelated part of the specification
88+
does, so two runs that executed identical assets report the same value even
89+
when pinned to different commits. It is also checkable rather than merely
90+
asserted, since ``git rev-parse <commit>:specification/assets/provider-tck``
91+
must reproduce it.
92+
"""
93+
commit = _git("rev-parse", "HEAD") or UNKNOWN_REVISION
94+
tree = _git("rev-parse", f"HEAD:{ASSETS_PATH_IN_SPEC}") or ""
95+
(DEST_BASE / REVISION_FILE).write_text(
96+
json.dumps({"specRevision": commit, "assetsTree": tree}, indent=2) + "\n",
97+
encoding="utf-8",
98+
)
99+
100+
101+
def _git(*args: str) -> str:
102+
"""Run git inside the submodule, returning its output or an empty string.
103+
104+
A build must not hard-fail because git is absent or the checkout is not a
105+
repository -- both are ordinary when building from an unpacked sdist. The
106+
failure is reported as a warning and the identity degrades to ``unknown``,
107+
which is legible in the resulting report rather than silently wrong.
108+
"""
109+
command = ["git", "-C", str(SPEC_ROOT), *args]
110+
try:
111+
completed = subprocess.run( # noqa: S603
112+
command, capture_output=True, check=True, text=True
113+
)
114+
except (OSError, subprocess.CalledProcessError) as error:
115+
warnings.warn(
116+
f"could not determine the spec revision ({' '.join(command)}: {error}); "
117+
f"conformance reports from this build will not name the revision they "
118+
f"ran against",
119+
stacklevel=2,
120+
)
121+
return ""
122+
return completed.stdout.strip()
123+
54124

55125
if __name__ == "__main__":
56126
sync()

tools/openfeature-provider-tck/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ artifacts = [
5858
"src/openfeature/contrib/tools/provider_tck/features/",
5959
"src/openfeature/contrib/tools/provider_tck/flag_data/",
6060
"src/openfeature/contrib/tools/provider_tck/control-api.yaml",
61+
# Which spec revision those assets came from, generated beside them. The
62+
# submodule is not in the wheel, so a conformance report emitted by an
63+
# installed copy has no other way to name the revision it ran against.
64+
"src/openfeature/contrib/tools/provider_tck/spec_revision.json",
6165
]
6266

6367
[tool.hatch.build.hooks.custom]

tools/openfeature-provider-tck/src/openfeature/contrib/tools/provider_tck/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,19 @@ def tck_config():
6262
ControllableInMemoryProvider,
6363
canonical_flag_set,
6464
)
65+
from .report import REPORT_DIR_ENV, SCHEMA_VERSION, Outcome
6566

6667
__all__ = [
6768
"ALL_CAPABILITIES",
6869
"CHANGING_FLAG_KEY",
70+
"REPORT_DIR_ENV",
71+
"SCHEMA_VERSION",
6972
"BackendControl",
7073
"Capability",
7174
"ConnectionControl",
7275
"ControllableInMemoryProvider",
7376
"InProcessControl",
77+
"Outcome",
7478
"TckConfig",
7579
"UnsupportedControlError",
7680
"canonical_flag_set",

tools/openfeature-provider-tck/src/openfeature/contrib/tools/provider_tck/capability.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def __str__(self) -> str:
120120
"""
121121

122122
_BY_MARKER: dict[str, Capability] = {c.value: c for c in Capability}
123+
_BY_TAG: dict[str, Capability] = {c.tag: c for c in Capability}
123124

124125

125126
def capability_for_marker(name: str) -> Capability | None:
@@ -129,3 +130,14 @@ def capability_for_marker(name: str) -> Capability | None:
129130
the canonical feature files carry organisational tags freely.
130131
"""
131132
return _BY_MARKER.get(name)
133+
134+
135+
def capability_for_tag(tag: str) -> Capability | None:
136+
"""Map a Gherkin tag, leading at-sign included, onto the capability it gates.
137+
138+
The tag form rather than the marker form because that is what the
139+
conformance report carries: the report records a scenario's tags as the
140+
feature files spell them, and deciding whether a failure counts against a
141+
capability means reading them back.
142+
"""
143+
return _BY_TAG.get(tag)

tools/openfeature-provider-tck/src/openfeature/contrib/tools/provider_tck/control.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ def change_flag(self) -> None:
7373
def description(self) -> str:
7474
"""A short description of what is being controlled, for messages a human reads."""
7575

76+
# OPTIONAL: ``control_api``
77+
#
78+
# A control may also offer a ``control_api`` property returning ``"http"``
79+
# for the normative HTTP control API, or ``"in-process"`` for the narrow
80+
# allowance made for providers with no backend. The conformance report
81+
# records it, so that a claim of in-process control by a provider that does
82+
# have a backend can be treated with the suspicion it deserves.
83+
#
84+
# It is deliberately not a member of this protocol. Adding one would make
85+
# every existing control incomplete for the sake of one string, and there is
86+
# nothing useful the TCK can do with a control that has not said: it cannot
87+
# tell from the outside whether a control spoke HTTP or reached into the
88+
# process, so the field is simply omitted. See ``report.control_api_of``.
89+
7690

7791
@typing.runtime_checkable
7892
class ConnectionControl(typing.Protocol):

0 commit comments

Comments
 (0)