feat(provider-tck): emit a machine-readable conformance report - #413
Draft
aepfli wants to merge 1 commit into
Draft
feat(provider-tck): emit a machine-readable conformance report#413aepfli wants to merge 1 commit into
aepfli wants to merge 1 commit into
Conversation
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>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Stacked on #409 — base is
feat/provider-tck, so the diff here is only the report emitter. Part of open-feature/spec#424; the schema is open-feature/spec#425. This mirrors the Go implementation in go-sdk-contrib.Setting
PROVIDER_TCK_REPORT_DIRmakes each suite write its run to<dir>/<name>.json.Both documents account for all 29 scenarios. A third suite, generated by the tests, produces all four outcomes at once:
{"passed": 22, "not-declared": 5, "not-applicable": 1, "failed": 1}, again 29 of 29.Why an environment variable
It is not a
TckConfigfield, because emitting a report is a property of the run and not of the code. CI sets it, a developer running the suite locally does not, and no adopter changes a line to publish one. Unset means no report, which is the default and is not an error. Several suites in one pytest session each write their own file, so flagd's RPC and in-process resolvers would not collide — and suite names are chosen to read well in a failure message rather than to be path-safe, soflagd/rpcis sanitised toflagd-rpc.jsoninstead of writing outside the directory it was given.Why every scenario is listed
Appendix F requires that a scenario skipped for an undeclared capability is reported as skipped with the reason and never as passed. Nothing downstream can check that against a summary line, so the report records the outcome of every scenario individually — and is 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, already reports a skip honestly, so the divergence between the runner and the document shows up somewhere else here. The one scenario the Python SDK cannot satisfy is marked
xfail(strict=True)(see finding 1 in the package README), so the run exits zero. The provider still did not satisfy it, and the document saysfailedwith the reason. An expected failure is a recorded deviation, not an excused one.Four outcomes rather than two:
passedfailedxfailnot-declarednot-applicablepytest.skipThe
not-declared/not-applicablesplit is decided from the scenario's tags and the suite's declared capabilities, never from the wording of the skip message: the message is prose and the distinction is not.Scenarios are enumerated at collection and resolved into records only at the end of the session. That ordering is load-bearing. A scenario skipped by a marker never runs a fixture, so an emitter that learned of a scenario when its fixtures ran would leave it out of the document entirely — which is the exact failure mode this format exists to rule out. An autouse fixture, requested by the capability gate so it is guaranteed to run first, is only what tells the emitter which
TckConfiga module is testing.Why the provider's own metadata name
provider.nameis what the provider reports throughget_metadata(), notTckConfig.name.TckConfig.nameis chosen to read well in a failure message —flagd-rpc— which makes it the configuration, and it is reported asprovider.configuration. One provider with two materially different modes therefore produces two reports that are not interchangeable, which is the property the schema'sconfigurationfield exists for. When a suite skipped every scenario and so never saw a provider, the name falls back to the suite name rather than the empty string the schema would reject.backend.controlApiis read off an optionalcontrol_apiproperty on theBackendControl, not a new protocol member: adding one would make every existing control incomplete for the sake of one string, and a control that stays quiet simply omits the field.InProcessControlreportsin-process; the plain in-memory suite's control says nothing, and its report has nocontrolApi— which is the intended behaviour, visible in the two files above.Identity, captured at build time
tck.specRevisionandtck.assetsTreecome from a generatedspec_revision.jsonthathatch_build_sync.pywrites beside the copied assets andhatch_build.pyforce-includes into the wheel and sdist. 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.The tree hash is carried as well as the commit 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 rather than asserted.
A build that cannot reach git warns and records
"unknown"(seven characters, the schema's minimum) rather than inventing a commit. The build never hard-fails on it.subprocess.runwith a list argv andpathlibthroughout, so the step works on Windows, Linux and macOS.Verification
Run on WSL Ubuntu, Python 3.10 (the repo's pinned version), against the schema as it stands on
feat/provider-tck-report-schema.Package test suite.
78 passed, 9 skipped, 2 xfailed.test_report.pyis new and adds 24 of those; four of its cases run a generated one-fixture adoption in a subprocess, because the properties it checks are properties of a whole pytest session.Lint and types.
ruff check,ruff format --checkandmypy(whose scope includestests) are all clean. No dependencies were added, souv.lockis untouched anduv sync --frozenis unaffected.Schema validation. Every emitted report validated with
jsonschemaas Draft 2020-12 againstspecification/assets/provider-tck/report/conformance-report.schema.json:in-memory.json{"passed": 23, "not-declared": 5, "failed": 1}controllable-in-memory.json{"passed": 24, "not-declared": 4, "failed": 1}report-fixture.json(generated by the tests){"passed": 22, "not-declared": 5, "not-applicable": 1, "failed": 1}Totals add up. In each report the outcome counts sum to the number of scenario entries (29), and the 29
(feature, name)pairs are distinct.test_every_collected_scenario_appears_exactly_oncechecks this against pytest's own--collect-onlycount rather than a number written down beside it, so adding a scenario to the specification cannot leave it passing while the report loses one.Tree hash is checkable.
git rev-parse dfa16586d91ca020ef1b3b82a7c972d833ff8f29:specification/assets/provider-tckin the submodule returns904aa7d5fd7a856a4f92ace24355bd1987143abc, which is what the reports carry.Packaging.
uv buildproduces a wheel containingspec_revision.jsonalongside the four feature files, the canonical flag set andcontrol-api.yaml. Building an sdist, unpacking it, deleting.gitand building a wheel from that — no submodule, no git — succeeds and carries the revision forward unchanged. The degraded path was exercised directly by pointing the sync at a directory that is not a repository: it warns twice and writes{"specRevision": "unknown", "assetsTree": ""}, which still validates.Failure to write. Verified end to end by pointing
PROVIDER_TCK_REPORT_DIRat a path under a regular file: the message appears and the session exits non-zero on an otherwise-passing run. A run that asked for a report and silently did not get one is how a publishing pipeline ends up serving a stale result forever.Things worth raising, rather than glossing
A report cannot name a Scenario Outline row portably. Every row of an outline shares one scenario name, and the schema has nowhere to put the row, so a report keyed on
(feature, name)would carry several indistinguishable entries — including, here, one row ofRequesting the wrong type returns the code defaultthat differs in outcome from its ten siblings. This implementation qualifies the name with pytest's example id (... [boolean-flag-Integer-1]), which is unambiguous but is not what godog would produce for the same row, so Go and Python reports do not key identically on outline rows. Either the schema needs a discriminator or the format needs to say how the row is spelled. Raised for open-feature/spec#424.There is no overall verdict, and a mandatory failure surfaces nowhere but
scenarios. The scenario the Python SDK fails carries no capability tag — it is a mandatory scenario — so it does not roll up intocapabilities, and the top level has no field that says the run failed.in-memory.jsonshows every declared capability aspassedwhile one mandatory scenario failed. A consumer that reads onlycapabilitieswould conclude the provider is fine. Worth deciding whether the format wants a top-level verdict or whether consumers are expected to aggregatescenariosthemselves.knownDeviationexists only oncapabilityResult, not onscenarioResult. The distinction it draws — "chose not to implement an optional feature" versus "has a known bug" — is exactly the one anxfailed scenario is recording, and there is nowhere to put it. The reason string carries the issue reference as prose instead.reasonis not required for a non-passedoutcome. The schema's own description says it is "required in practice"; nothing enforces it. This emitter always supplies one, but a report that omits it would validate.Not done here: CI does not upload the reports as artifacts. That belongs with whatever consumes them, and can follow.