Skip to content

Commit 8cea506

Browse files
feat: in-process offline flagd resolver (#74)
Signed-off-by: Cole Bailey <[email protected]> Co-authored-by: Michael Beemer <[email protected]>
1 parent 3e5f850 commit 8cea506

40 files changed

+1437
-95
lines changed

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[submodule "schemas"]
22
path = providers/openfeature-provider-flagd/schemas
33
url = https://github.com/open-feature/schemas
4+
[submodule "providers/openfeature-provider-flagd/test-harness"]
5+
path = providers/openfeature-provider-flagd/test-harness
6+
url = [email protected]:open-feature/flagd-testbed.git

.pre-commit-config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@ repos:
1919
rev: v1.9.0
2020
hooks:
2121
- id: mypy
22+
args: [--python-version=3.8]
2223
additional_dependencies:
2324
- openfeature-sdk>=0.4.0
2425
- opentelemetry-api
2526
- types-protobuf
27+
- types-PyYAML
28+
- mmh3
29+
- semver
30+
- panzi-json-logic
2631
exclude: proto|tests

providers/openfeature-provider-flagd/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ from openfeature.contrib.provider.flagd import FlagdProvider
1919
api.set_provider(FlagdProvider())
2020
```
2121

22+
To use in-process evaluation in offline mode with a file as source:
23+
24+
```python
25+
from openfeature import api
26+
from openfeature.contrib.provider.flagd import FlagdProvider
27+
from openfeature.contrib.provider.flagd.config import ResolverType
28+
29+
api.set_provider(FlagdProvider(
30+
resolver_type=ResolverType.IN_PROCESS,
31+
offline_flag_source_path="my-flag.json",
32+
))
33+
```
34+
2235
### Configuration options
2336

2437
The default options can be defined in the FlagdProvider constructor.

providers/openfeature-provider-flagd/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ dependencies = [
2020
"openfeature-sdk>=0.4.0",
2121
"grpcio>=1.60.0",
2222
"protobuf>=4.25.2",
23+
"mmh3>=4.1.0",
24+
"panzi-json-logic>=1.0.1",
25+
"semver>=3,<4",
26+
"pyyaml>=6.0.1",
2327
]
2428
requires-python = ">=3.8"
2529

@@ -32,6 +36,7 @@ Homepage = "https://github.com/open-feature/python-sdk-contrib"
3236
dependencies = [
3337
"coverage[toml]>=6.5",
3438
"pytest",
39+
"pytest-bdd",
3540
]
3641
post-install-commands = [
3742
"./scripts/gen_protos.sh"
@@ -42,6 +47,7 @@ test = "pytest {args:tests}"
4247
test-cov = "coverage run -m pytest {args:tests}"
4348
cov-report = [
4449
"coverage xml",
50+
"coverage html",
4551
]
4652
cov = [
4753
"test-cov",
@@ -61,4 +67,5 @@ packages = ["src/openfeature"]
6167
omit = [
6268
# exclude generated files
6369
"src/openfeature/contrib/provider/flagd/proto/*",
70+
"tests/**",
6471
]

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import typing
3+
from enum import Enum
34

45
T = typing.TypeVar("T")
56

@@ -17,13 +18,21 @@ def env_or_default(
1718
return val if cast is None else cast(val)
1819

1920

21+
class ResolverType(Enum):
22+
GRPC = "grpc"
23+
IN_PROCESS = "in-process"
24+
25+
2026
class Config:
21-
def __init__(
27+
def __init__( # noqa: PLR0913
2228
self,
2329
host: typing.Optional[str] = None,
2430
port: typing.Optional[int] = None,
2531
tls: typing.Optional[bool] = None,
2632
timeout: typing.Optional[int] = None,
33+
resolver_type: typing.Optional[ResolverType] = None,
34+
offline_flag_source_path: typing.Optional[str] = None,
35+
offline_poll_interval_seconds: typing.Optional[float] = None,
2736
):
2837
self.host = env_or_default("FLAGD_HOST", "localhost") if host is None else host
2938
self.port = (
@@ -33,3 +42,18 @@ def __init__(
3342
env_or_default("FLAGD_TLS", False, cast=str_to_bool) if tls is None else tls
3443
)
3544
self.timeout = 5 if timeout is None else timeout
45+
self.resolver_type = (
46+
ResolverType(env_or_default("FLAGD_RESOLVER_TYPE", "grpc"))
47+
if resolver_type is None
48+
else resolver_type
49+
)
50+
self.offline_flag_source_path = (
51+
env_or_default("FLAGD_OFFLINE_FLAG_SOURCE_PATH", None)
52+
if offline_flag_source_path is None
53+
else offline_flag_source_path
54+
)
55+
self.offline_poll_interval_seconds = (
56+
float(env_or_default("FLAGD_OFFLINE_POLL_INTERVAL_SECONDS", 1.0))
57+
if offline_poll_interval_seconds is None
58+
else offline_poll_interval_seconds
59+
)

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py

Lines changed: 36 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -23,37 +23,29 @@
2323

2424
import typing
2525

26-
import grpc
27-
from google.protobuf.struct_pb2 import Struct
28-
2926
from openfeature.evaluation_context import EvaluationContext
30-
from openfeature.exception import (
31-
FlagNotFoundError,
32-
GeneralError,
33-
InvalidContextError,
34-
ParseError,
35-
TypeMismatchError,
36-
)
3727
from openfeature.flag_evaluation import FlagResolutionDetails
3828
from openfeature.provider.metadata import Metadata
3929
from openfeature.provider.provider import AbstractProvider
4030

41-
from .config import Config
42-
from .flag_type import FlagType
43-
from .proto.schema.v1 import schema_pb2, schema_pb2_grpc
31+
from .config import Config, ResolverType
32+
from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver
4433

4534
T = typing.TypeVar("T")
4635

4736

4837
class FlagdProvider(AbstractProvider):
4938
"""Flagd OpenFeature Provider"""
5039

51-
def __init__(
40+
def __init__( # noqa: PLR0913
5241
self,
5342
host: typing.Optional[str] = None,
5443
port: typing.Optional[int] = None,
5544
tls: typing.Optional[bool] = None,
5645
timeout: typing.Optional[int] = None,
46+
resolver_type: typing.Optional[ResolverType] = None,
47+
offline_flag_source_path: typing.Optional[str] = None,
48+
offline_poll_interval_seconds: typing.Optional[float] = None,
5749
):
5850
"""
5951
Create an instance of the FlagdProvider
@@ -68,14 +60,26 @@ def __init__(
6860
port=port,
6961
tls=tls,
7062
timeout=timeout,
63+
resolver_type=resolver_type,
64+
offline_flag_source_path=offline_flag_source_path,
65+
offline_poll_interval_seconds=offline_poll_interval_seconds,
7166
)
7267

73-
channel_factory = grpc.secure_channel if tls else grpc.insecure_channel
74-
self.channel = channel_factory(f"{self.config.host}:{self.config.port}")
75-
self.stub = schema_pb2_grpc.ServiceStub(self.channel)
68+
self.resolver = self.setup_resolver()
69+
70+
def setup_resolver(self) -> AbstractResolver:
71+
if self.config.resolver_type == ResolverType.GRPC:
72+
return GrpcResolver(self.config)
73+
elif self.config.resolver_type == ResolverType.IN_PROCESS:
74+
return InProcessResolver(self.config, self)
75+
else:
76+
raise ValueError(
77+
f"`resolver_type` parameter invalid: {self.config.resolver_type}"
78+
)
7679

7780
def shutdown(self) -> None:
78-
self.channel.close()
81+
if self.resolver:
82+
self.resolver.shutdown()
7983

8084
def get_metadata(self) -> Metadata:
8185
"""Returns provider metadata"""
@@ -87,108 +91,46 @@ def resolve_boolean_details(
8791
default_value: bool,
8892
evaluation_context: typing.Optional[EvaluationContext] = None,
8993
) -> FlagResolutionDetails[bool]:
90-
return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context)
94+
return self.resolver.resolve_boolean_details(
95+
key, default_value, evaluation_context
96+
)
9197

9298
def resolve_string_details(
9399
self,
94100
key: str,
95101
default_value: str,
96102
evaluation_context: typing.Optional[EvaluationContext] = None,
97103
) -> FlagResolutionDetails[str]:
98-
return self._resolve(key, FlagType.STRING, default_value, evaluation_context)
104+
return self.resolver.resolve_string_details(
105+
key, default_value, evaluation_context
106+
)
99107

100108
def resolve_float_details(
101109
self,
102110
key: str,
103111
default_value: float,
104112
evaluation_context: typing.Optional[EvaluationContext] = None,
105113
) -> FlagResolutionDetails[float]:
106-
return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context)
114+
return self.resolver.resolve_float_details(
115+
key, default_value, evaluation_context
116+
)
107117

108118
def resolve_integer_details(
109119
self,
110120
key: str,
111121
default_value: int,
112122
evaluation_context: typing.Optional[EvaluationContext] = None,
113123
) -> FlagResolutionDetails[int]:
114-
return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context)
124+
return self.resolver.resolve_integer_details(
125+
key, default_value, evaluation_context
126+
)
115127

116128
def resolve_object_details(
117129
self,
118130
key: str,
119131
default_value: typing.Union[dict, list],
120132
evaluation_context: typing.Optional[EvaluationContext] = None,
121133
) -> FlagResolutionDetails[typing.Union[dict, list]]:
122-
return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context)
123-
124-
def _resolve(
125-
self,
126-
flag_key: str,
127-
flag_type: FlagType,
128-
default_value: T,
129-
evaluation_context: typing.Optional[EvaluationContext],
130-
) -> FlagResolutionDetails[T]:
131-
context = self._convert_context(evaluation_context)
132-
call_args = {"timeout": self.config.timeout}
133-
try:
134-
if flag_type == FlagType.BOOLEAN:
135-
request = schema_pb2.ResolveBooleanRequest( # type:ignore[attr-defined]
136-
flag_key=flag_key, context=context
137-
)
138-
response = self.stub.ResolveBoolean(request, **call_args)
139-
elif flag_type == FlagType.STRING:
140-
request = schema_pb2.ResolveStringRequest( # type:ignore[attr-defined]
141-
flag_key=flag_key, context=context
142-
)
143-
response = self.stub.ResolveString(request, **call_args)
144-
elif flag_type == FlagType.OBJECT:
145-
request = schema_pb2.ResolveObjectRequest( # type:ignore[attr-defined]
146-
flag_key=flag_key, context=context
147-
)
148-
response = self.stub.ResolveObject(request, **call_args)
149-
elif flag_type == FlagType.FLOAT:
150-
request = schema_pb2.ResolveFloatRequest( # type:ignore[attr-defined]
151-
flag_key=flag_key, context=context
152-
)
153-
response = self.stub.ResolveFloat(request, **call_args)
154-
elif flag_type == FlagType.INTEGER:
155-
request = schema_pb2.ResolveIntRequest( # type:ignore[attr-defined]
156-
flag_key=flag_key, context=context
157-
)
158-
response = self.stub.ResolveInt(request, **call_args)
159-
else:
160-
raise ValueError(f"Unknown flag type: {flag_type}")
161-
162-
except grpc.RpcError as e:
163-
code = e.code()
164-
message = f"received grpc status code {code}"
165-
166-
if code == grpc.StatusCode.NOT_FOUND:
167-
raise FlagNotFoundError(message) from e
168-
elif code == grpc.StatusCode.INVALID_ARGUMENT:
169-
raise TypeMismatchError(message) from e
170-
elif code == grpc.StatusCode.DATA_LOSS:
171-
raise ParseError(message) from e
172-
raise GeneralError(message) from e
173-
174-
# Got a valid flag and valid type. Return it.
175-
return FlagResolutionDetails(
176-
value=response.value,
177-
reason=response.reason,
178-
variant=response.variant,
134+
return self.resolver.resolve_object_details(
135+
key, default_value, evaluation_context
179136
)
180-
181-
def _convert_context(
182-
self, evaluation_context: typing.Optional[EvaluationContext]
183-
) -> Struct:
184-
s = Struct()
185-
if evaluation_context:
186-
try:
187-
s["targetingKey"] = evaluation_context.targeting_key
188-
s.update(evaluation_context.attributes)
189-
except ValueError as exc:
190-
message = (
191-
"could not serialize evaluation context to google.protobuf.Struct"
192-
)
193-
raise InvalidContextError(message) from exc
194-
return s
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import typing
2+
3+
from typing_extensions import Protocol
4+
5+
from openfeature.evaluation_context import EvaluationContext
6+
from openfeature.flag_evaluation import FlagResolutionDetails
7+
8+
from .grpc import GrpcResolver
9+
from .in_process import InProcessResolver
10+
11+
12+
class AbstractResolver(Protocol):
13+
def shutdown(self) -> None: ...
14+
15+
def resolve_boolean_details(
16+
self,
17+
key: str,
18+
default_value: bool,
19+
evaluation_context: typing.Optional[EvaluationContext] = None,
20+
) -> FlagResolutionDetails[bool]: ...
21+
22+
def resolve_string_details(
23+
self,
24+
key: str,
25+
default_value: str,
26+
evaluation_context: typing.Optional[EvaluationContext] = None,
27+
) -> FlagResolutionDetails[str]: ...
28+
29+
def resolve_float_details(
30+
self,
31+
key: str,
32+
default_value: float,
33+
evaluation_context: typing.Optional[EvaluationContext] = None,
34+
) -> FlagResolutionDetails[float]: ...
35+
36+
def resolve_integer_details(
37+
self,
38+
key: str,
39+
default_value: int,
40+
evaluation_context: typing.Optional[EvaluationContext] = None,
41+
) -> FlagResolutionDetails[int]: ...
42+
43+
def resolve_object_details(
44+
self,
45+
key: str,
46+
default_value: typing.Union[dict, list],
47+
evaluation_context: typing.Optional[EvaluationContext] = None,
48+
) -> FlagResolutionDetails[typing.Union[dict, list]]: ...
49+
50+
51+
__all__ = ["AbstractResolver", "GrpcResolver", "InProcessResolver"]

0 commit comments

Comments
 (0)