forkcast-sdk 0.1.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.
- forkcast_sdk-0.1.0/.gitignore +20 -0
- forkcast_sdk-0.1.0/LICENSE +21 -0
- forkcast_sdk-0.1.0/PKG-INFO +154 -0
- forkcast_sdk-0.1.0/README.md +110 -0
- forkcast_sdk-0.1.0/pyproject.toml +97 -0
- forkcast_sdk-0.1.0/src/forkcast/__init__.py +52 -0
- forkcast_sdk-0.1.0/src/forkcast/_js.py +138 -0
- forkcast_sdk-0.1.0/src/forkcast/_version.py +1 -0
- forkcast_sdk-0.1.0/src/forkcast/client.py +673 -0
- forkcast_sdk-0.1.0/src/forkcast/evaluate.py +117 -0
- forkcast_sdk-0.1.0/src/forkcast/hashing.py +75 -0
- forkcast_sdk-0.1.0/src/forkcast/integrations/__init__.py +23 -0
- forkcast_sdk-0.1.0/src/forkcast/integrations/fastapi.py +79 -0
- forkcast_sdk-0.1.0/src/forkcast/integrations/flask.py +98 -0
- forkcast_sdk-0.1.0/src/forkcast/py.typed +0 -0
- forkcast_sdk-0.1.0/src/forkcast/targeting.py +150 -0
- forkcast_sdk-0.1.0/src/forkcast/types.py +116 -0
- forkcast_sdk-0.1.0/tests/__init__.py +0 -0
- forkcast_sdk-0.1.0/tests/conftest.py +153 -0
- forkcast_sdk-0.1.0/tests/fixtures/parity.json +3580 -0
- forkcast_sdk-0.1.0/tests/test_client.py +303 -0
- forkcast_sdk-0.1.0/tests/test_core.py +114 -0
- forkcast_sdk-0.1.0/tests/test_e2e.py +160 -0
- forkcast_sdk-0.1.0/tests/test_integrations.py +179 -0
- forkcast_sdk-0.1.0/tests/test_parity.py +78 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.forkcast/
|
|
4
|
+
coverage/
|
|
5
|
+
*.log
|
|
6
|
+
.DS_Store
|
|
7
|
+
.env
|
|
8
|
+
.env.local
|
|
9
|
+
*.tgz
|
|
10
|
+
|
|
11
|
+
# Python
|
|
12
|
+
.venv/
|
|
13
|
+
__pycache__/
|
|
14
|
+
*.py[cod]
|
|
15
|
+
*.egg-info/
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
python/dist/
|
|
20
|
+
python/build/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alik Agarwala
|
|
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,154 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: forkcast-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Forkcast: self-hosted A/B testing & feature flags. Sticky bucketing identical to the JS SDK, targeting, kill switch, FastAPI and Flask integrations.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Alik-Agarwala/forkcast
|
|
6
|
+
Project-URL: Documentation, https://github.com/Alik-Agarwala/forkcast/blob/main/docs/python.md
|
|
7
|
+
Project-URL: Repository, https://github.com/Alik-Agarwala/forkcast
|
|
8
|
+
Project-URL: Issues, https://github.com/Alik-Agarwala/forkcast/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Alik-Agarwala/forkcast/blob/main/CHANGELOG.md
|
|
10
|
+
Author: Prantika Ghosh
|
|
11
|
+
Maintainer: Alik Agarwala
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: a/b testing,ab-testing,experiments,fastapi,feature-flags,feature-toggles,flask,kill-switch,rollout,split-testing
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Framework :: FastAPI
|
|
17
|
+
Classifier: Framework :: Flask
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
27
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build>=1; extra == 'dev'
|
|
32
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
33
|
+
Requires-Dist: flask>=2.2; extra == 'dev'
|
|
34
|
+
Requires-Dist: httpx>=0.24; extra == 'dev'
|
|
35
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
38
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
39
|
+
Provides-Extra: fastapi
|
|
40
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
41
|
+
Provides-Extra: flask
|
|
42
|
+
Requires-Dist: flask>=2.2; extra == 'flask'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# Forkcast for Python
|
|
46
|
+
|
|
47
|
+
[](https://pypi.org/project/forkcast-sdk/)
|
|
48
|
+
[](https://pypi.org/project/forkcast-sdk/)
|
|
49
|
+
[](https://github.com/Alik-Agarwala/forkcast/blob/main/LICENSE)
|
|
50
|
+
|
|
51
|
+
The Python SDK for [Forkcast](https://github.com/Alik-Agarwala/forkcast), the self-hosted A/B testing and feature flag tool. Use it in **FastAPI**, **Flask**, Django, scripts and workers.
|
|
52
|
+
|
|
53
|
+
- Same buckets as the JavaScript SDK. A user gets the same treatment from your Python API and your React frontend.
|
|
54
|
+
- Local evaluation. Config is fetched once and kept fresh in the background, so every `get_treatment()` is pure hashing with no network call.
|
|
55
|
+
- Works offline. If the server is unreachable, everything falls back to `C` (control) and your app never crashes.
|
|
56
|
+
- No dependencies. Only the Python standard library. FastAPI and Flask helpers are optional.
|
|
57
|
+
- Typed (`py.typed`). Python 3.10+.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install forkcast-sdk
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
You import it as `forkcast`.
|
|
64
|
+
|
|
65
|
+
## Quick start
|
|
66
|
+
|
|
67
|
+
1. Start a Forkcast server and dashboard (needs Node.js 18+):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx forkcast start
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Open http://localhost:4400, create an experiment (say `new-checkout`) and copy a client key from **Environments**.
|
|
74
|
+
|
|
75
|
+
2. Ask for a treatment in Python:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from forkcast import Forkcast
|
|
79
|
+
|
|
80
|
+
forkcast = Forkcast(
|
|
81
|
+
api_url="http://localhost:4400",
|
|
82
|
+
client_key="fc_prod_...", # from the dashboard
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
fc = forkcast.for_user({"id": "user-42", "country": "IN", "plan": "pro"})
|
|
86
|
+
|
|
87
|
+
if fc.get_treatment("new-checkout") == "T1":
|
|
88
|
+
show_one_click_checkout()
|
|
89
|
+
else: # "C", the control
|
|
90
|
+
show_cart_page()
|
|
91
|
+
|
|
92
|
+
fc.track("purchase", value=49.99) # conversion for the results page
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
3. Change traffic, targeting or the kill switch in the dashboard. Python picks it up within 30 seconds (or instantly with `realtime=True`). There's no redeploy.
|
|
96
|
+
|
|
97
|
+
## FastAPI
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from fastapi import Depends, FastAPI, Request
|
|
101
|
+
from forkcast import Forkcast, ScopedForkcast
|
|
102
|
+
from forkcast.integrations.fastapi import forkcast_dependency, forkcast_lifespan
|
|
103
|
+
|
|
104
|
+
forkcast = Forkcast(api_url="http://localhost:4400", client_key="fc_prod_...")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def current_user(request: Request) -> dict:
|
|
108
|
+
return {"id": request.headers.get("x-user-id"), "country": request.headers.get("cf-ipcountry")}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
get_forkcast = forkcast_dependency(forkcast, current_user)
|
|
112
|
+
app = FastAPI(lifespan=forkcast_lifespan(forkcast)) # flushes events on shutdown
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.get("/checkout")
|
|
116
|
+
def checkout(fc: ScopedForkcast = Depends(get_forkcast)):
|
|
117
|
+
return {"one_click": fc.get_treatment("new-checkout") == "T1"}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Flask
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from flask import Flask, request, session
|
|
124
|
+
from forkcast import Forkcast
|
|
125
|
+
from forkcast.integrations.flask import FlaskForkcast, current_forkcast
|
|
126
|
+
|
|
127
|
+
app = Flask(__name__)
|
|
128
|
+
forkcast = Forkcast(api_url="http://localhost:4400", client_key="fc_prod_...")
|
|
129
|
+
FlaskForkcast(
|
|
130
|
+
app,
|
|
131
|
+
forkcast,
|
|
132
|
+
get_user=lambda: {
|
|
133
|
+
"id": session.get("user_id"),
|
|
134
|
+
"country": request.headers.get("CF-IPCountry"),
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.get("/checkout")
|
|
140
|
+
def checkout():
|
|
141
|
+
if current_forkcast.get_treatment("new-checkout") == "T1":
|
|
142
|
+
return "one-click checkout"
|
|
143
|
+
return "cart page"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
In Jinja templates you can write `{% if forkcast.is_enabled("dark-mode") %}`.
|
|
147
|
+
|
|
148
|
+
## More
|
|
149
|
+
|
|
150
|
+
- Full Python guide, covering every option and method, Django, gunicorn, testing and local mode: [docs/python.md](https://github.com/Alik-Agarwala/forkcast/blob/main/docs/python.md)
|
|
151
|
+
- Concepts (C/T1/T2, traffic, targeting, environments): [docs/concepts.md](https://github.com/Alik-Agarwala/forkcast/blob/main/docs/concepts.md)
|
|
152
|
+
- Main project, JavaScript SDK and dashboard: [github.com/Alik-Agarwala/forkcast](https://github.com/Alik-Agarwala/forkcast)
|
|
153
|
+
|
|
154
|
+
MIT licensed.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Forkcast for Python
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/forkcast-sdk/)
|
|
4
|
+
[](https://pypi.org/project/forkcast-sdk/)
|
|
5
|
+
[](https://github.com/Alik-Agarwala/forkcast/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
The Python SDK for [Forkcast](https://github.com/Alik-Agarwala/forkcast), the self-hosted A/B testing and feature flag tool. Use it in **FastAPI**, **Flask**, Django, scripts and workers.
|
|
8
|
+
|
|
9
|
+
- Same buckets as the JavaScript SDK. A user gets the same treatment from your Python API and your React frontend.
|
|
10
|
+
- Local evaluation. Config is fetched once and kept fresh in the background, so every `get_treatment()` is pure hashing with no network call.
|
|
11
|
+
- Works offline. If the server is unreachable, everything falls back to `C` (control) and your app never crashes.
|
|
12
|
+
- No dependencies. Only the Python standard library. FastAPI and Flask helpers are optional.
|
|
13
|
+
- Typed (`py.typed`). Python 3.10+.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install forkcast-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
You import it as `forkcast`.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
1. Start a Forkcast server and dashboard (needs Node.js 18+):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx forkcast start
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Open http://localhost:4400, create an experiment (say `new-checkout`) and copy a client key from **Environments**.
|
|
30
|
+
|
|
31
|
+
2. Ask for a treatment in Python:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from forkcast import Forkcast
|
|
35
|
+
|
|
36
|
+
forkcast = Forkcast(
|
|
37
|
+
api_url="http://localhost:4400",
|
|
38
|
+
client_key="fc_prod_...", # from the dashboard
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
fc = forkcast.for_user({"id": "user-42", "country": "IN", "plan": "pro"})
|
|
42
|
+
|
|
43
|
+
if fc.get_treatment("new-checkout") == "T1":
|
|
44
|
+
show_one_click_checkout()
|
|
45
|
+
else: # "C", the control
|
|
46
|
+
show_cart_page()
|
|
47
|
+
|
|
48
|
+
fc.track("purchase", value=49.99) # conversion for the results page
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
3. Change traffic, targeting or the kill switch in the dashboard. Python picks it up within 30 seconds (or instantly with `realtime=True`). There's no redeploy.
|
|
52
|
+
|
|
53
|
+
## FastAPI
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from fastapi import Depends, FastAPI, Request
|
|
57
|
+
from forkcast import Forkcast, ScopedForkcast
|
|
58
|
+
from forkcast.integrations.fastapi import forkcast_dependency, forkcast_lifespan
|
|
59
|
+
|
|
60
|
+
forkcast = Forkcast(api_url="http://localhost:4400", client_key="fc_prod_...")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def current_user(request: Request) -> dict:
|
|
64
|
+
return {"id": request.headers.get("x-user-id"), "country": request.headers.get("cf-ipcountry")}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
get_forkcast = forkcast_dependency(forkcast, current_user)
|
|
68
|
+
app = FastAPI(lifespan=forkcast_lifespan(forkcast)) # flushes events on shutdown
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.get("/checkout")
|
|
72
|
+
def checkout(fc: ScopedForkcast = Depends(get_forkcast)):
|
|
73
|
+
return {"one_click": fc.get_treatment("new-checkout") == "T1"}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Flask
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from flask import Flask, request, session
|
|
80
|
+
from forkcast import Forkcast
|
|
81
|
+
from forkcast.integrations.flask import FlaskForkcast, current_forkcast
|
|
82
|
+
|
|
83
|
+
app = Flask(__name__)
|
|
84
|
+
forkcast = Forkcast(api_url="http://localhost:4400", client_key="fc_prod_...")
|
|
85
|
+
FlaskForkcast(
|
|
86
|
+
app,
|
|
87
|
+
forkcast,
|
|
88
|
+
get_user=lambda: {
|
|
89
|
+
"id": session.get("user_id"),
|
|
90
|
+
"country": request.headers.get("CF-IPCountry"),
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.get("/checkout")
|
|
96
|
+
def checkout():
|
|
97
|
+
if current_forkcast.get_treatment("new-checkout") == "T1":
|
|
98
|
+
return "one-click checkout"
|
|
99
|
+
return "cart page"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
In Jinja templates you can write `{% if forkcast.is_enabled("dark-mode") %}`.
|
|
103
|
+
|
|
104
|
+
## More
|
|
105
|
+
|
|
106
|
+
- Full Python guide, covering every option and method, Django, gunicorn, testing and local mode: [docs/python.md](https://github.com/Alik-Agarwala/forkcast/blob/main/docs/python.md)
|
|
107
|
+
- Concepts (C/T1/T2, traffic, targeting, environments): [docs/concepts.md](https://github.com/Alik-Agarwala/forkcast/blob/main/docs/concepts.md)
|
|
108
|
+
- Main project, JavaScript SDK and dashboard: [github.com/Alik-Agarwala/forkcast](https://github.com/Alik-Agarwala/forkcast)
|
|
109
|
+
|
|
110
|
+
MIT licensed.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "forkcast-sdk"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK for Forkcast: self-hosted A/B testing & feature flags. Sticky bucketing identical to the JS SDK, targeting, kill switch, FastAPI and Flask integrations."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Prantika Ghosh" }]
|
|
14
|
+
maintainers = [{ name = "Alik Agarwala" }]
|
|
15
|
+
keywords = [
|
|
16
|
+
"ab-testing",
|
|
17
|
+
"a/b testing",
|
|
18
|
+
"experiments",
|
|
19
|
+
"feature-flags",
|
|
20
|
+
"feature-toggles",
|
|
21
|
+
"split-testing",
|
|
22
|
+
"rollout",
|
|
23
|
+
"kill-switch",
|
|
24
|
+
"fastapi",
|
|
25
|
+
"flask",
|
|
26
|
+
]
|
|
27
|
+
classifiers = [
|
|
28
|
+
"Development Status :: 4 - Beta",
|
|
29
|
+
"Framework :: FastAPI",
|
|
30
|
+
"Framework :: Flask",
|
|
31
|
+
"Intended Audience :: Developers",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
35
|
+
"Programming Language :: Python :: 3.10",
|
|
36
|
+
"Programming Language :: Python :: 3.11",
|
|
37
|
+
"Programming Language :: Python :: 3.12",
|
|
38
|
+
"Programming Language :: Python :: 3.13",
|
|
39
|
+
"Programming Language :: Python :: 3.14",
|
|
40
|
+
"Topic :: Software Development :: Libraries",
|
|
41
|
+
"Typing :: Typed",
|
|
42
|
+
]
|
|
43
|
+
dependencies = []
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
fastapi = ["fastapi>=0.100"]
|
|
47
|
+
flask = ["flask>=2.2"]
|
|
48
|
+
dev = [
|
|
49
|
+
"pytest>=7",
|
|
50
|
+
"fastapi>=0.100",
|
|
51
|
+
"httpx>=0.24",
|
|
52
|
+
"flask>=2.2",
|
|
53
|
+
"ruff>=0.5",
|
|
54
|
+
"mypy>=1.8",
|
|
55
|
+
"build>=1",
|
|
56
|
+
"twine>=5",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[project.urls]
|
|
60
|
+
Homepage = "https://github.com/Alik-Agarwala/forkcast"
|
|
61
|
+
Documentation = "https://github.com/Alik-Agarwala/forkcast/blob/main/docs/python.md"
|
|
62
|
+
Repository = "https://github.com/Alik-Agarwala/forkcast"
|
|
63
|
+
Issues = "https://github.com/Alik-Agarwala/forkcast/issues"
|
|
64
|
+
Changelog = "https://github.com/Alik-Agarwala/forkcast/blob/main/CHANGELOG.md"
|
|
65
|
+
|
|
66
|
+
[tool.hatch.version]
|
|
67
|
+
path = "src/forkcast/_version.py"
|
|
68
|
+
|
|
69
|
+
[tool.hatch.build.targets.wheel]
|
|
70
|
+
packages = ["src/forkcast"]
|
|
71
|
+
|
|
72
|
+
[tool.hatch.build.targets.sdist]
|
|
73
|
+
include = ["src/forkcast", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
74
|
+
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
testpaths = ["tests"]
|
|
77
|
+
addopts = "-q"
|
|
78
|
+
filterwarnings = ["ignore:Using `httpx` with `starlette.testclient`"]
|
|
79
|
+
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
line-length = 120
|
|
82
|
+
target-version = "py310"
|
|
83
|
+
src = ["src", "tests"]
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint]
|
|
86
|
+
select = ["E", "F", "W", "I", "B", "UP", "BLE"]
|
|
87
|
+
ignore = ["UP006", "UP007", "UP035", "UP045"]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint.per-file-ignores]
|
|
90
|
+
"tests/*" = ["B008"]
|
|
91
|
+
"../examples/*" = ["B008"]
|
|
92
|
+
|
|
93
|
+
[tool.mypy]
|
|
94
|
+
python_version = "3.10"
|
|
95
|
+
strict = true
|
|
96
|
+
packages = ["forkcast"]
|
|
97
|
+
mypy_path = "src"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Forkcast: self-hosted A/B testing and feature flags.
|
|
2
|
+
|
|
3
|
+
Python SDK for the Forkcast server. Evaluation is local and deterministic, and
|
|
4
|
+
buckets users exactly like the JavaScript SDK, so a user gets the same
|
|
5
|
+
treatment from your Python API and your React frontend.
|
|
6
|
+
|
|
7
|
+
from forkcast import Forkcast
|
|
8
|
+
|
|
9
|
+
forkcast = Forkcast(api_url="http://localhost:4400", client_key="fc_prod_...")
|
|
10
|
+
fc = forkcast.for_user({"id": "user-42", "country": "IN"})
|
|
11
|
+
if fc.get_treatment("new-checkout") == "T1":
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
Framework helpers live in ``forkcast.integrations.fastapi`` and
|
|
15
|
+
``forkcast.integrations.flask``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .client import (
|
|
20
|
+
OVERRIDE_QUERY_PARAM,
|
|
21
|
+
Forkcast,
|
|
22
|
+
ForkcastError,
|
|
23
|
+
ScopedForkcast,
|
|
24
|
+
create_forkcast,
|
|
25
|
+
parse_override_param,
|
|
26
|
+
)
|
|
27
|
+
from .evaluate import evaluate, pick_variant
|
|
28
|
+
from .hashing import BUCKETS, bucket_of, murmur3
|
|
29
|
+
from .targeting import OPERATORS, compare_semver, get_attribute, match_audience, match_condition
|
|
30
|
+
from .types import CONTROL, Evaluation
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"BUCKETS",
|
|
34
|
+
"CONTROL",
|
|
35
|
+
"OPERATORS",
|
|
36
|
+
"OVERRIDE_QUERY_PARAM",
|
|
37
|
+
"Evaluation",
|
|
38
|
+
"Forkcast",
|
|
39
|
+
"ForkcastError",
|
|
40
|
+
"ScopedForkcast",
|
|
41
|
+
"__version__",
|
|
42
|
+
"bucket_of",
|
|
43
|
+
"compare_semver",
|
|
44
|
+
"create_forkcast",
|
|
45
|
+
"evaluate",
|
|
46
|
+
"get_attribute",
|
|
47
|
+
"match_audience",
|
|
48
|
+
"match_condition",
|
|
49
|
+
"murmur3",
|
|
50
|
+
"parse_override_param",
|
|
51
|
+
"pick_variant",
|
|
52
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""JavaScript value semantics needed for exact parity with the JS SDK.
|
|
2
|
+
|
|
3
|
+
Targeting rules are evaluated by both SDKs, so a condition such as
|
|
4
|
+
``plan eq "pro"`` or ``age gte 18`` must give the same answer in Python and
|
|
5
|
+
JavaScript. JS converts values with ``String(x)`` and ``Number(x)``; these
|
|
6
|
+
helpers reproduce those conversions for JSON-like Python values.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Undefined:
|
|
18
|
+
"""Stand-in for JavaScript ``undefined`` (a missing attribute or value)."""
|
|
19
|
+
|
|
20
|
+
_instance: _Undefined | None = None
|
|
21
|
+
|
|
22
|
+
def __new__(cls) -> _Undefined:
|
|
23
|
+
if cls._instance is None:
|
|
24
|
+
cls._instance = super().__new__(cls)
|
|
25
|
+
return cls._instance
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return "UNDEFINED"
|
|
29
|
+
|
|
30
|
+
def __bool__(self) -> bool:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
UNDEFINED: Any = _Undefined()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_nullish(value: Any) -> bool:
|
|
38
|
+
"""``value == null`` in JavaScript (``null`` or ``undefined``)."""
|
|
39
|
+
return value is None or value is UNDEFINED
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _number_to_string(value: float) -> str:
|
|
43
|
+
"""``Number.prototype.toString()`` for a finite, non-zero float."""
|
|
44
|
+
sign = "-" if value < 0 else ""
|
|
45
|
+
# repr() gives the shortest round-tripping digits, exactly like JS.
|
|
46
|
+
_, digits_tuple, exponent = Decimal(repr(abs(value))).as_tuple()
|
|
47
|
+
digits = "".join(str(d) for d in digits_tuple).rstrip("0") or "0"
|
|
48
|
+
k = len(digits)
|
|
49
|
+
# Trailing zeros were stripped, so recompute the decimal point position.
|
|
50
|
+
n = int(exponent) + len("".join(str(d) for d in digits_tuple))
|
|
51
|
+
if k <= n <= 21:
|
|
52
|
+
return sign + digits + "0" * (n - k)
|
|
53
|
+
if 0 < n <= 21:
|
|
54
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
55
|
+
if -6 < n <= 0:
|
|
56
|
+
return sign + "0." + "0" * (-n) + digits
|
|
57
|
+
e = n - 1
|
|
58
|
+
mantissa = digits[0] + ("." + digits[1:] if k > 1 else "")
|
|
59
|
+
return f"{sign}{mantissa}e{'+' if e > 0 else '-'}{abs(e)}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def js_string(value: Any) -> str:
|
|
63
|
+
"""``String(value)`` in JavaScript."""
|
|
64
|
+
if value is UNDEFINED:
|
|
65
|
+
return "undefined"
|
|
66
|
+
if value is None:
|
|
67
|
+
return "null"
|
|
68
|
+
if isinstance(value, bool):
|
|
69
|
+
return "true" if value else "false"
|
|
70
|
+
if isinstance(value, int):
|
|
71
|
+
# JSON numbers are doubles in JS, so huge integers lose precision there.
|
|
72
|
+
return str(value) if abs(value) <= 2**53 else js_string(float(value))
|
|
73
|
+
if isinstance(value, float):
|
|
74
|
+
if math.isnan(value):
|
|
75
|
+
return "NaN"
|
|
76
|
+
if math.isinf(value):
|
|
77
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
78
|
+
if value == 0:
|
|
79
|
+
return "0"
|
|
80
|
+
return _number_to_string(value)
|
|
81
|
+
if isinstance(value, str):
|
|
82
|
+
return value
|
|
83
|
+
if isinstance(value, (list, tuple)):
|
|
84
|
+
return ",".join("" if is_nullish(v) else js_string(v) for v in value)
|
|
85
|
+
if isinstance(value, dict):
|
|
86
|
+
return "[object Object]"
|
|
87
|
+
return str(value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_DECIMAL = re.compile(r"^[+-]?(?:\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)$")
|
|
91
|
+
_PREFIXED = {"0x": 16, "0o": 8, "0b": 2}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def js_number(value: Any) -> float:
|
|
95
|
+
"""``Number(value)`` in JavaScript."""
|
|
96
|
+
if value is UNDEFINED:
|
|
97
|
+
return math.nan
|
|
98
|
+
if value is None:
|
|
99
|
+
return 0.0
|
|
100
|
+
if isinstance(value, bool):
|
|
101
|
+
return 1.0 if value else 0.0
|
|
102
|
+
if isinstance(value, (int, float)):
|
|
103
|
+
try:
|
|
104
|
+
return float(value)
|
|
105
|
+
except OverflowError:
|
|
106
|
+
return math.inf if value > 0 else -math.inf
|
|
107
|
+
if isinstance(value, str):
|
|
108
|
+
s = value.strip()
|
|
109
|
+
if s == "":
|
|
110
|
+
return 0.0
|
|
111
|
+
if s in ("Infinity", "+Infinity"):
|
|
112
|
+
return math.inf
|
|
113
|
+
if s == "-Infinity":
|
|
114
|
+
return -math.inf
|
|
115
|
+
base = _PREFIXED.get(s[:2].lower())
|
|
116
|
+
if base:
|
|
117
|
+
try:
|
|
118
|
+
return float(int(s[2:], base))
|
|
119
|
+
except ValueError:
|
|
120
|
+
return math.nan
|
|
121
|
+
if _DECIMAL.match(s):
|
|
122
|
+
try:
|
|
123
|
+
return float(s)
|
|
124
|
+
except (ValueError, OverflowError):
|
|
125
|
+
return math.nan
|
|
126
|
+
return math.nan
|
|
127
|
+
if isinstance(value, (list, tuple)):
|
|
128
|
+
return js_number(js_string(value))
|
|
129
|
+
return math.nan
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_LEADING_INT = re.compile(r"^\s*([+-]?\d+)")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def js_parse_int(value: str) -> int:
|
|
136
|
+
"""``parseInt(value, 10) || 0`` in JavaScript."""
|
|
137
|
+
m = _LEADING_INT.match(value)
|
|
138
|
+
return int(m.group(1)) if m else 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|