flagrant-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flagrant/__init__.py +14 -0
- flagrant/cache.py +50 -0
- flagrant/client.py +64 -0
- flagrant/grpc_transport.py +92 -0
- flagrant/http_transport.py +53 -0
- flagrant/models.py +73 -0
- flagrant/proto/__init__.py +0 -0
- flagrant/proto/features_pb2.py +44 -0
- flagrant/proto/features_pb2.pyi +43 -0
- flagrant/proto/features_pb2_grpc.py +115 -0
- flagrant/transport.py +19 -0
- flagrant_client-0.1.0.dist-info/METADATA +61 -0
- flagrant_client-0.1.0.dist-info/RECORD +15 -0
- flagrant_client-0.1.0.dist-info/WHEEL +4 -0
- flagrant_client-0.1.0.dist-info/licenses/LICENSE +21 -0
flagrant/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from flagrant.client import FlagrantClient, FlagsmithOpts
|
|
2
|
+
from flagrant.grpc_transport import GrpcTransport
|
|
3
|
+
from flagrant.http_transport import HttpTransport
|
|
4
|
+
from flagrant.transport import PermanentError, Transport, TransientError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"FlagrantClient",
|
|
8
|
+
"FlagsmithOpts",
|
|
9
|
+
"GrpcTransport",
|
|
10
|
+
"HttpTransport",
|
|
11
|
+
"PermanentError",
|
|
12
|
+
"Transport",
|
|
13
|
+
"TransientError",
|
|
14
|
+
]
|
flagrant/cache.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections import OrderedDict
|
|
3
|
+
from typing import Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
K = TypeVar("K")
|
|
6
|
+
V = TypeVar("V")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LRUCache(Generic[K, V]):
|
|
10
|
+
"""A dict-backed cache with LRU eviction and optional per-entry TTL."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, max_size: int, ttl: float | None) -> None:
|
|
13
|
+
self.max_size = max_size
|
|
14
|
+
self.ttl = ttl
|
|
15
|
+
self._data: OrderedDict[K, tuple[float, V]] = OrderedDict()
|
|
16
|
+
|
|
17
|
+
def get(self, key: K) -> V | None:
|
|
18
|
+
"""Return the cached value, or None if missing or expired.
|
|
19
|
+
|
|
20
|
+
A hit only bumps recency (moves the key to the end of the
|
|
21
|
+
underlying OrderedDict) when it's within TTL; expired entries
|
|
22
|
+
are reported as missing but left in place, untouched, so
|
|
23
|
+
get_stale() can still recover them for failure fallback.
|
|
24
|
+
"""
|
|
25
|
+
entry = self._data.get(key)
|
|
26
|
+
|
|
27
|
+
if entry is None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
timestamp, value = entry
|
|
31
|
+
if self.ttl is not None and time.monotonic() - timestamp > self.ttl:
|
|
32
|
+
return None
|
|
33
|
+
self._data.move_to_end(key)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
def get_stale(self, key: K) -> V | None:
|
|
37
|
+
"""Return the cached value even if its TTL has expired, without evicting it.
|
|
38
|
+
|
|
39
|
+
Used as a fallback when a fresh fetch fails (e.g. an upstream
|
|
40
|
+
error), so callers can still serve a previously known value
|
|
41
|
+
instead of nothing.
|
|
42
|
+
"""
|
|
43
|
+
entry = self._data.get(key)
|
|
44
|
+
return entry[1] if entry is not None else None
|
|
45
|
+
|
|
46
|
+
def set(self, key: K, value: V) -> None:
|
|
47
|
+
self._data[key] = (time.monotonic(), value)
|
|
48
|
+
self._data.move_to_end(key)
|
|
49
|
+
if len(self._data) > self.max_size:
|
|
50
|
+
self._data.popitem(last=False)
|
flagrant/client.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from flagrant.cache import LRUCache
|
|
6
|
+
from flagrant.models import Feature
|
|
7
|
+
from flagrant.transport import Transport, TransientError
|
|
8
|
+
|
|
9
|
+
DefaultFlagHandler = Callable[[str, str], list[Feature] | None]
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class FlagsmithOpts:
|
|
13
|
+
cache_ttl: float | None = None
|
|
14
|
+
cache_size: int | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FlagrantClient:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
project: str,
|
|
21
|
+
transport: Transport,
|
|
22
|
+
*,
|
|
23
|
+
default_handler: DefaultFlagHandler | None = None,
|
|
24
|
+
opts: FlagsmithOpts = FlagsmithOpts()
|
|
25
|
+
) -> None:
|
|
26
|
+
self.project = project
|
|
27
|
+
self.transport = transport
|
|
28
|
+
self.opts = opts
|
|
29
|
+
self.default_handler = default_handler
|
|
30
|
+
|
|
31
|
+
self._cache: LRUCache[tuple[str, str], list[Feature]] | None = (
|
|
32
|
+
LRUCache(max_size=opts.cache_size, ttl=opts.cache_ttl)
|
|
33
|
+
if opts.cache_size
|
|
34
|
+
else None
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def get_features(self, environment: str, identity: str) -> list[Feature] | None:
|
|
38
|
+
key = (environment, identity)
|
|
39
|
+
if self._cache is not None:
|
|
40
|
+
if cached := self._cache.get(key):
|
|
41
|
+
return cached
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
features = self.transport.fetch(self.project, environment, identity)
|
|
45
|
+
except TransientError:
|
|
46
|
+
if self._cache is not None and (stale := self._cache.get_stale(key)) is not None:
|
|
47
|
+
return stale
|
|
48
|
+
if self.default_handler is None:
|
|
49
|
+
raise
|
|
50
|
+
return self.default_handler(environment, identity)
|
|
51
|
+
|
|
52
|
+
if self._cache is not None:
|
|
53
|
+
self._cache.set(key, features)
|
|
54
|
+
|
|
55
|
+
return features
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
self.transport.close()
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> "FlagrantClient":
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *_args: Any) -> None:
|
|
64
|
+
self.close()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import grpc
|
|
4
|
+
|
|
5
|
+
from flagrant.models import Feature, FeatureValue, JsonValue, TextValue, TomlValue
|
|
6
|
+
from flagrant.proto import features_pb2, features_pb2_grpc
|
|
7
|
+
from flagrant.transport import PermanentError, TransientError
|
|
8
|
+
|
|
9
|
+
_RETRYABLE_CODES = {
|
|
10
|
+
grpc.StatusCode.UNAVAILABLE,
|
|
11
|
+
grpc.StatusCode.UNKNOWN,
|
|
12
|
+
grpc.StatusCode.INTERNAL,
|
|
13
|
+
grpc.StatusCode.RESOURCE_EXHAUSTED,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _feature_from_proto(feature: features_pb2.Feature) -> Feature:
|
|
18
|
+
variant = feature.value
|
|
19
|
+
value: FeatureValue
|
|
20
|
+
match variant.WhichOneof("kind"):
|
|
21
|
+
case "text":
|
|
22
|
+
value = TextValue(variant.text)
|
|
23
|
+
case "json":
|
|
24
|
+
value = JsonValue(variant.json)
|
|
25
|
+
case "toml":
|
|
26
|
+
value = TomlValue(variant.toml)
|
|
27
|
+
case _:
|
|
28
|
+
raise PermanentError(f"feature {feature.name!r} has no value set")
|
|
29
|
+
|
|
30
|
+
return Feature(
|
|
31
|
+
feature_id=feature.feature_id,
|
|
32
|
+
name=feature.name,
|
|
33
|
+
value=value,
|
|
34
|
+
is_enabled=feature.is_enabled,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GrpcTransport:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
target: str,
|
|
42
|
+
*,
|
|
43
|
+
credentials: grpc.ChannelCredentials | None = None,
|
|
44
|
+
authority: str | None = None,
|
|
45
|
+
timeout: float = 5.0,
|
|
46
|
+
retries: int = 5,
|
|
47
|
+
backoff_factor: float = 0.5,
|
|
48
|
+
) -> None:
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.retries = retries
|
|
51
|
+
self.backoff_factor = backoff_factor
|
|
52
|
+
|
|
53
|
+
# For unix: targets, grpc-core derives :authority from the percent-encoded
|
|
54
|
+
# socket path (e.g. "tmp%2Fflagrant%2Fgrpc.sock") instead of a plain host,
|
|
55
|
+
# which strict/routing-aware HTTP/2 servers can reject. Default it to
|
|
56
|
+
# "localhost" there, matching what grpcurl/grpc-go send.
|
|
57
|
+
if authority is None and target.startswith("unix:"):
|
|
58
|
+
authority = "localhost"
|
|
59
|
+
|
|
60
|
+
options = [("grpc.default_authority", authority)] if authority is not None else []
|
|
61
|
+
|
|
62
|
+
self._channel = (
|
|
63
|
+
grpc.secure_channel(target, credentials, options=options)
|
|
64
|
+
if credentials is not None
|
|
65
|
+
else grpc.insecure_channel(target, options=options)
|
|
66
|
+
)
|
|
67
|
+
self._stub = features_pb2_grpc.FeatureResolverStub(self._channel)
|
|
68
|
+
|
|
69
|
+
def fetch(self, project: str, environment: str, identity: str) -> list[Feature]:
|
|
70
|
+
request = features_pb2.GetFeaturesRequest(project=project, environment=environment)
|
|
71
|
+
attempt = 0
|
|
72
|
+
while True:
|
|
73
|
+
try:
|
|
74
|
+
response = self._stub.GetFeatures(
|
|
75
|
+
request,
|
|
76
|
+
metadata=[("x-flagrant-identity", identity)],
|
|
77
|
+
timeout=self.timeout,
|
|
78
|
+
)
|
|
79
|
+
except grpc.RpcError as exc:
|
|
80
|
+
code = exc.code() # type: ignore[attr-defined]
|
|
81
|
+
if code in _RETRYABLE_CODES:
|
|
82
|
+
if attempt < self.retries:
|
|
83
|
+
time.sleep(self.backoff_factor * (2**attempt))
|
|
84
|
+
attempt += 1
|
|
85
|
+
continue
|
|
86
|
+
raise TransientError(f"grpc error: {code}") from exc
|
|
87
|
+
raise PermanentError(f"grpc error: {code}") from exc
|
|
88
|
+
|
|
89
|
+
return [_feature_from_proto(feature) for feature in response.features]
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
self._channel.close()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pydantic import TypeAdapter
|
|
5
|
+
|
|
6
|
+
from flagrant.models import Feature
|
|
7
|
+
from flagrant.transport import PermanentError, TransientError
|
|
8
|
+
|
|
9
|
+
_features_adapter = TypeAdapter(list[Feature])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HttpTransport:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
base_url: str,
|
|
16
|
+
*,
|
|
17
|
+
timeout: float = 5.0,
|
|
18
|
+
retries: int = 5,
|
|
19
|
+
backoff_factor: float = 0.5,
|
|
20
|
+
) -> None:
|
|
21
|
+
transport = httpx.HTTPTransport(retries=retries)
|
|
22
|
+
|
|
23
|
+
self.retries = retries
|
|
24
|
+
self.backoff_factor = backoff_factor
|
|
25
|
+
self._http = httpx.Client(
|
|
26
|
+
base_url=base_url.rstrip("/"), timeout=timeout, transport=transport
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def fetch(self, project: str, environment: str, identity: str) -> list[Feature]:
|
|
30
|
+
attempt = 0
|
|
31
|
+
while True:
|
|
32
|
+
try:
|
|
33
|
+
response = self._http.get(
|
|
34
|
+
f"/api/v1/projects/{project}/envs/{environment}/features",
|
|
35
|
+
headers={"X-Flagrant-Identity": identity},
|
|
36
|
+
)
|
|
37
|
+
except httpx.TransportError as exc:
|
|
38
|
+
raise TransientError from exc
|
|
39
|
+
|
|
40
|
+
if response.status_code >= 500:
|
|
41
|
+
if attempt < self.retries:
|
|
42
|
+
time.sleep(self.backoff_factor * (2**attempt))
|
|
43
|
+
attempt += 1
|
|
44
|
+
continue
|
|
45
|
+
raise TransientError(f"server error: {response.status_code}")
|
|
46
|
+
|
|
47
|
+
if response.status_code >= 400:
|
|
48
|
+
raise PermanentError(f"client error: {response.status_code}")
|
|
49
|
+
|
|
50
|
+
return _features_adapter.validate_python(response.json())
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
self._http.close()
|
flagrant/models.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Json, field_validator
|
|
4
|
+
|
|
5
|
+
_UNSET = object()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TextValue(BaseModel):
|
|
9
|
+
value: str
|
|
10
|
+
|
|
11
|
+
def __init__(self, value: Any = _UNSET, **data: Any) -> None:
|
|
12
|
+
if value is not _UNSET:
|
|
13
|
+
data["value"] = value
|
|
14
|
+
super().__init__(**data)
|
|
15
|
+
|
|
16
|
+
def __repr__(self) -> str:
|
|
17
|
+
return f"{type(self).__name__}({self.value!r})"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JsonValue(BaseModel):
|
|
21
|
+
# the API delivers json values as escaped JSON strings
|
|
22
|
+
value: Json[dict[str, Any]]
|
|
23
|
+
|
|
24
|
+
def __init__(self, value: Any = _UNSET, **data: Any) -> None:
|
|
25
|
+
if value is not _UNSET:
|
|
26
|
+
data["value"] = value
|
|
27
|
+
super().__init__(**data)
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
return f"{type(self).__name__}({self.value!r})"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TomlValue(BaseModel):
|
|
34
|
+
value: str
|
|
35
|
+
|
|
36
|
+
def __init__(self, value: Any = _UNSET, **data: Any) -> None:
|
|
37
|
+
if value is not _UNSET:
|
|
38
|
+
data["value"] = value
|
|
39
|
+
super().__init__(**data)
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return f"{type(self).__name__}({self.value!r})"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
FeatureValue = TextValue | JsonValue | TomlValue
|
|
46
|
+
|
|
47
|
+
_VALUE_MODELS = {
|
|
48
|
+
"text": TextValue,
|
|
49
|
+
"json": JsonValue,
|
|
50
|
+
"toml": TomlValue,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Feature(BaseModel):
|
|
55
|
+
feature_id: int
|
|
56
|
+
name: str
|
|
57
|
+
value: FeatureValue
|
|
58
|
+
is_enabled: bool
|
|
59
|
+
|
|
60
|
+
@field_validator("value", mode="before")
|
|
61
|
+
@classmethod
|
|
62
|
+
def _build_value(cls, v: Any) -> Any:
|
|
63
|
+
# The API sends the value as a single-key map: {"text": "..."},
|
|
64
|
+
# {"json": {...}} or {"toml": "..."}. The key tells us which
|
|
65
|
+
# value model to build.
|
|
66
|
+
if isinstance(v, dict) and len(v) == 1:
|
|
67
|
+
type_, value = next(iter(v.items()))
|
|
68
|
+
|
|
69
|
+
# Fall back to TextValue for unrecognized types, treating
|
|
70
|
+
# the payload as plain text.
|
|
71
|
+
model = _VALUE_MODELS.get(type_, TextValue)
|
|
72
|
+
return model(value)
|
|
73
|
+
return v
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: features.proto
|
|
5
|
+
# Protobuf Python Version: 7.35.1
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
7,
|
|
15
|
+
35,
|
|
16
|
+
1,
|
|
17
|
+
'',
|
|
18
|
+
'features.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0e\x66\x65\x61tures.proto\x12\x0b\x66lagrant.v1\":\n\x12GetFeaturesRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x65nvironment\x18\x02 \x01(\t\"=\n\x13GetFeaturesResponse\x12&\n\x08\x66\x65\x61tures\x18\x01 \x03(\x0b\x32\x14.flagrant.v1.Feature\"}\n\x07\x46\x65\x61ture\x12\x12\n\nfeature_id\x18\x01 \x01(\x05\x12\x0c\n\x04name\x18\x02 \x01(\t\x12(\n\x05value\x18\x03 \x01(\x0b\x32\x19.flagrant.v1.VariantValue\x12\x17\n\nis_enabled\x18\x04 \x01(\x08H\x00\x88\x01\x01\x42\r\n\x0b_is_enabled\"F\n\x0cVariantValue\x12\x0e\n\x04text\x18\x01 \x01(\tH\x00\x12\x0e\n\x04json\x18\x02 \x01(\tH\x00\x12\x0e\n\x04toml\x18\x03 \x01(\tH\x00\x42\x06\n\x04kind2c\n\x0f\x46\x65\x61tureResolver\x12P\n\x0bGetFeatures\x12\x1f.flagrant.v1.GetFeaturesRequest\x1a .flagrant.v1.GetFeaturesResponseb\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'features_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
DESCRIPTOR._loaded_options = None
|
|
34
|
+
_globals['_GETFEATURESREQUEST']._serialized_start=31
|
|
35
|
+
_globals['_GETFEATURESREQUEST']._serialized_end=89
|
|
36
|
+
_globals['_GETFEATURESRESPONSE']._serialized_start=91
|
|
37
|
+
_globals['_GETFEATURESRESPONSE']._serialized_end=152
|
|
38
|
+
_globals['_FEATURE']._serialized_start=154
|
|
39
|
+
_globals['_FEATURE']._serialized_end=279
|
|
40
|
+
_globals['_VARIANTVALUE']._serialized_start=281
|
|
41
|
+
_globals['_VARIANTVALUE']._serialized_end=351
|
|
42
|
+
_globals['_FEATURERESOLVER']._serialized_start=353
|
|
43
|
+
_globals['_FEATURERESOLVER']._serialized_end=452
|
|
44
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from google.protobuf.internal import containers as _containers
|
|
2
|
+
from google.protobuf import descriptor as _descriptor
|
|
3
|
+
from google.protobuf import message as _message
|
|
4
|
+
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
|
5
|
+
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
|
6
|
+
|
|
7
|
+
DESCRIPTOR: _descriptor.FileDescriptor
|
|
8
|
+
|
|
9
|
+
class GetFeaturesRequest(_message.Message):
|
|
10
|
+
__slots__ = ("project", "environment")
|
|
11
|
+
PROJECT_FIELD_NUMBER: _ClassVar[int]
|
|
12
|
+
ENVIRONMENT_FIELD_NUMBER: _ClassVar[int]
|
|
13
|
+
project: str
|
|
14
|
+
environment: str
|
|
15
|
+
def __init__(self, project: _Optional[str] = ..., environment: _Optional[str] = ...) -> None: ...
|
|
16
|
+
|
|
17
|
+
class GetFeaturesResponse(_message.Message):
|
|
18
|
+
__slots__ = ("features",)
|
|
19
|
+
FEATURES_FIELD_NUMBER: _ClassVar[int]
|
|
20
|
+
features: _containers.RepeatedCompositeFieldContainer[Feature]
|
|
21
|
+
def __init__(self, features: _Optional[_Iterable[_Union[Feature, _Mapping]]] = ...) -> None: ...
|
|
22
|
+
|
|
23
|
+
class Feature(_message.Message):
|
|
24
|
+
__slots__ = ("feature_id", "name", "value", "is_enabled")
|
|
25
|
+
FEATURE_ID_FIELD_NUMBER: _ClassVar[int]
|
|
26
|
+
NAME_FIELD_NUMBER: _ClassVar[int]
|
|
27
|
+
VALUE_FIELD_NUMBER: _ClassVar[int]
|
|
28
|
+
IS_ENABLED_FIELD_NUMBER: _ClassVar[int]
|
|
29
|
+
feature_id: int
|
|
30
|
+
name: str
|
|
31
|
+
value: VariantValue
|
|
32
|
+
is_enabled: bool
|
|
33
|
+
def __init__(self, feature_id: _Optional[int] = ..., name: _Optional[str] = ..., value: _Optional[_Union[VariantValue, _Mapping]] = ..., is_enabled: _Optional[bool] = ...) -> None: ...
|
|
34
|
+
|
|
35
|
+
class VariantValue(_message.Message):
|
|
36
|
+
__slots__ = ("text", "json", "toml")
|
|
37
|
+
TEXT_FIELD_NUMBER: _ClassVar[int]
|
|
38
|
+
JSON_FIELD_NUMBER: _ClassVar[int]
|
|
39
|
+
TOML_FIELD_NUMBER: _ClassVar[int]
|
|
40
|
+
text: str
|
|
41
|
+
json: str
|
|
42
|
+
toml: str
|
|
43
|
+
def __init__(self, text: _Optional[str] = ..., json: _Optional[str] = ..., toml: _Optional[str] = ...) -> None: ...
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
|
2
|
+
"""Client and server classes corresponding to protobuf-defined services."""
|
|
3
|
+
import grpc
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
from . import features_pb2 as features__pb2
|
|
7
|
+
|
|
8
|
+
GRPC_GENERATED_VERSION = '1.83.0'
|
|
9
|
+
GRPC_VERSION = grpc.__version__
|
|
10
|
+
_version_not_supported = False
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from grpc._utilities import first_version_is_lower
|
|
14
|
+
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
|
15
|
+
except ImportError:
|
|
16
|
+
_version_not_supported = True
|
|
17
|
+
|
|
18
|
+
if _version_not_supported:
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
f'The grpc package installed is at version {GRPC_VERSION},'
|
|
21
|
+
+ ' but the generated code in features_pb2_grpc.py depends on'
|
|
22
|
+
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
|
23
|
+
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
|
24
|
+
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FeatureResolverStub:
|
|
29
|
+
"""Mirrors GET /api/v1/projects/{project}/envs/{environment}/features.
|
|
30
|
+
|
|
31
|
+
Identity is sent via the `x-flagrant-identity` gRPC metadata key (mirroring the
|
|
32
|
+
`X-Flagrant-Identity` HTTP header), and the srv-token via the standard `authorization`
|
|
33
|
+
metadata key as `Bearer <token>` (mirroring the HTTP `Authorization` header) - neither
|
|
34
|
+
is a message field, same reasoning as HTTP headers not being query/body params.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, channel):
|
|
38
|
+
"""Constructor.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
channel: A grpc.Channel.
|
|
42
|
+
"""
|
|
43
|
+
self.GetFeatures = channel.unary_unary(
|
|
44
|
+
'/flagrant.v1.FeatureResolver/GetFeatures',
|
|
45
|
+
request_serializer=features__pb2.GetFeaturesRequest.SerializeToString,
|
|
46
|
+
response_deserializer=features__pb2.GetFeaturesResponse.FromString,
|
|
47
|
+
_registered_method=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FeatureResolverServicer:
|
|
51
|
+
"""Mirrors GET /api/v1/projects/{project}/envs/{environment}/features.
|
|
52
|
+
|
|
53
|
+
Identity is sent via the `x-flagrant-identity` gRPC metadata key (mirroring the
|
|
54
|
+
`X-Flagrant-Identity` HTTP header), and the srv-token via the standard `authorization`
|
|
55
|
+
metadata key as `Bearer <token>` (mirroring the HTTP `Authorization` header) - neither
|
|
56
|
+
is a message field, same reasoning as HTTP headers not being query/body params.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def GetFeatures(self, request, context):
|
|
60
|
+
"""Missing associated documentation comment in .proto file."""
|
|
61
|
+
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
62
|
+
context.set_details('Method not implemented!')
|
|
63
|
+
raise NotImplementedError('Method not implemented!')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def add_FeatureResolverServicer_to_server(servicer, server):
|
|
67
|
+
rpc_method_handlers = {
|
|
68
|
+
'GetFeatures': grpc.unary_unary_rpc_method_handler(
|
|
69
|
+
servicer.GetFeatures,
|
|
70
|
+
request_deserializer=features__pb2.GetFeaturesRequest.FromString,
|
|
71
|
+
response_serializer=features__pb2.GetFeaturesResponse.SerializeToString,
|
|
72
|
+
),
|
|
73
|
+
}
|
|
74
|
+
generic_handler = grpc.method_handlers_generic_handler(
|
|
75
|
+
'flagrant.v1.FeatureResolver', rpc_method_handlers)
|
|
76
|
+
server.add_generic_rpc_handlers((generic_handler,))
|
|
77
|
+
server.add_registered_method_handlers('flagrant.v1.FeatureResolver', rpc_method_handlers)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# This class is part of an EXPERIMENTAL API.
|
|
81
|
+
class FeatureResolver:
|
|
82
|
+
"""Mirrors GET /api/v1/projects/{project}/envs/{environment}/features.
|
|
83
|
+
|
|
84
|
+
Identity is sent via the `x-flagrant-identity` gRPC metadata key (mirroring the
|
|
85
|
+
`X-Flagrant-Identity` HTTP header), and the srv-token via the standard `authorization`
|
|
86
|
+
metadata key as `Bearer <token>` (mirroring the HTTP `Authorization` header) - neither
|
|
87
|
+
is a message field, same reasoning as HTTP headers not being query/body params.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def GetFeatures(request,
|
|
92
|
+
target,
|
|
93
|
+
options=(),
|
|
94
|
+
channel_credentials=None,
|
|
95
|
+
call_credentials=None,
|
|
96
|
+
insecure=False,
|
|
97
|
+
compression=None,
|
|
98
|
+
wait_for_ready=None,
|
|
99
|
+
timeout=None,
|
|
100
|
+
metadata=None):
|
|
101
|
+
return grpc.experimental.unary_unary(
|
|
102
|
+
request,
|
|
103
|
+
target,
|
|
104
|
+
'/flagrant.v1.FeatureResolver/GetFeatures',
|
|
105
|
+
features__pb2.GetFeaturesRequest.SerializeToString,
|
|
106
|
+
features__pb2.GetFeaturesResponse.FromString,
|
|
107
|
+
options,
|
|
108
|
+
channel_credentials,
|
|
109
|
+
insecure,
|
|
110
|
+
call_credentials,
|
|
111
|
+
compression,
|
|
112
|
+
wait_for_ready,
|
|
113
|
+
timeout,
|
|
114
|
+
metadata,
|
|
115
|
+
_registered_method=True)
|
flagrant/transport.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from flagrant.models import Feature
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TransientError(Exception):
|
|
7
|
+
"""A fetch failed in a way that may be resolved by a stale cache entry or default handler."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PermanentError(Exception):
|
|
11
|
+
"""A fetch failed in a way that should propagate to the caller immediately."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Transport(Protocol):
|
|
15
|
+
def fetch(self, project: str, environment: str, identity: str) -> list[Feature]:
|
|
16
|
+
"""Fetch features for an environment/identity, raising TransientError or PermanentError on failure."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: flagrant-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A python client for Flagrant feature-flagging API
|
|
5
|
+
Project-URL: Homepage, https://github.com/mbuczko/flagrant-client-python
|
|
6
|
+
Project-URL: Repository, https://github.com/mbuczko/flagrant-client-python
|
|
7
|
+
Author-email: Michal Buczko <michal@buczko.pl>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: grpcio>=1.83.0
|
|
14
|
+
Requires-Dist: httpx>=0.28.1
|
|
15
|
+
Requires-Dist: protobuf>=7.36.0
|
|
16
|
+
Requires-Dist: pydantic>=2.13.4
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# flagrant-client-python
|
|
20
|
+
|
|
21
|
+
A Python client for fetching feature flags from a [Flagrant](https://github.com/mbuczko/flagrant) server, with pluggable transports, optional caching, and stale-on-error fallback.
|
|
22
|
+
|
|
23
|
+
## Transports
|
|
24
|
+
|
|
25
|
+
`FlagrantClient` doesn't talk to the network itself - it delegates to a `Transport`, a small protocol with a single `fetch(project, environment, identity) -> list[Feature]` method (plus `close()`). This keeps the client's caching/fallback logic independent of how features are actually retrieved, and makes it easy to add new transports or swap them in tests.
|
|
26
|
+
|
|
27
|
+
Two transports ship today:
|
|
28
|
+
|
|
29
|
+
- **`HttpTransport`** - calls the endpoint `GET /api/v1/projects/{project}/envs/{environment}/features` over HTTP via `httpx`, sending the identity as an `X-Flagrant-Identity` header.
|
|
30
|
+
- **`GrpcTransport`** - calls for the features over gRPC, sending the identity as an `x-flagrant-identity` metadata entry. Accepts any grpc-core target string, including `unix:` sockets.
|
|
31
|
+
|
|
32
|
+
Both transports retry on transient failures with exponential backoff (`retries`, `backoff_factor`), and both classify errors into one of two exceptions defined in `transport.py`:
|
|
33
|
+
|
|
34
|
+
- `TransientError` - the fetch failed in a way that might be resolved by falling back to a stale cache entry or a default handler (e.g. server errors, `UNAVAILABLE`/`UNKNOWN`/`INTERNAL`/`RESOURCE_EXHAUSTED` gRPC codes, connection
|
|
35
|
+
errors).
|
|
36
|
+
- `PermanentError` - the failure should propagate to the caller immediately (e.g. 4xx responses, malformed feature payloads).
|
|
37
|
+
|
|
38
|
+
This split lets `FlagrantClient.get_features` react differently depending on whether a retry-worthy or a nonrecoverable failure occurred, regardless of which transport raised it.
|
|
39
|
+
|
|
40
|
+
## Caching and fallback
|
|
41
|
+
|
|
42
|
+
`FlagrantClient` optionally wraps fetches in an `LRUCache` (per `(environment, identity)` key), configured via `FlagsmithOpts(cache_size=..., cache_ttl=...)`. On a `TransientError`, the client tries a stale cache entry before falling back to a user-supplied `default_handler`, and only re-raises if neither is available.
|
|
43
|
+
|
|
44
|
+
## Testing in IPython
|
|
45
|
+
|
|
46
|
+
With the dev dependencies installed and a Flagrant gRPC server listening on a Unix socket at `/tmp/flagrant/grpc.sock`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv run ipython
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import flagrant.client as c
|
|
54
|
+
import flagrant.grpc_transport as gt
|
|
55
|
+
|
|
56
|
+
t = gt.GrpcTransport("unix:/tmp/flagrant/grpc.sock")
|
|
57
|
+
client = c.FlagrantClient(project="demo", transport=t)
|
|
58
|
+
client.get_features(environment="prod", identity="foo")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Swap in `flagrant.http_transport.HttpTransport("http://localhost:3030")` to hit an HTTP server instead - the rest of the snippet stays the same, since `FlagrantClient` only depends on the `Transport` protocol.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
flagrant/__init__.py,sha256=BaO41_qs_iNkEEbYZdXy4nDw65SyVw88hjS-m3YkOBc,392
|
|
2
|
+
flagrant/cache.py,sha256=7BfR3zyHvUmMIyg2XXnOcohK_SmOft_4RgIJsTjrGkw,1706
|
|
3
|
+
flagrant/client.py,sha256=wwBSqkUiWwUPSL1ppR9AqNwVVEcdZ8FIULiySH58brI,1900
|
|
4
|
+
flagrant/grpc_transport.py,sha256=Yg3Oeh4izRA-DsPRWF9lYwPPoQyuSaASlfyq32i6DEo,3236
|
|
5
|
+
flagrant/http_transport.py,sha256=13VEqVnPAX6S_iy0pxnu33vL_0pBtW3mUpSDb0meGBY,1656
|
|
6
|
+
flagrant/models.py,sha256=YOVf4nv7eQx6bdxpempVCcllWrUqRehMPiVXVnuzFAg,1910
|
|
7
|
+
flagrant/transport.py,sha256=B6G5rJ4cmMKl3A-uEsVwSFTsfhi7X1DTEtCpVGwTeQ4,587
|
|
8
|
+
flagrant/proto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
flagrant/proto/features_pb2.py,sha256=sa1n70I7tcXOQwuYI9ybE5WDsEhmK_iGhA9-g9x5yPA,2386
|
|
10
|
+
flagrant/proto/features_pb2.pyi,sha256=Y8zJKVaEzAD5Vg-DLIqskZVGxWsZBFmD_Ryj7gTxHLM,1842
|
|
11
|
+
flagrant/proto/features_pb2_grpc.py,sha256=_f22muYffSXLZw5e1fC_4XiE8i0WfrLappihibigL5Q,4583
|
|
12
|
+
flagrant_client-0.1.0.dist-info/METADATA,sha256=gVMCevOXdXCJ0riYeK0YwVGVe80w2olZ5j3o9N9Xr1k,3312
|
|
13
|
+
flagrant_client-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
flagrant_client-0.1.0.dist-info/licenses/LICENSE,sha256=r2QbH5GaSyVTmWpq81Fe639BVpEn44iKVVPFhSqezrY,1070
|
|
15
|
+
flagrant_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michal Buczko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|