Skip to content

Commit c649799

Browse files
committed
Merge remote-tracking branch 'upstream/main' into feat/gherkinmigration
Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com>
2 parents 4bf829a + 293fadb commit c649799

File tree

33 files changed

+879
-246
lines changed

33 files changed

+879
-246
lines changed

.github/workflows/build.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ jobs:
6060

6161
- if: matrix.python-version == '3.11'
6262
name: Upload coverage to Codecov
63-
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
63+
uses: codecov/codecov-action@5a605bd92782ce0810fa3b8acc235c921b497052 # v5.2.0
6464
with:
6565
name: Code Coverage for ${{ matrix.package }} on Python ${{ matrix.python-version }}
6666
directory: ${{ matrix.package }}
@@ -91,10 +91,10 @@ jobs:
9191
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
9292

9393
- name: Initialize CodeQL
94-
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3
94+
uses: github/codeql-action/init@dd196fa9ce80b6bacc74ca1c32bd5b0ba22efca7 # v3
9595
with:
9696
languages: python
9797
config-file: ./.github/codeql-config.yml
9898

9999
- name: Perform CodeQL Analysis
100-
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3
100+
uses: github/codeql-action/analyze@dd196fa9ce80b6bacc74ca1c32bd5b0ba22efca7 # v3

.github/workflows/lint-pr.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
env:
2626
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2727

28-
- uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 # v2
28+
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2
2929
# When the previous steps fails, the workflow would stop. By adding this
3030
# condition you can continue the execution with the populated error message.
3131
if: always() && (steps.lint_pr_title.outputs.error_message != null)
@@ -44,7 +44,7 @@ jobs:
4444
4545
# Delete a previous comment when the issue has been resolved
4646
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
47-
uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 # v2
47+
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2
4848
with:
4949
header: pr-title-lint-error
5050
delete: true

.github/workflows/release.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,16 @@ jobs:
4545
permissions:
4646
# IMPORTANT: this permission is mandatory for trusted publishing to pypi
4747
id-token: write
48-
container:
49-
image: "python:3.13"
5048

5149
steps:
5250
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
5351
with:
5452
submodules: recursive
5553

54+
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5
55+
with:
56+
python-version: '3.13'
57+
5658
- name: Upgrade pip
5759
run: pip install --upgrade pip
5860

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,6 @@ docs/_build/
5151

5252
# Virtual env directories
5353
.venv
54+
55+
# vscode
56+
.vscode/

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
default_stages: [commit]
22
repos:
33
- repo: https://github.com/astral-sh/ruff-pre-commit
4-
rev: v0.8.6
4+
rev: v0.9.2
55
hooks:
66
- id: ruff
77
args: [--fix]

.release-please-manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"hooks/openfeature-hooks-opentelemetry": "0.1.3",
33
"providers/openfeature-provider-flagd": "0.1.5",
4-
"providers/openfeature-provider-ofrep": "0.1.0"
4+
"providers/openfeature-provider-ofrep": "0.1.1",
5+
"providers/openfeature-provider-flipt": "0.1.3"
56
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,13 @@ def setup_resolver(self) -> AbstractResolver:
110110
self.config.resolver == ResolverType.IN_PROCESS
111111
or self.config.resolver == ResolverType.FILE
112112
):
113-
return InProcessResolver(self.config, self)
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+
)
114120
else:
115121
raise ValueError(
116122
f"`resolver_type` parameter invalid: {self.config.resolver}"

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,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import json
2+
import logging
3+
import os
4+
import threading
5+
import time
6+
import typing
7+
8+
import yaml
9+
10+
from openfeature.contrib.provider.flagd.config import Config
11+
from openfeature.contrib.provider.flagd.resolvers.process.connector import (
12+
FlagStateConnector,
13+
)
14+
from openfeature.contrib.provider.flagd.resolvers.process.flags import FlagStore
15+
from openfeature.evaluation_context import EvaluationContext
16+
from openfeature.event import ProviderEventDetails
17+
from openfeature.exception import ParseError, ProviderNotReadyError
18+
19+
logger = logging.getLogger("openfeature.contrib")
20+
21+
22+
class FileWatcher(FlagStateConnector):
23+
def __init__(
24+
self,
25+
config: Config,
26+
flag_store: FlagStore,
27+
emit_provider_ready: typing.Callable[[ProviderEventDetails], None],
28+
emit_provider_error: typing.Callable[[ProviderEventDetails], None],
29+
):
30+
if config.offline_flag_source_path is None:
31+
raise ValueError(
32+
f"`config.offline_flag_source_path` parameter invalid: {config.offline_flag_source_path}"
33+
)
34+
else:
35+
self.file_path = config.offline_flag_source_path
36+
37+
self.emit_provider_ready = emit_provider_ready
38+
self.emit_provider_error = emit_provider_error
39+
self.deadline_seconds = config.deadline_ms * 0.001
40+
41+
self.last_modified = 0.0
42+
self.flag_store = flag_store
43+
self.should_emit_ready_on_success = False
44+
45+
def initialize(self, evaluation_context: EvaluationContext) -> None:
46+
self.active = True
47+
self.thread = threading.Thread(
48+
target=self.refresh_file, daemon=True, name="FlagdFileWatcherWorkerThread"
49+
)
50+
self.thread.start()
51+
52+
# Let this throw exceptions so that provider status is set correctly
53+
try:
54+
self.should_emit_ready_on_success = True
55+
self._load_data()
56+
except Exception as err:
57+
raise ProviderNotReadyError from err
58+
59+
def shutdown(self) -> None:
60+
self.active = False
61+
62+
def refresh_file(self) -> None:
63+
while self.active:
64+
time.sleep(self.deadline_seconds)
65+
logger.debug("checking for new flag store contents from file")
66+
self.safe_load_data()
67+
68+
def safe_load_data(self) -> None:
69+
try:
70+
last_modified = os.path.getmtime(self.file_path)
71+
if last_modified > self.last_modified:
72+
self._load_data(last_modified)
73+
except FileNotFoundError:
74+
self.handle_error("Provided file path not valid")
75+
except json.JSONDecodeError:
76+
self.handle_error("Could not parse JSON flag data from file")
77+
except yaml.error.YAMLError:
78+
self.handle_error("Could not parse YAML flag data from file")
79+
except ParseError:
80+
self.handle_error("Could not parse flag data using flagd syntax")
81+
except Exception:
82+
self.handle_error("Could not read flags from file")
83+
84+
def _load_data(self, modified_time: typing.Optional[float] = None) -> None:
85+
with open(self.file_path) as file:
86+
if self.file_path.endswith(".yaml"):
87+
data = yaml.safe_load(file)
88+
else:
89+
data = json.load(file)
90+
91+
self.flag_store.update(data)
92+
93+
if self.should_emit_ready_on_success:
94+
self.emit_provider_ready(
95+
ProviderEventDetails(
96+
message="Reloading file contents recovered from error state"
97+
)
98+
)
99+
self.should_emit_ready_on_success = False
100+
101+
self.last_modified = modified_time or os.path.getmtime(self.file_path)
102+
103+
def handle_error(self, error_message: str) -> None:
104+
logger.exception(error_message)
105+
self.should_emit_ready_on_success = True
106+
self.emit_provider_error(ProviderEventDetails(message=error_message))

0 commit comments

Comments
 (0)