Skip to content

Commit d98b26c

Browse files
committed
feat: add type safety
1 parent 18d5268 commit d98b26c

9 files changed

Lines changed: 1861 additions & 351 deletions

File tree

README.md

Lines changed: 109 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Python SDK for [Replane](https://replane.dev) - a dynamic configuration platform
1515
- **Context-based overrides** for feature flags, A/B testing, and gradual rollouts
1616
- **Zero dependencies** for sync client (stdlib only)
1717
- **Both sync and async** clients available
18-
- **Type-safe** with full type hints
18+
- **Type-safe** with TypedDict support and full type hints
1919
- **Testing utilities** with in-memory client
2020

2121
## Installation
@@ -41,16 +41,14 @@ with Replane(
4141
sdk_key="rp_...",
4242
) as client:
4343
# Get a simple config value
44-
rate_limit = client.get("rate-limit")
44+
rate_limit = client.configs["rate-limit"]
4545

4646
# Get with context for override evaluation
47-
feature_enabled = client.get(
48-
"new-feature",
49-
context={"user_id": user.id, "plan": user.plan},
50-
)
47+
user_client = client.with_context({"user_id": user.id, "plan": user.plan})
48+
feature_enabled = user_client.configs["new-feature"]
5149

5250
# Get with fallback default
53-
timeout = client.get("request-timeout", default=30)
51+
timeout = client.configs.get("request-timeout", 30)
5452
```
5553

5654
### Asynchronous Client
@@ -64,13 +62,48 @@ async with AsyncReplane(
6462
base_url="https://replane.example.com",
6563
sdk_key="rp_...",
6664
) as client:
67-
# get() is sync since it reads from local cache
68-
rate_limit = client.get("rate-limit")
65+
# Access configs from local cache
66+
rate_limit = client.configs["rate-limit"]
6967

7068
# With context
71-
enabled = client.get("feature", context={"plan": "premium"})
69+
enabled = client.with_context({"plan": "premium"}).configs["feature"]
70+
```
71+
72+
### Type-Safe with Generated Types (Recommended)
73+
74+
Generate TypedDict types from your Replane dashboard for full type safety:
75+
76+
```python
77+
from replane import Replane
78+
from replane_types import Configs # Generated from Replane dashboard
79+
80+
# Use the Configs TypedDict as a type parameter
81+
with Replane[Configs](
82+
base_url="https://cloud.replane.dev",
83+
sdk_key="rp_...",
84+
) as client:
85+
# Access configs with dictionary-style notation
86+
settings = client.configs["app-settings"]
87+
88+
# Full type safety - IDE knows the structure of settings
89+
print(settings["maxUploadSizeMb"])
90+
print(settings["allowedFileTypes"])
91+
92+
# Check if config exists
93+
if "feature-flag" in client.configs:
94+
flag = client.configs["feature-flag"]
95+
96+
# Safe access with default
97+
timeout = client.configs.get("timeout", 30)
7298
```
7399

100+
The `.configs` property provides:
101+
102+
- **Dictionary-style access** with `client.configs["config-name"]`
103+
- **Type inference** when using generated TypedDict types
104+
- **Override evaluation** using the default context
105+
- **Familiar dict methods**: `.get()`, `.keys()`, `in` operator
106+
74107
## Configuration Options
75108

76109
Both clients accept the same configuration:
@@ -80,7 +113,7 @@ client = Replane(
80113
base_url="https://replane.example.com",
81114
sdk_key="rp_...",
82115

83-
# Default context applied to all get() calls
116+
# Default context applied to all config evaluations
84117
context={"environment": "production"},
85118

86119
# Default values used if server is unavailable during init
@@ -119,31 +152,82 @@ context = {
119152
"is_beta_tester": True,
120153
}
121154

122-
# Overrides are evaluated locally
123-
value = client.get("feature-flag", context=context)
155+
# Overrides are evaluated locally using with_context()
156+
value = client.with_context(context).configs["feature-flag"]
157+
```
158+
159+
### Scoped Clients with `with_context()`
160+
161+
Create scoped clients for specific users or requests using `with_context()`:
162+
163+
```python
164+
with Replane(
165+
base_url="https://cloud.replane.dev",
166+
sdk_key="rp_...",
167+
) as client:
168+
# Create a scoped client for a specific user
169+
user_client = client.with_context({
170+
"user_id": user.id,
171+
"plan": user.plan,
172+
})
173+
174+
# All operations use the merged context
175+
rate_limit = user_client.configs["rate-limit"]
176+
settings = user_client.configs["app-settings"]
177+
178+
# Can be chained for additional context
179+
request_client = user_client.with_context({"region": request.region})
180+
```
181+
182+
The original client is unaffected - scoped clients are lightweight wrappers.
183+
184+
### Scoped Defaults with `with_defaults()`
185+
186+
Create scoped clients with fallback values using `with_defaults()`:
187+
188+
```python
189+
with Replane(
190+
base_url="https://cloud.replane.dev",
191+
sdk_key="rp_...",
192+
) as client:
193+
# Create a client with fallback defaults
194+
safe_client = client.with_defaults({
195+
"timeout": 30,
196+
"max-retries": 3,
197+
})
198+
199+
# Returns the default if config doesn't exist
200+
timeout = safe_client.configs["timeout"] # 30 if not configured
201+
202+
# Chain with with_context() for both features
203+
user_client = client.with_context({"plan": "premium"}).with_defaults({
204+
"rate-limit": 1000,
205+
})
124206
```
125207

208+
Explicit defaults in `.configs.get()` take precedence over scoped defaults.
209+
126210
### Override Examples
127211

128212
**Percentage rollout** (gradual feature release):
129213

130214
```python
131215
# Server config has 10% rollout based on user_id
132216
# Same user always gets same result (deterministic hashing)
133-
enabled = client.get("new-checkout", context={"user_id": user.id})
217+
enabled = client.with_context({"user_id": user.id}).configs["new-checkout"]
134218
```
135219

136220
**Plan-based features**:
137221

138222
```python
139-
max_items = client.get("max-items", context={"plan": user.plan})
223+
max_items = client.with_context({"plan": user.plan}).configs["max-items"]
140224
# Returns different values for free/pro/enterprise plans
141225
```
142226

143227
**Geographic targeting**:
144228

145229
```python
146-
content = client.get("homepage-banner", context={"country": request.country})
230+
content = client.with_context({"country": request.country}).configs["homepage-banner"]
147231
```
148232

149233
## Subscribing to Changes
@@ -190,7 +274,7 @@ from replane import (
190274
)
191275

192276
try:
193-
value = client.get("my-config")
277+
value = client.configs["my-config"]
194278
except ConfigNotFoundError as e:
195279
print(f"Config not found: {e.config_name}")
196280
except TimeoutError as e:
@@ -214,7 +298,7 @@ client = create_test_client({
214298
"rate-limit": 100,
215299
})
216300

217-
assert client.get("feature-enabled") is True
301+
assert client.configs["feature-enabled"] is True
218302

219303
# With overrides
220304
client = InMemoryReplaneClient()
@@ -230,8 +314,8 @@ client.set_config(
230314
}],
231315
)
232316

233-
assert client.get("feature", context={"plan": "free"}) is False
234-
assert client.get("feature", context={"plan": "pro"}) is True
317+
assert client.with_context({"plan": "free"}).configs["feature"] is False
318+
assert client.with_context({"plan": "pro"}).configs["feature"] is True
235319
```
236320

237321
### Pytest Fixture Example
@@ -248,7 +332,7 @@ def replane_client():
248332
})
249333

250334
def test_feature_flag(replane_client):
251-
flags = replane_client.get("feature-flags")
335+
flags = replane_client.configs["feature-flags"]
252336
assert flags["dark-mode"] is True
253337
```
254338

@@ -261,15 +345,15 @@ If you prefer not to use context managers:
261345
client = Replane(base_url="...", sdk_key="...")
262346
client.connect() # Blocks until initialized
263347
try:
264-
value = client.get("config")
348+
value = client.configs["config"]
265349
finally:
266350
client.close()
267351

268352
# Async
269353
client = AsyncReplane(base_url="...", sdk_key="...")
270354
await client.connect()
271355
try:
272-
value = client.get("config")
356+
value = client.configs["config"]
273357
finally:
274358
await client.close()
275359
```
@@ -304,7 +388,7 @@ def get_replane() -> AsyncReplane:
304388

305389
@app.get("/items")
306390
async def get_items(replane: AsyncReplane = Depends(get_replane)):
307-
max_items = replane.get("max-items", context={"plan": "free"})
391+
max_items = replane.with_context({"plan": "free"}).configs["max-items"]
308392
return {"max_items": max_items}
309393
```
310394

@@ -328,7 +412,7 @@ def init_replane():
328412

329413
@app.route("/items")
330414
def get_items():
331-
max_items = replane_client.get("max-items")
415+
max_items = replane_client.configs["max-items"]
332416
return {"max_items": max_items}
333417
```
334418

replane/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@
2222
... ) as client:
2323
... rate_limit = client.get("rate-limit", context={"plan": user.plan})
2424
25+
With generated TypedDict types for better type safety (recommended):
26+
>>> from replane import Replane
27+
>>> from replane_types import Configs
28+
>>>
29+
>>> with Replane[Configs](
30+
... base_url="https://replane.example.com",
31+
... sdk_key="rp_...",
32+
... ) as client:
33+
... config = client.configs["my-feature"] # fully typed dict access
34+
... print(config["enabled"]) # type-safe property access
35+
2536
For testing:
2637
>>> from replane.testing import create_test_client
2738
>>>

0 commit comments

Comments
 (0)