Skip to content

Commit 5720a9a

Browse files
committed
feat(tck): refuse a capability this SDK cannot express, rather than leaving it to adopters
Re-pin the spec submodule to 89b1519a, which adds a fifth rule for declaring to Appendix F -- a capability the language's SDK cannot express is refused by the implementation, not left to adopters -- and corrects the canonical flag set's comments, which still told every reader that every scenario expects reason STATIC. Neither feature file changes. Two such capabilities exist anywhere: @large-integers where the integer accessor is a 32-bit Integer, and @numeric-coercion where the language has one numeric type and "a float requested as an integer" does not name two different requests. Neither says anything about a provider. Leaving it to adopters means every adopter in the language has to know a fact about their language and remember to act on it, and in one implementation three separate suites each left the same capability undeclared with its own comment restating the same property -- three places to get right, and a single wrong one puts a claim in a report that no scenario could have verified. So INEXPRESSIBLE_CAPABILITIES maps such a capability to the property of the SDK that puts the question out of reach, TckConfig refuses one at construction, the capability gate skips its scenarios with that reason, and a knownDeviations entry may not name one -- the gap would be the language's and the entry would attribute it to this provider. A mapping rather than a set because the message has to name the property: an adopter who reaches this has done nothing wrong and "the specification says you may not" is not something they can act on. The two refusals stay distinguishable, in separate predicates with separate messages and separate skip reasons. A reservation is global and temporary -- no scenario anywhere carries the tag, and it expires the moment the specification writes one. An inexpressibility is one language's and permanent: the scenarios exist and other languages run and pass them. A reader seeing a capability absent from a report has to be able to tell "this provider declined" from "no provider in this language can be asked", because only the first says anything about the provider. Where a scenario is gated by both kinds, the language-wide reason wins, because the provider's declaration could not have made that scenario run either way. **The mapping is empty in Python, and that was measured rather than assumed.** `int` is arbitrary-precision; FlagType.INTEGER and FlagType.FLOAT are separate, reach separate provider methods and are type-checked against `int` and `float` separately. All four questions the two tags ask were put through the SDK's own client against a provider implementing the borrowed coercion rule: 2^53 - 1 resolved exactly, 0.5 as an Integer gave TYPE_MISMATCH and the caller's default, 10.0 as an Integer gave 10 and 10 as a Float gave 10.0. The adoptions agree from the other direction, and this was measured too rather than reasoned from the source: declaring @numeric-coercion in both flagd suites and running it, the in-process resolver refuses 0.5 as an integer and widens 10 to a float, while the RPC resolver widens 10 and silently narrows 0.5 to 0. Two resolvers of one provider giving different answers to the same three questions is exactly what a language that could not ask them makes impossible. Both are defects in an implementation, withholding the tag is the honest report for each, and neither is anything the language prevents. The third scenario fails on both for a third reason again -- flagd-testbed seeds no integral-float-flag -- which is also why the run leaves both suites' declarations exactly as they were. So nothing here is in force, and the machinery is added anyway. The rule belongs to Appendix F rather than to this package, a future capability may hit it, and the costs are not symmetric: an unused mechanism is a few lines nobody reads, while a missing one is discovered by an adopter publishing a claim no scenario could have examined. It is exercised rather than left dead -- the tests supply an entry and drive the refusal, the deviation refusal, both skip reasons and the precedence between them, so a mechanism with no instances is still known to work. DECLARABLE_CAPABILITIES is derived from both sets rather than listing what it excludes, which matters precisely because the new one is empty here: a derivation that quietly dropped it would look right in Python forever and be wrong in the one language where an entry gets added. Self-tests move from 214 passed / 42 skipped to 221 passed / 42 skipped. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
1 parent 8221e4a commit 5720a9a

7 files changed

Lines changed: 593 additions & 114 deletions

File tree

tools/openfeature-tck/README.md

Lines changed: 131 additions & 94 deletions
Large diffs are not rendered by default.

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,12 @@ def tck_config():
8181

8282
import importlib.resources
8383

84-
from .capability import DECLARABLE_CAPABILITIES, RESERVED_CAPABILITIES, Capability
84+
from .capability import (
85+
DECLARABLE_CAPABILITIES,
86+
INEXPRESSIBLE_CAPABILITIES,
87+
RESERVED_CAPABILITIES,
88+
Capability,
89+
)
8590
from .compose import (
8691
DEFAULT_BACKEND_SERVICE,
8792
DEFAULT_CONTROL_PORT,
@@ -125,6 +130,7 @@ def tck_config():
125130
"DEFAULT_CONTROL_PORT",
126131
"DEFAULT_STARTUP_TIMEOUT",
127132
"EXTENSIONS_DIRECTORY",
133+
"INEXPRESSIBLE_CAPABILITIES",
128134
"RESERVED_CAPABILITIES",
129135
"BackendControl",
130136
"BackendEndpoint",

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

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from __future__ import annotations
44

55
import typing
6+
from collections.abc import Mapping
67
from enum import Enum
8+
from types import MappingProxyType
79

810
__all__ = ["Capability"]
911

@@ -24,6 +26,12 @@ class Capability(str, Enum):
2426
run is worse than no suite at all.
2527
2628
Scenarios with no capability tag are mandatory and always run.
29+
30+
Two kinds of capability are refused rather than declared, and they are
31+
refused for different reasons and with different messages:
32+
:data:`RESERVED_CAPABILITIES`, which no scenario anywhere carries yet, and
33+
:data:`INEXPRESSIBLE_CAPABILITIES`, whose question this language's SDK cannot
34+
put at all. :data:`DECLARABLE_CAPABILITIES` is what is left.
2735
"""
2836

2937
LIFECYCLE = "lifecycle"
@@ -199,8 +207,13 @@ class Capability(str, Enum):
199207
The SDK's own ``InMemoryProvider`` cannot declare this: it hands values
200208
back untouched and the client's type check is ``isinstance``-based, so
201209
``10.0`` requested as an integer is a ``TYPE_MISMATCH`` rather than ``10``.
202-
The width of the integer accessor is a separate property, and a separate
203-
capability: :attr:`LARGE_INTEGERS`.
210+
That is the provider declining to coerce, not the language refusing to ask:
211+
a provider that does coerce returns an ``int`` and the same check passes it.
212+
In a language with one numeric type the question could not be put at all,
213+
which is why Appendix F names this as inexpressible there and why
214+
:data:`INEXPRESSIBLE_CAPABILITIES` is empty here. The width of the integer
215+
accessor is a separate property, and a separate capability:
216+
:attr:`LARGE_INTEGERS`.
204217
"""
205218

206219
LARGE_INTEGERS = "large-integers"
@@ -214,7 +227,10 @@ class Capability(str, Enum):
214227
215228
Python's ``int`` is unbounded, so a Python provider declares it unless
216229
something of its own -- a 32-bit field in its wire format, a float on the
217-
way through -- narrows the value. Nothing above 2^53 - 1 is asked for:
230+
way through -- narrows the value. Which makes this one of the two
231+
capabilities Appendix F names as inexpressible somewhere and **not** here:
232+
:data:`INEXPRESSIBLE_CAPABILITIES` is empty in Python, and says on what
233+
measurement. Nothing above 2^53 - 1 is asked for:
218234
JavaScript cannot represent it, and what a provider owes a value that does
219235
not fit the requested accessor is the open question in
220236
`open-feature/spec#430 <https://github.com/open-feature/spec/issues/430>`_.
@@ -394,6 +410,25 @@ def reserved(self) -> bool:
394410
"""Whether this capability exists in the vocabulary but gates no scenario."""
395411
return self in RESERVED_CAPABILITIES
396412

413+
@property
414+
def inexpressible(self) -> bool:
415+
"""Whether this SDK cannot put the question this capability's scenarios ask.
416+
417+
Distinct from :attr:`reserved` in every respect except that both end in a
418+
refusal. See :data:`INEXPRESSIBLE_CAPABILITIES`.
419+
"""
420+
return self in INEXPRESSIBLE_CAPABILITIES
421+
422+
@property
423+
def inexpressible_reason(self) -> str | None:
424+
"""Which property of this SDK puts the question out of reach, or ``None``.
425+
426+
The property, not the rule: a message that only says "this cannot be
427+
declared" leaves the adopter to discover why, and the why is the part
428+
they could not have been expected to know.
429+
"""
430+
return INEXPRESSIBLE_CAPABILITIES.get(self)
431+
397432
def __str__(self) -> str:
398433
return self.tag
399434

@@ -418,10 +453,58 @@ def __str__(self) -> str:
418453
capability that *can* be verified and is refused the chance.
419454
"""
420455

456+
INEXPRESSIBLE_CAPABILITIES: Mapping[Capability, str] = MappingProxyType({})
457+
"""Capabilities this language's SDK cannot put the question for, and why.
458+
459+
**Empty in Python, and that is a measurement rather than an omission.** The two
460+
that exist anywhere are :attr:`Capability.LARGE_INTEGERS`, inexpressible where
461+
the integer accessor is a 32-bit ``Integer``, and
462+
:attr:`Capability.NUMERIC_COERCION`, inexpressible where the language has a
463+
single numeric type and "a float requested as an integer" does not name two
464+
different requests. Python has neither property: ``int`` is arbitrary-precision,
465+
and ``get_integer_details`` and ``get_float_details`` are separate accessors
466+
reaching separate provider methods, type-checked against ``int`` and ``float``
467+
separately. Both were checked by asking all four questions through the SDK
468+
rather than by reading its source, and every one of them was answered.
469+
470+
So this mapping carries no entries, and the machinery around it carries no load
471+
here. It exists anyway because the rule is Appendix F's rather than this
472+
package's, because the next capability may hit it, and because the cost of the
473+
two is not symmetric: an unused mechanism is a few lines nobody reads, while a
474+
missing one is discovered by an adopter publishing a claim no scenario could
475+
have examined.
476+
477+
**Not the same thing as a reservation, and the difference is what the two
478+
messages have to carry.** A reserved capability is global and temporary -- no
479+
scenario anywhere carries the tag, and the reservation expires the moment the
480+
specification writes one. An inexpressible capability is one language's and
481+
permanent: the scenarios exist, other languages run them and pass them, and
482+
nothing changes until the SDK does. A reader seeing a capability missing from a
483+
report has to be able to tell *"this provider declined"* from *"no provider in
484+
this language can be asked"*, because only the first says anything about the
485+
provider. Hence a mapping rather than a set: the value is the property of the
486+
SDK that puts the question out of reach, and it is the half of the message an
487+
adopter could not have worked out for themselves.
488+
489+
A capability belongs here only when **no** provider in this language could ever
490+
satisfy it. A provider that gets the answer wrong is a different thing entirely
491+
and belongs nowhere near this mapping: flagd's Python provider fails one of the
492+
three ``@numeric-coercion`` scenarios and passes the other two, which is a
493+
defect in one implementation, recorded where that adoption records its defects.
494+
Withholding the tag is the honest report for it. Listing it here would say the
495+
question cannot be asked, and the other two scenarios passing is the proof that
496+
it can.
497+
498+
Never overlaps :data:`RESERVED_CAPABILITIES`: a tag no scenario carries is
499+
reserved, whatever any SDK could express about it.
500+
"""
501+
421502
DECLARABLE_CAPABILITIES: frozenset[Capability] = (
422-
frozenset(Capability) - RESERVED_CAPABILITIES
503+
frozenset(Capability)
504+
- RESERVED_CAPABILITIES
505+
- frozenset(INEXPRESSIBLE_CAPABILITIES)
423506
)
424-
"""Every capability an adoption may declare: the vocabulary minus the reserved tags.
507+
"""Every capability an adoption may declare: the vocabulary minus what is refused.
425508
426509
A reasonable starting point for a new adoption: declare everything, run the
427510
suite, and remove only what the provider genuinely cannot do. Narrowing from this
@@ -434,6 +517,12 @@ def __str__(self) -> str:
434517
way past, which is how one implementation came to report ``@targeting`` and
435518
``@caching`` as declared without anyone deciding to claim them -- back when both
436519
were reserved.
520+
521+
It excludes :data:`INEXPRESSIBLE_CAPABILITIES` for the same reason and one more:
522+
that set is empty in Python, so a default spanning the whole enum would look
523+
correct here forever and be wrong the day an entry is added, in the one language
524+
where it was added. Derived rather than listed, so it cannot be the thing that is
525+
out of date.
437526
"""
438527

439528
_BY_MARKER: dict[str, Capability] = {c.value: c for c in Capability}

tools/openfeature-tck/src/openfeature/contrib/tools/tck/config.py

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,11 @@ class KnownDeviation:
8989
to no capability.
9090
9191
A reserved capability is refused: no scenario carries the tag, so there is
92-
nothing to deviate from. See :data:`~.capability.RESERVED_CAPABILITIES`.
92+
nothing to deviate from. See :data:`~.capability.RESERVED_CAPABILITIES`. So
93+
is one this SDK cannot express, for the opposite reason -- the scenarios
94+
exist and no provider here can attempt them, so the gap is the language's
95+
and not this provider's. See
96+
:data:`~.capability.INEXPRESSIBLE_CAPABILITIES`.
9397
"""
9498

9599
@classmethod
@@ -214,11 +218,14 @@ class TckConfig:
214218
Naming a reserved capability here is rejected at construction rather than
215219
passed into a report. See :data:`~.capability.RESERVED_CAPABILITIES`.
216220
217-
A capability that cannot hold in a language at all -- ``@numeric-coercion``
218-
where the language has a single numeric type, ``@large-integers`` on a
219-
32-bit accessor -- is a property of the SDK rather than of the provider, and
220-
Appendix F records it once rather than every report restating it. Here it is
221-
simply left undeclared, and the skip carries the reason.
221+
So is one this language's SDK cannot put the question for at all --
222+
``@numeric-coercion`` where the language has a single numeric type,
223+
``@large-integers`` on a 32-bit accessor. That is a property of the SDK
224+
rather than of the provider, so it is refused here rather than left for
225+
every adopter to know and remember, and the error names the property. The
226+
two refusals are deliberately not the same message, and the scenarios they
227+
skip do not carry the same reason: see
228+
:data:`~.capability.INEXPRESSIBLE_CAPABILITIES`, which is empty in Python.
222229
"""
223230

224231
known_deviations: Sequence[KnownDeviation] = ()
@@ -283,6 +290,7 @@ def __post_init__(self) -> None:
283290
object.__setattr__(self, "known_deviations", tuple(self.known_deviations))
284291

285292
problems.extend(reserved_problems(self.capabilities))
293+
problems.extend(inexpressible_problems(self.capabilities))
286294
problems.extend(deviation_problems(self.known_deviations))
287295

288296
if (
@@ -353,6 +361,51 @@ def reserved_problems(declared: Iterable[Capability]) -> list[str]:
353361
]
354362

355363

364+
def inexpressible_problems(declared: Iterable[Capability]) -> list[str]:
365+
"""Refuse a capability this language's SDK cannot put the question for.
366+
367+
Refused here rather than left to adopters, because leaving it to adopters
368+
means every adopter in the language has to know a fact about their language
369+
and remember to act on it. Three suites in one implementation each left the
370+
same capability undeclared with its own comment restating the same property
371+
of the language: three places to get right, every one of them re-paid by the
372+
next adoption, and a single wrong one puts a claim in a report that no
373+
scenario could have verified. Appendix F makes this the implementation's job
374+
for exactly that reason.
375+
376+
**The message names the property of the SDK, not the rule.** An adopter who
377+
reaches this has done nothing wrong -- they declared a capability their
378+
provider may well have -- so the error has to tell them something they could
379+
not have known, and "the specification says you may not" is not it.
380+
381+
Separate from :func:`reserved_problems` on purpose, and it stays separate
382+
even though both end in the same refusal. A reserved capability is global and
383+
temporary: nothing anywhere carries the tag, and the reservation expires when
384+
the specification writes a scenario. An inexpressible one is this language's
385+
and permanent: the scenarios exist and other languages pass them. Collapsing
386+
them into one predicate would make the two indistinguishable at the only
387+
moment anybody is looking.
388+
"""
389+
refused = [
390+
capability
391+
for capability in declared
392+
if isinstance(capability, Capability) and capability.inexpressible
393+
]
394+
if not refused:
395+
return []
396+
return [
397+
f"{capability.tag} cannot be declared in this language: {reason}. No "
398+
f"provider in this SDK can be asked the question its scenarios put, so a "
399+
f"declaration could not be verified either way, and its absence from a "
400+
f"report says nothing about your provider. Its scenarios are skipped with "
401+
f"that reason. This is not a reservation -- the scenarios exist and other "
402+
f"languages run them -- and there is nothing for you to fix; it changes "
403+
f"when the SDK does"
404+
for capability in sorted(refused, key=lambda c: c.tag)
405+
if (reason := capability.inexpressible_reason) is not None
406+
]
407+
408+
356409
def deviation_problems(deviations: Sequence[KnownDeviation]) -> list[str]:
357410
"""Refuse a deviation that says nothing a consumer can use.
358411
@@ -398,6 +451,15 @@ def deviation_problems(deviations: Sequence[KnownDeviation]) -> list[str]:
398451
f"could show the gap. Remove it, or name the capability whose "
399452
f"scenarios the gap actually affects"
400453
)
454+
elif capability.inexpressible:
455+
problems.append(
456+
f"known_deviations[{index}] names {capability.tag}, which cannot "
457+
f"be expressed in this language: {capability.inexpressible_reason}. "
458+
f"A deviation says this provider fails something it is required to "
459+
f"do, and no provider in this SDK can attempt these scenarios at "
460+
f"all -- so the entry would attribute to your provider a gap that "
461+
f"belongs to the language. The skip already carries that reason"
462+
)
401463

402464
return problems
403465

tools/openfeature-tck/src/openfeature/contrib/tools/tck/plugin.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from __future__ import annotations
2121

2222
import typing
23+
from collections.abc import Iterable
2324
from pathlib import Path
2425

2526
import pytest
@@ -212,6 +213,16 @@ def tck_state(tck_config: TckConfig) -> typing.Iterator[TckState]:
212213

213214
@pytest.fixture(autouse=True)
214215
def _tck_capability_gate(request: pytest.FixtureRequest) -> None:
216+
"""Autouse wrapper around :func:`capability_gate`.
217+
218+
A one-line fixture over a plain function, so the decision it makes can be
219+
put under test without reaching inside a fixture object for the callable
220+
pytest wrapped -- which is private, and has moved between pytest versions.
221+
"""
222+
capability_gate(request)
223+
224+
225+
def capability_gate(request: pytest.FixtureRequest) -> None:
215226
"""Skip a scenario whose capability the provider did not declare.
216227
217228
``pytest.skip`` here reports the scenario as skipped **with the reason**,
@@ -226,6 +237,17 @@ def _tck_capability_gate(request: pytest.FixtureRequest) -> None:
226237
227238
Checking markers first also means the gate costs nothing, and instantiates
228239
nothing, for tests that are not TCK scenarios.
240+
241+
**A capability this SDK cannot express is skipped first, and says so.** Its
242+
scenarios would be skipped anyway -- nothing may declare it, so nothing
243+
does -- but with the wrong reason. "The provider does not declare it" reads
244+
as a decision the provider made, and no provider in this language had one to
245+
make; a reader of the report has to be able to tell those apart, because only
246+
the first says anything about the provider. Checked before the declaration
247+
loop rather than inside it so that a scenario gated by both kinds reports the
248+
permanent, language-wide reason rather than whichever tag came first off the
249+
marker iterator. Empty in Python; see
250+
:data:`~.capability.INEXPRESSIBLE_CAPABILITIES`.
229251
"""
230252
gated = [
231253
capability
@@ -235,17 +257,59 @@ def _tck_capability_gate(request: pytest.FixtureRequest) -> None:
235257
if not gated:
236258
return
237259

260+
inexpressible = inexpressible_skip_reason(gated)
261+
if inexpressible is not None:
262+
pytest.skip(inexpressible)
263+
238264
try:
239265
config: TckConfig = request.getfixturevalue("tck_config")
240266
except pytest.FixtureLookupError:
241267
return
242268

269+
undeclared = undeclared_skip_reason(gated, config)
270+
if undeclared is not None:
271+
pytest.skip(undeclared)
272+
273+
274+
def inexpressible_skip_reason(gated: Iterable[Capability]) -> str | None:
275+
"""Why these scenarios cannot be run in this language at all, or ``None``.
276+
277+
Says nothing about the provider, and says so, because the alternative
278+
reading is the one a reader will reach for: a capability missing from a
279+
report usually means the provider declined. Here nothing declined -- no
280+
provider in this SDK could be asked -- and Appendix F makes telling those
281+
two apart the implementation's job rather than the reader's.
282+
283+
Deterministic when more than one applies: the tags are sorted, so the
284+
message does not depend on the order markers come off a node.
285+
"""
286+
for capability in sorted(gated, key=lambda c: c.tag):
287+
reason = capability.inexpressible_reason
288+
if reason is not None:
289+
return (
290+
f"{capability.tag} cannot be expressed by this SDK, so no provider "
291+
f"in this language can be asked: {reason}. Nothing about the "
292+
f"provider under test follows from this skip"
293+
)
294+
return None
295+
296+
297+
def undeclared_skip_reason(
298+
gated: Iterable[Capability], config: TckConfig
299+
) -> str | None:
300+
"""Why this provider is not being asked these scenarios, or ``None``.
301+
302+
The other half of the pair, and the one that *is* about the provider: it
303+
declined, and the declaration it did make is quoted so a reader can see what
304+
was claimed instead.
305+
"""
243306
for capability in gated:
244307
if not config.declares(capability):
245-
pytest.skip(
308+
return (
246309
f"provider does not declare capability {capability.tag}. "
247310
f"Declared: {' '.join(config.sorted_capabilities) or '(none)'}"
248311
)
312+
return None
249313

250314

251315
@pytest.fixture(scope="session", autouse=True)

0 commit comments

Comments
 (0)