flagstone-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.
@@ -0,0 +1,19 @@
1
+ """Python SDK for Flagstone feature flags.
2
+
3
+ from flagstone_sdk import FlagClient
4
+
5
+ client = FlagClient(sdk_key="...", base_url="https://flags.example.com")
6
+ if client.is_enabled("new-dashboard", {"id": "user-123"}):
7
+ ...
8
+ """
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ from .client import FlagClient
13
+
14
+ try:
15
+ __version__ = version("flagstone-sdk")
16
+ except PackageNotFoundError: # running from a source checkout that isn't installed
17
+ __version__ = "0.0.0"
18
+
19
+ __all__ = ["FlagClient", "__version__"]
@@ -0,0 +1,9 @@
1
+ import hashlib
2
+
3
+ def bucket(flag_key: str, user_id: str) -> int:
4
+ raw = f"{flag_key}:{user_id}".encode()
5
+ digest = hashlib.md5(raw).hexdigest()
6
+ return int(digest[:8], 16) % 100
7
+
8
+ def in_rollout(flag_key: str, user_id: str, percentage: int) -> bool:
9
+ return bucket(flag_key, user_id) < percentage
@@ -0,0 +1,138 @@
1
+ import threading
2
+
3
+ import httpx
4
+
5
+ from .disk_cache import DiskCache
6
+ from .evaluator import evaluate
7
+ from .poller import Poller
8
+ from .stream_listener import StreamListener
9
+
10
+
11
+ class FlagClient:
12
+ def __init__(
13
+ self,
14
+ sdk_key: str,
15
+ base_url: str = "http://localhost:8000",
16
+ poll_interval: int = 30,
17
+ use_sse: bool = False,
18
+ cache_path: str | None = "~/.flagstone_cache.json",
19
+ flush_interval: int = 10,
20
+ ) -> None:
21
+ self._sdk_key = sdk_key
22
+ self._base_url = base_url.rstrip("/")
23
+ self._flags: dict[str, dict] = {}
24
+ self._lock = threading.Lock()
25
+ self._http = httpx.Client(headers={"x-sdk-key": sdk_key}, timeout=10)
26
+ self._event_buffer: list[dict] = []
27
+ self._flush_lock = threading.Lock()
28
+ self._flush_interval = flush_interval
29
+ self._cache = DiskCache(cache_path) if cache_path else None
30
+ try:
31
+ self._refresh()
32
+ except Exception:
33
+ if self._cache:
34
+ cached = self._cache.read()
35
+ if cached:
36
+ with self._lock:
37
+ self._flags = cached.get("flags", {})
38
+ if use_sse:
39
+ self._listener = StreamListener(
40
+ url=f"{self._base_url}/sdk/stream",
41
+ sdk_key=sdk_key,
42
+ on_update=self._on_sse_update,
43
+ )
44
+ self._listener.start()
45
+ else:
46
+ self._poller = Poller(self._refresh, poll_interval)
47
+ self._poller.start()
48
+ self._schedule_flush()
49
+
50
+ def is_enabled(self, flag_key: str, user: dict) -> bool:
51
+ with self._lock:
52
+ flag = self._flags.get(flag_key)
53
+ if flag is None:
54
+ return False
55
+ variation_index = evaluate(flag, user)
56
+ self._buffer_event("exposure", flag_key, variation_index, user)
57
+ return bool(flag["variations"][variation_index])
58
+
59
+ def get_variation(self, flag_key: str, user: dict) -> int:
60
+ with self._lock:
61
+ flag = self._flags.get(flag_key)
62
+ if flag is None:
63
+ return 0
64
+ return evaluate(flag, user)
65
+
66
+ def track_conversion(self, flag_key: str, user: dict) -> None:
67
+ with self._lock:
68
+ flag = self._flags.get(flag_key)
69
+ variation_index = evaluate(flag, user) if flag else 0
70
+ self._buffer_event("conversion", flag_key, variation_index, user)
71
+
72
+ def _buffer_event(self, event_type: str, flag_key: str, variation_index: int, user: dict) -> None:
73
+ with self._flush_lock:
74
+ self._event_buffer.append({
75
+ "flag_key": flag_key,
76
+ "event_type": event_type,
77
+ "variation_index": variation_index,
78
+ "user_id": user.get("id", ""),
79
+ "attributes": {k: v for k, v in user.items() if k != "id"},
80
+ })
81
+ if len(self._event_buffer) >= 100:
82
+ self._flush_now()
83
+
84
+ def _flush_now(self) -> None:
85
+ events, self._event_buffer = self._event_buffer, []
86
+ if events:
87
+ try:
88
+ self._http.post(
89
+ f"{self._base_url}/sdk/events",
90
+ json={"events": events},
91
+ timeout=5,
92
+ )
93
+ except Exception:
94
+ pass
95
+
96
+ def _schedule_flush(self) -> None:
97
+ if hasattr(self, "_flush_timer"):
98
+ self._flush_timer.cancel()
99
+ t = threading.Timer(self._flush_interval, self._timed_flush)
100
+ t.daemon = True
101
+ t.start()
102
+ self._flush_timer = t
103
+
104
+ def _timed_flush(self) -> None:
105
+ with self._flush_lock:
106
+ self._flush_now()
107
+ self._schedule_flush()
108
+
109
+ def _refresh(self) -> None:
110
+ resp = self._http.get(f"{self._base_url}/sdk/config")
111
+ resp.raise_for_status()
112
+ config = resp.json()
113
+ with self._lock:
114
+ self._flags = config.get("flags", {})
115
+ if self._cache:
116
+ self._cache.write(config)
117
+
118
+ def _on_sse_update(self, event: dict) -> None:
119
+ if event.get("event") == "deleted":
120
+ key = event.get("key")
121
+ if key:
122
+ with self._lock:
123
+ self._flags.pop(key, None)
124
+ else:
125
+ # SSE is a change-signal; fetch updated config
126
+ try:
127
+ self._refresh()
128
+ except Exception:
129
+ pass
130
+
131
+ def close(self) -> None:
132
+ if hasattr(self, "_flush_timer"):
133
+ self._flush_timer.cancel()
134
+ if hasattr(self, "_poller"):
135
+ self._poller.stop()
136
+ if hasattr(self, "_listener"):
137
+ self._listener.stop()
138
+ self._http.close()
@@ -0,0 +1,26 @@
1
+ import json
2
+ from pathlib import Path
3
+ from datetime import datetime, timezone
4
+
5
+
6
+ class DiskCache:
7
+ def __init__(self, path: str = "~/.flagstone_cache.json") -> None:
8
+ self._path = Path(path).expanduser()
9
+
10
+ def write(self, config: dict) -> None:
11
+ self._path.parent.mkdir(parents=True, exist_ok=True)
12
+ tmp = self._path.with_suffix(".tmp")
13
+ tmp.write_text(json.dumps({
14
+ "written_at": datetime.now(timezone.utc).isoformat(),
15
+ "config": config,
16
+ }))
17
+ tmp.replace(self._path)
18
+
19
+ def read(self) -> dict | None:
20
+ if not self._path.exists():
21
+ return None
22
+ try:
23
+ data = json.loads(self._path.read_text())
24
+ return data["config"]
25
+ except (json.JSONDecodeError, KeyError):
26
+ return None
@@ -0,0 +1,25 @@
1
+ from .bucketing import in_rollout
2
+
3
+ def evaluate(flag: dict, user: dict) -> int:
4
+ if not flag.get("enabled", True):
5
+ return flag.get("default_variation", 0)
6
+ for rule in flag.get("rules", []):
7
+ if _match_rule(rule, user):
8
+ return rule["variation"]
9
+ rollout = flag.get("rollout")
10
+ if rollout:
11
+ user_id = user.get("id", "")
12
+ if in_rollout(flag["key"], user_id, rollout["percentage"]):
13
+ return rollout["variation"]
14
+ return flag.get("default_variation", 0)
15
+
16
+ def _match_rule(rule: dict, user: dict) -> bool:
17
+ attr = user.get(rule["attribute"])
18
+ op = rule["operator"]
19
+ val = rule["value"]
20
+ if op == "equals": return attr == val
21
+ if op == "contains": return isinstance(attr, str) and val in attr
22
+ if op == "gt": return isinstance(attr, (int, float)) and attr > val
23
+ if op == "lt": return isinstance(attr, (int, float)) and attr < val
24
+ if op == "in": return attr in val
25
+ return False
@@ -0,0 +1,24 @@
1
+ import threading
2
+ from collections.abc import Callable
3
+
4
+
5
+ class Poller:
6
+ def __init__(self, refresh: Callable[[], None], interval: int) -> None:
7
+ self._refresh = refresh
8
+ self._interval = interval
9
+ self._stop = threading.Event()
10
+ self._thread = threading.Thread(target=self._run, daemon=True)
11
+
12
+ def start(self) -> None:
13
+ self._thread.start()
14
+
15
+ def stop(self) -> None:
16
+ self._stop.set()
17
+ self._thread.join(timeout=5)
18
+
19
+ def _run(self) -> None:
20
+ while not self._stop.wait(self._interval):
21
+ try:
22
+ self._refresh()
23
+ except Exception:
24
+ pass
flagstone_sdk/py.typed ADDED
File without changes
@@ -0,0 +1,46 @@
1
+ import json
2
+ import threading
3
+ import time
4
+ from collections.abc import Callable
5
+
6
+ import httpx
7
+
8
+
9
+ class StreamListener:
10
+ def __init__(self, url: str, sdk_key: str, on_update: Callable[[dict], None]) -> None:
11
+ self._url = url
12
+ self._sdk_key = sdk_key
13
+ self._on_update = on_update
14
+ self._stop = threading.Event()
15
+ self._thread = threading.Thread(target=self._run, daemon=True)
16
+
17
+ def start(self) -> None:
18
+ self._thread.start()
19
+
20
+ def stop(self) -> None:
21
+ self._stop.set()
22
+ self._thread.join(timeout=5)
23
+
24
+ def _run(self) -> None:
25
+ backoff = 1
26
+ while not self._stop.is_set():
27
+ try:
28
+ with httpx.Client(
29
+ headers={"x-sdk-key": self._sdk_key}, timeout=None
30
+ ) as http:
31
+ with http.stream("GET", self._url) as resp:
32
+ backoff = 1
33
+ for line in resp.iter_lines():
34
+ if self._stop.is_set():
35
+ return
36
+ if line.startswith("data:"):
37
+ data = line[5:].strip()
38
+ if data:
39
+ try:
40
+ self._on_update(json.loads(data))
41
+ except json.JSONDecodeError:
42
+ pass
43
+ except Exception:
44
+ if not self._stop.is_set():
45
+ time.sleep(min(backoff, 60))
46
+ backoff = min(backoff * 2, 60)
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: flagstone-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Flagstone feature flags: local flag evaluation, percentage rollouts and A/B experiment events.
5
+ Author: Tejas Ashri
6
+ License-Expression: MIT
7
+ Keywords: feature flags,feature toggles,a/b testing,experiments,rollout
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: httpx>=0.27
23
+ Dynamic: license-file
24
+
25
+ # flagstone-sdk
26
+
27
+ Python SDK for Flagstone feature flags.
28
+
29
+ The client downloads your project's flag configuration once and **evaluates flags locally**, in your
30
+ application's process. A flag check is a dictionary lookup and a few comparisons, with no network
31
+ call, so it is safe to use in hot paths. The client keeps its copy up to date in the background,
32
+ and keeps working with the last configuration it had if the Flagstone server is unreachable.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install flagstone-sdk
38
+ ```
39
+
40
+ Requires Python 3.10 or newer. The only dependency is [httpx](https://www.python-httpx.org/).
41
+
42
+ ## Quickstart
43
+
44
+ ```python
45
+ from flagstone_sdk import FlagClient
46
+
47
+ client = FlagClient(
48
+ sdk_key="fs_...", # from your project's "SDK keys" page
49
+ base_url="https://flags.example.com", # where your Flagstone server runs
50
+ )
51
+
52
+ user = {"id": "user-123", "plan": "pro", "country": "IN"}
53
+
54
+ if client.is_enabled("new-dashboard", user):
55
+ show_new_dashboard()
56
+
57
+ # Record that this user converted, for the flag's experiment results.
58
+ client.track_conversion("new-dashboard", user)
59
+
60
+ client.close() # on shutdown
61
+ ```
62
+
63
+ Create one client when your application starts and share it. Creating a client fetches the
64
+ configuration and starts a background refresh thread.
65
+
66
+ ## Users and targeting
67
+
68
+ A user is a plain `dict`. `"id"` is required: it decides which side of a percentage rollout the
69
+ user falls on, and the same id always gets the same answer. Any other keys (`plan`, `country`,
70
+ `email`, ...) are attributes that targeting rules can match on. Rules and rollouts are configured in
71
+ the Flagstone admin UI.
72
+
73
+ ## API
74
+
75
+ | Method | Returns |
76
+ | --- | --- |
77
+ | `is_enabled(flag_key, user)` | `True` if the variation served to this user is truthy. Records an exposure event. Unknown flags return `False`. |
78
+ | `get_variation(flag_key, user)` | Index of the variation served to this user. Does not record an event. Unknown flags return `0`. |
79
+ | `track_conversion(flag_key, user)` | Records a conversion for the variation this user is served. |
80
+ | `close()` | Stops background refreshes and releases the HTTP connection. |
81
+
82
+ ### Options
83
+
84
+ | Argument | Default | Meaning |
85
+ | --- | --- | --- |
86
+ | `sdk_key` | required | The project's SDK key. |
87
+ | `base_url` | `http://localhost:8000` | URL of your Flagstone server. |
88
+ | `poll_interval` | `30` | Seconds between configuration refreshes when polling. |
89
+ | `use_sse` | `False` | Receive changes over Server-Sent Events instead of polling. Changes arrive within a second, but the connection must be allowed through any proxies in between. |
90
+ | `cache_path` | `~/.flagstone_cache.json` | Where the last configuration is saved, so the client can start even if the server is down. `None` disables the cache. |
91
+ | `flush_interval` | `10` | Seconds between sending batched events. Events are also sent every 100 events. |
92
+
93
+ ## Keep SDK keys on the server
94
+
95
+ The client downloads the full configuration of the key's project, including targeting rules and the
96
+ values they compare against. Use the SDK only in server-side code. Never put an SDK key in a browser,
97
+ mobile app or any code your users can read.
98
+
99
+ ## Good to know
100
+
101
+ - Events are delivered on a best-effort basis: if sending a batch fails, that batch is dropped.
102
+ - `close()` does not send events that are still buffered; up to `flush_interval` seconds of events
103
+ can be lost at shutdown.
104
+
105
+ ## License
106
+
107
+ MIT. The full license text is included in the package.
@@ -0,0 +1,13 @@
1
+ flagstone_sdk/__init__.py,sha256=wH170Q4hRznEE3i0M34cSyejo6W6Ky4IAeFaeUA3T-w,536
2
+ flagstone_sdk/bucketing.py,sha256=Q3JLwfTktvlEYrduJuZKd78RFTXu-uA5YYrSo8_t-rQ,307
3
+ flagstone_sdk/client.py,sha256=euGV7Kq71ZZDEqQzlZylBTerOO7YPbI7tvQdZKi_P5o,4659
4
+ flagstone_sdk/disk_cache.py,sha256=KjAAwCGkqic2H0X5paHZWT29ug8Hx9YdxVZf9RUYO6M,808
5
+ flagstone_sdk/evaluator.py,sha256=HdNDlcchUdJXsn6PjveNaqk7rTo1bmyWSh6OIfZDQhU,987
6
+ flagstone_sdk/poller.py,sha256=uZdOOrPtlfpeH-2fD2d2dxVRabDyVj8LPNnlCuOcQBU,652
7
+ flagstone_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ flagstone_sdk/stream_listener.py,sha256=L24it2TE3XsIsUdNfkFsjYwmZf02MLE0GdaXMGpCSPc,1596
9
+ flagstone_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=W5w8mnnDNnZod-UkbCMdgmuVHo-rBuFeMaLSUewjwuM,1068
10
+ flagstone_sdk-0.1.0.dist-info/METADATA,sha256=otM5sd-Yx_TtnnHRUkiNj7rDzMMq3bRK1sVgMN-Zdac,4403
11
+ flagstone_sdk-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ flagstone_sdk-0.1.0.dist-info/top_level.txt,sha256=5AEbBJdbj_V9BamhxKAdJn2PJmTSL6ahYP7ypGT_M7E,14
13
+ flagstone_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tejas Ashri
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 @@
1
+ flagstone_sdk