From 10c2d91ef17829c21914c5c0f98ed1ed5a34720f Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 12:17:21 +0200 Subject: [PATCH 1/8] ci: Run the Flagsmith SDK behavioural spec --- .github/workflows/sdk-spec.yml | 16 ++++++ renovate.json | 14 +++++ spec-adapter/Dockerfile | 14 +++++ spec-adapter/adapter.py | 97 ++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 .github/workflows/sdk-spec.yml create mode 100644 spec-adapter/Dockerfile create mode 100644 spec-adapter/adapter.py diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml new file mode 100644 index 0000000..ac83097 --- /dev/null +++ b/.github/workflows/sdk-spec.yml @@ -0,0 +1,16 @@ +name: SDK spec + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + sdk-spec: + # Keep the ref in `uses:` and `spec-ref` in sync. + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.0 + with: + spec-ref: v0.1.0 diff --git a/renovate.json b/renovate.json index 8bdf036..0ccc024 100644 --- a/renovate.json +++ b/renovate.json @@ -3,6 +3,15 @@ "extends": ["config:recommended", ":semanticCommitScopeDisabled"], "ignorePresets": [":semanticPrefixFixDepsChoreOthers"], "semanticCommitType": "deps", + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/workflows/sdk-spec\\.yml$/"], + "matchStrings": ["spec-ref: (?v[\\d.]+)"], + "depNameTemplate": "Flagsmith/sdk-spec", + "datasourceTemplate": "github-tags" + } + ], "packageRules": [ { "matchManagers": ["pip_requirements", "pip-compile", "pep621", "poetry", "github-actions"], @@ -22,6 +31,11 @@ { "matchPackageNames": ["flagsmith-flag-engine"], "enabled": true + }, + { + "matchPackageNames": ["Flagsmith/sdk-spec"], + "groupName": "SDK spec", + "enabled": true } ] } diff --git a/spec-adapter/Dockerfile b/spec-adapter/Dockerfile new file mode 100644 index 0000000..beb632d --- /dev/null +++ b/spec-adapter/Dockerfile @@ -0,0 +1,14 @@ +# Builds the SDK from source with the adapter for the Flagsmith SDK behavioural spec. +# Build from the repository root: docker build -f spec-adapter/Dockerfile . +FROM python:3.12-slim + +WORKDIR /sdk +COPY pyproject.toml poetry.lock README.md ./ +COPY flagsmith ./flagsmith +RUN pip install --no-cache-dir . + +COPY spec-adapter/adapter.py ./spec-adapter/adapter.py + +ENV PORT=8080 +EXPOSE 8080 +CMD ["python", "spec-adapter/adapter.py"] diff --git a/spec-adapter/adapter.py b/spec-adapter/adapter.py new file mode 100644 index 0000000..411052e --- /dev/null +++ b/spec-adapter/adapter.py @@ -0,0 +1,97 @@ +""" +Adapter that lets the Flagsmith SDK behavioural spec drive this SDK. + +Protocol: https://github.com/Flagsmith/sdk-spec/blob/main/ADAPTER.md +""" + +import itertools +import json +import os +import re +import traceback +import typing +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from flagsmith import Flagsmith +from flagsmith.models import Flags +from flagsmith.version import __version__ + +clients: typing.Dict[str, Flagsmith] = {} +client_ids = itertools.count(1) + +CLIENT_ROUTE = re.compile(r"^/clients/(?P[^/]+)(?P/[a-z-]+)?$") + + +def serialise_flags(flags: Flags) -> typing.Dict[str, typing.Any]: + return { + "flags": [ + {"featureName": flag.feature_name, "enabled": flag.enabled, "value": flag.value} + for flag in flags.all_flags() + ] + } + + +class Handler(BaseHTTPRequestHandler): + def _send(self, status: int, body: typing.Any = None) -> None: + payload = json.dumps(body).encode() if body is not None else b"" + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _body(self) -> typing.Any: + length = int(self.headers.get("Content-Length") or 0) + return json.loads(self.rfile.read(length)) if length else {} + + def _dispatch(self) -> None: + try: + self._route() + except Exception as e: # noqa: BLE001 - report SDK errors to the runner + traceback.print_exc() + self._send(500, {"error": f"{type(e).__name__}: {e}"}) + + def _route(self) -> None: + if self.command == "GET" and self.path == "/health": + return self._send(200, {"sdk": "flagsmith-python-client", "version": __version__}) + + if self.command == "POST" and self.path == "/clients": + body = self._body() + client_id = str(next(client_ids)) + clients[client_id] = Flagsmith( + environment_key=body["environmentKey"], + api_url=body["apiUrl"], + ) + return self._send(201, {"clientId": client_id}) + + match = CLIENT_ROUTE.match(self.path) + if not match or match["client_id"] not in clients: + return self._send(404, {"error": f"No route for {self.command} {self.path}"}) + client_id, action = match["client_id"], match["action"] + + if self.command == "DELETE" and action is None: + del clients[client_id] + return self._send(204) + + if self.command == "POST" and action == "/environment-flags": + return self._send(200, serialise_flags(clients[client_id].get_environment_flags())) + + if self.command == "POST" and action == "/identity-flags": + body = self._body() + flags = clients[client_id].get_identity_flags( + body["identifier"], traits=body.get("traits") or {} + ) + return self._send(200, serialise_flags(flags)) + + return self._send(404, {"error": f"No route for {self.command} {self.path}"}) + + do_GET = do_POST = do_DELETE = _dispatch + + def log_message(self, format: str, *args: typing.Any) -> None: + print(f"adapter: {format % args}", flush=True) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "8080")) + print(f"adapter: listening on :{port}", flush=True) + ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever() From 109ea4483e11784b29ed60434779274ab75a9bc9 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 12:31:37 +0200 Subject: [PATCH 2/8] ci: Bump SDK spec to v0.1.1 --- .github/workflows/sdk-spec.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index ac83097..6cbf98b 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,6 +11,6 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.1 with: - spec-ref: v0.1.0 + spec-ref: v0.1.1 From a98eeef3c6fda12bd5aae105b8cb81a00dab6fca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:31:55 +0000 Subject: [PATCH 3/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- spec-adapter/adapter.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/spec-adapter/adapter.py b/spec-adapter/adapter.py index 411052e..a6f878d 100644 --- a/spec-adapter/adapter.py +++ b/spec-adapter/adapter.py @@ -25,7 +25,11 @@ def serialise_flags(flags: Flags) -> typing.Dict[str, typing.Any]: return { "flags": [ - {"featureName": flag.feature_name, "enabled": flag.enabled, "value": flag.value} + { + "featureName": flag.feature_name, + "enabled": flag.enabled, + "value": flag.value, + } for flag in flags.all_flags() ] } @@ -53,7 +57,9 @@ def _dispatch(self) -> None: def _route(self) -> None: if self.command == "GET" and self.path == "/health": - return self._send(200, {"sdk": "flagsmith-python-client", "version": __version__}) + return self._send( + 200, {"sdk": "flagsmith-python-client", "version": __version__} + ) if self.command == "POST" and self.path == "/clients": body = self._body() @@ -66,7 +72,9 @@ def _route(self) -> None: match = CLIENT_ROUTE.match(self.path) if not match or match["client_id"] not in clients: - return self._send(404, {"error": f"No route for {self.command} {self.path}"}) + return self._send( + 404, {"error": f"No route for {self.command} {self.path}"} + ) client_id, action = match["client_id"], match["action"] if self.command == "DELETE" and action is None: @@ -74,7 +82,9 @@ def _route(self) -> None: return self._send(204) if self.command == "POST" and action == "/environment-flags": - return self._send(200, serialise_flags(clients[client_id].get_environment_flags())) + return self._send( + 200, serialise_flags(clients[client_id].get_environment_flags()) + ) if self.command == "POST" and action == "/identity-flags": body = self._body() From 9338a2c9a5a5fc372101f8730c7fae1490f92c00 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 12:59:55 +0200 Subject: [PATCH 4/8] ci: Implement the SDK spec as a steps file --- .github/workflows/sdk-spec.yml | 5 +- sdk-spec/Dockerfile | 13 ++++ sdk-spec/steps.py | 32 ++++++++++ spec-adapter/Dockerfile | 14 ----- spec-adapter/adapter.py | 107 --------------------------------- 5 files changed, 48 insertions(+), 123 deletions(-) create mode 100644 sdk-spec/Dockerfile create mode 100644 sdk-spec/steps.py delete mode 100644 spec-adapter/Dockerfile delete mode 100644 spec-adapter/adapter.py diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 6cbf98b..0a7d6e0 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,6 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.1 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@feat/v0.2.0 with: - spec-ref: v0.1.1 + spec-ref: feat/v0.2.0 + language: python diff --git a/sdk-spec/Dockerfile b/sdk-spec/Dockerfile new file mode 100644 index 0000000..d565079 --- /dev/null +++ b/sdk-spec/Dockerfile @@ -0,0 +1,13 @@ +# Runs this SDK, built from source, under the Flagsmith SDK behavioural spec. +# docker build -f sdk-spec/Dockerfile --build-context harness=/harness/python . +FROM python:3.12-slim + +WORKDIR /sdk +COPY pyproject.toml poetry.lock README.md ./ +COPY flagsmith ./flagsmith +RUN pip install --no-cache-dir . + +COPY --from=harness harness.py /harness/harness.py +COPY sdk-spec/steps.py ./sdk-spec/steps.py + +ENTRYPOINT ["python", "/harness/harness.py", "sdk-spec/steps.py"] diff --git a/sdk-spec/steps.py b/sdk-spec/steps.py new file mode 100644 index 0000000..8b7a2fb --- /dev/null +++ b/sdk-spec/steps.py @@ -0,0 +1,32 @@ +""" +Steps for the Flagsmith SDK behavioural spec: https://github.com/Flagsmith/sdk-spec + +Each function implements the Gherkin step in the comment above it. +""" + +from flagsmith import Flagsmith +from flagsmith.models import Flags + + +def as_spec_flags(flags: Flags) -> list[dict]: + return [ + {"feature": flag.feature_name, "enabled": flag.enabled, "value": flag.value} + for flag in flags.all_flags() + ] + + +# Given an SDK client for the environment +def create_client(environment_key: str, api_url: str) -> Flagsmith: + return Flagsmith(environment_key=environment_key, api_url=api_url) + + +# When the application gets the environment flags +def get_environment_flags(flagsmith: Flagsmith) -> list[dict]: + return as_spec_flags(flagsmith.get_environment_flags()) + + +# When the application gets the flags for identity "{identifier}" with traits: +def get_identity_flags( + flagsmith: Flagsmith, identifier: str, traits: dict +) -> list[dict]: + return as_spec_flags(flagsmith.get_identity_flags(identifier, traits=traits)) diff --git a/spec-adapter/Dockerfile b/spec-adapter/Dockerfile deleted file mode 100644 index beb632d..0000000 --- a/spec-adapter/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -# Builds the SDK from source with the adapter for the Flagsmith SDK behavioural spec. -# Build from the repository root: docker build -f spec-adapter/Dockerfile . -FROM python:3.12-slim - -WORKDIR /sdk -COPY pyproject.toml poetry.lock README.md ./ -COPY flagsmith ./flagsmith -RUN pip install --no-cache-dir . - -COPY spec-adapter/adapter.py ./spec-adapter/adapter.py - -ENV PORT=8080 -EXPOSE 8080 -CMD ["python", "spec-adapter/adapter.py"] diff --git a/spec-adapter/adapter.py b/spec-adapter/adapter.py deleted file mode 100644 index a6f878d..0000000 --- a/spec-adapter/adapter.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -Adapter that lets the Flagsmith SDK behavioural spec drive this SDK. - -Protocol: https://github.com/Flagsmith/sdk-spec/blob/main/ADAPTER.md -""" - -import itertools -import json -import os -import re -import traceback -import typing -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -from flagsmith import Flagsmith -from flagsmith.models import Flags -from flagsmith.version import __version__ - -clients: typing.Dict[str, Flagsmith] = {} -client_ids = itertools.count(1) - -CLIENT_ROUTE = re.compile(r"^/clients/(?P[^/]+)(?P/[a-z-]+)?$") - - -def serialise_flags(flags: Flags) -> typing.Dict[str, typing.Any]: - return { - "flags": [ - { - "featureName": flag.feature_name, - "enabled": flag.enabled, - "value": flag.value, - } - for flag in flags.all_flags() - ] - } - - -class Handler(BaseHTTPRequestHandler): - def _send(self, status: int, body: typing.Any = None) -> None: - payload = json.dumps(body).encode() if body is not None else b"" - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def _body(self) -> typing.Any: - length = int(self.headers.get("Content-Length") or 0) - return json.loads(self.rfile.read(length)) if length else {} - - def _dispatch(self) -> None: - try: - self._route() - except Exception as e: # noqa: BLE001 - report SDK errors to the runner - traceback.print_exc() - self._send(500, {"error": f"{type(e).__name__}: {e}"}) - - def _route(self) -> None: - if self.command == "GET" and self.path == "/health": - return self._send( - 200, {"sdk": "flagsmith-python-client", "version": __version__} - ) - - if self.command == "POST" and self.path == "/clients": - body = self._body() - client_id = str(next(client_ids)) - clients[client_id] = Flagsmith( - environment_key=body["environmentKey"], - api_url=body["apiUrl"], - ) - return self._send(201, {"clientId": client_id}) - - match = CLIENT_ROUTE.match(self.path) - if not match or match["client_id"] not in clients: - return self._send( - 404, {"error": f"No route for {self.command} {self.path}"} - ) - client_id, action = match["client_id"], match["action"] - - if self.command == "DELETE" and action is None: - del clients[client_id] - return self._send(204) - - if self.command == "POST" and action == "/environment-flags": - return self._send( - 200, serialise_flags(clients[client_id].get_environment_flags()) - ) - - if self.command == "POST" and action == "/identity-flags": - body = self._body() - flags = clients[client_id].get_identity_flags( - body["identifier"], traits=body.get("traits") or {} - ) - return self._send(200, serialise_flags(flags)) - - return self._send(404, {"error": f"No route for {self.command} {self.path}"}) - - do_GET = do_POST = do_DELETE = _dispatch - - def log_message(self, format: str, *args: typing.Any) -> None: - print(f"adapter: {format % args}", flush=True) - - -if __name__ == "__main__": - port = int(os.environ.get("PORT", "8080")) - print(f"adapter: listening on :{port}", flush=True) - ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever() From 5378141e343404a609b63f651fc06f3c31fb2921 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 13:21:25 +0200 Subject: [PATCH 5/8] ci: Type the SDK spec steps file for mypy --strict --- sdk-spec/steps.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/sdk-spec/steps.py b/sdk-spec/steps.py index 8b7a2fb..9e4dd7e 100644 --- a/sdk-spec/steps.py +++ b/sdk-spec/steps.py @@ -4,11 +4,16 @@ Each function implements the Gherkin step in the comment above it. """ +import typing + from flagsmith import Flagsmith from flagsmith.models import Flags +from flagsmith.types import TraitMapping + +SpecFlag = dict[str, typing.Any] -def as_spec_flags(flags: Flags) -> list[dict]: +def as_spec_flags(flags: Flags) -> list[SpecFlag]: return [ {"feature": flag.feature_name, "enabled": flag.enabled, "value": flag.value} for flag in flags.all_flags() @@ -21,12 +26,12 @@ def create_client(environment_key: str, api_url: str) -> Flagsmith: # When the application gets the environment flags -def get_environment_flags(flagsmith: Flagsmith) -> list[dict]: +def get_environment_flags(flagsmith: Flagsmith) -> list[SpecFlag]: return as_spec_flags(flagsmith.get_environment_flags()) # When the application gets the flags for identity "{identifier}" with traits: def get_identity_flags( - flagsmith: Flagsmith, identifier: str, traits: dict -) -> list[dict]: + flagsmith: Flagsmith, identifier: str, traits: TraitMapping +) -> list[SpecFlag]: return as_spec_flags(flagsmith.get_identity_flags(identifier, traits=traits)) From e6d4004b3e101ad4be6cb08f0098e22d141e3aeb Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 13:34:23 +0200 Subject: [PATCH 6/8] ci: Pin the SDK spec to v0.2.0 --- .github/workflows/sdk-spec.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 0a7d6e0..9521e97 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@feat/v0.2.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.2.0 with: - spec-ref: feat/v0.2.0 + spec-ref: v0.2.0 language: python From 71148a14b00de4b0f8c8588ee65faedd2cd48d44 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 16:18:23 +0200 Subject: [PATCH 7/8] ci: Move to SDK spec v0.3.0 --- .github/workflows/sdk-spec.yml | 4 ++-- sdk-spec/steps.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 9521e97..6952fea 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.2.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0 with: - spec-ref: v0.2.0 + spec-ref: v0.3.0 language: python diff --git a/sdk-spec/steps.py b/sdk-spec/steps.py index 9e4dd7e..5cd373d 100644 --- a/sdk-spec/steps.py +++ b/sdk-spec/steps.py @@ -20,7 +20,7 @@ def as_spec_flags(flags: Flags) -> list[SpecFlag]: ] -# Given an SDK client for the environment +# Create the SDK client def create_client(environment_key: str, api_url: str) -> Flagsmith: return Flagsmith(environment_key=environment_key, api_url=api_url) From 6ec56c8a6ea8b967d9f9e7a9ce83f93cae640fbc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 16:37:56 +0200 Subject: [PATCH 8/8] ci: Run the SDK spec against the v0.3.0 tag