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
1 change: 1 addition & 0 deletions docs/packages/oai-statsig-python-core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ versions:
- filename: oai_statsig_python_core-0.29.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl
sha256: 4013eb4b25041580420e101e7d0f73239d2c22d583a9e9e3d6cdf72ee85e8d09
requires-python: '>=3.10'
- version: 0.30.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()
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Ludovic Henry <git@ludovic.dev>
Date: Mon, 21 Sep 2026 00:43:31 +0000
Subject: [PATCH] fix: read code.co_qualname only where it exists (CPython >=
3.11)

`_find_exposure_callsite` reads `frame.f_code.co_qualname` unconditionally, but
that attribute was added in CPython 3.11, while this package declares
`requires-python = ">=3.10"` and ships a single `pyo3/abi3-py310` wheel. On
CPython 3.10 the access raises `AttributeError: 'code' object has no attribute
'co_qualname'`; `ErrorBoundary.wrap` swallows it, prints "Statsig SDK Error
(Python Bindings): _find_exposure_callsite", and returns None, so
`_get_exposure_callsite_metadata` falls back to its "unknown" branch and every
exposure event gets `exposure_source_file`/`exposure_source_function` of
"unknown" and a null `exposure_source_line`. Callsite logging is therefore
silently dead on the oldest interpreter the wheel supports, and the two
`test_exposure_logging.py` callsite tests fail.

This is not architecture-specific: against upstream's own published
oai_statsig_python_core-0.29.0-cp310-abi3-manylinux_2_17_x86_64 wheel, both
tests fail on x86_64 under CPython 3.10 with the identical assertion and
warning, and both pass under 3.11. Our riscv64 CI sees it because the abi3
wheel is built and tested with cp310, the lowest supported interpreter.

Fall back to `co_name`, which every supported version has. For a module-level
function the two are identical; for a method `co_name` loses the enclosing
class prefix, which is a far better result on 3.10 than "unknown".

Upstream-Status: To upstream [not yet submitted; the release is cut from a non-public tree -- public statsig-server-core stops at 0.23.0, which predates this callsite code entirely -- so this needs a maintainer discussion rather than a drive-by PR]
---
py_src/statsig_python_core/statsig.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/py_src/statsig_python_core/statsig.py b/py_src/statsig_python_core/statsig.py
index 26b3e7a..d7cf090 100644
--- a/py_src/statsig_python_core/statsig.py
+++ b/py_src/statsig_python_core/statsig.py
@@ -287,9 +287,11 @@ class Statsig(StatsigBasePy):
while frame is not None:
module_name = frame.f_globals.get("__name__", "")
if not self._is_exposure_callsite_module_ignored(module_name):
+ code = frame.f_code
+ # code.co_qualname is CPython >= 3.11; this package supports 3.10.
return (
- Path(frame.f_code.co_filename).name,
- frame.f_code.co_qualname,
+ Path(code.co_filename).name,
+ getattr(code, "co_qualname", code.co_name),
frame.f_lineno,
)
frame = frame.f_back
Loading