Skip to content

Commit eed1ee0

Browse files
authored
feat(flagd): migrate to new provider mode file and update e2e tests (#121)
* feat(flagd-rpc): add caching with tests Signed-off-by: Simon Schrottner <[email protected]> * fixup: using new test-harness Signed-off-by: Simon Schrottner <[email protected]> * fixup(flagd): remove merge conflict error as stated by warber Signed-off-by: Simon Schrottner <[email protected]> * feat(flagd): add graceful attempts Signed-off-by: Simon Schrottner <[email protected]> * feat(flagd): add graceful attempts Signed-off-by: Simon Schrottner <[email protected]> * fixup: rename method Signed-off-by: Simon Schrottner <[email protected]> * fixup: naming linting Signed-off-by: Simon Schrottner <[email protected]> * feat: better reconnect gherkins Signed-off-by: Simon Schrottner <[email protected]> --------- Signed-off-by: Simon Schrottner <[email protected]>
1 parent 02dcfc0 commit eed1ee0

38 files changed

+996
-1128
lines changed

CONTRIBUTING.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ We use `pytest` for our unit testing, making use of `parametrized` to inject cas
3030

3131
### Integration tests
3232

33-
These are planned once the SDK has been stabilized and a Flagd provider implemented. At that point, we will utilize the [gherkin integration tests](https://github.com/open-feature/test-harness/blob/main/features/evaluation.feature) to validate against a live, seeded Flagd instance.
33+
The Flagd provider utilizes the [gherkin integration tests](https://github.com/open-feature/test-harness/blob/main/features/evaluation.feature) to validate against a live, seeded Flagd instance.
34+
35+
To run the integration tests you need to have a container runtime, like docker, ranger, etc. installed.
36+
37+
```bash
38+
hatch run test
39+
```
3440

3541
### Type checking
3642

@@ -52,6 +58,13 @@ Navigate to the repository folder
5258
cd python-sdk-contrib
5359
```
5460

61+
Checkout submodules
62+
63+
```bash
64+
git submodule update --init --recursive
65+
```
66+
67+
5568
Add your fork as an origin
5669

5770
```bash
@@ -62,15 +75,16 @@ Ensure your development environment is all set up by building and testing
6275

6376
```bash
6477
cd <package>
65-
hatch run test
78+
hatch build
79+
hatch test
6680
```
6781

6882
To start working on a new feature or bugfix, create a new branch and start working on it.
6983

7084
```bash
7185
git checkout -b feat/NAME_OF_FEATURE
7286
# Make your changes
73-
git commit
87+
git commit -s -m "feat: my feature"
7488
git push fork feat/NAME_OF_FEATURE
7589
```
7690

providers/openfeature-provider-flagd/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ classifiers = [
1818
keywords = []
1919
dependencies = [
2020
"openfeature-sdk>=0.6.0",
21-
"grpcio>=1.68.0",
22-
"protobuf>=4.25.2",
21+
"grpcio>=1.68.1",
22+
"protobuf>=4.29.2",
2323
"mmh3>=4.1.0",
2424
"panzi-json-logic>=1.0.1",
2525
"semver>=3,<4",
Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
[pytest]
22
markers =
33
rpc: tests for rpc mode.
4-
in-process: tests for rpc mode.
4+
in-process: tests for in-process mode.
5+
file: tests for file mode.
6+
unavailable: tests for unavailable providers.
57
customCert: Supports custom certs.
68
unixsocket: Supports unixsockets.
9+
targetURI: Supports targetURI.
10+
grace: Supports grace attempts.
11+
targeting: Supports targeting.
12+
fractional: Supports fractional.
13+
string: Supports string.
14+
semver: Supports semver.
15+
reconnect: Supports reconnect.
716
events: Supports events.
817
sync: Supports sync.
918
caching: Supports caching.
1019
offline: Supports offline.
20+
os.linux: linux mark.
21+
stream: Supports streams.
22+
bdd_features_base_dir = tests/features

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
class ResolverType(Enum):
88
RPC = "rpc"
99
IN_PROCESS = "in-process"
10+
FILE = "file"
1011

1112

1213
class CacheType(Enum):
@@ -158,6 +159,17 @@ def __init__( # noqa: PLR0913
158159
else offline_flag_source_path
159160
)
160161

162+
if (
163+
self.offline_flag_source_path is not None
164+
and self.resolver is ResolverType.IN_PROCESS
165+
):
166+
self.resolver = ResolverType.FILE
167+
168+
if self.resolver is ResolverType.FILE and self.offline_flag_source_path is None:
169+
raise AttributeError(
170+
"Resolver Type 'FILE' requires a offlineFlagSourcePath"
171+
)
172+
161173
self.offline_poll_interval_ms: int = (
162174
int(
163175
env_or_default(

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626

2727
from openfeature.evaluation_context import EvaluationContext
2828
from openfeature.flag_evaluation import FlagResolutionDetails
29+
from openfeature.provider import AbstractProvider
2930
from openfeature.provider.metadata import Metadata
30-
from openfeature.provider.provider import AbstractProvider
3131

3232
from .config import CacheType, Config, ResolverType
3333
from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver
@@ -43,14 +43,14 @@ def __init__( # noqa: PLR0913
4343
host: typing.Optional[str] = None,
4444
port: typing.Optional[int] = None,
4545
tls: typing.Optional[bool] = None,
46-
deadline: typing.Optional[int] = None,
46+
deadline_ms: typing.Optional[int] = None,
4747
timeout: typing.Optional[int] = None,
4848
retry_backoff_ms: typing.Optional[int] = None,
4949
resolver_type: typing.Optional[ResolverType] = None,
5050
offline_flag_source_path: typing.Optional[str] = None,
5151
stream_deadline_ms: typing.Optional[int] = None,
5252
keep_alive_time: typing.Optional[int] = None,
53-
cache_type: typing.Optional[CacheType] = None,
53+
cache: typing.Optional[CacheType] = None,
5454
max_cache_size: typing.Optional[int] = None,
5555
retry_backoff_max_ms: typing.Optional[int] = None,
5656
retry_grace_period: typing.Optional[int] = None,
@@ -62,16 +62,16 @@ def __init__( # noqa: PLR0913
6262
:param host: the host to make requests to
6363
:param port: the port the flagd service is available on
6464
:param tls: enable/disable secure TLS connectivity
65-
:param deadline: the maximum to wait before a request times out
65+
:param deadline_ms: the maximum to wait before a request times out
6666
:param timeout: the maximum time to wait before a request times out
6767
:param retry_backoff_ms: the number of milliseconds to backoff
6868
:param offline_flag_source_path: the path to the flag source file
6969
:param stream_deadline_ms: the maximum time to wait before a request times out
7070
:param keep_alive_time: the number of milliseconds to keep alive
7171
:param resolver_type: the type of resolver to use
7272
"""
73-
if deadline is None and timeout is not None:
74-
deadline = timeout * 1000
73+
if deadline_ms is None and timeout is not None:
74+
deadline_ms = timeout * 1000
7575
warnings.warn(
7676
"'timeout' property is deprecated, please use 'deadline' instead, be aware that 'deadline' is in milliseconds",
7777
DeprecationWarning,
@@ -82,15 +82,15 @@ def __init__( # noqa: PLR0913
8282
host=host,
8383
port=port,
8484
tls=tls,
85-
deadline_ms=deadline,
85+
deadline_ms=deadline_ms,
8686
retry_backoff_ms=retry_backoff_ms,
8787
retry_backoff_max_ms=retry_backoff_max_ms,
8888
retry_grace_period=retry_grace_period,
8989
resolver=resolver_type,
9090
offline_flag_source_path=offline_flag_source_path,
9191
stream_deadline_ms=stream_deadline_ms,
9292
keep_alive_time=keep_alive_time,
93-
cache=cache_type,
93+
cache=cache,
9494
max_cache_size=max_cache_size,
9595
cert_path=cert_path,
9696
)
@@ -106,8 +106,17 @@ def setup_resolver(self) -> AbstractResolver:
106106
self.emit_provider_stale,
107107
self.emit_provider_configuration_changed,
108108
)
109-
elif self.config.resolver == ResolverType.IN_PROCESS:
110-
return InProcessResolver(self.config, self)
109+
elif (
110+
self.config.resolver == ResolverType.IN_PROCESS
111+
or self.config.resolver == ResolverType.FILE
112+
):
113+
return InProcessResolver(
114+
self.config,
115+
self.emit_provider_ready,
116+
self.emit_provider_error,
117+
self.emit_provider_stale,
118+
self.emit_provider_configuration_changed,
119+
)
111120
else:
112121
raise ValueError(
113122
f"`resolver_type` parameter invalid: {self.config.resolver}"

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,11 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
105105

106106
def shutdown(self) -> None:
107107
self.active = False
108+
self.channel.unsubscribe(self._state_change_callback)
108109
self.channel.close()
110+
if self.timer and self.timer.is_alive():
111+
logger.debug("gRPC error timer cancelled due to shutdown")
112+
self.timer.cancel()
109113
if self.cache:
110114
self.cache.clear()
111115

@@ -179,21 +183,22 @@ def listen(self) -> None:
179183
if self.streamline_deadline_seconds > 0
180184
else {}
181185
)
182-
call_args["wait_for_ready"] = True
183186
request = evaluation_pb2.EventStreamRequest()
184187

185188
# defining a never ending loop to recreate the stream
186189
while self.active:
187190
try:
188191
logger.debug("Setting up gRPC sync flags connection")
189-
for message in self.stub.EventStream(request, **call_args):
192+
for message in self.stub.EventStream(
193+
request, wait_for_ready=True, **call_args
194+
):
190195
if message.type == "provider_ready":
191-
self.connected = True
192196
self.emit_provider_ready(
193197
ProviderEventDetails(
194198
message="gRPC sync connection established"
195199
)
196200
)
201+
self.connected = True
197202
elif message.type == "configuration_change":
198203
data = MessageToDict(message)["data"]
199204
self.handle_changed_flags(data)

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

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,47 @@
11
import typing
22

3+
from openfeature.contrib.provider.flagd.resolvers.process.connector.file_watcher import (
4+
FileWatcher,
5+
)
36
from openfeature.evaluation_context import EvaluationContext
7+
from openfeature.event import ProviderEventDetails
48
from openfeature.exception import FlagNotFoundError, ParseError
59
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
6-
from openfeature.provider import AbstractProvider
710

811
from ..config import Config
9-
from .process.file_watcher import FileWatcherFlagStore
12+
from .process.connector import FlagStateConnector
13+
from .process.flags import FlagStore
1014
from .process.targeting import targeting
1115

1216
T = typing.TypeVar("T")
1317

1418

1519
class InProcessResolver:
16-
def __init__(self, config: Config, provider: AbstractProvider):
20+
def __init__(
21+
self,
22+
config: Config,
23+
emit_provider_ready: typing.Callable[[ProviderEventDetails], None],
24+
emit_provider_error: typing.Callable[[ProviderEventDetails], None],
25+
emit_provider_stale: typing.Callable[[ProviderEventDetails], None],
26+
emit_provider_configuration_changed: typing.Callable[
27+
[ProviderEventDetails], None
28+
],
29+
):
1730
self.config = config
18-
self.provider = provider
1931
if not self.config.offline_flag_source_path:
2032
raise ValueError(
2133
"offline_flag_source_path must be provided when using in-process resolver"
2234
)
23-
self.flag_store = FileWatcherFlagStore(
24-
self.config.offline_flag_source_path,
25-
self.provider,
26-
self.config.retry_backoff_ms * 0.001,
35+
self.flag_store = FlagStore(emit_provider_configuration_changed)
36+
self.connector: FlagStateConnector = FileWatcher(
37+
self.config, self.flag_store, emit_provider_ready, emit_provider_error
2738
)
2839

2940
def initialize(self, evaluation_context: EvaluationContext) -> None:
30-
pass
41+
self.connector.initialize(evaluation_context)
3142

3243
def shutdown(self) -> None:
33-
self.flag_store.shutdown()
44+
self.connector.shutdown()
3445

3546
def resolve_boolean_details(
3647
self,
@@ -54,7 +65,10 @@ def resolve_float_details(
5465
default_value: float,
5566
evaluation_context: typing.Optional[EvaluationContext] = None,
5667
) -> FlagResolutionDetails[float]:
57-
return self._resolve(key, default_value, evaluation_context)
68+
result = self._resolve(key, default_value, evaluation_context)
69+
if isinstance(result.value, int):
70+
result.value = float(result.value)
71+
return result
5872

5973
def resolve_integer_details(
6074
self,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import typing
2+
3+
from openfeature.evaluation_context import EvaluationContext
4+
5+
6+
class FlagStateConnector(typing.Protocol):
7+
def initialize(
8+
self, evaluation_context: EvaluationContext
9+
) -> None: ... # pragma: no cover
10+
11+
def shutdown(self) -> None: ... # pragma: no cover

0 commit comments

Comments
 (0)