Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions .github/workflows/build-oai-statsig-python-core.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# SPDX-FileCopyrightText: 2026 The RISE Project
# SPDX-License-Identifier: MIT
---
# Based on: https://github.com/statsig-io/statsig-server-core/blob/0.23.0/.github/workflows/build.yml
# oai-statsig-python-core is a second distribution of the same statsig-pyo3 crate (renamed
# to oai-statsig-rust) on its own version line, so this mirrors
# build-statsig-python-core.yml. Every public tag and branch of statsig-server-core stops
# at 0.23.0, so the PyPI sdist is the only published form of 0.29.0 and is the upstream
# tree here (gotcha 156); the CI.yml it bundles is maturin boilerplate, not the pipeline
# that built the released wheels.
name: Build oai-statsig-python-core wheels (riscv64)

on:
workflow_dispatch:
inputs:
version:
description: 'Version glob to (re)build; empty builds every version of docs/packages/oai-statsig-python-core.yaml not released yet'
required: false
default: ''
pull_request:
branches: [main]
paths:
- '.github/workflows/build-oai-statsig-python-core.yml'
- 'docs/packages/oai-statsig-python-core.yaml'
push:
branches: [main]
paths:
- '.github/workflows/build-oai-statsig-python-core.yml'
- 'docs/packages/oai-statsig-python-core.yaml'

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

permissions:
contents: read # to fetch code (actions/checkout)

env:
MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64

jobs:
setup:
uses: $/.github/workflows/_setup.yml
with:
package: oai-statsig-python-core
version: ${{ inputs.version }}

build_wheels:
needs: [setup]
if: needs.setup.outputs.versions != '[]'
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.setup.outputs.versions) }}
name: Build oai-statsig-python-core ${{ matrix.version }} cp310-abi3-manylinux_riscv64
runs-on: ubuntu-24.04-riscv
timeout-minutes: 720

env:
STATSIG_VERSION: ${{ matrix.version }}
SDIST_DIR: oai_statsig_python_core-${{ matrix.version }}

steps:
- name: Checkout python-wheels
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: python-wheels
sparse-checkout: patches/oai-statsig-python-core
persist-credentials: false

- name: Fetch oai-statsig-python-core ${{ env.STATSIG_VERSION }} sdist
run: |
url="$(curl -sSfL "https://pypi.org/pypi/oai-statsig-python-core/${STATSIG_VERSION}/json" \
| python3 -c 'import json,sys; print(next(u["url"] for u in json.load(sys.stdin)["urls"] if u["packagetype"] == "sdist"))')"
curl -sSfL -o sdist.tar.gz "$url"
# Extracted rather than handed to cibuildwheel as a tarball: for a tarball
# package-dir cibuildwheel chdirs into its own extraction temp dir, and
# CIBW_TEST_SOURCES resolves against that cwd, so the staged trees below
# would be invisible.
tar xzf sdist.tar.gz
test -f "${SDIST_DIR}/pyproject.toml"

- name: Apply patches
run: cd "${SDIST_DIR}" && git apply -v ../python-wheels/patches/oai-statsig-python-core/"${STATSIG_VERSION}"/*.patch

# The sdist carries no licence file at all, so maturin's default glob beside
# pyproject.toml finds nothing and upstream's wheels ship no licence text on any
# platform, despite the ISC declaration in their metadata.
- name: Stage the project licence beside pyproject.toml
run: |
cat > "${SDIST_DIR}/LICENSE" <<'LICENCE'
ISC License (ISC)
Copyright (c) 2024, Statsig, Inc.

Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
LICENCE

- name: Build wheels
uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0
with:
package-dir: ${{ env.SDIST_DIR }}
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. The two CARGO_PROFILE_RELEASE_* vars
# reproduce this tree's `python-release` profile, which keeps the symbols
# upstream's wheels ship, without naming it in MATURIN_PEP517_ARGS where every
# other maturin sdist in the container would inherit it (gotcha 141).
CIBW_ENVIRONMENT: >-
PATH=$PATH:$HOME/.cargo/bin
PROTOC_INCLUDE=/usr/include
CARGO_PROFILE_RELEASE_DEBUG=1
CARGO_PROFILE_RELEASE_STRIP=none
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 sdist root.
CIBW_TEST_SOURCES: ${{ env.SDIST_DIR }}/statsig-pyo3/tests ${{ env.SDIST_DIR }}/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('oai_statsig_python_core'))" &&
cd ${{ env.SDIST_DIR }}/statsig-pyo3 && python -m pytest tests -v --reruns 3

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: oai_statsig_python_core-${{ env.STATSIG_VERSION }}-cp310-abi3-manylinux_riscv64
path: wheelhouse/*.whl
if-no-files-found: error

publish:
name: Publish oai-statsig-python-core ${{ matrix.version }}
needs: [setup, build_wheels]
if: needs.setup.outputs.versions != '[]'
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.setup.outputs.versions) }}
permissions:
contents: write
pull-requests: write
uses: $/.github/workflows/_publish-wheel.yml
secrets:
app-private-key: ${{ secrets.RISEPROJECT_APP_PRIVATE_KEY }}
with:
artifact-pattern: oai_statsig_python_core-${{ matrix.version }}-*-manylinux_riscv64
5 changes: 5 additions & 0 deletions docs/packages/oai-statsig-python-core.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package-name: oai-statsig-python-core
source-code: https://github.com/statsig-io/statsig-server-core
license: ISC
versions:
- version: 0.29.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Ludovic Henry <git@ludovic.dev>
Date: Sun, 20 Sep 2026 22:48:16 +0000
Subject: [PATCH] tests: wait for background specs syncs instead of assuming a
1ms interval

`StatsigOptions` rejects a `specs_sync_interval_ms` below `MIN_SYNC_INTERVAL`
(1000) and drops it back to the default, logging "Invalid
'specs_sync_interval_ms', value must be greater than 1000, received Some(1)".
Both fixtures in this file still ask for 1, so the six polling tests never get
the near-instant background sync they were written against and fail on every
platform -- reproduced here against the published
manylinux_2_17_x86_64 wheel, where 6 of the 7 tests in this file fail.

Use the smallest accepted interval -- the same 1000 upstream already switched
`test_observability_client.py` to -- 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.

Upstream-Status: To upstream [not yet submitted; the release is cut from a non-public tree, so this needs a maintainer discussion rather than a drive-by PR]
---
statsig-pyo3/tests/test_data_store.py | 46 +++++++++++++++++----------
1 file changed, 30 insertions(+), 16 deletions(-)

diff --git a/statsig-pyo3/tests/test_data_store.py b/statsig-pyo3/tests/test_data_store.py
index 9df3ac7..708c81b 100644
--- a/statsig-pyo3/tests/test_data_store.py
+++ b/statsig-pyo3/tests/test_data_store.py
@@ -17,6 +17,12 @@ from utils import get_test_data_resource, get_test_data_resource_bytes

known_lcut = 1767981029384

+# StatsigOptions rejects a specs_sync_interval_ms below this and falls back to the
+# default, so the polling tests below have to wait for a real sync to land.
+MIN_SYNC_INTERVAL_MS = 1000
+SYNC_WAIT_ATTEMPTS = 200
+SYNC_WAIT_INTERVAL_S = 0.05
+
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")
@@ -169,7 +175,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=MIN_SYNC_INTERVAL_MS,
)

statsig = Statsig("secret-key", options)
@@ -193,7 +199,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=MIN_SYNC_INTERVAL_MS,
)

statsig = Statsig("secret-key", options)
@@ -223,10 +229,10 @@ def test_data_store_usage_get(statsig_setup):
assert gate.value == True
assert gate.details.lcut == known_lcut

- for _ in range(100):
+ for _ in range(SYNC_WAIT_ATTEMPTS):
if data_store.get_called_count >= 2:
break
- sleep(0.05)
+ sleep(SYNC_WAIT_INTERVAL_S)

assert data_store.get_called_count > 1

@@ -240,9 +246,13 @@ def test_data_store_usage_set(statsig_setup):

assert data_store.init_called
assert gate.details.reason == "Adapter(DataStore):Recognized"
- sleep(1)

- gate_after = statsig.get_feature_gate(user, "test_public")
+ for _ in range(SYNC_WAIT_ATTEMPTS):
+ gate_after = statsig.get_feature_gate(user, "test_public")
+ if gate_after.details.lcut == known_lcut + 10:
+ break
+ sleep(SYNC_WAIT_INTERVAL_S)
+
statsig.flush_events().wait()

assert gate_after.value == True
@@ -265,10 +275,10 @@ def test_data_store_usage_get_bytes(statsig_bytes_setup):
assert gate.value == True
assert gate.details.lcut == known_lcut

- for _ in range(5):
+ for _ in range(SYNC_WAIT_ATTEMPTS):
if data_store.set_bytes_called_count > 0:
break
- sleep(0.05)
+ sleep(SYNC_WAIT_INTERVAL_S)

assert data_store.get_bytes_called_count >= 1
assert data_store.get_called_count == 0
@@ -283,10 +293,10 @@ def test_data_store_usage_get_bytes_request_has_since_time_after_initial_poll(st
gate = statsig.get_feature_gate(user, "test_public")
assert gate.details.reason == "Adapter(DataStore):Recognized"

- for _ in range(100):
+ for _ in range(SYNC_WAIT_ATTEMPTS):
if data_store.get_bytes_called_count >= 2:
break
- sleep(0.05)
+ sleep(SYNC_WAIT_INTERVAL_S)

assert data_store.get_bytes_called_count >= 2

@@ -308,10 +318,10 @@ def test_data_store_usage_get_bytes_request_checksum_match_returns_no_update(sta
gate = statsig.get_feature_gate(user, "test_public")
assert gate.details.reason == "Adapter(DataStore):Recognized"

- for _ in range(100):
+ for _ in range(SYNC_WAIT_ATTEMPTS):
if data_store.get_bytes_called_count >= 2:
break
- sleep(0.05)
+ sleep(SYNC_WAIT_INTERVAL_S)

assert data_store.get_bytes_called_count >= 2
assert data_store.returned_no_update
@@ -325,10 +335,10 @@ def test_data_store_usage_get_bytes_request_checksum_match_returns_no_update(sta
assert request_with_checksum.since_time == data_store.stored_time

# no second write should occur when server responds with {"has_updates": false}
- for _ in range(100):
+ for _ in range(SYNC_WAIT_ATTEMPTS):
if data_store.get_bytes_called_count > 2:
break
- sleep(0.05)
+ sleep(SYNC_WAIT_INTERVAL_S)



@@ -341,9 +351,13 @@ def test_data_store_usage_set_bytes(statsig_bytes_setup):

assert data_store.init_called
assert gate.details.reason == "Adapter(DataStore):Recognized"
- sleep(1)

- gate_after = statsig.get_feature_gate(user, "test_public")
+ for _ in range(SYNC_WAIT_ATTEMPTS):
+ gate_after = statsig.get_feature_gate(user, "test_public")
+ if gate_after.details.lcut == known_lcut + 10:
+ break
+ sleep(SYNC_WAIT_INTERVAL_S)
+
statsig.flush_events().wait()

assert gate_after.value == True
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Ludovic Henry <git@ludovic.dev>
Date: Sun, 20 Sep 2026 22:48:16 +0000
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 is cut from a non-public tree, so this needs a maintainer discussion rather than a drive-by PR]
---
statsig-pyo3/tests/test_forking.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)

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()
Loading
Loading