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 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"