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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.