diff --git a/providers/openfeature-provider-ofrep/README.md b/providers/openfeature-provider-ofrep/README.md index cad6a584c..14871f9bd 100644 --- a/providers/openfeature-provider-ofrep/README.md +++ b/providers/openfeature-provider-ofrep/README.md @@ -23,6 +23,57 @@ api.set_provider(OFREPProvider()) +## Provider conformance suite + +This provider runs the [OpenFeature Provider Conformance Suite][tck] against a flagd-testbed stack +serving OFREP, in `tests/tck`. The suite owns the container stack: `tests/tck/conftest.py` declares +a Compose file and the port the provider connects to, and nothing else. + +`tests/tck/docker-compose.yaml` is one definition of the backend for the whole repository, and the +flagd adoption carries a byte-identical copy — each provider package publishes its own distribution +and must not read the other's files, so the two are kept in step by `diff` rather than by sharing a +path. Change one, copy it to the other. + +**It is excluded from the default build, and a maintainer runs it by hand before merging a change to +it.** + +``` +poe test-tck # needs Docker +poe test # everything else, which is what CI runs +``` + +The exclusion lives in `pyproject.toml`: `--ignore=tests/tck` on the two tasks `build.yml` reaches, +with the reason in a comment above them. Why a conformance suite is not a required gate is +[Appendix F, "Running the suite in CI"][appendix-f], and is not restated here. + +Two things that are this provider's rather than the policy's: + +- **Docker is not what decides it.** The flagd package's `tests/e2e` needs Docker too and does run in + the default build. What decides it is the run: **2 failed, 45 passed, 17 skipped, 1 xfailed** — + both failures are canonical flags that flagd-testbed v3.8.0 does not seed yet, and the `xfail` is + the one genuine provider gap, recorded as a `KnownDeviation` rather than hidden. + `tests/tck/conftest.py` and `tests/tck/test_ofrep.py` account for each one, so a + reviewer running the suite can tell a new failure from a known one. +- **The default build still collects the suite** — `poe test` and `poe test-cov` end in + `pytest tests/tck --collect-only`, which imports every module and starts no container. An excluded + suite that has quietly stopped importing against the harness is worse than one that runs and + fails, and `mypy` here is configured over `src` alone, so nothing else would notice. + +**This suite races a known backend defect, and deliberately does not compensate for it.** +flagd-testbed's `POST /start` returns before it serves the reseeded flag set, which the control API +forbids, and a stateless provider has no initialisation to hide that window behind — so a run can +report `FLAG_NOT_FOUND` for flags the configuration plainly defines. The defect is +[flagd-testbed#394](https://github.com/open-feature/flagd-testbed/pull/394). + +This suite used to wrap the control in a `SettledControl` that polled until the flags were served. +That is removed. Compensating here made this suite's results incomparable with every other adoption +run against the same backend: it read a clean floor while the others bounced, and the difference was +the wait rather than the provider. **Read a red run against the documented floor and repeat it before +blaming the provider** — the race moves between scenarios, a real defect does not. + +[tck]: ../../tools/openfeature-tck/README.md +[appendix-f]: https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md + ## License Apache 2.0 - See [LICENSE](./LICENSE) for more information. diff --git a/providers/openfeature-provider-ofrep/pyproject.toml b/providers/openfeature-provider-ofrep/pyproject.toml index bb054ad55..0e1ff9ff2 100644 --- a/providers/openfeature-provider-ofrep/pyproject.toml +++ b/providers/openfeature-provider-ofrep/pyproject.toml @@ -29,12 +29,22 @@ Homepage = "https://github.com/open-feature/python-sdk-contrib" dev = [ "coverage[toml]>=7.10.0,<8.0.0", "mypy>=1.18.0,<2.0.0", + # The OpenFeature conformance suite. Ships the feature files, the flag set, + # the control-API client and the Compose harness that owns the container + # stack, and registers its step definitions through a pytest11 entry point -- + # so tests/tck declares a Compose file and nothing else. The `compose` extra + # is what pulls testcontainers in for the harness. + "openfeature-tck[compose]", "poethepoet>=0.37.0", "pytest>=9.0.0,<10.0.0", + "pytest-bdd>=8.1.0,<9.0.0", "requests-mock>=1.12.0,<2.0.0", "types-requests>=2.32.0,<3.0.0", ] +[tool.uv.sources] +openfeature-tck = { workspace = true } + [tool.uv.build-backend] module-name = "openfeature" module-root = "src" @@ -69,8 +79,35 @@ disallow_any_generics = false strict = true [tool.poe.tasks] -test = "pytest tests" -test-cov = "coverage run -m pytest tests" +# `tests/tck` is excluded from the default build on purpose, and a maintainer +# runs `poe test-tck` by hand before merging a change to it. Why a conformance +# suite is not a required gate is Appendix F, "Running the suite in CI" -- +# linked from tools/openfeature-tck/README.md -- and is deliberately not +# restated here. +# +# What is local to this package: a full run is 2 failed, 45 passed, 17 skipped, +# 1 xfailed. Both failures are canonical flags flagd-testbed does not seed yet, +# and the xfail is the one genuine provider gap; tests/tck/conftest.py accounts +# for them. Docker is not what decides the exclusion -- the flagd package's +# `tests/e2e` needs Docker too and does run. +# +# The suite is still *collected* on every default build, so it cannot quietly +# stop importing against the harness while nobody runs it. `--ignore` would +# otherwise leave nothing checking that, since mypy here is configured over +# `src` alone. +# +# `ignore_fail = "return_non_zero"` because poe aborts a sequence at its first +# failing subtask, which would put the collect step behind the default suite's +# result. It is green in this package today, and a check that only runs while +# everything else passes is not a check. Every subtask runs and a non-zero exit +# still propagates. The flagd package carries the same pair, where the default +# suite is red and the collect step was in fact never reached. +test = { sequence = ["test-default", "test-tck-collect"], ignore_fail = "return_non_zero" } +test-cov = { sequence = ["test-cov-default", "test-tck-collect"], ignore_fail = "return_non_zero" } +test-default = "pytest tests --ignore=tests/tck" +test-cov-default = "coverage run -m pytest tests --ignore=tests/tck" +test-tck = "pytest tests/tck" +test-tck-collect = "pytest tests/tck --collect-only -q" cov-report = "coverage xml" cov = [ "test-cov", diff --git a/providers/openfeature-provider-ofrep/tests/tck/__init__.py b/providers/openfeature-provider-ofrep/tests/tck/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/providers/openfeature-provider-ofrep/tests/tck/conftest.py b/providers/openfeature-provider-ofrep/tests/tck/conftest.py new file mode 100644 index 000000000..f1a9f4a00 --- /dev/null +++ b/providers/openfeature-provider-ofrep/tests/tck/conftest.py @@ -0,0 +1,120 @@ +"""Session fixtures for the OFREP conformance suite, and one recorded deviation. + +The container lifecycle belongs to the TCK -- see its README for what +``tck_backend`` does with the declaration below, and Appendix F, "The control +API", for why the stack is started once and never restarted. What is left here is +the declaration, the one wrapper this provider needs around the control, and the +xfail for the single scenario it cannot satisfy. + +A full run is ``2 failed, 45 passed, 17 skipped, 1 xfailed``. Both failures ask +for ``large-integer-flag``, which no released flagd-testbed seeds -- +open-feature/flagd-testbed#392 adds it, along with the two other canonical flags +the image is missing, and says what each catches. Neither carries a +``KnownDeviation``: the gap is the backend's flag set, not the provider's. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from openfeature.contrib.tools.tck import BackendControl, ComposeBackend, RunningBackend + +OFREP_PORT = 8016 +"""flagd's OFREP HTTP port, and the one port this provider connects to. + +flagd's own default, which the testbed's launchpad does not override. The +launchpad's control port is exposed by the harness and must not be listed here. +""" + + +@pytest.fixture(scope="session") +def compose_backend() -> ComposeBackend: + """The stack under test, as the TCK's ``tck_backend`` fixture wants it. + + The Compose file is the same one the flagd adoption uses -- one definition of + the backend, copied per package -- so it publishes flagd's two resolver ports + as well. Nothing here declares them, and a port nobody declares is neither + waited on nor looked up. + + The path is absolute so that pytest run from the repository root works too; + a relative one resolves against the working directory. + """ + return ComposeBackend( + compose_file=Path(__file__).parent / "docker-compose.yaml", + backend_ports=[OFREP_PORT], + ) + + +@pytest.fixture(scope="session") +def ofrep_base_url(tck_backend: RunningBackend) -> str: + """The origin flagd serves OFREP on, resolved once the stack is up. + + A fixture rather than a constant the suite module reads, because the mapped + host port does not exist until the stack has started. The provider appends + ``ofrep/v1/evaluate/flags/{key}`` itself (``ofrep/__init__.py:115-119``), so + this is the bare origin. + """ + endpoint = tck_backend.endpoint + return f"http://{endpoint.host}:{endpoint.port(OFREP_PORT)}" + + +@pytest.fixture(scope="session") +def ofrep_control(tck_backend: RunningBackend) -> BackendControl: + """The control API client, used exactly as the harness provides it. + + ``tck_backend.control`` is the TCK's own ``HttpControl``, already pointed at + the launchpad's mapped port and awaited ready. The launchpad registers no + ``/reset``, so every ``prepare_scenario`` takes the harness's documented + ``/start`` fallback and one 404 is logged per session. + + This suite used to wrap it in a ``SettledControl`` that polled the OFREP + endpoint until the reseeded flags were actually served, because this + backend's ``/start`` returns before that is true and a stateless provider + has no initialisation to hide the window behind. That wrapper is gone. + A backend returning before it serves breaks the control API contract, and + compensating for it here made this suite's results incomparable with every + other adoption run against the same backend -- this one read a clean floor + while the others bounced, and the difference was the wait, not the provider. + The defect is open-feature/flagd-testbed#394 and belongs there. + + So this suite now races the window like the others do. Read a red run + against the documented floor and repeat it before blaming the provider: the + race moves between scenarios, a real defect does not. + """ + return tck_backend.control + + +# --------------------------------------------------------------------------- +# One known deviation, recorded rather than hidden. +# +# A conformance suite that quietly goes green on a scenario it ran and failed is +# as bad as one that goes green on a scenario it skipped. So the single scenario +# this provider cannot satisfy is marked xfail(strict=True), which keeps it in +# the report with its reason attached and fails the suite the moment it starts +# passing -- so the marker is removed when the bug is fixed rather than +# lingering as a lie. Same mechanism, and same bug, as the TCK's own self-test +# (tools/openfeature-tck/tests/conftest.py). + +_BOOL_AS_INT = ( + "test_requesting_the_wrong_type_returns_the_code_default[boolean-flag-Integer-1]" +) + +_REASON = ( + "bool satisfies an Integer request. OFREP is an untyped protocol -- the " + "backend returns the JSON value with no knowledge of the requested type -- so " + "the whole type check is the provider's, at ofrep/__init__.py:244-256: " + "FlagType.INTEGER maps to `int` and the check is isinstance(value, int), which " + "bool is a subclass of in Python. boolean-flag requested as an Integer " + "therefore returns True with reason STATIC and no error code, where the " + "specification requires the code default and TYPE_MISMATCH. The Python SDK " + "client type-checks the same way, so fixing only one of the two is not enough. " + "See https://github.com/open-feature/python-sdk/issues/619" +) + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + for item in items: + if item.name == _BOOL_AS_INT: + item.add_marker(pytest.mark.xfail(reason=_REASON, strict=True)) diff --git a/providers/openfeature-provider-ofrep/tests/tck/docker-compose.yaml b/providers/openfeature-provider-ofrep/tests/tck/docker-compose.yaml new file mode 100644 index 000000000..9ed476d52 --- /dev/null +++ b/providers/openfeature-provider-ofrep/tests/tck/docker-compose.yaml @@ -0,0 +1,20 @@ +# The backend every conformance suite in this repository runs against: the unmodified +# flagd testbed image, serving flagd and its launchpad control API. +# +# Not flagd-testbed's own compose file, which adds an envoy sidecar for its +# forbidden-endpoint scenarios, names its service `flagd`, and bind-mounts a flags +# directory the launchpad writes into. None of that is needed to drive the launchpad. +# +# This file exists twice, once per provider package, and the two are byte-identical: +# providers/openfeature-provider-flagd/tests/tck/docker-compose.yaml +# providers/openfeature-provider-ofrep/tests/tck/docker-compose.yaml +# Each package publishes its own distribution and must not read the other's files, so +# `diff` the two paths after changing either -- that diff is what catches drift. +services: + backend: + image: ghcr.io/open-feature/flagd-testbed:v3.10.1 + ports: + - 8013 # flagd RPC evaluation (gRPC) + - 8015 # flagd in-process sync (gRPC) + - 8016 # flagd's OFREP HTTP API + - 8080 # launchpad control API diff --git a/providers/openfeature-provider-ofrep/tests/tck/test_ofrep.py b/providers/openfeature-provider-ofrep/tests/tck/test_ofrep.py new file mode 100644 index 000000000..eb489f846 --- /dev/null +++ b/providers/openfeature-provider-ofrep/tests/tck/test_ofrep.py @@ -0,0 +1,237 @@ +"""The OpenFeature provider conformance suite, run against the OFREP provider. + +OFREP is the vendor-neutral remote evaluation protocol, so what is under test +here is a pure mapping: one HTTP request per evaluation, and the translation of +its JSON response -- or its error status -- into typed resolution details. There +is no cache, no stream and no local ruleset, so unlike the flagd suites there is +nothing here that a lifecycle could be wrong about. + +The backend is flagd, which serves OFREP on port 8016 alongside its own +protocols, driven through the same launchpad control API and seeded with the +same canonical flag set as the flagd conformance suites. Running two providers +against one backend is the point of a cross-provider conformance suite: a +difference in the results is a difference an application would see when it +switches provider. +""" + +from __future__ import annotations + +import pytest +from pytest_bdd import scenarios + +from openfeature.contrib.provider.ofrep import OFREPProvider +from openfeature.contrib.tools.tck import ( + BackendControl, + Capability, + TckConfig, + feature_paths, +) +from openfeature.provider import FeatureProvider + +TIMEOUT_SECONDS = 10.0 +"""Bounds a single OFREP request. + +Generous, because every scenario is preceded by a control-API ``/start`` that +restarts the flagd process. It is the only timing knob this provider has +(``ofrep/__init__.py:52``); everything the TCK offers -- event timeouts, ready +timeouts -- has nothing to bound here, for the reasons below. +""" + +# Every capability below was declared, the suite run, and its scenarios seen to +# pass -- bar the one row named under VARIANTS, which fails on a flag the testbed +# does not seed. The code references say where the behaviour lives, so that a +# reader can check the claim; the run is what it rests on. +# +# OBJECT ofrep/__init__.py:105-113 resolves structured values, and the +# type check at ofrep/__init__.py:248 admits `(dict, list)` for +# FlagType.OBJECT -- so a JSON object comes back as one rather than +# being rejected or flattened. +# VARIANTS ofrep/__init__.py:160 carries the response's `variant` into the +# resolution details. Seven of the outline's eight rows pass; the +# eighth asks for large-integer-flag's `max-int32`, which +# no released flagd-testbed seeds (see conftest.py). Withholding +# over it would say this provider does not name variants, which the +# other seven rows show is false. +# TARGETING ofrep/__init__.py:229-230 puts the evaluation context's targeting +# key into the request body's `context`, so flagd evaluates +# targeting-key-flag's rule against it. All three scenarios pass -- +# the matching context, the non-matching one and no context at all +# -- and for a protocol with no types on the wire that is the whole +# of what is verified about passthrough: the key reached flagd. +# +# STANDARD_REASONS +# Eight of the file's nine scenarios run here and all eight pass: the four +# rule-less rows as STATIC, an unknown flag and a type mismatch as ERROR +# beside their error codes, and -- because TARGETING is declared above -- +# TARGETING_MATCH for the matched rule and DEFAULT for the miss. The ninth +# composes with @disabled-flags, withheld below, so it skips with that reason. +# +# **The obstacle that was expected here is not the one that exists**, and it +# was measured rather than reasoned about. ofrep/__init__.py:159 indexes +# `Reason[data["reason"]]` by *name*, so a server reporting a reason outside +# the SDK's enum raises KeyError -- which is why this capability looked like +# the risky one. It is not: every reason reason.feature asserts is an enum +# member, DISABLED included. Declaring @disabled-flags alongside this one and +# running the ninth scenario shows that index surviving the DISABLED reason +# and the *next* keyword argument failing -- `variant=data["variant"]` on line +# 160, KeyError: 'variant', reported to the application as GENERAL. Same +# one-line defect the @disabled-flags note below records, and nothing to do +# with the reason vocabulary. +# +# Not declared, and why. +# +# NUMERIC_COERCION +# Withheld, because this provider does not coerce at all -- which is the case +# Appendix F reserves withholding for, and the reason the flagd adoption in +# this repository reads the opposite way on the same rule. +# +# A declarer must satisfy all three scenarios; the two lossless rows exist to +# catch the shortcut of rejecting every float, and this provider takes that +# shortcut. It keeps the two numeric types strictly apart: json.loads yields +# `int` for 10 and `float` for 0.5, and the check at +# ofrep/__init__.py:249-256 admits a value only on an exact isinstance +# against one of them. Nothing in that path widens or narrows. So the lossy +# row passes -- float-flag asked for as an Integer is a TYPE_MISMATCH rather +# than a silent 0 -- and integer-flag asked for as a Float fails, because 10 +# is not an instance of float. Over OFREP the capability follows the +# language's JSON library rather than anything the provider author chose. +# +# No KnownDeviation: nothing requires numeric coercion, so there is no +# requirement to deviate from. The honest record is the undeclared tag and +# the three skips it produces. +# +# DISABLED_FLAGS +# Withheld, and **this is the one declaration in these suites that should +# change shape**: the provider attempts the behaviour and fails on one +# unconditional index, which is the declare-and-deviate case rather than the +# withhold case. It is left standing only because the pass that found it was a +# documentation pass and changing it moves a count; PR #414 records the +# decision, and the next change here declares the tag, accepts four failing +# rows and records the defect as a deviation. +# +# Measured, then read back, then probed at the wire. Declaring the tag fails +# all four rows, each on the error code rather than the value: "error-code was +# 'GENERAL', expected none" (probed at 56 collected, before reason.feature +# took the suite to 65; the four rows move from skipped to failed and nothing +# else changes). flagd's OFREP endpoint answers a disabled flag +# `200 {"key": ..., "reason": "DISABLED", "metadata": {}}` -- no `value` and +# no `variant`. The absent value is not the obstacle: +# ofrep/__init__.py:153 already reads `data.get("value", default_value)` and +# the type check on the next line passes on it. What fails is +# ofrep/__init__.py:160, indexing `data["variant"]` unconditionally, where +# types.md types the field `variant (string, optional)`. **The whole of the +# difference between passing and failing these four rows is one `.get`**, and +# the same index breaks on any variant-less OFREP response, not only a +# disabled flag. Filed as open-feature/python-sdk-contrib#418. +# +# So the capability is within reach of this provider rather than outside it, +# and flagd's RPC resolver satisfies the tag from the same signal in a +# different envelope. +# +# Everything below follows from the provider being stateless: it holds a +# requests.Session and a rate-limit timestamp, and nothing else survives +# between evaluations. +# +# LIFECYCLE +# OFREPProvider does not override `initialize`, so it inherits +# AbstractProvider's, which is `pass` (python-sdk +# openfeature/provider/__init__.py:138-139). Nothing contacts the backend +# before the first evaluation, so initialisation has no outcome to observe, +# and lifecycle.feature -- which carries @lifecycle at feature level -- skips +# as a whole. +# +# EVENTS +# The provider never emits: it inherits `attach` from AbstractProvider, but +# `_on_emit` is never called anywhere in ofrep/__init__.py, because there is +# no stream, no poll and no background thread to notice anything. +# +# The SDK's registry does dispatch PROVIDER_READY around `initialize` for any +# provider (python-sdk openfeature/provider/_registry.py:73-77), so declaring +# EVENTS would make the readiness scenario pass without demonstrating +# anything -- a NoOpProvider passes it identically. That is the vacuity +# @lifecycle was split out to end. +# +# STALE, CONFIGURATION_CHANGE +# Both follow from EVENTS. There is no connection to lose -- every evaluation +# is an independent HTTP request -- so no state between them can go stale, and +# nothing watches the backend for a change. events.feature is gated @events at +# feature level and skips as a whole. +# +# Note that a *change* is nonetheless visible to an application: the next +# evaluation issues a fresh request and returns the new value. What is missing +# is the signal, and the @configuration-change scenario asserts the event as +# well as the behaviour, deliberately. +# +# UNAVAILABLE_INIT +# A provider pointed at a closed port reaches READY, because `initialize` +# does nothing and the registry dispatches PROVIDER_READY unconditionally +# (python-sdk openfeature/provider/_registry.py:73-77). The failure surfaces +# on the first evaluation as GeneralError from ofrep/__init__.py:167, not as +# PROVIDER_ERROR, so the scenario's premise does not hold. `TckConfig` also +# rejects the capability without a `new_unavailable_provider`, and none is +# supplied here for the same reason. +# +# REINITIALIZATION +# Reuse would in fact work -- a stateless provider holding only a Session has +# nothing to release and nothing to rebuild, and `shutdown` is inherited and +# does nothing either -- but the scenario cannot be reached to demonstrate it: +# it inherits @lifecycle from its feature, withheld above, so declaring this +# would leave the scenario skipped and the claim unexamined. Requirement 2.5.2 +# permits reuse rather than requiring it, so withholding needs no +# KnownDeviation. +# +# LARGE_INTEGERS +# The one withholding here that is about the backend rather than the provider. +# Exactly one scenario carries the tag, it asks for `huge-integer-flag`, and +# no released flagd-testbed seeds such a flag -- so none of the tag's scenarios +# can be put to this provider, and Appendix F's sixth declaring rule says +# withhold. Contrast VARIANTS above, where seven of eight rows do reach the +# provider and the tag is declared on their strength. +# +# Nothing in this path would narrow the value: a JSON number decodes through +# `json.loads` into an unbounded Python `int` and the type check at +# ofrep/__init__.py:249-256 admits it unchanged -- and the suite cannot show +# that, which is the point. +# +# No KnownDeviation, in either shape: the gap is the fixture's and an entry +# would attribute it to the provider. Unlike the withholdings above, this one +# is temporary -- open-feature/flagd-testbed#392 adds the flag; declare the +# tag when the image carries it, or it outlives its reason and starts reading +# as a claim about the provider. +# +# CACHING +# Reserved, and the harness refuses it: no scenario carries the tag. +CAPABILITIES = frozenset( + { + Capability.OBJECT, + Capability.VARIANTS, + Capability.TARGETING, + Capability.STANDARD_REASONS, + } +) + + +@pytest.fixture(scope="session") +def tck_config( + ofrep_base_url: str, + ofrep_control: BackendControl, +) -> TckConfig: + """Wire the provider up to the running testbed. + + Both fixtures come from ``tests/tck/conftest.py`` and both resolve after the + TCK has started the stack, which is when the mapped host port exists. + """ + base_url = ofrep_base_url + + def new_provider() -> FeatureProvider: + return OFREPProvider(base_url, timeout=TIMEOUT_SECONDS) + + return TckConfig( + name="ofrep", + control=ofrep_control, + new_provider=new_provider, + capabilities=CAPABILITIES, + ) + + +scenarios(*feature_paths()) diff --git a/uv.lock b/uv.lock index 8017df40a..de3b3fea6 100644 --- a/uv.lock +++ b/uv.lock @@ -1970,8 +1970,10 @@ dependencies = [ dev = [ { name = "coverage", extra = ["toml"] }, { name = "mypy" }, + { name = "openfeature-tck", extra = ["compose"] }, { name = "poethepoet" }, { name = "pytest" }, + { name = "pytest-bdd" }, { name = "requests-mock" }, { name = "types-requests" }, ] @@ -1986,8 +1988,10 @@ requires-dist = [ dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.10.0,<8.0.0" }, { name = "mypy", specifier = ">=1.18.0,<2.0.0" }, + { name = "openfeature-tck", extras = ["compose"], editable = "tools/openfeature-tck" }, { name = "poethepoet", specifier = ">=0.37.0" }, { name = "pytest", specifier = ">=9.0.0,<10.0.0" }, + { name = "pytest-bdd", specifier = ">=8.1.0,<9.0.0" }, { name = "requests-mock", specifier = ">=1.12.0,<2.0.0" }, { name = "types-requests", specifier = ">=2.32.0,<3.0.0" }, ]