rocketflag 0.1.0__tar.gz → 0.2.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.
- {rocketflag-0.1.0/src/rocketflag.egg-info → rocketflag-0.2.0}/PKG-INFO +26 -3
- {rocketflag-0.1.0 → rocketflag-0.2.0}/README.md +25 -2
- {rocketflag-0.1.0 → rocketflag-0.2.0}/pyproject.toml +1 -1
- {rocketflag-0.1.0 → rocketflag-0.2.0}/src/rocketflag/__init__.py +14 -2
- {rocketflag-0.1.0 → rocketflag-0.2.0}/src/rocketflag/client.py +39 -19
- {rocketflag-0.1.0 → rocketflag-0.2.0/src/rocketflag.egg-info}/PKG-INFO +26 -3
- rocketflag-0.2.0/tests/test_client.py +266 -0
- rocketflag-0.1.0/tests/test_client.py +0 -139
- {rocketflag-0.1.0 → rocketflag-0.2.0}/LICENSE.md +0 -0
- {rocketflag-0.1.0 → rocketflag-0.2.0}/setup.cfg +0 -0
- {rocketflag-0.1.0 → rocketflag-0.2.0}/src/rocketflag/errors.py +0 -0
- {rocketflag-0.1.0 → rocketflag-0.2.0}/src/rocketflag/py.typed +0 -0
- {rocketflag-0.1.0 → rocketflag-0.2.0}/src/rocketflag.egg-info/SOURCES.txt +0 -0
- {rocketflag-0.1.0 → rocketflag-0.2.0}/src/rocketflag.egg-info/dependency_links.txt +0 -0
- {rocketflag-0.1.0 → rocketflag-0.2.0}/src/rocketflag.egg-info/requires.txt +0 -0
- {rocketflag-0.1.0 → rocketflag-0.2.0}/src/rocketflag.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rocketflag
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK of the RocketFlag Service
|
|
5
5
|
Author: RocketFlag Developers
|
|
6
6
|
License: MIT
|
|
@@ -29,6 +29,8 @@ Dynamic: license-file
|
|
|
29
29
|
|
|
30
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
31
|
|
|
32
|
+
**Beta:** the evaluation API matches Node and Go, but packaging and docs may still change. Prefer pinning a version in production.
|
|
33
|
+
|
|
32
34
|
## Installation
|
|
33
35
|
|
|
34
36
|
```bash
|
|
@@ -81,7 +83,24 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
|
|
|
81
83
|
})
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
`env`
|
|
86
|
+
`env` selects the environment of a group flag. Environment names contain letters, numbers, hyphens and underscores.
|
|
87
|
+
|
|
88
|
+
## Sticky rollouts and audiences
|
|
89
|
+
|
|
90
|
+
Pass a `targetingKey` to make percentage rollouts sticky, and any other keys as audience attributes:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
|
|
94
|
+
"targetingKey": user.id,
|
|
95
|
+
"plan": "pro",
|
|
96
|
+
"country": "AU",
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- **`targetingKey`**: a stable identifier for the user. The same key always gets the same answer from a percentage rollout, in every environment of a group flag, and raising the percentage only ever adds users. Without a `targetingKey` the `cohort` is used, and with neither each request is a fresh random roll. Prefer an opaque id over an email address: the key is part of the request URL.
|
|
101
|
+
- **Any other key** is an audience attribute, matched against the flag's audience exactly and case-sensitively. An attribute you don't send never matches. `cohort`, `env` and `targetingKey` are reserved and can't be audience attributes.
|
|
102
|
+
|
|
103
|
+
Context values must be `str`, `int`, `float` or `bool`. `None` raises `ValueError`, so leave out an attribute you don't have. The `UserContext` type hint excludes `None` too, so a type checker catches it first.
|
|
85
104
|
|
|
86
105
|
## Caching
|
|
87
106
|
|
|
@@ -100,7 +119,11 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=0)
|
|
|
100
119
|
flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=10)
|
|
101
120
|
```
|
|
102
121
|
|
|
103
|
-
Without a client default or per-call TTL, every call hits the API. The cache
|
|
122
|
+
Without a client default or per-call TTL, every call hits the API. Each distinct context is its own cache entry, so a `targetingKey` per user means an entry per user. The cache holds at most 10,000 entries and evicts the least recently used one when it is full. Change the cap with `max_entries`:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
rocketflag = create_client(ttl_seconds=300, max_entries=50_000)
|
|
126
|
+
```
|
|
104
127
|
|
|
105
128
|
## Errors
|
|
106
129
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
+
**Beta:** the evaluation API matches Node and Go, but packaging and docs may still change. Prefer pinning a version in production.
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -54,7 +56,24 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
|
|
|
54
56
|
})
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
`env`
|
|
59
|
+
`env` selects the environment of a group flag. Environment names contain letters, numbers, hyphens and underscores.
|
|
60
|
+
|
|
61
|
+
## Sticky rollouts and audiences
|
|
62
|
+
|
|
63
|
+
Pass a `targetingKey` to make percentage rollouts sticky, and any other keys as audience attributes:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
|
|
67
|
+
"targetingKey": user.id,
|
|
68
|
+
"plan": "pro",
|
|
69
|
+
"country": "AU",
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- **`targetingKey`**: a stable identifier for the user. The same key always gets the same answer from a percentage rollout, in every environment of a group flag, and raising the percentage only ever adds users. Without a `targetingKey` the `cohort` is used, and with neither each request is a fresh random roll. Prefer an opaque id over an email address: the key is part of the request URL.
|
|
74
|
+
- **Any other key** is an audience attribute, matched against the flag's audience exactly and case-sensitively. An attribute you don't send never matches. `cohort`, `env` and `targetingKey` are reserved and can't be audience attributes.
|
|
75
|
+
|
|
76
|
+
Context values must be `str`, `int`, `float` or `bool`. `None` raises `ValueError`, so leave out an attribute you don't have. The `UserContext` type hint excludes `None` too, so a type checker catches it first.
|
|
58
77
|
|
|
59
78
|
## Caching
|
|
60
79
|
|
|
@@ -73,7 +92,11 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=0)
|
|
|
73
92
|
flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=10)
|
|
74
93
|
```
|
|
75
94
|
|
|
76
|
-
Without a client default or per-call TTL, every call hits the API. The cache
|
|
95
|
+
Without a client default or per-call TTL, every call hits the API. Each distinct context is its own cache entry, so a `targetingKey` per user means an entry per user. The cache holds at most 10,000 entries and evicts the least recently used one when it is full. Change the cap with `max_entries`:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
rocketflag = create_client(ttl_seconds=300, max_entries=50_000)
|
|
99
|
+
```
|
|
77
100
|
|
|
78
101
|
## Errors
|
|
79
102
|
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
"""Official Python SDK for RocketFlag."""
|
|
2
2
|
|
|
3
|
-
from .client import
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_API_URL,
|
|
5
|
+
DEFAULT_MAX_CACHE_ENTRIES,
|
|
6
|
+
DEFAULT_VERSION,
|
|
7
|
+
Client,
|
|
8
|
+
ContextValue,
|
|
9
|
+
Flag,
|
|
10
|
+
UserContext,
|
|
11
|
+
create_client,
|
|
12
|
+
)
|
|
4
13
|
from .errors import APIError, InvalidResponseError, NetworkError, RocketFlagError
|
|
5
14
|
|
|
6
15
|
__all__ = [
|
|
7
16
|
"APIError",
|
|
8
17
|
"Client",
|
|
18
|
+
"ContextValue",
|
|
9
19
|
"DEFAULT_API_URL",
|
|
20
|
+
"DEFAULT_MAX_CACHE_ENTRIES",
|
|
10
21
|
"DEFAULT_VERSION",
|
|
11
22
|
"Flag",
|
|
12
23
|
"InvalidResponseError",
|
|
13
24
|
"NetworkError",
|
|
14
25
|
"RocketFlagError",
|
|
26
|
+
"UserContext",
|
|
15
27
|
"create_client",
|
|
16
28
|
]
|
|
17
29
|
|
|
18
|
-
__version__ = "0.
|
|
30
|
+
__version__ = "0.2.0"
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
"""RocketFlag evaluation client.
|
|
2
2
|
|
|
3
3
|
Mirrors the Node and Go SDKs: GET {api_url}/{version}/flags/{flag_id}
|
|
4
|
-
with optional query-string user context (cohort, env,
|
|
5
|
-
in-memory TTL cache keyed by flag ID +
|
|
4
|
+
with optional query-string user context (cohort, env, targetingKey and
|
|
5
|
+
audience attributes) and an opt-in in-memory TTL cache keyed by flag ID +
|
|
6
|
+
context, capped at a maximum number of entries.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
10
11
|
import json
|
|
11
12
|
import re
|
|
13
|
+
import threading
|
|
12
14
|
import time
|
|
13
15
|
import urllib.error
|
|
14
16
|
import urllib.parse
|
|
15
17
|
import urllib.request
|
|
18
|
+
from collections import OrderedDict
|
|
16
19
|
from dataclasses import dataclass
|
|
17
20
|
from typing import Any, Mapping, Optional, Union
|
|
18
21
|
|
|
@@ -20,9 +23,13 @@ from .errors import APIError, InvalidResponseError, NetworkError
|
|
|
20
23
|
|
|
21
24
|
DEFAULT_API_URL = "https://api.rocketflag.app"
|
|
22
25
|
DEFAULT_VERSION = "v1"
|
|
23
|
-
|
|
26
|
+
DEFAULT_MAX_CACHE_ENTRIES = 10_000
|
|
27
|
+
_ENV_NAME = re.compile(r"[A-Za-z0-9_-]+")
|
|
24
28
|
|
|
25
29
|
ContextValue = Union[str, int, float, bool]
|
|
30
|
+
# Every key is sent as a query parameter: cohort, env, targetingKey (a stable
|
|
31
|
+
# user identifier that makes percentage rollouts sticky) and any other key as
|
|
32
|
+
# an audience attribute. None is not a value; leave the key out instead.
|
|
26
33
|
UserContext = Mapping[str, ContextValue]
|
|
27
34
|
|
|
28
35
|
|
|
@@ -48,12 +55,12 @@ def _validate_flag(payload: Any) -> Flag:
|
|
|
48
55
|
|
|
49
56
|
def _validate_context(user_context: UserContext) -> None:
|
|
50
57
|
for key, value in user_context.items():
|
|
51
|
-
if isinstance(value,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
if isinstance(value, (str, int, float)): # bool is an int
|
|
59
|
+
if key == "env" and (not isinstance(value, str) or not _ENV_NAME.fullmatch(value)):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"env values may only contain letters, numbers, hyphens and underscores. "
|
|
62
|
+
f"Invalid value for env: {value!r}"
|
|
63
|
+
)
|
|
57
64
|
continue
|
|
58
65
|
raise ValueError(
|
|
59
66
|
f"userContext values must be of type str, int, float, or bool. Invalid value for key: {key}"
|
|
@@ -75,12 +82,18 @@ class Client:
|
|
|
75
82
|
api_url: str = DEFAULT_API_URL,
|
|
76
83
|
*,
|
|
77
84
|
ttl_seconds: Optional[float] = None,
|
|
85
|
+
max_entries: int = DEFAULT_MAX_CACHE_ENTRIES,
|
|
78
86
|
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
79
87
|
) -> None:
|
|
88
|
+
if isinstance(max_entries, bool) or not isinstance(max_entries, int) or max_entries < 1:
|
|
89
|
+
raise ValueError("max_entries must be a positive integer")
|
|
80
90
|
self.version = version
|
|
81
91
|
self.api_url = api_url.rstrip("/")
|
|
82
92
|
self._default_ttl_ms = 0.0 if ttl_seconds is None else float(ttl_seconds) * 1000.0
|
|
83
|
-
self.
|
|
93
|
+
self._max_entries = max_entries
|
|
94
|
+
# Least recently used first; a hit moves its entry to the end.
|
|
95
|
+
self._cache: OrderedDict[str, tuple[Flag, float]] = OrderedDict()
|
|
96
|
+
self._cache_lock = threading.Lock()
|
|
84
97
|
self._opener = opener or urllib.request.build_opener()
|
|
85
98
|
|
|
86
99
|
def get_flag(
|
|
@@ -108,12 +121,14 @@ class Client:
|
|
|
108
121
|
cache_key = ""
|
|
109
122
|
if effective_ttl_ms > 0:
|
|
110
123
|
cache_key = _cache_key(flag_id, params)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
124
|
+
with self._cache_lock:
|
|
125
|
+
entry = self._cache.get(cache_key)
|
|
126
|
+
if entry is not None:
|
|
127
|
+
flag, expires_at = entry
|
|
128
|
+
if expires_at > time.time() * 1000.0:
|
|
129
|
+
self._cache.move_to_end(cache_key)
|
|
130
|
+
return Flag(name=flag.name, enabled=flag.enabled, id=flag.id)
|
|
131
|
+
del self._cache[cache_key]
|
|
117
132
|
|
|
118
133
|
request = urllib.request.Request(url, method="GET")
|
|
119
134
|
try:
|
|
@@ -139,7 +154,11 @@ class Client:
|
|
|
139
154
|
|
|
140
155
|
flag = _validate_flag(payload)
|
|
141
156
|
if effective_ttl_ms > 0:
|
|
142
|
-
self.
|
|
157
|
+
with self._cache_lock:
|
|
158
|
+
self._cache.pop(cache_key, None)
|
|
159
|
+
if len(self._cache) >= self._max_entries:
|
|
160
|
+
self._cache.popitem(last=False)
|
|
161
|
+
self._cache[cache_key] = (flag, time.time() * 1000.0 + effective_ttl_ms)
|
|
143
162
|
return flag
|
|
144
163
|
|
|
145
164
|
|
|
@@ -147,6 +166,7 @@ def create_client(
|
|
|
147
166
|
version: str = DEFAULT_VERSION,
|
|
148
167
|
api_url: str = DEFAULT_API_URL,
|
|
149
168
|
ttl_seconds: Optional[float] = None,
|
|
169
|
+
max_entries: int = DEFAULT_MAX_CACHE_ENTRIES,
|
|
150
170
|
) -> Client:
|
|
151
|
-
"""Create a client.
|
|
152
|
-
return Client(version, api_url, ttl_seconds=ttl_seconds)
|
|
171
|
+
"""Create a client. Matches Node's createRocketflagClient(version, apiUrl, { ttlSeconds, maxEntries })."""
|
|
172
|
+
return Client(version, api_url, ttl_seconds=ttl_seconds, max_entries=max_entries)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rocketflag
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK of the RocketFlag Service
|
|
5
5
|
Author: RocketFlag Developers
|
|
6
6
|
License: MIT
|
|
@@ -29,6 +29,8 @@ Dynamic: license-file
|
|
|
29
29
|
|
|
30
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
31
|
|
|
32
|
+
**Beta:** the evaluation API matches Node and Go, but packaging and docs may still change. Prefer pinning a version in production.
|
|
33
|
+
|
|
32
34
|
## Installation
|
|
33
35
|
|
|
34
36
|
```bash
|
|
@@ -81,7 +83,24 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
|
|
|
81
83
|
})
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
`env`
|
|
86
|
+
`env` selects the environment of a group flag. Environment names contain letters, numbers, hyphens and underscores.
|
|
87
|
+
|
|
88
|
+
## Sticky rollouts and audiences
|
|
89
|
+
|
|
90
|
+
Pass a `targetingKey` to make percentage rollouts sticky, and any other keys as audience attributes:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
|
|
94
|
+
"targetingKey": user.id,
|
|
95
|
+
"plan": "pro",
|
|
96
|
+
"country": "AU",
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- **`targetingKey`**: a stable identifier for the user. The same key always gets the same answer from a percentage rollout, in every environment of a group flag, and raising the percentage only ever adds users. Without a `targetingKey` the `cohort` is used, and with neither each request is a fresh random roll. Prefer an opaque id over an email address: the key is part of the request URL.
|
|
101
|
+
- **Any other key** is an audience attribute, matched against the flag's audience exactly and case-sensitively. An attribute you don't send never matches. `cohort`, `env` and `targetingKey` are reserved and can't be audience attributes.
|
|
102
|
+
|
|
103
|
+
Context values must be `str`, `int`, `float` or `bool`. `None` raises `ValueError`, so leave out an attribute you don't have. The `UserContext` type hint excludes `None` too, so a type checker catches it first.
|
|
85
104
|
|
|
86
105
|
## Caching
|
|
87
106
|
|
|
@@ -100,7 +119,11 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=0)
|
|
|
100
119
|
flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=10)
|
|
101
120
|
```
|
|
102
121
|
|
|
103
|
-
Without a client default or per-call TTL, every call hits the API. The cache
|
|
122
|
+
Without a client default or per-call TTL, every call hits the API. Each distinct context is its own cache entry, so a `targetingKey` per user means an entry per user. The cache holds at most 10,000 entries and evicts the least recently used one when it is full. Change the cap with `max_entries`:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
rocketflag = create_client(ttl_seconds=300, max_entries=50_000)
|
|
126
|
+
```
|
|
104
127
|
|
|
105
128
|
## Errors
|
|
106
129
|
|
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
DEFAULT_MAX_CACHE_ENTRIES,
|
|
10
|
+
APIError,
|
|
11
|
+
Client,
|
|
12
|
+
Flag,
|
|
13
|
+
InvalidResponseError,
|
|
14
|
+
NetworkError,
|
|
15
|
+
create_client,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FlagHandler(BaseHTTPRequestHandler):
|
|
20
|
+
responses = {}
|
|
21
|
+
hits = 0
|
|
22
|
+
|
|
23
|
+
def do_GET(self):
|
|
24
|
+
type(self).hits += 1
|
|
25
|
+
parsed = urlparse(self.path)
|
|
26
|
+
body, status = self.responses.get(parsed.path, (b"{}", 404))
|
|
27
|
+
if callable(body):
|
|
28
|
+
body, status = body(parsed)
|
|
29
|
+
self.send_response(status)
|
|
30
|
+
self.send_header("Content-Type", "application/json")
|
|
31
|
+
self.end_headers()
|
|
32
|
+
self.wfile.write(body)
|
|
33
|
+
|
|
34
|
+
def log_message(self, format, *args):
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def server():
|
|
40
|
+
FlagHandler.hits = 0
|
|
41
|
+
FlagHandler.responses = {}
|
|
42
|
+
httpd = HTTPServer(("127.0.0.1", 0), FlagHandler)
|
|
43
|
+
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
44
|
+
thread.start()
|
|
45
|
+
host, port = httpd.server_address
|
|
46
|
+
yield f"http://{host}:{port}", FlagHandler
|
|
47
|
+
httpd.shutdown()
|
|
48
|
+
httpd.server_close()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def flag_json(**overrides):
|
|
52
|
+
payload = {"name": "signup", "enabled": True, "id": "abc123"}
|
|
53
|
+
payload.update(overrides)
|
|
54
|
+
return json.dumps(payload).encode()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_get_flag_success(server):
|
|
58
|
+
base, handler = server
|
|
59
|
+
handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
|
|
60
|
+
client = create_client(api_url=base)
|
|
61
|
+
flag = client.get_flag("abc123")
|
|
62
|
+
assert flag == Flag(name="signup", enabled=True, id="abc123")
|
|
63
|
+
assert flag.enabled is True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_passes_context_as_query_string(server):
|
|
67
|
+
base, handler = server
|
|
68
|
+
seen = {}
|
|
69
|
+
|
|
70
|
+
def body(parsed):
|
|
71
|
+
seen["query"] = parse_qs(parsed.query)
|
|
72
|
+
return flag_json(), 200
|
|
73
|
+
|
|
74
|
+
handler.responses["/v1/flags/abc123"] = (body, 200)
|
|
75
|
+
client = Client(api_url=base)
|
|
76
|
+
client.get_flag("abc123", {"cohort": "beta", "env": "prod"})
|
|
77
|
+
assert seen["query"]["cohort"] == ["beta"]
|
|
78
|
+
assert seen["query"]["env"] == ["prod"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_cache_hits_on_second_call(server):
|
|
82
|
+
base, handler = server
|
|
83
|
+
handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
|
|
84
|
+
client = create_client(api_url=base, ttl_seconds=60)
|
|
85
|
+
first = client.get_flag("abc123", {"cohort": "beta"})
|
|
86
|
+
second = client.get_flag("abc123", {"cohort": "beta"})
|
|
87
|
+
assert first == second
|
|
88
|
+
assert handler.hits == 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_ttl_zero_bypasses_cache(server):
|
|
92
|
+
base, handler = server
|
|
93
|
+
handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
|
|
94
|
+
client = create_client(api_url=base, ttl_seconds=60)
|
|
95
|
+
client.get_flag("abc123")
|
|
96
|
+
client.get_flag("abc123", ttl_seconds=0)
|
|
97
|
+
assert handler.hits == 2
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_api_error_on_404(server):
|
|
101
|
+
base, handler = server
|
|
102
|
+
handler.responses["/v1/flags/missing"] = (b"not found", 404)
|
|
103
|
+
client = create_client(api_url=base)
|
|
104
|
+
with pytest.raises(APIError) as exc:
|
|
105
|
+
client.get_flag("missing")
|
|
106
|
+
assert exc.value.status == 404
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_invalid_json(server):
|
|
110
|
+
base, handler = server
|
|
111
|
+
handler.responses["/v1/flags/abc123"] = (b"not-json", 200)
|
|
112
|
+
client = create_client(api_url=base)
|
|
113
|
+
with pytest.raises(InvalidResponseError):
|
|
114
|
+
client.get_flag("abc123")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_invalid_shape(server):
|
|
118
|
+
base, handler = server
|
|
119
|
+
handler.responses["/v1/flags/abc123"] = (b'{"name": 1}', 200)
|
|
120
|
+
client = create_client(api_url=base)
|
|
121
|
+
with pytest.raises(InvalidResponseError):
|
|
122
|
+
client.get_flag("abc123")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_requires_flag_id():
|
|
126
|
+
client = create_client()
|
|
127
|
+
with pytest.raises(ValueError):
|
|
128
|
+
client.get_flag("")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class StubResponse:
|
|
132
|
+
status = 200
|
|
133
|
+
|
|
134
|
+
def __enter__(self):
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def __exit__(self, *exc):
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def read(self):
|
|
141
|
+
return flag_json()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class StubOpener:
|
|
145
|
+
"""Answers every request with a flag, recording the URLs it was asked for."""
|
|
146
|
+
|
|
147
|
+
def __init__(self):
|
|
148
|
+
self.urls = []
|
|
149
|
+
|
|
150
|
+
def open(self, request):
|
|
151
|
+
self.urls.append(request.full_url)
|
|
152
|
+
return StubResponse()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_passes_targeting_key_and_audience_attributes(server):
|
|
156
|
+
base, handler = server
|
|
157
|
+
seen = {}
|
|
158
|
+
|
|
159
|
+
def body(parsed):
|
|
160
|
+
seen["query"] = parse_qs(parsed.query, keep_blank_values=True)
|
|
161
|
+
return flag_json(), 200
|
|
162
|
+
|
|
163
|
+
handler.responses["/v1/flags/abc123"] = (body, 200)
|
|
164
|
+
client = Client(api_url=base)
|
|
165
|
+
client.get_flag("abc123", {"targetingKey": "user-42", "plan": "pro", "country": "AU", "seats": 5, "beta": True})
|
|
166
|
+
assert seen["query"] == {
|
|
167
|
+
"targetingKey": ["user-42"],
|
|
168
|
+
"plan": ["pro"],
|
|
169
|
+
"country": ["AU"],
|
|
170
|
+
"seats": ["5"],
|
|
171
|
+
"beta": ["true"],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_none_value_is_rejected():
|
|
176
|
+
opener = StubOpener()
|
|
177
|
+
client = Client(opener=opener)
|
|
178
|
+
with pytest.raises(ValueError, match="Invalid value for key: plan"):
|
|
179
|
+
client.get_flag("abc", {"plan": None})
|
|
180
|
+
assert opener.urls == []
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@pytest.mark.parametrize("env", ["prod", "prod-portals", "staging_v2", "production1"])
|
|
184
|
+
def test_env_accepts_letters_numbers_hyphens_and_underscores(env):
|
|
185
|
+
opener = StubOpener()
|
|
186
|
+
client = Client(opener=opener)
|
|
187
|
+
client.get_flag("abc", {"env": env})
|
|
188
|
+
assert opener.urls == [f"https://api.rocketflag.app/v1/flags/abc?env={env}"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.parametrize("env", ["prod 1", "prod.1", "prod!", "prod\n", "", True, 5])
|
|
192
|
+
def test_env_rejects_other_characters(env):
|
|
193
|
+
opener = StubOpener()
|
|
194
|
+
client = Client(opener=opener)
|
|
195
|
+
with pytest.raises(ValueError, match="env values may only contain letters, numbers, hyphens and underscores"):
|
|
196
|
+
client.get_flag("abc", {"env": env})
|
|
197
|
+
assert opener.urls == []
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_cache_evicts_least_recently_used_when_full():
|
|
201
|
+
opener = StubOpener()
|
|
202
|
+
client = Client(ttl_seconds=60, max_entries=2, opener=opener)
|
|
203
|
+
client.get_flag("abc", {"targetingKey": "a"})
|
|
204
|
+
client.get_flag("abc", {"targetingKey": "b"})
|
|
205
|
+
client.get_flag("abc", {"targetingKey": "a"}) # hit, so "b" is now least recently used
|
|
206
|
+
assert len(opener.urls) == 2
|
|
207
|
+
|
|
208
|
+
client.get_flag("abc", {"targetingKey": "c"}) # evicts "b"
|
|
209
|
+
client.get_flag("abc", {"targetingKey": "a"})
|
|
210
|
+
assert len(opener.urls) == 3
|
|
211
|
+
|
|
212
|
+
client.get_flag("abc", {"targetingKey": "b"})
|
|
213
|
+
assert len(opener.urls) == 4
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_cache_holds_10000_entries_by_default():
|
|
217
|
+
assert DEFAULT_MAX_CACHE_ENTRIES == 10_000
|
|
218
|
+
opener = StubOpener()
|
|
219
|
+
client = Client(ttl_seconds=60, opener=opener)
|
|
220
|
+
for i in range(10_001):
|
|
221
|
+
client.get_flag("abc", {"targetingKey": f"user-{i}"})
|
|
222
|
+
assert len(opener.urls) == 10_001
|
|
223
|
+
|
|
224
|
+
client.get_flag("abc", {"targetingKey": "user-10000"})
|
|
225
|
+
assert len(opener.urls) == 10_001
|
|
226
|
+
client.get_flag("abc", {"targetingKey": "user-0"})
|
|
227
|
+
assert len(opener.urls) == 10_002
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_cache_is_safe_under_concurrent_use():
|
|
231
|
+
opener = StubOpener()
|
|
232
|
+
client = Client(ttl_seconds=60, max_entries=8, opener=opener)
|
|
233
|
+
errors = []
|
|
234
|
+
|
|
235
|
+
def worker(n):
|
|
236
|
+
try:
|
|
237
|
+
for i in range(500):
|
|
238
|
+
client.get_flag("abc", {"targetingKey": f"user-{(n * i) % 20}"})
|
|
239
|
+
except Exception as exc: # pragma: no cover - surfaced by the assert below
|
|
240
|
+
errors.append(exc)
|
|
241
|
+
|
|
242
|
+
threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
|
|
243
|
+
for thread in threads:
|
|
244
|
+
thread.start()
|
|
245
|
+
for thread in threads:
|
|
246
|
+
thread.join()
|
|
247
|
+
|
|
248
|
+
assert errors == []
|
|
249
|
+
assert len(client._cache) <= 8
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@pytest.mark.parametrize("max_entries", [0, -1, 1.5, True, "10"])
|
|
253
|
+
def test_max_entries_must_be_a_positive_integer(max_entries):
|
|
254
|
+
with pytest.raises(ValueError, match="max_entries must be a positive integer"):
|
|
255
|
+
Client(max_entries=max_entries)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_create_client_passes_max_entries():
|
|
259
|
+
with pytest.raises(ValueError, match="max_entries must be a positive integer"):
|
|
260
|
+
create_client(max_entries=0)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_network_error():
|
|
264
|
+
client = create_client(api_url="http://127.0.0.1:1")
|
|
265
|
+
with pytest.raises(NetworkError):
|
|
266
|
+
client.get_flag("abc123")
|
|
@@ -1,139 +0,0 @@
|
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|