From 365aad11b2122bf58ef8f4dcda9750e5be1f2dde Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Sun, 20 Sep 2026 23:01:07 +0000 Subject: [PATCH 1/2] oai-statsig-python-core: Add version 0.29.0 oai-statsig-python-core is a second PyPI distribution of statsig-pyo3, the PyO3/maturin binding for statsig's feature-flagging and experimentation server core. It runs on its own version line -- 0.29.0 was published on 2026-08-04, ten days before statsig-python-core 0.22.0 -- and renames the Rust crates it builds (statsig-rust becomes oai-statsig-rust), so it is a separate package rather than an alias of the statsig-python-core this repo already serves. Every public tag and branch of statsig-io/statsig-server-core stops at 0.23.0 (164 tags, 68 releases/* branches, main's workspace version), so 0.24.0 onwards are cut from a tree that is not published. The PyPI sdist is therefore the only published form of this release and is the upstream tree the build consumes; it is self-contained, carrying the Cargo workspace, Cargo.lock, the Python sources and both test trees the suite needs. The build otherwise mirrors build-statsig-python-core.yml, which compiles the same crate: one cp310-abi3 wheel covers every non-free-threaded CPython >= 3.10, protoc comes from Rocky 10's CRB repo for statsig-grpc's build script, and the Rust toolchain is installed in-container. Two deviations are specific to this distribution: - CARGO_PROFILE_RELEASE_{DEBUG,STRIP} reproduce the `python-release` profile this tree adds on top of `release` (debug = 1, strip = "none"), which is why upstream's published wheels ship a 39 MB unstripped extension with symbols their production profiles can resolve. Expressing it through the cargo profile env vars rather than `--profile python-release` keeps the name out of MATURIN_PEP517_ARGS, which would otherwise be inherited by every unrelated maturin sdist built in the same container. - The sdist carries no licence file, so maturin's default glob beside pyproject.toml finds nothing and upstream's wheels ship no licence text on any platform despite declaring ISC. The build stages the project's ISC licence so ours does, and the test command asserts it landed. The two patches fix tests that fail on upstream's own published x86_64 wheel, verified by running the suite against it: StatsigOptions now rejects a specs_sync_interval_ms below 1000 and falls back to the default, which breaks six polling tests in test_data_store.py that still ask for 1, and fork_runner.py's 50 SDK initialisations need more than ten seconds on a riscv64 runner. Both carry forward the equivalent patches this repo already ships for statsig-python-core 0.22.0 and 0.23.0. cargo metadata --filter-platform riscv64gc-unknown-linux-gnu resolves all 335 crates, and the native ones are pinned to the same versions the 0.23.0 riscv64 build already compiled: ring 0.17.14, zstd-sys 2.0.13+zstd.1.5.6, prost 0.13.4 and tonic 0.12.3. --- .../build-oai-statsig-python-core.yml | 166 ++++++++++++++++++ docs/packages/oai-statsig-python-core.yaml | 5 + ...ackground-specs-syncs-instead-of-ass.patch | 156 ++++++++++++++++ ...runner.py-a-timeout-a-slow-machine-c.patch | 32 ++++ 4 files changed, 359 insertions(+) create mode 100644 .github/workflows/build-oai-statsig-python-core.yml create mode 100644 docs/packages/oai-statsig-python-core.yaml create mode 100644 patches/oai-statsig-python-core/0.29.0/0001-tests-wait-for-background-specs-syncs-instead-of-ass.patch create mode 100644 patches/oai-statsig-python-core/0.29.0/0002-tests-give-fork_runner.py-a-timeout-a-slow-machine-c.patch diff --git a/.github/workflows/build-oai-statsig-python-core.yml b/.github/workflows/build-oai-statsig-python-core.yml new file mode 100644 index 00000000000..a0f7f9798b1 --- /dev/null +++ b/.github/workflows/build-oai-statsig-python-core.yml @@ -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 diff --git a/docs/packages/oai-statsig-python-core.yaml b/docs/packages/oai-statsig-python-core.yaml new file mode 100644 index 00000000000..330fa15a5a3 --- /dev/null +++ b/docs/packages/oai-statsig-python-core.yaml @@ -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 diff --git a/patches/oai-statsig-python-core/0.29.0/0001-tests-wait-for-background-specs-syncs-instead-of-ass.patch b/patches/oai-statsig-python-core/0.29.0/0001-tests-wait-for-background-specs-syncs-instead-of-ass.patch new file mode 100644 index 00000000000..832832f3ec8 --- /dev/null +++ b/patches/oai-statsig-python-core/0.29.0/0001-tests-wait-for-background-specs-syncs-instead-of-ass.patch @@ -0,0 +1,156 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +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 diff --git a/patches/oai-statsig-python-core/0.29.0/0002-tests-give-fork_runner.py-a-timeout-a-slow-machine-c.patch b/patches/oai-statsig-python-core/0.29.0/0002-tests-give-fork_runner.py-a-timeout-a-slow-machine-c.patch new file mode 100644 index 00000000000..82afe1f1a09 --- /dev/null +++ b/patches/oai-statsig-python-core/0.29.0/0002-tests-give-fork_runner.py-a-timeout-a-slow-machine-c.patch @@ -0,0 +1,32 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +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() From 4d26dae3b04d079f94ba65ed29426f547dbb3a37 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Mon, 21 Sep 2026 00:45:11 +0000 Subject: [PATCH 2/2] oai-statsig-python-core: patch the callsite logging that CPython 3.10 cannot run The cp310-abi3 build's test step failed on `co_qualname`: two test_exposure_logging.py tests asserted the automatic exposure-callsite metadata and got "unknown", each preceded by "Statsig SDK Error (Python Bindings): _find_exposure_callsite 'code' object has no attribute 'co_qualname'". 227 passed, 2 failed, and --reruns 3 did not move them. Nothing to do with riscv64, and nothing to do with the Rust core: the callsite walk is pure Python in py_src/statsig_python_core/statsig.py, and it reads `frame.f_code.co_qualname`, an attribute CPython only grew in 3.11. This package declares requires-python >= 3.10 and ships a single pyo3/abi3-py310 wheel, so 3.10 is in its support range -- and it is exactly the interpreter cibuildwheel builds and tests `only: cp310-manylinux_riscv64` with, the floor of the abi3 range. ErrorBoundary.wrap swallows the AttributeError into that warning and returns None, so _get_exposure_callsite_metadata takes its "unknown" branch and callsite logging is silently dead on 3.10 everywhere. Reproduced off riscv64 to prove the point: against upstream's own published oai_statsig_python_core-0.29.0-cp310-abi3-manylinux_2_17_x86_64 wheel on x86_64, both tests fail under CPython 3.10 with the identical assertion and warning, and all 21 tests in the file pass under 3.11. The earlier rehearsal that reported 229 passed ran on cp312, which has the attribute, so it could never have seen this. Patch 0003 falls back to `co_name`, which every supported version has, rather than skipping the tests: the tests are asserting a real product behaviour that should work on 3.10. For a module-level function the two names are identical, and for a method `co_name` only loses the class prefix -- still far better than "unknown". With all three patches applied the full suite is 229 passed under CPython 3.10 on x86_64, matching the count the riscv64 job reached. --- ..._qualname-only-where-it-exists-CPyth.patch | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 patches/oai-statsig-python-core/0.29.0/0003-fix-read-code.co_qualname-only-where-it-exists-CPyth.patch diff --git a/patches/oai-statsig-python-core/0.29.0/0003-fix-read-code.co_qualname-only-where-it-exists-CPyth.patch b/patches/oai-statsig-python-core/0.29.0/0003-fix-read-code.co_qualname-only-where-it-exists-CPyth.patch new file mode 100644 index 00000000000..d5e71400e65 --- /dev/null +++ b/patches/oai-statsig-python-core/0.29.0/0003-fix-read-code.co_qualname-only-where-it-exists-CPyth.patch @@ -0,0 +1,51 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +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