Skip to content

Commit 641e9f2

Browse files
lopezm1deleteLater
andauthored
feat: support and validate CPython 3.14 (#17)
Co-authored-by: deleteLater <mikcczhang@gmail.com>
1 parent 9a05180 commit 641e9f2

9 files changed

Lines changed: 227 additions & 39 deletions

File tree

‎.github/workflows/code-quality.yaml‎

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,17 @@ on:
88
- synchronize
99

1010
jobs:
11-
build:
12-
# compatible for py 3.6
13-
runs-on: ubuntu-20.04
11+
linux:
12+
runs-on: ubuntu-24.04
1413
strategy:
15-
# You can use PyPy versions in python-version.
14+
fail-fast: false
1615
matrix:
17-
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
16+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
1817

1918
steps:
20-
- uses: actions/checkout@v3
19+
- uses: actions/checkout@v7
2120
- name: Set up Python ${{ matrix.python-version }}
22-
uses: actions/setup-python@v4
21+
uses: actions/setup-python@v7
2322
with:
2423
python-version: ${{ matrix.python-version }}
2524
# You can test your matrix by printing the current Python version
@@ -28,21 +27,77 @@ jobs:
2827
- name: Install dependencies
2928
run: |
3029
python -m pip install --upgrade pip
31-
pip install flake8 pytest pytest-mock build
32-
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
30+
python -m pip install flake8 pytest pytest-mock build
31+
if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi
32+
python -m pip install --no-deps .
3333
- name: Lint with flake8
3434
run: |
3535
# stop the build if there are Python syntax errors
36-
flake8 .
36+
python -m flake8 .
3737
- name: Test with pytest
3838
run: |
39-
pytest
39+
python -m pytest
4040
- name: Audit concurrency, memory, and thread cleanup
41-
if: matrix.python-version == '3.12'
41+
if: matrix.python-version == '3.12' || matrix.python-version == '3.14'
4242
run: python scripts/resource_audit.py --evaluations 20000 --workers 8
4343
- name: Build production distributions
44-
if: matrix.python-version == '3.12'
44+
if: matrix.python-version == '3.12' || matrix.python-version == '3.14'
4545
run: python -m build
4646
- name: Verify wheel contains production packages only
47-
if: matrix.python-version == '3.12'
47+
if: matrix.python-version == '3.12' || matrix.python-version == '3.14'
4848
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)"
49+
- name: Install and smoke-test the built wheel
50+
if: matrix.python-version == '3.14'
51+
run: |
52+
python -m venv "$RUNNER_TEMP/featbit-wheel-test"
53+
"$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip install --upgrade pip
54+
"$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip install dist/*.whl
55+
"$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip check
56+
"$RUNNER_TEMP/featbit-wheel-test/bin/python" scripts/wheel_smoke.py \
57+
--bootstrap tests/fbclient_test_data.json \
58+
--expected-version-file release/package.json
59+
60+
windows:
61+
runs-on: windows-latest
62+
strategy:
63+
fail-fast: false
64+
matrix:
65+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
66+
steps:
67+
- uses: actions/checkout@v7
68+
- name: Set up Python ${{ matrix.python-version }}
69+
uses: actions/setup-python@v7
70+
with:
71+
python-version: ${{ matrix.python-version }}
72+
- name: Install dependencies
73+
run: |
74+
python -m pip install --upgrade pip
75+
python -m pip install pytest pytest-mock
76+
python -m pip install -r requirements.txt
77+
python -m pip install --no-deps .
78+
- name: Test with pytest
79+
run: python -m pytest
80+
81+
minimum-dependencies:
82+
runs-on: ubuntu-24.04
83+
steps:
84+
- uses: actions/checkout@v7
85+
- name: Set up Python 3.14
86+
uses: actions/setup-python@v7
87+
with:
88+
python-version: "3.14"
89+
- name: Install exact minimum dependencies
90+
run: |
91+
python -m pip install --upgrade pip
92+
python -m pip install pytest pytest-mock
93+
python -m pip install \
94+
certifi==2018.4.16 \
95+
urllib3==1.26.5 \
96+
python-dateutil==2.8.2 \
97+
websocket-client==1.0.0
98+
python -m pip install --no-deps .
99+
python -m pip check
100+
- name: Test with pytest
101+
run: python -m pytest
102+
- name: Audit concurrency, memory, and thread cleanup
103+
run: python scripts/resource_audit.py --evaluations 20000 --workers 8

‎.github/workflows/release.yaml‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ jobs:
1919
deploy:
2020
permissions:
2121
contents: write
22-
runs-on: ubuntu-latest
22+
runs-on: ubuntu-24.04
2323
environment: py-production
2424
steps:
25-
- uses: actions/checkout@v4
25+
- uses: actions/checkout@v7
2626
- name: Read current version
2727
id: version
2828
run: |
@@ -31,26 +31,39 @@ jobs:
3131
with open("release/package.json") as file:
3232
print("value=%s" % json.load(file)["version"])
3333
PY
34-
- name: Create GitHub Release
35-
env:
36-
GH_TOKEN: ${{ github.token }}
37-
run: |
38-
gh release create "v${{ steps.version.outputs.value }}" \
39-
--title "v${{ steps.version.outputs.value }}" \
40-
--notes-file release/description.md
4134
- name: Set up Python
42-
uses: actions/setup-python@v4
35+
uses: actions/setup-python@v7
4336
with:
44-
python-version: '3.x'
37+
python-version: '3.14'
4538
- name: Install dependencies
4639
run: |
4740
python -m pip install --upgrade pip
48-
pip install build
41+
python -m pip install build twine
4942
- name: Build package
5043
run: python -m build
44+
- name: Check package metadata
45+
run: python -m twine check dist/*
46+
- name: Install and smoke-test the built wheel
47+
env:
48+
VERSION: ${{ steps.version.outputs.value }}
49+
run: |
50+
python -m venv "$RUNNER_TEMP/featbit-wheel-test"
51+
"$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip install --upgrade pip
52+
"$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip install dist/*.whl
53+
"$RUNNER_TEMP/featbit-wheel-test/bin/python" -m pip check
54+
"$RUNNER_TEMP/featbit-wheel-test/bin/python" scripts/wheel_smoke.py \
55+
--bootstrap tests/fbclient_test_data.json \
56+
--expected-version "$VERSION"
57+
- name: Create GitHub Release
58+
env:
59+
GH_TOKEN: ${{ github.token }}
60+
VERSION: ${{ steps.version.outputs.value }}
61+
run: |
62+
gh release create "v$VERSION" \
63+
--title "v$VERSION" \
64+
--notes-file release/description.md
5165
- name: Publish package distributions to PyPI
5266
uses: pypa/gh-action-pypi-publish@v1.12.4
5367
with:
5468
user: __token__
5569
password: ${{ secrets.PYPI_PASSWORD }}
56-
verify-metadata: false

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ If you want to use your own data source, see [Offline Mode](#offline-mode).
1818
## Get Started
1919

2020
### Installation
21-
Install the SDK using pip. This version is compatible with Python 3.6 through 3.12.
21+
Install the SDK using pip. This version is compatible with Python 3.10 through 3.14.
22+
CI is configured to test compatibility on Linux and Windows through Python 3.14,
23+
including the full unit suite and SDK resource-lifecycle audit.
24+
Free-threaded CPython 3.14 builds are not currently tested or supported.
2225

2326
```shell
2427
pip install fb-python-sdk

‎dev-requirements.txt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
certifi>=2018.4.16
2-
urllib3>=1.22.0
2+
urllib3>=1.26.5,<3
33
websocket-client>=1.0.0
44
python-dateutil>=2.8.2
55
flake8
66
pytest
77
pytest-mock
88
autopep8
99
build
10-
twine
10+
twine

‎release/description.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
version: 1.1.8
2+
version: 1.1.9
33

44
## Break changes
55

@@ -16,6 +16,9 @@ version: 1.1.8
1616

1717
## Updates
1818

19+
- support and test CPython versions 3.13 and 3.14
20+
- align the supported Python range with 3.10 through 3.14
21+
- require urllib3 1.26.5 or newer and lower than 3
1922
- handle Python versions 3.12.x
2023
- make client, WebSocket, event, and notice shutdown deterministic and idempotent
2124
- isolate runtime event and shutdown failures from application code

‎release/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"version":"1.1.8"
2+
"version":"1.1.9"
33
}

‎requirements.txt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
certifi>=2018.4.16
2-
urllib3>=1.22.0
2+
urllib3>=1.26.5,<3
33
python-dateutil>=2.8.2
4-
websocket-client>=1.0.0
4+
websocket-client>=1.0.0

‎scripts/wheel_smoke.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env python3
2+
"""Exercise the installed wheel through the public offline client API."""
3+
4+
import argparse
5+
import base64
6+
import json
7+
import site
8+
import threading
9+
from importlib import metadata
10+
from pathlib import Path
11+
12+
import fbclient
13+
from fbclient.client import FBClient
14+
from fbclient.config import Config
15+
16+
17+
PACKAGE_NAME = "fb-python-sdk"
18+
FAKE_ENV_SECRET = base64.b64encode(b"fake_env_secret").decode()
19+
FAKE_URL = "http://fake"
20+
USER_ENABLED = {"key": "test-user-1", "name": "test-user-1", "country": "us"}
21+
USER_DISABLED = {
22+
"key": "test-user-3",
23+
"name": "test-user-3",
24+
"country": "cn",
25+
"major": "cs",
26+
}
27+
28+
29+
def parse_args():
30+
parser = argparse.ArgumentParser()
31+
parser.add_argument("--bootstrap", type=Path, required=True)
32+
parser.add_argument("--expected-version")
33+
parser.add_argument("--expected-version-file", type=Path)
34+
return parser.parse_args()
35+
36+
37+
def assert_installed_package():
38+
package_path = Path(fbclient.__file__).resolve()
39+
site_paths = [Path(path).resolve() for path in site.getsitepackages()]
40+
assert any(package_path.is_relative_to(path) for path in site_paths), (
41+
"fbclient was not imported from the wheel environment: %s" % package_path
42+
)
43+
44+
45+
def assert_metadata(expected_version):
46+
distribution = metadata.distribution(PACKAGE_NAME)
47+
if expected_version:
48+
assert distribution.version == expected_version
49+
50+
package_metadata = distribution.metadata
51+
assert package_metadata["Requires-Python"] == ">=3.10, <3.15"
52+
classifiers = package_metadata.get_all("Classifier") or []
53+
assert "Programming Language :: Python :: 3.10" in classifiers
54+
assert "Programming Language :: Python :: 3.14" in classifiers
55+
requirements = distribution.requires or []
56+
assert any(requirement.startswith("urllib3<3,>=1.26.5") for requirement in requirements)
57+
return distribution.version
58+
59+
60+
def featbit_thread_ids():
61+
return {
62+
thread.ident
63+
for thread in threading.enumerate()
64+
if thread.ident is not None and thread.name.startswith("featbit-")
65+
}
66+
67+
68+
def exercise_client(bootstrap_path):
69+
baseline_threads = featbit_thread_ids()
70+
states = []
71+
config = Config(
72+
FAKE_ENV_SECRET,
73+
event_url=FAKE_URL,
74+
streaming_url=FAKE_URL,
75+
offline=True,
76+
)
77+
client = FBClient(config)
78+
79+
def on_status_change(state):
80+
states.append(state.state_type.name)
81+
82+
client.update_status_provider.add_listener(on_status_change)
83+
try:
84+
assert client.initialize_from_external_json(bootstrap_path.read_text())
85+
assert client.initialize
86+
assert states == ["OK"]
87+
assert client.variation("ff-test-bool", USER_ENABLED, False) is True
88+
assert client.variation("ff-test-bool", USER_DISABLED, True) is False
89+
assert client.variation("missing-flag", USER_ENABLED, False) is False
90+
for _ in range(100):
91+
assert client.variation("ff-test-bool", USER_ENABLED, False) is True
92+
finally:
93+
client.update_status_provider.remove_listener(on_status_change)
94+
client.stop()
95+
client.stop()
96+
97+
leaked_threads = featbit_thread_ids() - baseline_threads
98+
assert not leaked_threads, "FeatBit worker threads leaked: %s" % leaked_threads
99+
return states
100+
101+
102+
def main():
103+
args = parse_args()
104+
expected_version = args.expected_version
105+
if args.expected_version_file:
106+
expected_version = json.loads(
107+
args.expected_version_file.read_text()
108+
)["version"]
109+
assert_installed_package()
110+
version = assert_metadata(expected_version)
111+
states = exercise_client(args.bootstrap)
112+
print(json.dumps({"package": PACKAGE_NAME, "states": states, "version": version}))
113+
114+
115+
if __name__ == "__main__":
116+
main()

‎setup.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,14 @@ def parse_requirements(filename):
4444
'Operating System :: OS Independent',
4545
'Topic :: Software Development',
4646
'Topic :: Software Development :: Libraries',
47-
'Programming Language :: Python :: 3.6',
48-
'Programming Language :: Python :: 3.7',
49-
'Programming Language :: Python :: 3.8',
50-
'Programming Language :: Python :: 3.9',
5147
'Programming Language :: Python :: 3.10',
5248
'Programming Language :: Python :: 3.11',
5349
'Programming Language :: Python :: 3.12',
50+
'Programming Language :: Python :: 3.13',
51+
'Programming Language :: Python :: 3.14',
5452
],
5553
extras_require={
5654
"dev": dev_reqs
5755
},
58-
python_requires='>=3.6, <3.13'
56+
python_requires='>=3.10, <3.15'
5957
)

0 commit comments

Comments
 (0)