flagdrop-sdk 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.
- flagdrop_sdk-0.1.0/PKG-INFO +106 -0
- flagdrop_sdk-0.1.0/README.md +79 -0
- flagdrop_sdk-0.1.0/flagdrop/__init__.py +10 -0
- flagdrop_sdk-0.1.0/flagdrop/client.py +150 -0
- flagdrop_sdk-0.1.0/flagdrop/evaluator.py +113 -0
- flagdrop_sdk-0.1.0/flagdrop/providers.py +35 -0
- flagdrop_sdk-0.1.0/flagdrop/types.py +93 -0
- flagdrop_sdk-0.1.0/flagdrop_sdk.egg-info/PKG-INFO +106 -0
- flagdrop_sdk-0.1.0/flagdrop_sdk.egg-info/SOURCES.txt +15 -0
- flagdrop_sdk-0.1.0/flagdrop_sdk.egg-info/dependency_links.txt +1 -0
- flagdrop_sdk-0.1.0/flagdrop_sdk.egg-info/requires.txt +6 -0
- flagdrop_sdk-0.1.0/flagdrop_sdk.egg-info/top_level.txt +1 -0
- flagdrop_sdk-0.1.0/pyproject.toml +47 -0
- flagdrop_sdk-0.1.0/setup.cfg +4 -0
- flagdrop_sdk-0.1.0/tests/test_client.py +198 -0
- flagdrop_sdk-0.1.0/tests/test_evaluator.py +171 -0
- flagdrop_sdk-0.1.0/tests/test_integration.py +219 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flagdrop-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FlagDrop Python SDK — evaluate feature flags from your cloud bucket
|
|
5
|
+
Author-email: FlagDrop <support@flagdrop.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://flagdrop.io
|
|
8
|
+
Project-URL: Documentation, https://flagdrop.io/docs
|
|
9
|
+
Keywords: feature-flags,flagdrop,sdk,s3,feature-toggles
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: boto3>=1.28.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
26
|
+
Requires-Dist: moto[s3]>=5.0; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# FlagDrop Python SDK
|
|
29
|
+
|
|
30
|
+
Feature flag evaluation that runs entirely in your cloud. No vendor servers, no data leaving your infrastructure.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install flagdrop-sdk
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from flagdrop import FlagClient
|
|
42
|
+
|
|
43
|
+
client = FlagClient(
|
|
44
|
+
bucket="my-app-flags",
|
|
45
|
+
environment="production",
|
|
46
|
+
provider="aws",
|
|
47
|
+
region="us-east-1",
|
|
48
|
+
)
|
|
49
|
+
client.initialize()
|
|
50
|
+
|
|
51
|
+
# Boolean flag
|
|
52
|
+
enabled = client.get_bool("new-checkout", False)
|
|
53
|
+
|
|
54
|
+
# String flag with targeting context
|
|
55
|
+
theme = client.get_string("app-theme", "light", {"plan": "enterprise"})
|
|
56
|
+
|
|
57
|
+
# Number flag
|
|
58
|
+
max_items = client.get_number("max-items", 10)
|
|
59
|
+
|
|
60
|
+
# JSON flag
|
|
61
|
+
config = client.get_json("feature-config", {"limit": 5})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## How It Works
|
|
65
|
+
|
|
66
|
+
1. You define flags in the [FlagDrop dashboard](https://flagdrop.io)
|
|
67
|
+
2. FlagDrop pushes a JSON config file to an S3 bucket in **your** AWS account
|
|
68
|
+
3. The SDK reads that file and evaluates flags locally at runtime
|
|
69
|
+
|
|
70
|
+
No network calls to FlagDrop servers during evaluation. No latency. No single point of failure.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
| Parameter | Description |
|
|
75
|
+
|-----------|-------------|
|
|
76
|
+
| `bucket` | S3 bucket name where flag configs are stored |
|
|
77
|
+
| `environment` | Environment name (e.g., `production`, `staging`) |
|
|
78
|
+
| `provider` | Cloud provider (`aws`) |
|
|
79
|
+
| `region` | AWS region (e.g., `us-east-1`) |
|
|
80
|
+
| `scope` | Config scope: `backend` (default) or `frontend` |
|
|
81
|
+
| `refresh_interval_seconds` | How often to re-fetch config (default: 30s, 0 to disable) |
|
|
82
|
+
|
|
83
|
+
## Targeting Rules
|
|
84
|
+
|
|
85
|
+
The SDK evaluates targeting rules locally. Supported operators:
|
|
86
|
+
|
|
87
|
+
- `eq`, `neq` — exact match / not equal
|
|
88
|
+
- `in`, `notIn` — value in list / not in list
|
|
89
|
+
- `lt`, `gt` — less than / greater than (numeric)
|
|
90
|
+
- `startsWith`, `endsWith`, `contains` — string matching
|
|
91
|
+
- `segment` — segment membership
|
|
92
|
+
|
|
93
|
+
## Rollouts
|
|
94
|
+
|
|
95
|
+
Percentage-based rollouts use deterministic hashing, so the same user always gets the same result.
|
|
96
|
+
|
|
97
|
+
## Requirements
|
|
98
|
+
|
|
99
|
+
- Python 3.9+
|
|
100
|
+
- `boto3` (AWS SDK)
|
|
101
|
+
- AWS credentials with read access to your flag config bucket
|
|
102
|
+
|
|
103
|
+
## Links
|
|
104
|
+
|
|
105
|
+
- [FlagDrop](https://flagdrop.io) — Feature flags in your cloud
|
|
106
|
+
- [Documentation](https://flagdrop.io/docs)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# FlagDrop Python SDK
|
|
2
|
+
|
|
3
|
+
Feature flag evaluation that runs entirely in your cloud. No vendor servers, no data leaving your infrastructure.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install flagdrop-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from flagdrop import FlagClient
|
|
15
|
+
|
|
16
|
+
client = FlagClient(
|
|
17
|
+
bucket="my-app-flags",
|
|
18
|
+
environment="production",
|
|
19
|
+
provider="aws",
|
|
20
|
+
region="us-east-1",
|
|
21
|
+
)
|
|
22
|
+
client.initialize()
|
|
23
|
+
|
|
24
|
+
# Boolean flag
|
|
25
|
+
enabled = client.get_bool("new-checkout", False)
|
|
26
|
+
|
|
27
|
+
# String flag with targeting context
|
|
28
|
+
theme = client.get_string("app-theme", "light", {"plan": "enterprise"})
|
|
29
|
+
|
|
30
|
+
# Number flag
|
|
31
|
+
max_items = client.get_number("max-items", 10)
|
|
32
|
+
|
|
33
|
+
# JSON flag
|
|
34
|
+
config = client.get_json("feature-config", {"limit": 5})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## How It Works
|
|
38
|
+
|
|
39
|
+
1. You define flags in the [FlagDrop dashboard](https://flagdrop.io)
|
|
40
|
+
2. FlagDrop pushes a JSON config file to an S3 bucket in **your** AWS account
|
|
41
|
+
3. The SDK reads that file and evaluates flags locally at runtime
|
|
42
|
+
|
|
43
|
+
No network calls to FlagDrop servers during evaluation. No latency. No single point of failure.
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
| Parameter | Description |
|
|
48
|
+
|-----------|-------------|
|
|
49
|
+
| `bucket` | S3 bucket name where flag configs are stored |
|
|
50
|
+
| `environment` | Environment name (e.g., `production`, `staging`) |
|
|
51
|
+
| `provider` | Cloud provider (`aws`) |
|
|
52
|
+
| `region` | AWS region (e.g., `us-east-1`) |
|
|
53
|
+
| `scope` | Config scope: `backend` (default) or `frontend` |
|
|
54
|
+
| `refresh_interval_seconds` | How often to re-fetch config (default: 30s, 0 to disable) |
|
|
55
|
+
|
|
56
|
+
## Targeting Rules
|
|
57
|
+
|
|
58
|
+
The SDK evaluates targeting rules locally. Supported operators:
|
|
59
|
+
|
|
60
|
+
- `eq`, `neq` — exact match / not equal
|
|
61
|
+
- `in`, `notIn` — value in list / not in list
|
|
62
|
+
- `lt`, `gt` — less than / greater than (numeric)
|
|
63
|
+
- `startsWith`, `endsWith`, `contains` — string matching
|
|
64
|
+
- `segment` — segment membership
|
|
65
|
+
|
|
66
|
+
## Rollouts
|
|
67
|
+
|
|
68
|
+
Percentage-based rollouts use deterministic hashing, so the same user always gets the same result.
|
|
69
|
+
|
|
70
|
+
## Requirements
|
|
71
|
+
|
|
72
|
+
- Python 3.9+
|
|
73
|
+
- `boto3` (AWS SDK)
|
|
74
|
+
- AWS credentials with read access to your flag config bucket
|
|
75
|
+
|
|
76
|
+
## Links
|
|
77
|
+
|
|
78
|
+
- [FlagDrop](https://flagdrop.io) — Feature flags in your cloud
|
|
79
|
+
- [Documentation](https://flagdrop.io/docs)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""FlagDrop Python SDK client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from flagdrop.evaluator import evaluate_flag
|
|
8
|
+
from flagdrop.providers import ConfigProvider, S3ConfigProvider
|
|
9
|
+
from flagdrop.types import ConfigFile, FlagClientConfig, UserContext
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FlagClient:
|
|
15
|
+
"""
|
|
16
|
+
FlagDrop feature flag client.
|
|
17
|
+
|
|
18
|
+
Fetches config from your cloud bucket and evaluates flags locally.
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
from flagdrop import FlagClient
|
|
23
|
+
|
|
24
|
+
client = FlagClient(
|
|
25
|
+
bucket="my-app-flags",
|
|
26
|
+
environment="production",
|
|
27
|
+
provider="aws",
|
|
28
|
+
region="us-east-1",
|
|
29
|
+
)
|
|
30
|
+
client.initialize()
|
|
31
|
+
|
|
32
|
+
enabled = client.get_bool("new-checkout", False)
|
|
33
|
+
theme = client.get_string("app-theme", "light", {"user_id": "user-123"})
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
bucket: str,
|
|
39
|
+
environment: str,
|
|
40
|
+
provider: str,
|
|
41
|
+
region: str,
|
|
42
|
+
scope: str = "backend",
|
|
43
|
+
refresh_interval_seconds: float = 30,
|
|
44
|
+
config_provider: ConfigProvider | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._config = FlagClientConfig(
|
|
47
|
+
bucket=bucket,
|
|
48
|
+
environment=environment,
|
|
49
|
+
provider=provider, # type: ignore[arg-type]
|
|
50
|
+
region=region,
|
|
51
|
+
scope=scope,
|
|
52
|
+
refresh_interval_seconds=refresh_interval_seconds,
|
|
53
|
+
)
|
|
54
|
+
self._cache: ConfigFile | None = None
|
|
55
|
+
self._last_fetched_at: float = 0
|
|
56
|
+
|
|
57
|
+
if config_provider is not None:
|
|
58
|
+
self._provider = config_provider
|
|
59
|
+
else:
|
|
60
|
+
self._provider = self._create_provider()
|
|
61
|
+
|
|
62
|
+
def _create_provider(self) -> ConfigProvider:
|
|
63
|
+
if self._config.provider == "aws":
|
|
64
|
+
return S3ConfigProvider(self._config.region)
|
|
65
|
+
elif self._config.provider in ("gcp", "azure"):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Provider '{self._config.provider}' is not yet supported. Use 'aws'."
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
raise ValueError(f"Unknown provider: '{self._config.provider}'")
|
|
71
|
+
|
|
72
|
+
def _config_key(self) -> str:
|
|
73
|
+
return f"flags-{self._config.environment}-{self._config.scope}.json"
|
|
74
|
+
|
|
75
|
+
def _is_stale(self) -> bool:
|
|
76
|
+
if self._cache is None:
|
|
77
|
+
return True
|
|
78
|
+
if self._config.refresh_interval_seconds <= 0:
|
|
79
|
+
return False
|
|
80
|
+
return (
|
|
81
|
+
time.time() - self._last_fetched_at
|
|
82
|
+
> self._config.refresh_interval_seconds
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _load_config(self) -> ConfigFile:
|
|
86
|
+
if self._cache is not None and not self._is_stale():
|
|
87
|
+
return self._cache
|
|
88
|
+
self._cache = self._provider.fetch(self._config.bucket, self._config_key())
|
|
89
|
+
self._last_fetched_at = time.time()
|
|
90
|
+
return self._cache
|
|
91
|
+
|
|
92
|
+
def initialize(self) -> None:
|
|
93
|
+
"""Fetch config from the bucket. Call this before evaluating flags."""
|
|
94
|
+
self._load_config()
|
|
95
|
+
|
|
96
|
+
def get_config(self) -> ConfigFile | None:
|
|
97
|
+
"""Return cached config, or None if not yet loaded."""
|
|
98
|
+
return self._cache
|
|
99
|
+
|
|
100
|
+
def get_bool(
|
|
101
|
+
self,
|
|
102
|
+
flag_key: str,
|
|
103
|
+
default_value: bool,
|
|
104
|
+
context: UserContext | None = None,
|
|
105
|
+
) -> bool:
|
|
106
|
+
config = self._load_config()
|
|
107
|
+
result = evaluate_flag(config, flag_key, context)
|
|
108
|
+
if not result.found or not result.enabled:
|
|
109
|
+
return default_value
|
|
110
|
+
return result.value if isinstance(result.value, bool) else default_value
|
|
111
|
+
|
|
112
|
+
def get_string(
|
|
113
|
+
self,
|
|
114
|
+
flag_key: str,
|
|
115
|
+
default_value: str,
|
|
116
|
+
context: UserContext | None = None,
|
|
117
|
+
) -> str:
|
|
118
|
+
config = self._load_config()
|
|
119
|
+
result = evaluate_flag(config, flag_key, context)
|
|
120
|
+
if not result.found or not result.enabled:
|
|
121
|
+
return default_value
|
|
122
|
+
return result.value if isinstance(result.value, str) else default_value
|
|
123
|
+
|
|
124
|
+
def get_number(
|
|
125
|
+
self,
|
|
126
|
+
flag_key: str,
|
|
127
|
+
default_value: float,
|
|
128
|
+
context: UserContext | None = None,
|
|
129
|
+
) -> float:
|
|
130
|
+
config = self._load_config()
|
|
131
|
+
result = evaluate_flag(config, flag_key, context)
|
|
132
|
+
if not result.found or not result.enabled:
|
|
133
|
+
return default_value
|
|
134
|
+
return (
|
|
135
|
+
result.value
|
|
136
|
+
if isinstance(result.value, (int, float))
|
|
137
|
+
else default_value
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def get_json(
|
|
141
|
+
self,
|
|
142
|
+
flag_key: str,
|
|
143
|
+
default_value: T,
|
|
144
|
+
context: UserContext | None = None,
|
|
145
|
+
) -> T:
|
|
146
|
+
config = self._load_config()
|
|
147
|
+
result = evaluate_flag(config, flag_key, context)
|
|
148
|
+
if not result.found or not result.enabled:
|
|
149
|
+
return default_value
|
|
150
|
+
return result.value # type: ignore[return-value]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Flag evaluation engine — mirrors @flagdrop/evaluator from the Node.js SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from flagdrop.types import ConfigFile, EvalResult, Rule, UserContext
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def fnv1a_hash(input_str: str) -> int:
|
|
8
|
+
"""FNV-1a 32-bit hash — deterministic bucketing."""
|
|
9
|
+
hash_val = 0x811C9DC5 # FNV offset basis
|
|
10
|
+
for ch in input_str:
|
|
11
|
+
hash_val ^= ord(ch)
|
|
12
|
+
hash_val = (hash_val * 0x01000193) & 0xFFFFFFFF # FNV prime, mask to 32 bits
|
|
13
|
+
return hash_val
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_in_rollout(attribute_value: str, flag_key: str, percentage: float) -> bool:
|
|
17
|
+
"""Deterministic percentage bucketing — same user always gets same result."""
|
|
18
|
+
if percentage <= 0:
|
|
19
|
+
return False
|
|
20
|
+
if percentage >= 100:
|
|
21
|
+
return True
|
|
22
|
+
hash_val = fnv1a_hash(flag_key + attribute_value)
|
|
23
|
+
bucket = hash_val % 100
|
|
24
|
+
return bucket < percentage
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def match_rule(rule: Rule, context: UserContext) -> bool:
|
|
28
|
+
"""Evaluate a single targeting rule against user context."""
|
|
29
|
+
# Segment operator — check context["segments"] array
|
|
30
|
+
if rule.operator == "segment":
|
|
31
|
+
segments = context.get("segments")
|
|
32
|
+
if isinstance(segments, list):
|
|
33
|
+
return any(str(s) == str(rule.value) for s in segments)
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
context_value = context.get(rule.attribute)
|
|
37
|
+
|
|
38
|
+
# Missing attribute — only neq/notIn match on absence
|
|
39
|
+
if context_value is None:
|
|
40
|
+
return rule.operator in ("neq", "notIn")
|
|
41
|
+
|
|
42
|
+
op = rule.operator
|
|
43
|
+
|
|
44
|
+
if op == "eq":
|
|
45
|
+
return str(context_value) == str(rule.value)
|
|
46
|
+
elif op == "neq":
|
|
47
|
+
return str(context_value) != str(rule.value)
|
|
48
|
+
elif op == "in":
|
|
49
|
+
values = rule.value if isinstance(rule.value, list) else []
|
|
50
|
+
return any(str(v) == str(context_value) for v in values)
|
|
51
|
+
elif op == "notIn":
|
|
52
|
+
values = rule.value if isinstance(rule.value, list) else []
|
|
53
|
+
return not any(str(v) == str(context_value) for v in values)
|
|
54
|
+
elif op == "lt":
|
|
55
|
+
try:
|
|
56
|
+
return float(context_value) < float(rule.value)
|
|
57
|
+
except (ValueError, TypeError):
|
|
58
|
+
return False
|
|
59
|
+
elif op == "gt":
|
|
60
|
+
try:
|
|
61
|
+
return float(context_value) > float(rule.value)
|
|
62
|
+
except (ValueError, TypeError):
|
|
63
|
+
return False
|
|
64
|
+
elif op == "startsWith":
|
|
65
|
+
return str(context_value).startswith(str(rule.value))
|
|
66
|
+
elif op == "endsWith":
|
|
67
|
+
return str(context_value).endswith(str(rule.value))
|
|
68
|
+
elif op == "contains":
|
|
69
|
+
return str(rule.value) in str(context_value)
|
|
70
|
+
else:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def evaluate_flag(
|
|
75
|
+
config: ConfigFile, flag_key: str, context: UserContext | None = None
|
|
76
|
+
) -> EvalResult:
|
|
77
|
+
"""
|
|
78
|
+
Evaluate a flag from a config file against optional user context.
|
|
79
|
+
|
|
80
|
+
Evaluation order:
|
|
81
|
+
1. Look up flag — if not found, return found=False
|
|
82
|
+
2. If disabled, return enabled=False with flag default
|
|
83
|
+
3. Rules evaluated in order — first match wins
|
|
84
|
+
4. Rollout check — deterministic hash bucketing
|
|
85
|
+
5. No match → return flag default with enabled=True
|
|
86
|
+
"""
|
|
87
|
+
flag = config.flags.get(flag_key)
|
|
88
|
+
|
|
89
|
+
if flag is None:
|
|
90
|
+
return EvalResult(found=False, enabled=False, value=None)
|
|
91
|
+
|
|
92
|
+
if not flag.enabled:
|
|
93
|
+
return EvalResult(found=True, enabled=False, value=flag.default_value)
|
|
94
|
+
|
|
95
|
+
# Rules — first match wins
|
|
96
|
+
if flag.rules and context:
|
|
97
|
+
for rule in flag.rules:
|
|
98
|
+
if match_rule(rule, context):
|
|
99
|
+
return EvalResult(
|
|
100
|
+
found=True,
|
|
101
|
+
enabled=True,
|
|
102
|
+
value=rule.serve_value,
|
|
103
|
+
rule_match=rule.attribute,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Rollout — deterministic percentage bucketing
|
|
107
|
+
if flag.rollout and context:
|
|
108
|
+
attr_value = context.get(flag.rollout.attribute)
|
|
109
|
+
if attr_value is not None:
|
|
110
|
+
if not is_in_rollout(str(attr_value), flag_key, flag.rollout.percentage):
|
|
111
|
+
return EvalResult(found=True, enabled=False, value=flag.default_value)
|
|
112
|
+
|
|
113
|
+
return EvalResult(found=True, enabled=True, value=flag.default_value)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Cloud storage providers for fetching flag config files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from flagdrop.types import ConfigFile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigProvider(Protocol):
|
|
11
|
+
"""Interface for fetching config from cloud storage."""
|
|
12
|
+
|
|
13
|
+
def fetch(self, bucket: str, key: str) -> ConfigFile: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class S3ConfigProvider:
|
|
17
|
+
"""Fetches flag config from AWS S3."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, region: str) -> None:
|
|
20
|
+
self._region = region
|
|
21
|
+
self._client = None
|
|
22
|
+
|
|
23
|
+
def _get_client(self):
|
|
24
|
+
if self._client is None:
|
|
25
|
+
import boto3
|
|
26
|
+
|
|
27
|
+
self._client = boto3.client("s3", region_name=self._region)
|
|
28
|
+
return self._client
|
|
29
|
+
|
|
30
|
+
def fetch(self, bucket: str, key: str) -> ConfigFile:
|
|
31
|
+
client = self._get_client()
|
|
32
|
+
resp = client.get_object(Bucket=bucket, Key=key)
|
|
33
|
+
body = resp["Body"].read().decode("utf-8")
|
|
34
|
+
data = json.loads(body)
|
|
35
|
+
return ConfigFile.from_dict(data)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
UserContext = dict[str, Any]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Rule:
|
|
11
|
+
attribute: str
|
|
12
|
+
operator: str # eq, neq, in, notIn, lt, gt, startsWith, endsWith, contains, segment
|
|
13
|
+
value: Any
|
|
14
|
+
serve_value: Any
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_dict(cls, d: dict[str, Any]) -> Rule:
|
|
18
|
+
return cls(
|
|
19
|
+
attribute=d.get("attribute", ""),
|
|
20
|
+
operator=d.get("operator", ""),
|
|
21
|
+
value=d.get("value"),
|
|
22
|
+
serve_value=d.get("serveValue"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Rollout:
|
|
28
|
+
percentage: float
|
|
29
|
+
attribute: str
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, d: dict[str, Any]) -> Rollout:
|
|
33
|
+
return cls(percentage=d.get("percentage", 0), attribute=d.get("attribute", ""))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ConfigFlag:
|
|
38
|
+
type: str # boolean, string, number, json
|
|
39
|
+
enabled: bool
|
|
40
|
+
default_value: Any
|
|
41
|
+
rules: list[Rule] = field(default_factory=list)
|
|
42
|
+
rollout: Rollout | None = None
|
|
43
|
+
description: str = ""
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, d: dict[str, Any]) -> ConfigFlag:
|
|
47
|
+
rules = [Rule.from_dict(r) for r in (d.get("rules") or [])]
|
|
48
|
+
rollout_data = d.get("rollout")
|
|
49
|
+
rollout = Rollout.from_dict(rollout_data) if rollout_data else None
|
|
50
|
+
return cls(
|
|
51
|
+
type=d.get("type", "boolean"),
|
|
52
|
+
enabled=d.get("enabled", False),
|
|
53
|
+
default_value=d.get("defaultValue"),
|
|
54
|
+
rules=rules,
|
|
55
|
+
rollout=rollout,
|
|
56
|
+
description=d.get("description", ""),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ConfigFile:
|
|
62
|
+
version: int
|
|
63
|
+
updated_at: str
|
|
64
|
+
scope: str
|
|
65
|
+
flags: dict[str, ConfigFlag]
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, d: dict[str, Any]) -> ConfigFile:
|
|
69
|
+
flags = {k: ConfigFlag.from_dict(v) for k, v in (d.get("flags") or {}).items()}
|
|
70
|
+
return cls(
|
|
71
|
+
version=d.get("version", 0),
|
|
72
|
+
updated_at=d.get("updatedAt", ""),
|
|
73
|
+
scope=d.get("scope", ""),
|
|
74
|
+
flags=flags,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class EvalResult:
|
|
80
|
+
found: bool
|
|
81
|
+
enabled: bool
|
|
82
|
+
value: Any
|
|
83
|
+
rule_match: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class FlagClientConfig:
|
|
88
|
+
bucket: str
|
|
89
|
+
environment: str
|
|
90
|
+
provider: Literal["aws", "gcp", "azure"]
|
|
91
|
+
region: str
|
|
92
|
+
scope: str = "backend"
|
|
93
|
+
refresh_interval_seconds: float = 30
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flagdrop-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FlagDrop Python SDK — evaluate feature flags from your cloud bucket
|
|
5
|
+
Author-email: FlagDrop <support@flagdrop.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://flagdrop.io
|
|
8
|
+
Project-URL: Documentation, https://flagdrop.io/docs
|
|
9
|
+
Keywords: feature-flags,flagdrop,sdk,s3,feature-toggles
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: boto3>=1.28.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
26
|
+
Requires-Dist: moto[s3]>=5.0; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# FlagDrop Python SDK
|
|
29
|
+
|
|
30
|
+
Feature flag evaluation that runs entirely in your cloud. No vendor servers, no data leaving your infrastructure.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install flagdrop-sdk
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from flagdrop import FlagClient
|
|
42
|
+
|
|
43
|
+
client = FlagClient(
|
|
44
|
+
bucket="my-app-flags",
|
|
45
|
+
environment="production",
|
|
46
|
+
provider="aws",
|
|
47
|
+
region="us-east-1",
|
|
48
|
+
)
|
|
49
|
+
client.initialize()
|
|
50
|
+
|
|
51
|
+
# Boolean flag
|
|
52
|
+
enabled = client.get_bool("new-checkout", False)
|
|
53
|
+
|
|
54
|
+
# String flag with targeting context
|
|
55
|
+
theme = client.get_string("app-theme", "light", {"plan": "enterprise"})
|
|
56
|
+
|
|
57
|
+
# Number flag
|
|
58
|
+
max_items = client.get_number("max-items", 10)
|
|
59
|
+
|
|
60
|
+
# JSON flag
|
|
61
|
+
config = client.get_json("feature-config", {"limit": 5})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## How It Works
|
|
65
|
+
|
|
66
|
+
1. You define flags in the [FlagDrop dashboard](https://flagdrop.io)
|
|
67
|
+
2. FlagDrop pushes a JSON config file to an S3 bucket in **your** AWS account
|
|
68
|
+
3. The SDK reads that file and evaluates flags locally at runtime
|
|
69
|
+
|
|
70
|
+
No network calls to FlagDrop servers during evaluation. No latency. No single point of failure.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
| Parameter | Description |
|
|
75
|
+
|-----------|-------------|
|
|
76
|
+
| `bucket` | S3 bucket name where flag configs are stored |
|
|
77
|
+
| `environment` | Environment name (e.g., `production`, `staging`) |
|
|
78
|
+
| `provider` | Cloud provider (`aws`) |
|
|
79
|
+
| `region` | AWS region (e.g., `us-east-1`) |
|
|
80
|
+
| `scope` | Config scope: `backend` (default) or `frontend` |
|
|
81
|
+
| `refresh_interval_seconds` | How often to re-fetch config (default: 30s, 0 to disable) |
|
|
82
|
+
|
|
83
|
+
## Targeting Rules
|
|
84
|
+
|
|
85
|
+
The SDK evaluates targeting rules locally. Supported operators:
|
|
86
|
+
|
|
87
|
+
- `eq`, `neq` — exact match / not equal
|
|
88
|
+
- `in`, `notIn` — value in list / not in list
|
|
89
|
+
- `lt`, `gt` — less than / greater than (numeric)
|
|
90
|
+
- `startsWith`, `endsWith`, `contains` — string matching
|
|
91
|
+
- `segment` — segment membership
|
|
92
|
+
|
|
93
|
+
## Rollouts
|
|
94
|
+
|
|
95
|
+
Percentage-based rollouts use deterministic hashing, so the same user always gets the same result.
|
|
96
|
+
|
|
97
|
+
## Requirements
|
|
98
|
+
|
|
99
|
+
- Python 3.9+
|
|
100
|
+
- `boto3` (AWS SDK)
|
|
101
|
+
- AWS credentials with read access to your flag config bucket
|
|
102
|
+
|
|
103
|
+
## Links
|
|
104
|
+
|
|
105
|
+
- [FlagDrop](https://flagdrop.io) — Feature flags in your cloud
|
|
106
|
+
- [Documentation](https://flagdrop.io/docs)
|