rocketflag 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.
@@ -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,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,120 @@
1
+ # RocketFlag Python SDK
2
+
3
+ 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`.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install rocketflag
9
+ ```
10
+
11
+ Python 3.9+. No third-party runtime dependencies.
12
+
13
+ Published releases go to [PyPI](https://pypi.org/project/rocketflag/) via GitHub Releases. For an unreleased commit:
14
+
15
+ ```bash
16
+ pip install git+https://github.com/RocketFlag/python-sdk.git
17
+ ```
18
+
19
+ ## Basic usage
20
+
21
+ ```python
22
+ from rocketflag import create_client
23
+
24
+ rocketflag = create_client() # default API URL and version (v1)
25
+
26
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL")
27
+ if flag.enabled:
28
+ render_new_dashboard()
29
+ else:
30
+ render_legacy_dashboard()
31
+ ```
32
+
33
+ You can also construct `Client` directly:
34
+
35
+ ```python
36
+ from rocketflag import Client
37
+
38
+ client = Client()
39
+ flag = client.get_flag("IFldMzqP5jtv9wAL")
40
+ ```
41
+
42
+ ### Custom version or API URL
43
+
44
+ ```python
45
+ rocketflag = create_client("v2", "https://your-api-domain.com")
46
+ ```
47
+
48
+ ## Cohorts and environment
49
+
50
+ ```python
51
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
52
+ "cohort": "beta",
53
+ "env": "prod",
54
+ })
55
+ ```
56
+
57
+ `env` must be alphanumeric, matching the Node SDK.
58
+
59
+ ## Caching
60
+
61
+ Opt-in in-memory cache, keyed by flag ID **and** user context. Same behaviour as Node/Go.
62
+
63
+ ```python
64
+ # Cache flag responses for 5 minutes.
65
+ rocketflag = create_client(ttl_seconds=300)
66
+
67
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {"cohort": "beta"})
68
+
69
+ # Force a fresh fetch.
70
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=0)
71
+
72
+ # Shorter TTL for one call.
73
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=10)
74
+ ```
75
+
76
+ 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.
77
+
78
+ ## Errors
79
+
80
+ ```python
81
+ from rocketflag import APIError, InvalidResponseError, NetworkError, create_client
82
+
83
+ rocketflag = create_client()
84
+
85
+ try:
86
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL")
87
+ except APIError as err:
88
+ print(f"API Error: {err.status} {err.status_text}")
89
+ except InvalidResponseError as err:
90
+ print(f"Invalid Response Error: {err}")
91
+ except NetworkError as err:
92
+ print(f"Network Error: {err}")
93
+ ```
94
+
95
+ - `APIError` — non-OK HTTP status (includes `status` / `status_text`). A `404` means the flag ID is unknown.
96
+ - `InvalidResponseError` — body is not JSON or not a flag object (`name`, `enabled`, `id`).
97
+ - `NetworkError` — connection failure.
98
+
99
+ ## Response
100
+
101
+ A successful call returns a `Flag`:
102
+
103
+ ```python
104
+ Flag(name="The user-created flag name", enabled=True, id="asklWQQZdslhfsszZWkj")
105
+ ```
106
+
107
+ The HTTP API may also return:
108
+
109
+ 1. `200` with that object
110
+ 2. `404` — flag ID not found
111
+ 3. `500` — rare server error; RocketFlag is alerted
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ python -m pip install -e ".[dev]"
117
+ python -m pytest
118
+ ```
119
+
120
+ A container demo that hits the live API lives in [`demo/`](demo/).
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rocketflag"
7
+ version = "0.1.0"
8
+ description = "Python SDK of the RocketFlag Service"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "RocketFlag Developers" }]
13
+ keywords = ["feature", "flags", "experiments"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.urls]
29
+ Homepage = "https://rocketflag.app"
30
+ Repository = "https://github.com/RocketFlag/python-sdk"
31
+ Issues = "https://github.com/RocketFlag/python-sdk/issues"
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["pytest>=8"]
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"
@@ -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)
@@ -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."""
@@ -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,13 @@
1
+ LICENSE.md
2
+ README.md
3
+ pyproject.toml
4
+ src/rocketflag/__init__.py
5
+ src/rocketflag/client.py
6
+ src/rocketflag/errors.py
7
+ src/rocketflag/py.typed
8
+ src/rocketflag.egg-info/PKG-INFO
9
+ src/rocketflag.egg-info/SOURCES.txt
10
+ src/rocketflag.egg-info/dependency_links.txt
11
+ src/rocketflag.egg-info/requires.txt
12
+ src/rocketflag.egg-info/top_level.txt
13
+ tests/test_client.py
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=8
@@ -0,0 +1 @@
1
+ rocketflag
@@ -0,0 +1,139 @@
1
+ import json
2
+ import threading
3
+ from http.server import BaseHTTPRequestHandler, HTTPServer
4
+ from urllib.parse import parse_qs, urlparse
5
+
6
+ import pytest
7
+
8
+ from rocketflag import (
9
+ APIError,
10
+ Client,
11
+ Flag,
12
+ InvalidResponseError,
13
+ NetworkError,
14
+ create_client,
15
+ )
16
+
17
+
18
+ class FlagHandler(BaseHTTPRequestHandler):
19
+ responses = {}
20
+ hits = 0
21
+
22
+ def do_GET(self):
23
+ type(self).hits += 1
24
+ parsed = urlparse(self.path)
25
+ body, status = self.responses.get(parsed.path, (b"{}", 404))
26
+ if callable(body):
27
+ body, status = body(parsed)
28
+ self.send_response(status)
29
+ self.send_header("Content-Type", "application/json")
30
+ self.end_headers()
31
+ self.wfile.write(body)
32
+
33
+ def log_message(self, format, *args):
34
+ return
35
+
36
+
37
+ @pytest.fixture
38
+ def server():
39
+ FlagHandler.hits = 0
40
+ FlagHandler.responses = {}
41
+ httpd = HTTPServer(("127.0.0.1", 0), FlagHandler)
42
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
43
+ thread.start()
44
+ host, port = httpd.server_address
45
+ yield f"http://{host}:{port}", FlagHandler
46
+ httpd.shutdown()
47
+ httpd.server_close()
48
+
49
+
50
+ def flag_json(**overrides):
51
+ payload = {"name": "signup", "enabled": True, "id": "abc123"}
52
+ payload.update(overrides)
53
+ return json.dumps(payload).encode()
54
+
55
+
56
+ def test_get_flag_success(server):
57
+ base, handler = server
58
+ handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
59
+ client = create_client(api_url=base)
60
+ flag = client.get_flag("abc123")
61
+ assert flag == Flag(name="signup", enabled=True, id="abc123")
62
+ assert flag.enabled is True
63
+
64
+
65
+ def test_passes_context_as_query_string(server):
66
+ base, handler = server
67
+ seen = {}
68
+
69
+ def body(parsed):
70
+ seen["query"] = parse_qs(parsed.query)
71
+ return flag_json(), 200
72
+
73
+ handler.responses["/v1/flags/abc123"] = (body, 200)
74
+ client = Client(api_url=base)
75
+ client.get_flag("abc123", {"cohort": "beta", "env": "prod"})
76
+ assert seen["query"]["cohort"] == ["beta"]
77
+ assert seen["query"]["env"] == ["prod"]
78
+
79
+
80
+ def test_cache_hits_on_second_call(server):
81
+ base, handler = server
82
+ handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
83
+ client = create_client(api_url=base, ttl_seconds=60)
84
+ first = client.get_flag("abc123", {"cohort": "beta"})
85
+ second = client.get_flag("abc123", {"cohort": "beta"})
86
+ assert first == second
87
+ assert handler.hits == 1
88
+
89
+
90
+ def test_ttl_zero_bypasses_cache(server):
91
+ base, handler = server
92
+ handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
93
+ client = create_client(api_url=base, ttl_seconds=60)
94
+ client.get_flag("abc123")
95
+ client.get_flag("abc123", ttl_seconds=0)
96
+ assert handler.hits == 2
97
+
98
+
99
+ def test_api_error_on_404(server):
100
+ base, handler = server
101
+ handler.responses["/v1/flags/missing"] = (b"not found", 404)
102
+ client = create_client(api_url=base)
103
+ with pytest.raises(APIError) as exc:
104
+ client.get_flag("missing")
105
+ assert exc.value.status == 404
106
+
107
+
108
+ def test_invalid_json(server):
109
+ base, handler = server
110
+ handler.responses["/v1/flags/abc123"] = (b"not-json", 200)
111
+ client = create_client(api_url=base)
112
+ with pytest.raises(InvalidResponseError):
113
+ client.get_flag("abc123")
114
+
115
+
116
+ def test_invalid_shape(server):
117
+ base, handler = server
118
+ handler.responses["/v1/flags/abc123"] = (b'{"name": 1}', 200)
119
+ client = create_client(api_url=base)
120
+ with pytest.raises(InvalidResponseError):
121
+ client.get_flag("abc123")
122
+
123
+
124
+ def test_requires_flag_id():
125
+ client = create_client()
126
+ with pytest.raises(ValueError):
127
+ client.get_flag("")
128
+
129
+
130
+ def test_env_must_be_alphanumeric():
131
+ client = create_client()
132
+ with pytest.raises(ValueError, match="alphanumeric"):
133
+ client.get_flag("abc", {"env": "prod-1"})
134
+
135
+
136
+ def test_network_error():
137
+ client = create_client(api_url="http://127.0.0.1:1")
138
+ with pytest.raises(NetworkError):
139
+ client.get_flag("abc123")