rocketflag 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.
rocketflag/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Official Python SDK for RocketFlag."""
2
+
3
+ from .client import DEFAULT_API_URL, DEFAULT_VERSION, Client, Flag, create_client
4
+ from .errors import APIError, InvalidResponseError, NetworkError, RocketFlagError
5
+
6
+ __all__ = [
7
+ "APIError",
8
+ "Client",
9
+ "DEFAULT_API_URL",
10
+ "DEFAULT_VERSION",
11
+ "Flag",
12
+ "InvalidResponseError",
13
+ "NetworkError",
14
+ "RocketFlagError",
15
+ "create_client",
16
+ ]
17
+
18
+ __version__ = "0.1.0"
rocketflag/client.py ADDED
@@ -0,0 +1,152 @@
1
+ """RocketFlag evaluation client.
2
+
3
+ Mirrors the Node and Go SDKs: GET {api_url}/{version}/flags/{flag_id}
4
+ with optional query-string user context (cohort, env, …) and an opt-in
5
+ in-memory TTL cache keyed by flag ID + context.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import time
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ from dataclasses import dataclass
17
+ from typing import Any, Mapping, Optional, Union
18
+
19
+ from .errors import APIError, InvalidResponseError, NetworkError
20
+
21
+ DEFAULT_API_URL = "https://api.rocketflag.app"
22
+ DEFAULT_VERSION = "v1"
23
+ _ALPHANUMERIC = re.compile(r"^[a-zA-Z0-9]+$")
24
+
25
+ ContextValue = Union[str, int, float, bool]
26
+ UserContext = Mapping[str, ContextValue]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Flag:
31
+ """A evaluated feature flag."""
32
+
33
+ name: str
34
+ enabled: bool
35
+ id: str
36
+
37
+
38
+ def _validate_flag(payload: Any) -> Flag:
39
+ if not isinstance(payload, dict):
40
+ raise InvalidResponseError("Invalid response format: response is not an object")
41
+ name = payload.get("name")
42
+ enabled = payload.get("enabled")
43
+ flag_id = payload.get("id")
44
+ if not isinstance(name, str) or not isinstance(enabled, bool) or not isinstance(flag_id, str):
45
+ raise InvalidResponseError("Invalid response from server")
46
+ return Flag(name=name, enabled=enabled, id=flag_id)
47
+
48
+
49
+ def _validate_context(user_context: UserContext) -> None:
50
+ for key, value in user_context.items():
51
+ if isinstance(value, bool):
52
+ continue
53
+ if isinstance(value, (str, int, float)):
54
+ if key == "env":
55
+ if not isinstance(value, str) or not _ALPHANUMERIC.match(value):
56
+ raise ValueError(f"env values must be alphanumeric. Invalid value for env: {value!r}")
57
+ continue
58
+ raise ValueError(
59
+ f"userContext values must be of type str, int, float, or bool. Invalid value for key: {key}"
60
+ )
61
+
62
+
63
+ def _cache_key(flag_id: str, params: Mapping[str, str]) -> str:
64
+ items = sorted(params.items())
65
+ query = urllib.parse.urlencode(items)
66
+ return f"{flag_id}?{query}"
67
+
68
+
69
+ class Client:
70
+ """RocketFlag API client."""
71
+
72
+ def __init__(
73
+ self,
74
+ version: str = DEFAULT_VERSION,
75
+ api_url: str = DEFAULT_API_URL,
76
+ *,
77
+ ttl_seconds: Optional[float] = None,
78
+ opener: Optional[urllib.request.OpenerDirector] = None,
79
+ ) -> None:
80
+ self.version = version
81
+ self.api_url = api_url.rstrip("/")
82
+ self._default_ttl_ms = 0.0 if ttl_seconds is None else float(ttl_seconds) * 1000.0
83
+ self._cache: dict[str, tuple[Flag, float]] = {}
84
+ self._opener = opener or urllib.request.build_opener()
85
+
86
+ def get_flag(
87
+ self,
88
+ flag_id: str,
89
+ user_context: Optional[UserContext] = None,
90
+ *,
91
+ ttl_seconds: Optional[float] = None,
92
+ ) -> Flag:
93
+ if not flag_id:
94
+ raise ValueError("flagId is required")
95
+ if not isinstance(flag_id, str):
96
+ raise TypeError("flagId must be a string")
97
+
98
+ context: UserContext = user_context or {}
99
+ if not isinstance(context, Mapping):
100
+ raise TypeError("userContext must be an object")
101
+ _validate_context(context)
102
+
103
+ params = {key: str(value).lower() if isinstance(value, bool) else str(value) for key, value in context.items()}
104
+ path = f"{self.api_url}/{self.version}/flags/{urllib.parse.quote(flag_id, safe='')}"
105
+ url = f"{path}?{urllib.parse.urlencode(params)}" if params else path
106
+
107
+ effective_ttl_ms = self._default_ttl_ms if ttl_seconds is None else float(ttl_seconds) * 1000.0
108
+ cache_key = ""
109
+ if effective_ttl_ms > 0:
110
+ cache_key = _cache_key(flag_id, params)
111
+ entry = self._cache.get(cache_key)
112
+ if entry is not None:
113
+ flag, expires_at = entry
114
+ if expires_at > time.time() * 1000.0:
115
+ return Flag(name=flag.name, enabled=flag.enabled, id=flag.id)
116
+ del self._cache[cache_key]
117
+
118
+ request = urllib.request.Request(url, method="GET")
119
+ try:
120
+ with self._opener.open(request) as response:
121
+ status = getattr(response, "status", None) or response.getcode()
122
+ if status != 200:
123
+ status_text = getattr(response, "reason", "") or ""
124
+ raise APIError(f"API request failed with status {status}", status, status_text)
125
+ raw = response.read()
126
+ except APIError:
127
+ raise
128
+ except urllib.error.HTTPError as exc:
129
+ raise APIError(f"API request failed with status {exc.code}", exc.code, exc.reason or "") from exc
130
+ except urllib.error.URLError as exc:
131
+ raise NetworkError(f"Network error: {exc.reason}") from exc
132
+ except OSError as exc:
133
+ raise NetworkError(f"Network error: {exc}") from exc
134
+
135
+ try:
136
+ payload = json.loads(raw.decode("utf-8"))
137
+ except (ValueError, UnicodeDecodeError) as exc:
138
+ raise InvalidResponseError("Failed to parse JSON response") from exc
139
+
140
+ flag = _validate_flag(payload)
141
+ if effective_ttl_ms > 0:
142
+ self._cache[cache_key] = (flag, time.time() * 1000.0 + effective_ttl_ms)
143
+ return flag
144
+
145
+
146
+ def create_client(
147
+ version: str = DEFAULT_VERSION,
148
+ api_url: str = DEFAULT_API_URL,
149
+ ttl_seconds: Optional[float] = None,
150
+ ) -> Client:
151
+ """Create a client. Signature matches Node's createRocketflagClient(version, apiUrl, { ttlSeconds })."""
152
+ return Client(version, api_url, ttl_seconds=ttl_seconds)
rocketflag/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ """Errors raised by the RocketFlag Python SDK."""
2
+
3
+
4
+ class RocketFlagError(Exception):
5
+ """Base class for SDK errors."""
6
+
7
+
8
+ class NetworkError(RocketFlagError):
9
+ """Raised when the request fails to reach the API."""
10
+
11
+
12
+ class APIError(RocketFlagError):
13
+ """Raised when the API returns a non-OK HTTP status."""
14
+
15
+ def __init__(self, message: str, status: int, status_text: str = "") -> None:
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.status_text = status_text
19
+
20
+
21
+ class InvalidResponseError(RocketFlagError):
22
+ """Raised when the API response is not a valid flag object."""
rocketflag/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: rocketflag
3
+ Version: 0.1.0
4
+ Summary: Python SDK of the RocketFlag Service
5
+ Author: RocketFlag Developers
6
+ License: MIT
7
+ Project-URL: Homepage, https://rocketflag.app
8
+ Project-URL: Repository, https://github.com/RocketFlag/python-sdk
9
+ Project-URL: Issues, https://github.com/RocketFlag/python-sdk/issues
10
+ Keywords: feature,flags,experiments
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE.md
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # RocketFlag Python SDK
29
+
30
+ Official Python SDK for [RocketFlag](https://rocketflag.app). Same evaluation API as the [Node](https://github.com/RocketFlag/node-sdk) and [Go](https://github.com/RocketFlag/go-sdk) SDKs: create a client, fetch a flag, branch on `flag.enabled`.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install rocketflag
36
+ ```
37
+
38
+ Python 3.9+. No third-party runtime dependencies.
39
+
40
+ Published releases go to [PyPI](https://pypi.org/project/rocketflag/) via GitHub Releases. For an unreleased commit:
41
+
42
+ ```bash
43
+ pip install git+https://github.com/RocketFlag/python-sdk.git
44
+ ```
45
+
46
+ ## Basic usage
47
+
48
+ ```python
49
+ from rocketflag import create_client
50
+
51
+ rocketflag = create_client() # default API URL and version (v1)
52
+
53
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL")
54
+ if flag.enabled:
55
+ render_new_dashboard()
56
+ else:
57
+ render_legacy_dashboard()
58
+ ```
59
+
60
+ You can also construct `Client` directly:
61
+
62
+ ```python
63
+ from rocketflag import Client
64
+
65
+ client = Client()
66
+ flag = client.get_flag("IFldMzqP5jtv9wAL")
67
+ ```
68
+
69
+ ### Custom version or API URL
70
+
71
+ ```python
72
+ rocketflag = create_client("v2", "https://your-api-domain.com")
73
+ ```
74
+
75
+ ## Cohorts and environment
76
+
77
+ ```python
78
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
79
+ "cohort": "beta",
80
+ "env": "prod",
81
+ })
82
+ ```
83
+
84
+ `env` must be alphanumeric, matching the Node SDK.
85
+
86
+ ## Caching
87
+
88
+ Opt-in in-memory cache, keyed by flag ID **and** user context. Same behaviour as Node/Go.
89
+
90
+ ```python
91
+ # Cache flag responses for 5 minutes.
92
+ rocketflag = create_client(ttl_seconds=300)
93
+
94
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {"cohort": "beta"})
95
+
96
+ # Force a fresh fetch.
97
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=0)
98
+
99
+ # Shorter TTL for one call.
100
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=10)
101
+ ```
102
+
103
+ Without a client default or per-call TTL, every call hits the API. The cache has no size cap; entries expire on the next lookup after TTL.
104
+
105
+ ## Errors
106
+
107
+ ```python
108
+ from rocketflag import APIError, InvalidResponseError, NetworkError, create_client
109
+
110
+ rocketflag = create_client()
111
+
112
+ try:
113
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL")
114
+ except APIError as err:
115
+ print(f"API Error: {err.status} {err.status_text}")
116
+ except InvalidResponseError as err:
117
+ print(f"Invalid Response Error: {err}")
118
+ except NetworkError as err:
119
+ print(f"Network Error: {err}")
120
+ ```
121
+
122
+ - `APIError` — non-OK HTTP status (includes `status` / `status_text`). A `404` means the flag ID is unknown.
123
+ - `InvalidResponseError` — body is not JSON or not a flag object (`name`, `enabled`, `id`).
124
+ - `NetworkError` — connection failure.
125
+
126
+ ## Response
127
+
128
+ A successful call returns a `Flag`:
129
+
130
+ ```python
131
+ Flag(name="The user-created flag name", enabled=True, id="asklWQQZdslhfsszZWkj")
132
+ ```
133
+
134
+ The HTTP API may also return:
135
+
136
+ 1. `200` with that object
137
+ 2. `404` — flag ID not found
138
+ 3. `500` — rare server error; RocketFlag is alerted
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ python -m pip install -e ".[dev]"
144
+ python -m pytest
145
+ ```
146
+
147
+ A container demo that hits the live API lives in [`demo/`](demo/).
@@ -0,0 +1,9 @@
1
+ rocketflag/__init__.py,sha256=imGCk95VYBVBuxGrCGl_yna7_m56qwlg9Lt2lLXEKzU,425
2
+ rocketflag/client.py,sha256=ZrBU-wyNqyz7fl8OTY-N79srvn7C1KwFlHxnWwK9h60,5585
3
+ rocketflag/errors.py,sha256=iq5zeIAOjwYkoC8XQkuo2GvNWFUAx_ZZInLBfgKvKNQ,614
4
+ rocketflag/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
+ rocketflag-0.1.0.dist-info/licenses/LICENSE.md,sha256=nnTqnHayljxSS4sRzG22yaqrp4J8GjbuIp1fT3MrtcE,1058
6
+ rocketflag-0.1.0.dist-info/METADATA,sha256=sBxFQ1ttnwpaCSr8qIbWLNl8Zeg7TLs1A-P-ym3K1Ag,3993
7
+ rocketflag-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ rocketflag-0.1.0.dist-info/top_level.txt,sha256=SOYXa8LVr54deealtuW0SiTu-UVyW4wL0oAHYlH9j50,11
9
+ rocketflag-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,13 @@
1
+ Copyright 2026 JK Gunnink
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the
4
+ “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish,
5
+ distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
6
+ following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
11
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
12
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
13
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ rocketflag