From 486ff1da0b585b47cc540916c88e340fff62a14b Mon Sep 17 00:00:00 2001 From: lopezm1 <4165471+lopezm1@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:36:04 -0700 Subject: [PATCH] feat: support CPython 3.14 --- .github/workflows/code-quality.yaml | 97 +++++++++++++++++++---- .github/workflows/release.yaml | 39 ++++++---- README.md | 5 +- dev-requirements.txt | 4 +- release/description.md | 4 +- release/package.json | 2 +- requirements.txt | 4 +- scripts/wheel_smoke.py | 115 ++++++++++++++++++++++++++++ setup.py | 4 +- 9 files changed, 240 insertions(+), 34 deletions(-) create mode 100644 scripts/wheel_smoke.py diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index fb7b364..0737606 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -8,18 +8,54 @@ on: - synchronize jobs: + legacy-python-3-6: + runs-on: ubuntu-24.04 + container: ubuntu:20.04 + steps: + - uses: actions/checkout@v7 + - name: Set up Python 3.6 + uses: actions/setup-python@v7 + with: + python-version: "3.6" + - name: Display Python version + run: python -c "import sys; print(sys.version)" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest pytest-mock + python -m pip install -r requirements.txt + python -m pip install --no-deps . + - name: Lint with flake8 + run: python -m flake8 . + - name: Test with pytest + run: python -m pytest + build: - # compatible for py 3.6 - runs-on: ubuntu-20.04 + runs-on: ${{ matrix.runner }} strategy: - # You can use PyPy versions in python-version. matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + include: + - python-version: "3.7" + runner: ubuntu-22.04 + - python-version: "3.8" + runner: ubuntu-24.04 + - python-version: "3.9" + runner: ubuntu-24.04 + - python-version: "3.10" + runner: ubuntu-24.04 + - python-version: "3.11" + runner: ubuntu-24.04 + - python-version: "3.12" + runner: ubuntu-24.04 + - python-version: "3.13" + runner: ubuntu-24.04 + - python-version: "3.14" + runner: ubuntu-24.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} # You can test your matrix by printing the current Python version @@ -28,21 +64,56 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest pytest-mock build - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + python -m pip install flake8 pytest pytest-mock build + if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi + python -m pip install --no-deps . - name: Lint with flake8 run: | # stop the build if there are Python syntax errors - flake8 . + python -m flake8 . - name: Test with pytest run: | - pytest + python -m pytest - name: Audit concurrency, memory, and thread cleanup - if: matrix.python-version == '3.12' + if: matrix.python-version == '3.12' || matrix.python-version == '3.14' run: python scripts/resource_audit.py --evaluations 20000 --workers 8 - name: Build production distributions - if: matrix.python-version == '3.12' + if: matrix.python-version == '3.12' || matrix.python-version == '3.14' run: python -m build - name: Verify wheel contains production packages only - if: matrix.python-version == '3.12' + if: matrix.python-version == '3.12' || matrix.python-version == '3.14' run: python -c "import glob, zipfile; names = zipfile.ZipFile(glob.glob('dist/*.whl')[0]).namelist(); assert not any(name.startswith(('tests/', 'featbit_openfeature/')) for name in names)" + - name: Install and smoke-test the built wheel + if: matrix.python-version == '3.14' + run: | + python -m venv "$RUNNER_TEMP/featbit-wheel-test" + "$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip install --upgrade pip + "$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip install dist/*.whl + "$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip check + "$RUNNER_TEMP/featbit-wheel-test/bin/python" scripts/wheel_smoke.py \ + --bootstrap tests/fbclient_test_data.json \ + --expected-version-file release/package.json + + minimum-dependencies: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Set up Python 3.14 + uses: actions/setup-python@v7 + with: + python-version: "3.14" + - name: Install exact minimum dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest pytest-mock + python -m pip install \ + certifi==2018.4.16 \ + urllib3==1.26.5 \ + python-dateutil==2.8.2 \ + websocket-client==1.0.0 + python -m pip install --no-deps . + python -m pip check + - name: Test with pytest + run: python -m pytest + - name: Audit concurrency, memory, and thread cleanup + run: python scripts/resource_audit.py --evaluations 20000 --workers 8 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e6c8c32..e43ffe1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,10 +19,10 @@ jobs: deploy: permissions: contents: write - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 environment: py-production steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Read current version id: version run: | @@ -31,26 +31,39 @@ jobs: with open("release/package.json") as file: print("value=%s" % json.load(file)["version"]) PY - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create "v${{ steps.version.outputs.value }}" \ - --title "v${{ steps.version.outputs.value }}" \ - --notes-file release/description.md - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v7 with: - python-version: '3.x' + python-version: '3.14' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install build + python -m pip install build twine - name: Build package run: python -m build + - name: Check package metadata + run: python -m twine check dist/* + - name: Install and smoke-test the built wheel + env: + VERSION: ${{ steps.version.outputs.value }} + run: | + python -m venv "$RUNNER_TEMP/featbit-wheel-test" + "$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip install --upgrade pip + "$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip install dist/*.whl + "$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip check + "$RUNNER_TEMP/featbit-wheel-test/bin/python" scripts/wheel_smoke.py \ + --bootstrap tests/fbclient_test_data.json \ + --expected-version "$VERSION" + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.value }} + run: | + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes-file release/description.md - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@v1.12.4 with: user: __token__ password: ${{ secrets.PYPI_PASSWORD }} - verify-metadata: false \ No newline at end of file diff --git a/README.md b/README.md index 0a883ac..414db82 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,10 @@ If you want to use your own data source, see [Offline Mode](#offline-mode). ## Get Started ### Installation -Install the SDK using pip. This version is compatible with Python 3.6 through 3.12. +Install the SDK using pip. This version is compatible with Python 3.6 through 3.14. +CI is configured to test compatibility through Python 3.14, including the +full unit suite and SDK resource-lifecycle audit. +Free-threaded CPython 3.14 builds are not currently tested or supported. ```shell pip install fb-python-sdk diff --git a/dev-requirements.txt b/dev-requirements.txt index fb03fa4..a8514ee 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ certifi>=2018.4.16 -urllib3>=1.22.0 +urllib3>=1.26.5 websocket-client>=1.0.0 python-dateutil>=2.8.2 flake8 @@ -7,4 +7,4 @@ pytest pytest-mock autopep8 build -twine \ No newline at end of file +twine diff --git a/release/description.md b/release/description.md index c5b46f8..464b5fe 100644 --- a/release/description.md +++ b/release/description.md @@ -1,5 +1,5 @@ -version: 1.1.8 +version: 1.1.9 ## Break changes @@ -16,6 +16,8 @@ version: 1.1.8 ## Updates +- support and test CPython versions 3.13 and 3.14 +- require urllib3 1.26.5 or newer for CPython 3.14 compatibility - handle Python versions 3.12.x - make client, WebSocket, event, and notice shutdown deterministic and idempotent - isolate runtime event and shutdown failures from application code diff --git a/release/package.json b/release/package.json index 8f7d62d..1f9b3b1 100644 --- a/release/package.json +++ b/release/package.json @@ -1,3 +1,3 @@ { - "version":"1.1.8" + "version":"1.1.9" } diff --git a/requirements.txt b/requirements.txt index c5511ed..4a48134 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ certifi>=2018.4.16 -urllib3>=1.22.0 +urllib3>=1.26.5 python-dateutil>=2.8.2 -websocket-client>=1.0.0 \ No newline at end of file +websocket-client>=1.0.0 diff --git a/scripts/wheel_smoke.py b/scripts/wheel_smoke.py new file mode 100644 index 0000000..210e8fd --- /dev/null +++ b/scripts/wheel_smoke.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Exercise the installed wheel through the public offline client API.""" + +import argparse +import base64 +import json +import site +import threading +from importlib import metadata +from pathlib import Path + +import fbclient +from fbclient.client import FBClient +from fbclient.config import Config + + +PACKAGE_NAME = "fb-python-sdk" +FAKE_ENV_SECRET = base64.b64encode(b"fake_env_secret").decode() +FAKE_URL = "http://fake" +USER_ENABLED = {"key": "test-user-1", "name": "test-user-1", "country": "us"} +USER_DISABLED = { + "key": "test-user-3", + "name": "test-user-3", + "country": "cn", + "major": "cs", +} + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--bootstrap", type=Path, required=True) + parser.add_argument("--expected-version") + parser.add_argument("--expected-version-file", type=Path) + return parser.parse_args() + + +def assert_installed_package(): + package_path = Path(fbclient.__file__).resolve() + site_paths = [Path(path).resolve() for path in site.getsitepackages()] + assert any(package_path.is_relative_to(path) for path in site_paths), ( + "fbclient was not imported from the wheel environment: %s" % package_path + ) + + +def assert_metadata(expected_version): + distribution = metadata.distribution(PACKAGE_NAME) + if expected_version: + assert distribution.version == expected_version + + package_metadata = distribution.metadata + assert package_metadata["Requires-Python"] == ">=3.6, <3.15" + classifiers = package_metadata.get_all("Classifier") or [] + assert "Programming Language :: Python :: 3.14" in classifiers + requirements = distribution.requires or [] + assert any(requirement.startswith("urllib3>=1.26.5") for requirement in requirements) + return distribution.version + + +def featbit_thread_ids(): + return { + thread.ident + for thread in threading.enumerate() + if thread.ident is not None and thread.name.startswith("featbit-") + } + + +def exercise_client(bootstrap_path): + baseline_threads = featbit_thread_ids() + states = [] + config = Config( + FAKE_ENV_SECRET, + event_url=FAKE_URL, + streaming_url=FAKE_URL, + offline=True, + ) + client = FBClient(config) + + def on_status_change(state): + states.append(state.state_type.name) + + client.update_status_provider.add_listener(on_status_change) + try: + assert client.initialize_from_external_json(bootstrap_path.read_text()) + assert client.initialize + assert states == ["OK"] + assert client.variation("ff-test-bool", USER_ENABLED, False) is True + assert client.variation("ff-test-bool", USER_DISABLED, True) is False + assert client.variation("missing-flag", USER_ENABLED, False) is False + for _ in range(100): + assert client.variation("ff-test-bool", USER_ENABLED, False) is True + finally: + client.update_status_provider.remove_listener(on_status_change) + client.stop() + client.stop() + + leaked_threads = featbit_thread_ids() - baseline_threads + assert not leaked_threads, "FeatBit worker threads leaked: %s" % leaked_threads + return states + + +def main(): + args = parse_args() + expected_version = args.expected_version + if args.expected_version_file: + expected_version = json.loads( + args.expected_version_file.read_text() + )["version"] + assert_installed_package() + version = assert_metadata(expected_version) + states = exercise_client(args.bootstrap) + print(json.dumps({"package": PACKAGE_NAME, "states": states, "version": version})) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index cf74fd7..d48c0ae 100644 --- a/setup.py +++ b/setup.py @@ -51,9 +51,11 @@ def parse_requirements(filename): 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', ], extras_require={ "dev": dev_reqs }, - python_requires='>=3.6, <3.13' + python_requires='>=3.6, <3.15' )