forkcast-sdk 0.1.0__py3-none-any.whl
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/__init__.py +52 -0
- forkcast/_js.py +138 -0
- forkcast/_version.py +1 -0
- forkcast/client.py +673 -0
- forkcast/evaluate.py +117 -0
- forkcast/hashing.py +75 -0
- forkcast/integrations/__init__.py +23 -0
- forkcast/integrations/fastapi.py +79 -0
- forkcast/integrations/flask.py +98 -0
- forkcast/py.typed +0 -0
- forkcast/targeting.py +150 -0
- forkcast/types.py +116 -0
- forkcast_sdk-0.1.0.dist-info/METADATA +154 -0
- forkcast_sdk-0.1.0.dist-info/RECORD +16 -0
- forkcast_sdk-0.1.0.dist-info/WHEEL +4 -0
- forkcast_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
forkcast/__init__.py
ADDED
|
@@ -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
|
+
]
|
forkcast/_js.py
ADDED
|
@@ -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
|
forkcast/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|