traffical 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. traffical-0.3.0/.github/workflows/ci.yml +95 -0
  2. traffical-0.3.0/.github/workflows/publish.yml +25 -0
  3. traffical-0.3.0/.github/workflows/release-please.yml +39 -0
  4. traffical-0.3.0/.gitignore +13 -0
  5. traffical-0.3.0/.release-please-manifest.json +3 -0
  6. traffical-0.3.0/AGENTS.md +29 -0
  7. traffical-0.3.0/CHANGELOG.md +27 -0
  8. traffical-0.3.0/CONTRIBUTING.md +120 -0
  9. traffical-0.3.0/LICENSE +21 -0
  10. traffical-0.3.0/PKG-INFO +283 -0
  11. traffical-0.3.0/README.md +260 -0
  12. traffical-0.3.0/SECURITY.md +35 -0
  13. traffical-0.3.0/examples/fastapi_app.py +78 -0
  14. traffical-0.3.0/examples/quickstart_async.py +57 -0
  15. traffical-0.3.0/examples/quickstart_sync.py +55 -0
  16. traffical-0.3.0/pyproject.toml +62 -0
  17. traffical-0.3.0/release-please-config.json +13 -0
  18. traffical-0.3.0/src/traffical/__init__.py +45 -0
  19. traffical-0.3.0/src/traffical/_fork.py +41 -0
  20. traffical-0.3.0/src/traffical/async_client.py +335 -0
  21. traffical-0.3.0/src/traffical/client.py +637 -0
  22. traffical-0.3.0/src/traffical/config/__init__.py +38 -0
  23. traffical-0.3.0/src/traffical/config/poller.py +289 -0
  24. traffical-0.3.0/src/traffical/config/readiness.py +128 -0
  25. traffical-0.3.0/src/traffical/config/source.py +199 -0
  26. traffical-0.3.0/src/traffical/engine/__init__.py +57 -0
  27. traffical-0.3.0/src/traffical/engine/conditions.py +159 -0
  28. traffical-0.3.0/src/traffical/engine/contextual.py +141 -0
  29. traffical-0.3.0/src/traffical/engine/hashing.py +55 -0
  30. traffical-0.3.0/src/traffical/engine/ids.py +51 -0
  31. traffical-0.3.0/src/traffical/engine/resolution.py +404 -0
  32. traffical-0.3.0/src/traffical/engine/strings.py +90 -0
  33. traffical-0.3.0/src/traffical/engine/types.py +489 -0
  34. traffical-0.3.0/src/traffical/engine/weighted.py +40 -0
  35. traffical-0.3.0/src/traffical/events/__init__.py +45 -0
  36. traffical-0.3.0/src/traffical/events/assignments.py +177 -0
  37. traffical-0.3.0/src/traffical/events/dedup.py +98 -0
  38. traffical-0.3.0/src/traffical/events/flusher.py +378 -0
  39. traffical-0.3.0/src/traffical/events/logger.py +204 -0
  40. traffical-0.3.0/src/traffical/events/queue.py +65 -0
  41. traffical-0.3.0/src/traffical/options.py +88 -0
  42. traffical-0.3.0/src/traffical/py.typed +0 -0
  43. traffical-0.3.0/tests/client/.gitkeep +0 -0
  44. traffical-0.3.0/tests/client/test_client_async.py +158 -0
  45. traffical-0.3.0/tests/client/test_client_sync.py +521 -0
  46. traffical-0.3.0/tests/client/test_config_poller.py +368 -0
  47. traffical-0.3.0/tests/client/test_config_source.py +210 -0
  48. traffical-0.3.0/tests/client/test_config_store.py +95 -0
  49. traffical-0.3.0/tests/client/test_events_async.py +240 -0
  50. traffical-0.3.0/tests/client/test_events_dedup.py +116 -0
  51. traffical-0.3.0/tests/client/test_events_flusher.py +234 -0
  52. traffical-0.3.0/tests/client/test_events_pipeline.py +138 -0
  53. traffical-0.3.0/tests/client/test_events_queue.py +61 -0
  54. traffical-0.3.0/tests/conformance/.gitkeep +0 -0
  55. traffical-0.3.0/tests/conformance/test_events_conformance.py +213 -0
  56. traffical-0.3.0/tests/conformance/test_spec_vectors.py +333 -0
  57. traffical-0.3.0/tests/conftest.py +37 -0
  58. traffical-0.3.0/tests/engine/.gitkeep +0 -0
  59. traffical-0.3.0/tests/engine/test_conditions.py +312 -0
  60. traffical-0.3.0/tests/engine/test_contextual.py +377 -0
  61. traffical-0.3.0/tests/engine/test_hashing.py +133 -0
  62. traffical-0.3.0/tests/engine/test_resolution.py +869 -0
  63. traffical-0.3.0/tests/engine/test_strings.py +48 -0
  64. traffical-0.3.0/tests/engine/test_weighted.py +100 -0
  65. traffical-0.3.0/tests/test_exports.py +40 -0
  66. traffical-0.3.0/uv.lock +798 -0
@@ -0,0 +1,95 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ env:
9
+ # The published npm pin the SDK conforms to. The stale-pin gate below fails
10
+ # CI when a newer sdk-spec tag exists, so this must be bumped in lockstep.
11
+ SDK_SPEC_VERSION: "0.7.0"
12
+ # CI checks out this published sdk-spec tag for the fixtures + schemas.
13
+ SDK_SPEC_REF: "v0.7.0"
14
+
15
+ jobs:
16
+ test:
17
+ runs-on: ubuntu-latest
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
22
+ steps:
23
+ - uses: actions/checkout@v6
24
+
25
+ - name: Check out sdk-spec (fixtures + schemas)
26
+ uses: actions/checkout@v6
27
+ with:
28
+ repository: traffical/sdk-spec
29
+ ref: ${{ env.SDK_SPEC_REF }}
30
+ path: .sdk-spec
31
+
32
+ - name: Point tests at the sdk-spec checkout
33
+ run: echo "TRAFFICAL_SDK_SPEC_PATH=${GITHUB_WORKSPACE}/.sdk-spec" >> "$GITHUB_ENV"
34
+
35
+ - uses: astral-sh/setup-uv@v8.2.0
36
+ with:
37
+ python-version: ${{ matrix.python-version }}
38
+
39
+ - name: Install dependencies
40
+ # --frozen (not --locked): install exactly the locked dependency
41
+ # versions without asserting uv.lock matches pyproject. release-please
42
+ # bumps the project version in pyproject.toml but not in uv.lock, so
43
+ # --locked would fail on every release PR over the project's own version.
44
+ run: uv sync --frozen
45
+
46
+ - name: Tests (unit + sdk-spec conformance)
47
+ run: uv run pytest
48
+
49
+ - name: Type check (mypy --strict)
50
+ run: uv run mypy src/ tests/
51
+
52
+ - name: Lint
53
+ run: uv run ruff check .
54
+
55
+ - name: Format check
56
+ run: uv run ruff format --check .
57
+
58
+ spec-pin-check:
59
+ # Stale-pin gate: fail when the sdk-spec pin is behind the latest published
60
+ # tag, so the conformance suite cannot silently lag the spec.
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - name: Fail if SDK_SPEC_VERSION is behind the latest sdk-spec tag
64
+ run: |
65
+ set -euo pipefail
66
+ pinned="${SDK_SPEC_VERSION#v}"
67
+ latest="$(git ls-remote --tags --refs https://github.com/traffical/sdk-spec \
68
+ | sed -E 's#.*/tags/v?##' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1 || true)"
69
+ if [ -z "$latest" ]; then
70
+ echo "No published sdk-spec tags found (pre-release); skipping stale-pin gate."
71
+ exit 0
72
+ fi
73
+ newest="$(printf '%s\n%s\n' "$pinned" "$latest" | sort -V | tail -n1)"
74
+ echo "pinned=$pinned latest=$latest"
75
+ if [ "$latest" != "$pinned" ] && [ "$newest" = "$latest" ]; then
76
+ echo "::error::sdk-spec pin $pinned is behind the latest published tag $latest. Bump SDK_SPEC_VERSION."
77
+ exit 1
78
+ fi
79
+ echo "sdk-spec pin is current."
80
+
81
+ build:
82
+ runs-on: ubuntu-latest
83
+ steps:
84
+ - uses: actions/checkout@v6
85
+ - uses: astral-sh/setup-uv@v8.2.0
86
+ - name: Build wheel and sdist
87
+ run: uv build
88
+ - name: Check wheel contents
89
+ run: |
90
+ uv run --no-project --with check-wheel-contents check-wheel-contents dist/*.whl
91
+ unzip -l dist/*.whl | grep -q "traffical/py.typed"
92
+ - uses: actions/upload-artifact@v7
93
+ with:
94
+ name: dist
95
+ path: dist/
@@ -0,0 +1,25 @@
1
+ name: Publish to PyPI
2
+
3
+ # Token-based publish. Runs on a GitHub Release (for user-created releases) and
4
+ # via manual dispatch (e.g. to re-publish a release whose auto-publish did not
5
+ # fire). Releases cut by release-please publish inline in release-please.yml —
6
+ # see the note there — because a release created with GITHUB_TOKEN cannot trigger
7
+ # this `release`-triggered workflow.
8
+ on:
9
+ release:
10
+ types: [published]
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ publish:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v6
18
+ - uses: astral-sh/setup-uv@v8.2.0
19
+ - name: Build wheel and sdist
20
+ run: uv build
21
+ - name: Publish
22
+ uses: pypa/gh-action-pypi-publish@release/v1
23
+ with:
24
+ password: ${{ secrets.PYPI_API_TOKEN }}
25
+ skip-existing: true
@@ -0,0 +1,39 @@
1
+ name: Release Please
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: write
9
+ pull-requests: write
10
+
11
+ jobs:
12
+ release-please:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: googleapis/release-please-action@v5
16
+ id: release
17
+ with:
18
+ # Optional RELEASE_PLEASE_TOKEN (a PAT) makes CI run on the release PR;
19
+ # the default GITHUB_TOKEN cannot trigger downstream workflows.
20
+ token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
21
+
22
+ # Publish to PyPI in THIS run when release-please cuts a release. Doing it
23
+ # inline (rather than a separate `release`-triggered workflow) avoids
24
+ # GitHub's anti-recursion rule: a release created with GITHUB_TOKEN cannot
25
+ # trigger a downstream `release:` workflow, so publish.yml would never fire
26
+ # for an automated release.
27
+ - uses: actions/checkout@v6
28
+ if: ${{ steps.release.outputs.release_created }}
29
+ - uses: astral-sh/setup-uv@v8.2.0
30
+ if: ${{ steps.release.outputs.release_created }}
31
+ - name: Build wheel and sdist
32
+ if: ${{ steps.release.outputs.release_created }}
33
+ run: uv build
34
+ - name: Publish to PyPI
35
+ if: ${{ steps.release.outputs.release_created }}
36
+ uses: pypa/gh-action-pypi-publish@release/v1
37
+ with:
38
+ password: ${{ secrets.PYPI_API_TOKEN }}
39
+ skip-existing: true
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.egg-info/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .pytest_cache/
10
+ .coverage
11
+ coverage.xml
12
+ .DS_Store
13
+ .sdk-spec/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.3.0"
3
+ }
@@ -0,0 +1,29 @@
1
+ # Agent Guidelines for the Traffical Python SDK
2
+
3
+ ## Important Files
4
+
5
+ - **`CONTRIBUTING.md`** — full development workflow (uv, pytest, mypy, ruff, conformance fixtures).
6
+ Always read and follow it when making changes.
7
+
8
+ ## Hard Rules
9
+
10
+ - `src/traffical/engine/` must stay pure: stdlib only, no I/O, no clocks, no globals, frozen dataclasses.
11
+ - Public client methods are fail-open — they return the caller's defaults instead of raising.
12
+ - The cross-language contract in `../sdk-spec` is authoritative. If a conformance vector fails, fix the
13
+ code, never the fixture.
14
+ - Never manually edit the version (`pyproject.toml` + `SDK_VERSION` in `src/traffical/options.py`)
15
+ outside a dedicated release commit.
16
+
17
+ ## Workflow
18
+
19
+ Run from the package root:
20
+
21
+ ```bash
22
+ uv sync
23
+ uv run pytest
24
+ uv run mypy src/traffical # --strict, configured in pyproject
25
+ uv run ruff check
26
+ ```
27
+
28
+ All three must be green before committing. Conformance fixtures resolve via `TRAFFICAL_SDK_SPEC_PATH`,
29
+ defaulting to the sibling `../sdk-spec` (see `tests/conftest.py`).
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0](https://github.com/traffical/python-sdk/compare/v0.2.0...v0.3.0) (2026-07-15)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * adopt 0.7.0 public API contract (A1) + exposure dedup (S4) + track options
9
+
10
+ ### Features
11
+
12
+ * adopt 0.7.0 public API contract (A1) + exposure dedup (S4) + track options ([eccbf9d](https://github.com/traffical/python-sdk/commit/eccbf9d929a4a8392a203c6aae31e2772391271b))
13
+ * **config:** honor suggestedRefreshMs from the config response ([1862de9](https://github.com/traffical/python-sdk/commit/1862de95bc613834ff326003ec0ad2d8cc9ba032))
14
+ * emit propensity, modelVersion, configVersion per sdk-spec 0.6.0 ([d7eb8de](https://github.com/traffical/python-sdk/commit/d7eb8de5683b7b6b37dfe0c2cd9f07d614b3df95))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **ci:** use uv sync --frozen; release pre-1.0 (bump-minor-pre-major) ([#3](https://github.com/traffical/python-sdk/issues/3)) ([fc99d8a](https://github.com/traffical/python-sdk/commit/fc99d8aed53496b73e39a21e817d65b79cdd43cb))
20
+ * **config:** discard malformed bundles at ingest (S8) ([255858c](https://github.com/traffical/python-sdk/commit/255858c1f93b4601c8160ab57ecdf1fb75fe898d))
21
+ * **engine:** align condition/stringify/exposure semantics with spec 0.7.0 ([2c00c7b](https://github.com/traffical/python-sdk/commit/2c00c7bc4b625f60b7bef839e1e652bfdd529ba7))
22
+ * **events:** exposure event carries full assignment map (S4) ([2b01705](https://github.com/traffical/python-sdk/commit/2b01705354230d8c71bf491d5c0bbe91b3ce3229))
23
+
24
+
25
+ ### Documentation
26
+
27
+ * **ci:** pin sdk-spec 0.7.0, add stale-pin gate, SECURITY.md, README refresh ([ff01a73](https://github.com/traffical/python-sdk/commit/ff01a735d3ae436368d7da24ee97df0cf9fbe342))
@@ -0,0 +1,120 @@
1
+ # Contributing to the Traffical Python SDK
2
+
3
+ Thank you for your interest in contributing!
4
+
5
+ ## Getting started
6
+
7
+ ### Prerequisites
8
+
9
+ - Python 3.10+
10
+ - [uv](https://docs.astral.sh/uv/) (manages the venv and dev dependencies via `pyproject.toml`)
11
+
12
+ ### Setup
13
+
14
+ ```bash
15
+ cd python-sdk
16
+ uv sync
17
+ ```
18
+
19
+ ### Everyday commands
20
+
21
+ Run everything from the `python-sdk` package root:
22
+
23
+ ```bash
24
+ uv run pytest # full suite (unit + conformance)
25
+ uv run pytest tests/engine # just the pure-engine tests
26
+ uv run pytest tests/conformance
27
+ uv run mypy src/traffical # must pass --strict (configured in pyproject)
28
+ uv run ruff check # lint
29
+ ```
30
+
31
+ All three (pytest, mypy, ruff) must be green before a PR is mergeable.
32
+
33
+ ## Conformance fixtures (sdk-spec)
34
+
35
+ The cross-language contract lives in the [sdk-spec](https://github.com/traffical/sdk-spec) repository:
36
+ JSON schemas plus deterministic test vectors (`test-vectors/fixtures/bundle_*.json` +
37
+ `expected_*.json`). `tests/conftest.py` resolves the spec checkout in this order:
38
+
39
+ 1. `TRAFFICAL_SDK_SPEC_PATH` environment variable, if set:
40
+ ```bash
41
+ TRAFFICAL_SDK_SPEC_PATH=/path/to/sdk-spec uv run pytest tests/conformance
42
+ ```
43
+ 2. Otherwise the sibling directory `../sdk-spec` (the layout of the traffical-sdk workspace).
44
+
45
+ A pinned `tests/sdk-spec` git submodule (like the PHP SDK's) is planned for the standalone repository;
46
+ the `conftest.py` resolution order will stay the same, with the submodule becoming the default.
47
+
48
+ If a conformance test fails, treat the spec as the source of truth: fix the engine, not the fixture.
49
+ Spec changes go through the sdk-spec repository first.
50
+
51
+ ## Project layout
52
+
53
+ - `src/traffical/engine/` — pure resolution engine: stdlib only, no I/O, no clocks, no globals;
54
+ frozen dataclasses for models. Keep it that way.
55
+ - `src/traffical/config/` — bundle sources, store/readiness, background pollers.
56
+ - `src/traffical/events/` — event queue, flushers, dedup, BYO assignment logger.
57
+ - `src/traffical/client.py` / `async_client.py` — the public sync/async clients.
58
+ - `tests/` — `engine/`, `client/`, `conformance/` plus `conftest.py` (fixture loading).
59
+ - `examples/` — runnable offline examples; keep them working when the public API changes.
60
+
61
+ ## Code style
62
+
63
+ - Full type annotations; `mypy --strict` clean.
64
+ - PEP 8 / snake_case public API; the surface mirrors the JS and PHP SDKs
65
+ (`get_params`, `decide`, `track_exposure`, `track`, `flush_events`, `close`, `wait_for_ready`).
66
+ - No comments that narrate the obvious.
67
+ - Fail-open: public client methods must never raise into application code; defaults win on errors.
68
+
69
+ ## Pull request process
70
+
71
+ 1. Ensure `uv run pytest`, `uv run mypy src/traffical`, and `uv run ruff check` pass.
72
+ 2. Update README/examples if the public surface changed.
73
+ 3. New behavior needs tests; cross-SDK behavior needs a pointer to the matching JS/PHP reference or
74
+ sdk-spec section in the PR description.
75
+
76
+ ## Releases
77
+
78
+ Versioning and publishing are automated with [release-please](https://github.com/googleapis/release-please)
79
+ (the Python-world equivalent of the changesets flow used by `js-sdk`) and PyPI
80
+ [trusted publishing](https://docs.pypi.org/trusted-publishers/). Never bump versions by hand.
81
+
82
+ ### How it works
83
+
84
+ 1. **Commit messages drive version bumps** ([Conventional Commits](https://www.conventionalcommits.org/)):
85
+ - `fix: ...` → patch (0.1.0 → 0.1.1)
86
+ - `feat: ...` → minor (0.1.0 → 0.2.0)
87
+ - `feat!: ...` or a `BREAKING CHANGE:` footer → major (pre-1.0: minor)
88
+ - `chore:`/`docs:`/`test:`/`refactor:` → no release
89
+ Where changesets asks "what kind of change is this?" at `bunx changeset` time, here you answer it
90
+ in the commit (squash-merge) message. PR titles should follow the same convention since PRs are
91
+ squash-merged.
92
+ 2. **Release PR**: on every push to `main`, the `release-please` workflow opens/updates a release PR
93
+ (the analog of changesets' "Version Packages" PR) that bumps `version` in `pyproject.toml` and
94
+ `SDK_VERSION` in `src/traffical/options.py` (via the `x-release-please-version` annotation) and
95
+ updates `CHANGELOG.md`.
96
+ 3. **Merging the release PR** tags `vX.Y.Z` and creates a GitHub release.
97
+ 4. **The release triggers `publish.yml`**, which builds with `uv build` and publishes to PyPI via
98
+ trusted publishing (OIDC; the `pypi` GitHub environment, no long-lived API token).
99
+
100
+ Version state lives in `.release-please-manifest.json` + `release-please-config.json`.
101
+
102
+ ### One-time setup (maintainers)
103
+
104
+ - On [pypi.org](https://pypi.org/manage/account/publishing/): add a **pending trusted publisher** for
105
+ project `traffical` → owner `traffical`, repository `python-sdk`, workflow `publish.yml`,
106
+ environment `pypi`. The first publish claims the name.
107
+ - In the GitHub repo: create the `pypi` environment (optionally with required reviewers as a
108
+ publish gate). Optional: a `RELEASE_PLEASE_TOKEN` PAT secret so CI runs on release PRs
109
+ (the default `GITHUB_TOKEN` cannot trigger downstream workflows).
110
+
111
+ > **Warning:** publishing to PyPI makes the source public (the sdist contains the full package).
112
+ > Do not publish a release while this repository is meant to stay private.
113
+
114
+ ### Release gates
115
+
116
+ - CI (`ci.yml`) must be green: full test suite including all sdk-spec conformance vector suites
117
+ against the pinned spec version (`SDK_SPEC_VERSION` in `ci.yml`, fetched from the published
118
+ `@traffical/sdk-spec` npm package), `mypy --strict`, `ruff check`, `ruff format --check`, and a
119
+ wheel/sdist build whose contents are checked (`py.typed` present, no test files).
120
+ - Publishing to PyPI happens from CI only, never from developer machines.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Traffical
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,283 @@
1
+ Metadata-Version: 2.4
2
+ Name: traffical
3
+ Version: 0.3.0
4
+ Summary: Traffical Python SDK: deterministic experimentation and parameter resolution
5
+ Project-URL: Homepage, https://traffical.io
6
+ Project-URL: Repository, https://github.com/traffical/python-sdk
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: ab-testing,experimentation,feature-flags,traffical
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx<1.0,>=0.27
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Traffical Python SDK
25
+
26
+ [Traffical](https://traffical.io) is one control plane for experiments, feature flags, and adaptive
27
+ optimization. Instead of hard-coding decisions, you expose **typed parameters** — numbers, strings, booleans,
28
+ and JSON, not just on/off toggles — and control behavior across web, mobile, push, and backend from a single
29
+ place. Parameters are resolved **locally in the SDK** (sub-millisecond, no network round trips at runtime), and
30
+ metrics are computed **warehouse-native**, against data you own. Start with a feature flag, graduate it to an
31
+ A/B test, and let adaptive optimization shift traffic to the winning variant — all on the same parameter,
32
+ without a deploy.
33
+
34
+ This package is the **official Python SDK** (Python 3.10+) for Traffical, with first-class sync and asyncio
35
+ clients.
36
+
37
+ ## Features
38
+
39
+ - **Local, in-process evaluation** — resolve a cached config bundle with no per-decision network call.
40
+ - **Typed parameters with safe defaults** — bool / string / number / JSON, each with a caller-provided
41
+ fallback; every public call is fail-open (your defaults always win on errors).
42
+ - **Layered experiments & targeting** — Google-style layered isolation, condition/attribute segmentation, and
43
+ progressive (percentage) rollouts.
44
+ - **Adaptive optimization** — contextual-bandit scoring evaluated client-side.
45
+ - **BYO warehouse-native assignment logging** — route structured assignment rows through your own pipeline
46
+ (a DB, a queue, Segment, RudderStack) so assignment data never has to leave your infrastructure.
47
+ - **Event tracking** — exposure, decision, and custom track events, batched and flushed on a background
48
+ thread (or asyncio task), with retries and an atexit safety net.
49
+ - **Fork-safe by construction** — works under gunicorn pre-fork, celery, and anything else that calls
50
+ `os.fork()` after client creation.
51
+ - **Pure, deterministic engine** — `traffical.engine` is stdlib-only (no I/O, no clocks, no globals) and
52
+ conformance-tested against the cross-language SDK spec.
53
+
54
+ ## Modes
55
+
56
+ - **Bundle mode (default)** — the SDK fetches a config bundle, refreshes it on a background poller, and
57
+ resolves every parameter locally. No per-decision network call.
58
+ - **Server mode** — resolution is delegated to the Traffical edge via `POST /v1/resolve`. Use it when you
59
+ want zero client-side evaluation logic.
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install traffical
65
+ # or
66
+ uv add traffical
67
+ ```
68
+
69
+ Requires Python 3.10+. The only runtime dependency is [httpx](https://www.python-httpx.org/).
70
+
71
+ ## Quick start
72
+
73
+ ```python
74
+ from traffical import TrafficalClient, TrafficalClientOptions
75
+
76
+ client = TrafficalClient(
77
+ TrafficalClientOptions(
78
+ api_key="sk_...",
79
+ project_id="proj_...",
80
+ env="production",
81
+ org_id="org_...",
82
+ )
83
+ )
84
+ client.wait_for_ready(timeout=5.0)
85
+
86
+ # Resolve parameters with defaults as the fallback.
87
+ params = client.get_params(
88
+ context={"userId": "user-abc", "country": "US"},
89
+ defaults={"ui.primaryColor": "#000000", "pricing.discount": 0},
90
+ )
91
+ color = params["ui.primaryColor"]
92
+
93
+ # decide() returns a Decision (assignments + metadata). Call track_exposure()
94
+ # when the user actually sees the treatment.
95
+ decision = client.decide({"userId": "user-abc"}, {"ui.primaryColor": "#000000"})
96
+ variant = decision.assignments["ui.primaryColor"]
97
+ client.track_exposure(decision)
98
+
99
+ # Custom analytics events, attributed to the decision. Numeric metrics go in
100
+ # the value / values options (not properties):
101
+ client.track("checkout_completed", value=49.0, decision_id=decision.decision_id)
102
+
103
+ client.close() # flushes pending events; also usable as a context manager
104
+ ```
105
+
106
+ `TrafficalClient` supports `with`-statement usage; `close()` is idempotent and flushes the event queue.
107
+
108
+ ## Async quick start
109
+
110
+ `AsyncTrafficalClient` has the same surface with awaitable `get_params` / `decide` / `flush_events` /
111
+ `close`. Background work runs on the event loop; enter the context manager (or call `start()`) from a
112
+ running loop.
113
+
114
+ ```python
115
+ import asyncio
116
+
117
+ from traffical import AsyncTrafficalClient, TrafficalClientOptions
118
+
119
+
120
+ async def main() -> None:
121
+ async with AsyncTrafficalClient(
122
+ TrafficalClientOptions(
123
+ api_key="sk_...",
124
+ project_id="proj_...",
125
+ env="production",
126
+ org_id="org_...",
127
+ )
128
+ ) as client:
129
+ await client.wait_for_ready(timeout=5.0)
130
+ decision = await client.decide({"userId": "user-abc"}, {"ui.primaryColor": "#000000"})
131
+ client.track_exposure(decision)
132
+
133
+
134
+ asyncio.run(main())
135
+ ```
136
+
137
+ ## Server evaluation mode
138
+
139
+ Delegate resolution to the edge worker instead of evaluating a local bundle. Each `get_params()` /
140
+ `decide()` performs a `POST /v1/resolve`:
141
+
142
+ ```python
143
+ options = TrafficalClientOptions(
144
+ api_key="sk_...",
145
+ project_id="proj_...",
146
+ env="production",
147
+ org_id="org_...",
148
+ evaluation_mode="server",
149
+ )
150
+ ```
151
+
152
+ In server mode `wait_for_ready()` returns immediately (there is no bundle to wait for) and resolve failures
153
+ fall back to your defaults.
154
+
155
+ ## Warehouse-native assignment logging
156
+
157
+ Pass an `assignment_logger` callable to route structured per-layer rows through your own pipeline. Each
158
+ `AssignmentLogEntry` carries the stable `policy_key` / `allocation_key` used for warehouse joins, plus
159
+ `unit_key`, `layer_id`, `decision_id`, timestamps and SDK metadata. Combine with `disable_cloud_events=True`
160
+ to keep assignment data entirely on your own infrastructure:
161
+
162
+ ```python
163
+ from traffical import AssignmentLogEntry, TrafficalClient, TrafficalClientOptions
164
+
165
+
166
+ def log_assignment(entry: AssignmentLogEntry) -> None:
167
+ my_warehouse.insert("assignments", entry.__dict__) # DB, queue, CDP, ...
168
+
169
+
170
+ client = TrafficalClient(
171
+ TrafficalClientOptions(
172
+ api_key="sk_...",
173
+ project_id="proj_...",
174
+ env="production",
175
+ org_id="org_...",
176
+ assignment_logger=log_assignment,
177
+ disable_cloud_events=True, # keep assignment data on your own infra
178
+ )
179
+ )
180
+ ```
181
+
182
+ The logger fires on every `decide()` (type `"decision"`) and `track_exposure()` (type `"exposure"`),
183
+ independent of `track_decisions`, decision deduplication, and `disable_cloud_events`. Entries are
184
+ deduplicated per `unit:policy:allocation:type` with a 1-hour TTL; set
185
+ `deduplicate_assignment_logger=False` to receive every row.
186
+
187
+ `AssignmentLogEntry.to_row()` maps the entry onto a flat, warehouse-friendly
188
+ snake_case row. Two field names are deliberately renamed for the warehouse
189
+ convention: the propensity is emitted as **`propensity`** (not `probability`),
190
+ and the entry `id` as **`assignment_id`**. Off-policy training columns
191
+ (`bucket`, `propensity`, `model_version`, `config_version`) are included, and
192
+ any filtered context (`properties`) is spread to the top level as CUPED
193
+ covariates.
194
+
195
+ ## Options reference
196
+
197
+ `TrafficalClientOptions` is a frozen dataclass; construct it with keyword arguments.
198
+
199
+ | Option | Default | Description |
200
+ |--------|---------|-------------|
201
+ | `api_key`, `project_id`, `env`, `org_id` | — | Required scoping + auth |
202
+ | `base_url` | `https://sdk.traffical.io` | Control-plane base URL |
203
+ | `evaluation_mode` | `"bundle"` | `"bundle"` (local) or `"server"` (`POST /v1/resolve`) |
204
+ | `refresh_interval_seconds` | `60.0` | Bundle refresh interval (seconds); `<= 0` fetches once, no background refresh |
205
+ | `config_timeout_seconds` | `10.0` | HTTP timeout for config-bundle fetch (seconds) |
206
+ | `events_timeout_seconds` | `10.0` | HTTP timeout for event delivery (seconds) |
207
+ | `resolve_timeout_seconds` | `5.0` | HTTP timeout for server-mode resolve calls (seconds) |
208
+ | `track_decisions` | `True` | Emit decision events on `decide()` |
209
+ | `decision_dedup_ttl_seconds` | `3600.0` | Decision-event dedup window (seconds) |
210
+ | `deduplicate_exposures` | `True` | Session-dedup exposures per unit/policy/allocation |
211
+ | `exposure_session_ttl_seconds` | `1800.0` | Exposure session-dedup window (seconds; 30 min) |
212
+ | `batch_size` | `10` | Auto-flush threshold |
213
+ | `flush_interval_seconds` | `30.0` | Background flush interval (seconds) |
214
+ | `max_event_queue_size` | `1000` | Bounded event queue; overflow drops oldest |
215
+ | `event_max_retries` | `3` | Delivery retries per batch |
216
+ | `assignment_logger` | `None` | BYO warehouse logger, `(AssignmentLogEntry) -> None` |
217
+ | `deduplicate_assignment_logger` | `True` | Dedup logger rows per unit/policy/allocation/type |
218
+ | `disable_cloud_events` | `False` | Stop sending events to Traffical |
219
+ | `local_config` | `None` | Bootstrap/offline `ConfigBundle` (or raw `dict`) |
220
+ | `config_source` | `None` | Custom bundle source (`FileConfigSource`, `InlineConfigSource`, `CallableConfigSource`, or your own) |
221
+ | `transport`, `async_transport` | `None` | httpx transport seams (e.g. `MockTransport` in tests) |
222
+
223
+ ## Fork safety (gunicorn, celery, …)
224
+
225
+ Pre-fork servers `os.fork()` after your app module — and your client — are created. Background threads do
226
+ not survive a fork, and inherited locks can be held by threads that no longer exist. The SDK handles this
227
+ automatically via `os.register_at_fork`:
228
+
229
+ - locks in the event queue, flusher, dedup caches, and config poller are re-armed in the child,
230
+ - dead background threads (event flusher, config poller) are dropped and restarted lazily on next use,
231
+ - each child process flushes its own events; an atexit/`weakref.finalize` safety net stops flushers of
232
+ clients that are garbage-collected without `close()`.
233
+
234
+ What you should know:
235
+
236
+ - Creating the client at module import (pre-fork) is fine and recommended — workers share nothing at runtime.
237
+ - Call `close()` (or `flush_events()`) in worker shutdown hooks if you need a hard delivery guarantee.
238
+ - The asyncio client is bound to the event loop it was started on; create it per process/loop, not pre-fork.
239
+
240
+ ## Cross-language conformance
241
+
242
+ The Python SDK shares the language-agnostic [Traffical SDK spec](https://github.com/traffical/sdk-spec)
243
+ (0.7.0) with the JS/TS and PHP SDKs: the same SHA-256 v2 (UTF-8 byte) assignment hashing, the same layered
244
+ resolution engine, and the same contextual-bandit scoring. Every release is gated on the spec's
245
+ deterministic conformance vectors — all resolution fixture suites (`basic`, `conditions`,
246
+ `conditions_omitted`, `contextual`, `contextual_boundary`, `contextual_gamma_zero`,
247
+ `contextual_high_floor`, `edge_policies`, `per_layer_unit_key`, `empty_unit_key`, `numeric_unit_key`,
248
+ `unicode`, plus the entity-weight and resolve vectors) — so a given unit buckets identically on every
249
+ platform.
250
+
251
+ The 0.7.0 drift-remediation vectors lock the cross-SDK behavior decisions this SDK implements: empty layer
252
+ `unitKey` overrides skip the layer (S1), numeric unit keys stringify via ECMAScript `Number::toString`
253
+ (S2), strict condition typing with `.length` paths (S3), single-event exposure with session dedup (S4),
254
+ omitted relational values never match (S5), the `safeGamma`/`effectiveFloor` softmax guards (S6), and
255
+ `modelVersion = generatedAt ?? modelVersion` (S7). Emitted exposure/decision/track payloads are also
256
+ validated against `events.schema.json` and the `events_conformance.json` vectors.
257
+
258
+ ## Examples
259
+
260
+ Runnable scripts live in [examples/](examples/): a sync quick start, an asyncio variant, and a FastAPI
261
+ integration. They run offline against a local bundle file (`FileConfigSource`), so no API key is needed.
262
+
263
+ ## Development
264
+
265
+ ```bash
266
+ uv sync # create the venv + install dev deps
267
+ uv run pytest # full suite (unit + conformance)
268
+ uv run mypy src/traffical # strict type checking
269
+ uv run ruff check # lint
270
+ ```
271
+
272
+ Conformance fixtures are loaded from the sdk-spec checkout, resolved from the `TRAFFICAL_SDK_SPEC_PATH`
273
+ environment variable and defaulting to the sibling `../sdk-spec` directory:
274
+
275
+ ```bash
276
+ TRAFFICAL_SDK_SPEC_PATH=/path/to/sdk-spec uv run pytest tests/conformance
277
+ ```
278
+
279
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow.
280
+
281
+ ## License
282
+
283
+ MIT — see [LICENSE](LICENSE).