From 030d8101b702f10b468de63ef03c3200a4aaa19d Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Fri, 28 Aug 2026 12:36:36 +0200 Subject: [PATCH 1/2] statsig-python-core: add build-statsig-python-core.yml for riscv64 wheels Builds the PyO3 extension from the statsig-server-core workspace with cibuildwheel, mirroring upstream's maturin build. The pyo3 abi3-py310 feature makes one cp310 build cover every non-free-threaded CPython >= 3.10. Co-Authored-By: Claude Opus 5 --- .../workflows/build-statsig-python-core.yml | 115 ++++++++++++++ ...ests-wait-for-background-specs-syncs.patch | 147 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 .github/workflows/build-statsig-python-core.yml create mode 100644 patches/statsig-python-core/0.22.0/0001-tests-wait-for-background-specs-syncs.patch diff --git a/.github/workflows/build-statsig-python-core.yml b/.github/workflows/build-statsig-python-core.yml new file mode 100644 index 00000000..696db6c0 --- /dev/null +++ b/.github/workflows/build-statsig-python-core.yml @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# Based on: https://github.com/statsig-io/statsig-server-core/blob/0.22.0/.github/workflows/build.yml +# Upstream builds the Linux wheels with `maturin build --release --strip` inside its own +# manylinux2014 image; this is the same build driven by cibuildwheel. +name: Build statsig-python-core wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'statsig-python-core version to build (git tag, e.g. 0.22.0)' + required: true + default: '0.22.0' + pull_request: + paths: + - '.github/workflows/build-statsig-python-core.yml' + +concurrency: + group: ${{ github.workflow }}-${{ inputs.version || '0.22.0' }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + STATSIG_VERSION: ${{ inputs.version || '0.22.0' }} + MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64 + +jobs: + build_wheels: + name: Build statsig-python-core ${{ inputs.version || '0.22.0' }} cp310-abi3-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 720 + + steps: + - name: Checkout statsig-server-core ${{ env.STATSIG_VERSION }} + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: statsig-io/statsig-server-core + ref: ${{ env.STATSIG_VERSION }} + persist-credentials: false + + - name: Checkout python-wheels + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: python-wheels + sparse-checkout: patches/statsig-python-core + persist-credentials: false + + - name: Apply patches + run: git apply -v python-wheels/patches/statsig-python-core/${{ env.STATSIG_VERSION }}/*.patch + + # The pyproject.toml sits in statsig-pyo3/ while the ISC LICENSE is at the repo + # root, so maturin's licence glob finds nothing and upstream's wheels carry no + # licence text on any platform. + - name: Stage the project licence beside pyproject.toml + run: cp LICENSE statsig-pyo3/LICENSE + + - name: Build wheels + uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0 + with: + package-dir: statsig-pyo3 + output-dir: wheelhouse/ + # `[tool.maturin] features` carries pyo3/abi3-py310, so this one build covers + # every non-free-threaded CPython >= 3.10. + only: cp310-manylinux_riscv64 + env: + CIBW_MANYLINUX_RISCV64_IMAGE: ${{ env.MANYLINUX_RISCV64_IMAGE }} + # Upstream's image bakes in rustup and a protoc release build; Rocky 10's CRB + # repo (enabled in the manylinux image) has protoc. + CIBW_BEFORE_ALL_LINUX: >- + yum install -y protobuf-compiler protobuf-devel && + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + # PROTOC_INCLUDE is needed because protoc 3.19 resolves google/protobuf/*.proto + # from disk rather than from the binary. + CIBW_ENVIRONMENT: >- + PATH=$PATH:$HOME/.cargo/bin + PROTOC_INCLUDE=/usr/include + PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + # tests/utils.py reads its fixtures through ../../statsig-rust/tests/data, so + # both trees are staged at their positions relative to the repo root. + CIBW_TEST_SOURCES: statsig-pyo3/tests statsig-rust/tests/data + CIBW_TEST_REQUIRES: pytest pytest-httpserver pytest-rerunfailures uvloop + CIBW_TEST_COMMAND: >- + python -c "from statsig_python_core import statsig_python_core as m; + assert m.__file__.endswith('.so'), m.__file__; + import importlib.metadata as md; + assert any('.dist-info/licenses/LICENSE' in str(p) for p in md.files('statsig_python_core'))" && + cd statsig-pyo3 && python -m pytest tests -v --reruns 3 + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: statsig_python_core-${{ env.STATSIG_VERSION }}-cp310-abi3-manylinux_riscv64 + path: wheelhouse/*.whl + if-no-files-found: error + + publish: + name: Publish statsig-python-core ${{ inputs.version || '0.22.0' }} to GitLab + needs: [build_wheels] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Publish wheels and open docs PR + uses: riseproject-dev/python-wheels/actions/publish-wheels@main + with: + artifact-pattern: statsig_python_core-${{ env.STATSIG_VERSION }}-*-manylinux_riscv64 + gitlab-username: ${{ vars.GITLAB_DEPLOY_USER }} + gitlab-token: ${{ secrets.GITLAB_DEPLOY_TOKEN }} + gitlab-project-id: ${{ vars.GITLAB_PROJECT_ID }} + gh-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/patches/statsig-python-core/0.22.0/0001-tests-wait-for-background-specs-syncs.patch b/patches/statsig-python-core/0.22.0/0001-tests-wait-for-background-specs-syncs.patch new file mode 100644 index 00000000..b48c09ab --- /dev/null +++ b/patches/statsig-python-core/0.22.0/0001-tests-wait-for-background-specs-syncs.patch @@ -0,0 +1,147 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Fri, 28 Aug 2026 08:00:00 +0200 +Subject: [PATCH] tests: wait for background specs syncs instead of assuming a 1ms interval + +`StatsigOptions.specs_sync_interval_ms` is now rejected below 1000 ("Invalid +'specs_sync_interval_ms', value must be greater than 1000"), so the five tests +that set it to 1 no longer get the near-instant background sync they were +written against. They fail at the 0.22.0 tag on every platform, against the +wheels published on PyPI -- x86_64, aarch64 and macOS alike. + +Use the smallest accepted interval and wait for the condition each test is +really about rather than for a fixed duration. The waits return as soon as the +sync lands, so fast hardware pays nothing and the whole file gets quicker. + +Upstream-Status: To upstream [not yet submitted; the release tag is not covered by upstream's test workflow, which runs on branches only, so this needs a maintainer discussion rather than a drive-by PR] +--- +diff --git a/statsig-pyo3/tests/test_data_store.py b/statsig-pyo3/tests/test_data_store.py +index 7959adf..d9d615d 100644 +--- a/statsig-pyo3/tests/test_data_store.py ++++ b/statsig-pyo3/tests/test_data_store.py +@@ -1,4 +1,5 @@ + import json ++import time + from time import sleep + from typing import Optional + import pytest +@@ -16,6 +17,13 @@ from utils import get_test_data_resource, get_test_data_resource_bytes + + known_lcut = 1767981029384 + ++ ++def wait_for(predicate, timeout=30.0, interval=0.05): ++ deadline = time.monotonic() + timeout ++ while not predicate() and time.monotonic() < deadline: ++ sleep(interval) ++ ++ + dcs_content = get_test_data_resource("eval_proj_dcs.json") + json_data = json.loads(dcs_content) + eval_proj_protobuf = get_test_data_resource_bytes("eval_proj_dcs.pb.br") +@@ -142,7 +150,7 @@ def statsig_setup(httpserver: HTTPServer): + specs_url=httpserver.url_for("/v2/download_config_specs"), + log_event_url=httpserver.url_for("/v1/log_event"), + data_store=data_store, +- specs_sync_interval_ms=1, ++ specs_sync_interval_ms=1001, + ) + + statsig = Statsig("secret-key", options) +@@ -166,7 +174,7 @@ def statsig_bytes_setup(httpserver: HTTPServer): + specs_url=httpserver.url_for("/v2/download_config_specs"), + log_event_url=httpserver.url_for("/v1/log_event"), + data_store=data_store, +- specs_sync_interval_ms=1, ++ specs_sync_interval_ms=1001, + ) + + statsig = Statsig("secret-key", options) +@@ -191,6 +199,8 @@ def test_data_store_usage_get(statsig_setup): + + statsig.flush_events().wait() + ++ wait_for(lambda: data_store.get_called_count > 1) ++ + assert data_store.init_called + assert gate.details.reason == "Adapter(DataStore):Recognized" + assert gate.value == True +@@ -207,7 +217,7 @@ def test_data_store_usage_set(statsig_setup): + + assert data_store.init_called + assert gate.details.reason == "Adapter(DataStore):Recognized" +- sleep(1) ++ wait_for(lambda: data_store.content_set is not None) + + gate_after = statsig.get_feature_gate(user, "test_public") + statsig.flush_events().wait() +@@ -232,10 +242,7 @@ def test_data_store_usage_get_bytes(statsig_bytes_setup): + assert gate.value == True + assert gate.details.lcut == known_lcut + +- for _ in range(5): +- if data_store.set_bytes_called_count > 0: +- break +- sleep(0.05) ++ wait_for(lambda: data_store.set_bytes_called_count > 0) + + assert data_store.get_bytes_called_count >= 1 + assert data_store.get_called_count == 0 +@@ -251,7 +258,10 @@ def test_data_store_usage_set_bytes(statsig_bytes_setup): + + assert data_store.init_called + assert gate.details.reason == "Adapter(DataStore):Recognized" +- sleep(1) ++ wait_for( ++ lambda: statsig.get_feature_gate(user, "test_public").details.lcut ++ == known_lcut + 10 ++ ) + + gate_after = statsig.get_feature_gate(user, "test_public") + statsig.flush_events().wait() +diff --git a/statsig-pyo3/tests/test_observability_client.py b/statsig-pyo3/tests/test_observability_client.py +index 1459a60..b4d9243 100644 +--- a/statsig-pyo3/tests/test_observability_client.py ++++ b/statsig-pyo3/tests/test_observability_client.py +@@ -85,7 +85,7 @@ def statsig_setup(httpserver: HTTPServer): + options.specs_url = httpserver.url_for("/v2/download_config_specs") + options.log_event_url = httpserver.url_for("/v1/log_event") + options.observability_client = observability_client +- options.specs_sync_interval_ms = 1 ++ options.specs_sync_interval_ms = 1001 + options.output_log_level = "error" + statsig = Statsig("secret-key", options) + +@@ -165,16 +165,23 @@ def test_metric_with_high_card(statsig_setup): + statsig.flush_events().wait() + + assert observability_client.init_called, "init() should have been called" +- time.sleep(3) + +- dist_event = next( +- ( +- m +- for m in observability_client.metrics +- if m[0] == "distribution" and m[1] == "statsig.sdk.config_propagation_diff" +- ), +- None, +- ) ++ def find_dist_event(): ++ return next( ++ ( ++ m ++ for m in observability_client.metrics ++ if m[0] == "distribution" ++ and m[1] == "statsig.sdk.config_propagation_diff" ++ ), ++ None, ++ ) ++ ++ deadline = time.monotonic() + 30.0 ++ while find_dist_event() is None and time.monotonic() < deadline: ++ time.sleep(0.05) ++ ++ dist_event = find_dist_event() + assert dist_event is not None, "distribution() should have been called" + assert isinstance(dist_event[2], float) + assert isinstance(int(dist_event[3].get("lcut")), (int, float)) From 8ff808fb5aae7ab46b769d041589fb942306b47a Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Fri, 28 Aug 2026 13:48:39 +0200 Subject: [PATCH 2/2] statsig-python-core: raise the fork_runner subprocess timeout The riscv64 runner cannot finish 50 SDK initialisations within upstream's 10s budget, so the subprocess is SIGTERMed and test_forking fails with -15. Co-Authored-By: Claude Opus 5 --- ...-tests-raise-the-fork-runner-timeout.patch | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 patches/statsig-python-core/0.22.0/0002-tests-raise-the-fork-runner-timeout.patch diff --git a/patches/statsig-python-core/0.22.0/0002-tests-raise-the-fork-runner-timeout.patch b/patches/statsig-python-core/0.22.0/0002-tests-raise-the-fork-runner-timeout.patch new file mode 100644 index 00000000..e75f111c --- /dev/null +++ b/patches/statsig-python-core/0.22.0/0002-tests-raise-the-fork-runner-timeout.patch @@ -0,0 +1,29 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Fri, 28 Aug 2026 08:10:00 +0200 +Subject: [PATCH] tests: give fork_runner.py a timeout a slow machine can meet + +`fork_runner.py` performs 50 full SDK initialisations -- ten iterations, each +followed by four nested forks that initialise, evaluate and shut down -- against +a local mock server. Ten seconds is enough on upstream's x86_64 CI and not on a +riscv64 runner, where the subprocess is SIGTERMed part way through and the test +fails with `assert -15 == 0`. + +`communicate()` returns as soon as the subprocess exits, so a larger budget +costs nothing where the old one was already sufficient. + +Upstream-Status: To upstream [not yet submitted; the release tag is not covered by upstream's test workflow, which runs on branches only, so this needs a maintainer discussion rather than a drive-by PR] +--- +diff --git a/statsig-pyo3/tests/test_forking.py b/statsig-pyo3/tests/test_forking.py +index a5f27c4..8d2bd7d 100644 +--- a/statsig-pyo3/tests/test_forking.py ++++ b/statsig-pyo3/tests/test_forking.py +@@ -45,7 +45,7 @@ def test_forking(httpserver: HTTPServer): + env={**os.environ, "RUST_BACKTRACE": "full"}, + ) + try: +- proc.communicate(timeout=10) ++ proc.communicate(timeout=180) + except subprocess.TimeoutExpired: + proc.terminate() + proc.wait()