Skip to content

Commit 401bdde

Browse files
committed
oai-statsig-python-core: carry 0.29.0 patches forward to 0.30.0
The nightly-upgrade bot bumped docs/packages/oai-statsig-python-core.yaml to 0.30.0 but did not copy patches/oai-statsig-python-core/0.29.0/ forward, so the riscv64 build's "Apply patches" step failed instantly with 'No such file or directory' for the missing patches/oai-statsig-python-core/0.30.0/ directory. Verified all three patches (test_data_store.py sync waits, fork_runner.py timeout, statsig.py co_qualname fallback) apply cleanly, unmodified, against the real 0.30.0 sdist from PyPI -- none of the patched files changed between 0.29.0 and 0.30.0.
1 parent a853984 commit 401bdde

3 files changed

Lines changed: 239 additions & 0 deletions
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
2+
From: Ludovic Henry <git@ludovic.dev>
3+
Date: Sun, 20 Sep 2026 22:48:16 +0000
4+
Subject: [PATCH] tests: wait for background specs syncs instead of assuming a
5+
1ms interval
6+
7+
`StatsigOptions` rejects a `specs_sync_interval_ms` below `MIN_SYNC_INTERVAL`
8+
(1000) and drops it back to the default, logging "Invalid
9+
'specs_sync_interval_ms', value must be greater than 1000, received Some(1)".
10+
Both fixtures in this file still ask for 1, so the six polling tests never get
11+
the near-instant background sync they were written against and fail on every
12+
platform -- reproduced here against the published
13+
manylinux_2_17_x86_64 wheel, where 6 of the 7 tests in this file fail.
14+
15+
Use the smallest accepted interval -- the same 1000 upstream already switched
16+
`test_observability_client.py` to -- and wait for the condition each test is
17+
really about rather than for a fixed duration. The waits return as soon as the
18+
sync lands, so fast hardware pays nothing.
19+
20+
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]
21+
---
22+
statsig-pyo3/tests/test_data_store.py | 46 +++++++++++++++++----------
23+
1 file changed, 30 insertions(+), 16 deletions(-)
24+
25+
diff --git a/statsig-pyo3/tests/test_data_store.py b/statsig-pyo3/tests/test_data_store.py
26+
index 9df3ac7..708c81b 100644
27+
--- a/statsig-pyo3/tests/test_data_store.py
28+
+++ b/statsig-pyo3/tests/test_data_store.py
29+
@@ -17,6 +17,12 @@ from utils import get_test_data_resource, get_test_data_resource_bytes
30+
31+
known_lcut = 1767981029384
32+
33+
+# StatsigOptions rejects a specs_sync_interval_ms below this and falls back to the
34+
+# default, so the polling tests below have to wait for a real sync to land.
35+
+MIN_SYNC_INTERVAL_MS = 1000
36+
+SYNC_WAIT_ATTEMPTS = 200
37+
+SYNC_WAIT_INTERVAL_S = 0.05
38+
+
39+
dcs_content = get_test_data_resource("eval_proj_dcs.json")
40+
json_data = json.loads(dcs_content)
41+
eval_proj_protobuf = get_test_data_resource_bytes("eval_proj_dcs.pb.br")
42+
@@ -169,7 +175,7 @@ def statsig_setup(httpserver: HTTPServer):
43+
specs_url=httpserver.url_for("/v2/download_config_specs"),
44+
log_event_url=httpserver.url_for("/v1/log_event"),
45+
data_store=data_store,
46+
- specs_sync_interval_ms=1,
47+
+ specs_sync_interval_ms=MIN_SYNC_INTERVAL_MS,
48+
)
49+
50+
statsig = Statsig("secret-key", options)
51+
@@ -193,7 +199,7 @@ def statsig_bytes_setup(httpserver: HTTPServer):
52+
specs_url=httpserver.url_for("/v2/download_config_specs"),
53+
log_event_url=httpserver.url_for("/v1/log_event"),
54+
data_store=data_store,
55+
- specs_sync_interval_ms=1,
56+
+ specs_sync_interval_ms=MIN_SYNC_INTERVAL_MS,
57+
)
58+
59+
statsig = Statsig("secret-key", options)
60+
@@ -223,10 +229,10 @@ def test_data_store_usage_get(statsig_setup):
61+
assert gate.value == True
62+
assert gate.details.lcut == known_lcut
63+
64+
- for _ in range(100):
65+
+ for _ in range(SYNC_WAIT_ATTEMPTS):
66+
if data_store.get_called_count >= 2:
67+
break
68+
- sleep(0.05)
69+
+ sleep(SYNC_WAIT_INTERVAL_S)
70+
71+
assert data_store.get_called_count > 1
72+
73+
@@ -240,9 +246,13 @@ def test_data_store_usage_set(statsig_setup):
74+
75+
assert data_store.init_called
76+
assert gate.details.reason == "Adapter(DataStore):Recognized"
77+
- sleep(1)
78+
79+
- gate_after = statsig.get_feature_gate(user, "test_public")
80+
+ for _ in range(SYNC_WAIT_ATTEMPTS):
81+
+ gate_after = statsig.get_feature_gate(user, "test_public")
82+
+ if gate_after.details.lcut == known_lcut + 10:
83+
+ break
84+
+ sleep(SYNC_WAIT_INTERVAL_S)
85+
+
86+
statsig.flush_events().wait()
87+
88+
assert gate_after.value == True
89+
@@ -265,10 +275,10 @@ def test_data_store_usage_get_bytes(statsig_bytes_setup):
90+
assert gate.value == True
91+
assert gate.details.lcut == known_lcut
92+
93+
- for _ in range(5):
94+
+ for _ in range(SYNC_WAIT_ATTEMPTS):
95+
if data_store.set_bytes_called_count > 0:
96+
break
97+
- sleep(0.05)
98+
+ sleep(SYNC_WAIT_INTERVAL_S)
99+
100+
assert data_store.get_bytes_called_count >= 1
101+
assert data_store.get_called_count == 0
102+
@@ -283,10 +293,10 @@ def test_data_store_usage_get_bytes_request_has_since_time_after_initial_poll(st
103+
gate = statsig.get_feature_gate(user, "test_public")
104+
assert gate.details.reason == "Adapter(DataStore):Recognized"
105+
106+
- for _ in range(100):
107+
+ for _ in range(SYNC_WAIT_ATTEMPTS):
108+
if data_store.get_bytes_called_count >= 2:
109+
break
110+
- sleep(0.05)
111+
+ sleep(SYNC_WAIT_INTERVAL_S)
112+
113+
assert data_store.get_bytes_called_count >= 2
114+
115+
@@ -308,10 +318,10 @@ def test_data_store_usage_get_bytes_request_checksum_match_returns_no_update(sta
116+
gate = statsig.get_feature_gate(user, "test_public")
117+
assert gate.details.reason == "Adapter(DataStore):Recognized"
118+
119+
- for _ in range(100):
120+
+ for _ in range(SYNC_WAIT_ATTEMPTS):
121+
if data_store.get_bytes_called_count >= 2:
122+
break
123+
- sleep(0.05)
124+
+ sleep(SYNC_WAIT_INTERVAL_S)
125+
126+
assert data_store.get_bytes_called_count >= 2
127+
assert data_store.returned_no_update
128+
@@ -325,10 +335,10 @@ def test_data_store_usage_get_bytes_request_checksum_match_returns_no_update(sta
129+
assert request_with_checksum.since_time == data_store.stored_time
130+
131+
# no second write should occur when server responds with {"has_updates": false}
132+
- for _ in range(100):
133+
+ for _ in range(SYNC_WAIT_ATTEMPTS):
134+
if data_store.get_bytes_called_count > 2:
135+
break
136+
- sleep(0.05)
137+
+ sleep(SYNC_WAIT_INTERVAL_S)
138+
139+
140+
141+
@@ -341,9 +351,13 @@ def test_data_store_usage_set_bytes(statsig_bytes_setup):
142+
143+
assert data_store.init_called
144+
assert gate.details.reason == "Adapter(DataStore):Recognized"
145+
- sleep(1)
146+
147+
- gate_after = statsig.get_feature_gate(user, "test_public")
148+
+ for _ in range(SYNC_WAIT_ATTEMPTS):
149+
+ gate_after = statsig.get_feature_gate(user, "test_public")
150+
+ if gate_after.details.lcut == known_lcut + 10:
151+
+ break
152+
+ sleep(SYNC_WAIT_INTERVAL_S)
153+
+
154+
statsig.flush_events().wait()
155+
156+
assert gate_after.value == True
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
2+
From: Ludovic Henry <git@ludovic.dev>
3+
Date: Sun, 20 Sep 2026 22:48:16 +0000
4+
Subject: [PATCH] tests: give fork_runner.py a timeout a slow machine can meet
5+
6+
`fork_runner.py` performs 50 full SDK initialisations -- ten iterations, each
7+
followed by four nested forks that initialise, evaluate and shut down -- against
8+
a local mock server. Ten seconds is enough on upstream's x86_64 CI and not on a
9+
riscv64 runner, where the subprocess is SIGTERMed part way through and the test
10+
fails with `assert -15 == 0`.
11+
12+
`communicate()` returns as soon as the subprocess exits, so a larger budget
13+
costs nothing where the old one was already sufficient.
14+
15+
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]
16+
---
17+
statsig-pyo3/tests/test_forking.py | 2 +-
18+
1 file changed, 1 insertion(+), 1 deletion(-)
19+
20+
diff --git a/statsig-pyo3/tests/test_forking.py b/statsig-pyo3/tests/test_forking.py
21+
index a5f27c4..8d2bd7d 100644
22+
--- a/statsig-pyo3/tests/test_forking.py
23+
+++ b/statsig-pyo3/tests/test_forking.py
24+
@@ -45,7 +45,7 @@ def test_forking(httpserver: HTTPServer):
25+
env={**os.environ, "RUST_BACKTRACE": "full"},
26+
)
27+
try:
28+
- proc.communicate(timeout=10)
29+
+ proc.communicate(timeout=180)
30+
except subprocess.TimeoutExpired:
31+
proc.terminate()
32+
proc.wait()
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
2+
From: Ludovic Henry <git@ludovic.dev>
3+
Date: Mon, 21 Sep 2026 00:43:31 +0000
4+
Subject: [PATCH] fix: read code.co_qualname only where it exists (CPython >=
5+
3.11)
6+
7+
`_find_exposure_callsite` reads `frame.f_code.co_qualname` unconditionally, but
8+
that attribute was added in CPython 3.11, while this package declares
9+
`requires-python = ">=3.10"` and ships a single `pyo3/abi3-py310` wheel. On
10+
CPython 3.10 the access raises `AttributeError: 'code' object has no attribute
11+
'co_qualname'`; `ErrorBoundary.wrap` swallows it, prints "Statsig SDK Error
12+
(Python Bindings): _find_exposure_callsite", and returns None, so
13+
`_get_exposure_callsite_metadata` falls back to its "unknown" branch and every
14+
exposure event gets `exposure_source_file`/`exposure_source_function` of
15+
"unknown" and a null `exposure_source_line`. Callsite logging is therefore
16+
silently dead on the oldest interpreter the wheel supports, and the two
17+
`test_exposure_logging.py` callsite tests fail.
18+
19+
This is not architecture-specific: against upstream's own published
20+
oai_statsig_python_core-0.29.0-cp310-abi3-manylinux_2_17_x86_64 wheel, both
21+
tests fail on x86_64 under CPython 3.10 with the identical assertion and
22+
warning, and both pass under 3.11. Our riscv64 CI sees it because the abi3
23+
wheel is built and tested with cp310, the lowest supported interpreter.
24+
25+
Fall back to `co_name`, which every supported version has. For a module-level
26+
function the two are identical; for a method `co_name` loses the enclosing
27+
class prefix, which is a far better result on 3.10 than "unknown".
28+
29+
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]
30+
---
31+
py_src/statsig_python_core/statsig.py | 6 ++++--
32+
1 file changed, 4 insertions(+), 2 deletions(-)
33+
34+
diff --git a/py_src/statsig_python_core/statsig.py b/py_src/statsig_python_core/statsig.py
35+
index 26b3e7a..d7cf090 100644
36+
--- a/py_src/statsig_python_core/statsig.py
37+
+++ b/py_src/statsig_python_core/statsig.py
38+
@@ -287,9 +287,11 @@ class Statsig(StatsigBasePy):
39+
while frame is not None:
40+
module_name = frame.f_globals.get("__name__", "")
41+
if not self._is_exposure_callsite_module_ignored(module_name):
42+
+ code = frame.f_code
43+
+ # code.co_qualname is CPython >= 3.11; this package supports 3.10.
44+
return (
45+
- Path(frame.f_code.co_filename).name,
46+
- frame.f_code.co_qualname,
47+
+ Path(code.co_filename).name,
48+
+ getattr(code, "co_qualname", code.co_name),
49+
frame.f_lineno,
50+
)
51+
frame = frame.f_back

0 commit comments

Comments
 (0)