openfeature-provider-flagd 0.1.4__tar.gz → 0.1.5__tar.gz

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.
Files changed (93) hide show
  1. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/CHANGELOG.md +7 -0
  2. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/PKG-INFO +18 -1
  3. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/README.md +13 -0
  4. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/pyproject.toml +8 -1
  5. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/config.py +25 -1
  6. openfeature_provider_flagd-0.1.5/src/openfeature/contrib/provider/flagd/provider.py +136 -0
  7. openfeature_provider_flagd-0.1.5/src/openfeature/contrib/provider/flagd/resolvers/__init__.py +51 -0
  8. openfeature_provider_flagd-0.1.4/src/openfeature/contrib/provider/flagd/provider.py → openfeature_provider_flagd-0.1.5/src/openfeature/contrib/provider/flagd/resolvers/grpc.py +8 -57
  9. openfeature_provider_flagd-0.1.5/src/openfeature/contrib/provider/flagd/resolvers/in_process.py +122 -0
  10. openfeature_provider_flagd-0.1.5/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py +126 -0
  11. openfeature_provider_flagd-0.1.5/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py +89 -0
  12. openfeature_provider_flagd-0.1.5/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py +51 -0
  13. openfeature_provider_flagd-0.1.5/test-harness/.gherkin-lintrc +57 -0
  14. openfeature_provider_flagd-0.1.5/test-harness/.git +1 -0
  15. openfeature_provider_flagd-0.1.5/test-harness/.github/workflows/ci.yml +59 -0
  16. openfeature_provider_flagd-0.1.5/test-harness/.github/workflows/lint-pr.yml +42 -0
  17. openfeature_provider_flagd-0.1.5/test-harness/.github/workflows/release-please.yml +93 -0
  18. openfeature_provider_flagd-0.1.5/test-harness/.release-please-manifest.json +3 -0
  19. openfeature_provider_flagd-0.1.5/test-harness/CHANGELOG.md +210 -0
  20. openfeature_provider_flagd-0.1.5/test-harness/Makefile +6 -0
  21. openfeature_provider_flagd-0.1.5/test-harness/README.md +41 -0
  22. openfeature_provider_flagd-0.1.5/test-harness/flagd/Dockerfile +17 -0
  23. openfeature_provider_flagd-0.1.5/test-harness/flagd/Dockerfile.unstable +12 -0
  24. openfeature_provider_flagd-0.1.5/test-harness/flags/changing-flag-bar.json +12 -0
  25. openfeature_provider_flagd-0.1.5/test-harness/flags/changing-flag-foo.json +12 -0
  26. openfeature_provider_flagd-0.1.5/test-harness/flags/custom-ops.json +167 -0
  27. openfeature_provider_flagd-0.1.5/test-harness/flags/edge-case-flags.json +57 -0
  28. openfeature_provider_flagd-0.1.5/test-harness/flags/evaluator-refs.json +52 -0
  29. openfeature_provider_flagd-0.1.5/test-harness/flags/testing-flags.json +156 -0
  30. openfeature_provider_flagd-0.1.5/test-harness/flags/zero-flags.json +36 -0
  31. openfeature_provider_flagd-0.1.5/test-harness/gherkin/flagd-json-evaluator.feature +117 -0
  32. openfeature_provider_flagd-0.1.5/test-harness/gherkin/flagd-reconnect.feature +15 -0
  33. openfeature_provider_flagd-0.1.5/test-harness/gherkin/flagd.feature +36 -0
  34. openfeature_provider_flagd-0.1.5/test-harness/package-lock.json +682 -0
  35. openfeature_provider_flagd-0.1.5/test-harness/package.json +8 -0
  36. openfeature_provider_flagd-0.1.5/test-harness/release-please-config.json +11 -0
  37. openfeature_provider_flagd-0.1.5/test-harness/renovate.json +15 -0
  38. openfeature_provider_flagd-0.1.5/test-harness/scripts/change-flag-wrapper.sh +22 -0
  39. openfeature_provider_flagd-0.1.5/test-harness/scripts/change-flag.sh +15 -0
  40. openfeature_provider_flagd-0.1.5/test-harness/scripts/restart-wrapper.sh +31 -0
  41. openfeature_provider_flagd-0.1.5/test-harness/sync/Dockerfile +22 -0
  42. openfeature_provider_flagd-0.1.5/test-harness/sync/Dockerfile.unstable +17 -0
  43. openfeature_provider_flagd-0.1.5/test-harness/sync/README.md +91 -0
  44. openfeature_provider_flagd-0.1.5/test-harness/sync/go.mod +20 -0
  45. openfeature_provider_flagd-0.1.5/test-harness/sync/go.sum +1138 -0
  46. openfeature_provider_flagd-0.1.5/test-harness/sync/main.go +13 -0
  47. openfeature_provider_flagd-0.1.5/test-harness/sync/pkg/config.go +50 -0
  48. openfeature_provider_flagd-0.1.5/test-harness/sync/pkg/file_watcher.go +150 -0
  49. openfeature_provider_flagd-0.1.5/test-harness/sync/pkg/server.go +136 -0
  50. openfeature_provider_flagd-0.1.5/tests/conftest.py +21 -0
  51. openfeature_provider_flagd-0.1.5/tests/e2e/conftest.py +208 -0
  52. openfeature_provider_flagd-0.1.5/tests/e2e/parsers.py +2 -0
  53. openfeature_provider_flagd-0.1.5/tests/e2e/test_inprocess_custom_ops.py +38 -0
  54. openfeature_provider_flagd-0.1.5/tests/e2e/test_inprocess_edge_cases.py +15 -0
  55. openfeature_provider_flagd-0.1.5/tests/e2e/test_inprocess_evaluator_reuse.py +13 -0
  56. openfeature_provider_flagd-0.1.5/tests/e2e/test_inprocess_events.py +91 -0
  57. openfeature_provider_flagd-0.1.5/tests/e2e/test_inprocess_testing_flags.py +24 -0
  58. openfeature_provider_flagd-0.1.5/tests/e2e/test_inprocess_zero_evals.py +28 -0
  59. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag-broken-default.json +13 -0
  60. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag-broken-state.json +13 -0
  61. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag-broken-targeting.json +15 -0
  62. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag-broken-variants.json +15 -0
  63. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag-disabled.json +13 -0
  64. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag-invalid.not-json +13 -0
  65. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag-no-state.json +12 -0
  66. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag-wrong-structure.json +11 -0
  67. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag-wrong-variant.json +12 -0
  68. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag.json +13 -0
  69. openfeature_provider_flagd-0.1.5/tests/flags/basic-flag.yaml +8 -0
  70. openfeature_provider_flagd-0.1.5/tests/flags/invalid-fractional-args.json +16 -0
  71. openfeature_provider_flagd-0.1.5/tests/flags/invalid-fractional-weights.json +19 -0
  72. openfeature_provider_flagd-0.1.5/tests/flags/invalid-semver-args.json +16 -0
  73. openfeature_provider_flagd-0.1.5/tests/flags/invalid-semver-op.json +16 -0
  74. openfeature_provider_flagd-0.1.5/tests/flags/invalid-stringcomp-args.json +16 -0
  75. openfeature_provider_flagd-0.1.5/tests/test_errors.py +79 -0
  76. openfeature_provider_flagd-0.1.5/tests/test_file_store.py +33 -0
  77. openfeature_provider_flagd-0.1.4/tests/conftest.py +0 -10
  78. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/.gitignore +0 -0
  79. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/LICENSE +0 -0
  80. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/scripts/gen_protos.sh +0 -0
  81. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/__init__.py +0 -0
  82. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/flag_type.py +0 -0
  83. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/proto/flagd/evaluation/v1/evaluation_pb2.py +0 -0
  84. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/proto/flagd/evaluation/v1/evaluation_pb2_grpc.py +0 -0
  85. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/proto/flagd/sync/v1/sync_pb2.py +0 -0
  86. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/proto/flagd/sync/v1/sync_pb2_grpc.py +0 -0
  87. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/proto/schema/v1/schema_pb2.py +0 -0
  88. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/proto/schema/v1/schema_pb2_grpc.py +0 -0
  89. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/proto/sync/v1/sync_service_pb2.py +0 -0
  90. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/src/openfeature/contrib/provider/flagd/proto/sync/v1/sync_service_pb2_grpc.py +0 -0
  91. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/tests/__init__.py +0 -0
  92. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/tests/test_config.py +0 -0
  93. {openfeature_provider_flagd-0.1.4 → openfeature_provider_flagd-0.1.5}/tests/test_flagd.py +0 -0
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.5](https://github.com/open-feature/python-sdk-contrib/compare/openfeature-provider-flagd/v0.1.4...openfeature-provider-flagd/v0.1.5) (2024-04-11)
4
+
5
+
6
+ ### ✨ New Features
7
+
8
+ * in-process offline flagd resolver ([#74](https://github.com/open-feature/python-sdk-contrib/issues/74)) ([8cea506](https://github.com/open-feature/python-sdk-contrib/commit/8cea5066ee96f637f3108a9dc3a7539c450a14be))
9
+
3
10
  ## [0.1.4](https://github.com/open-feature/python-sdk-contrib/compare/openfeature-provider-flagd/v0.1.3...openfeature-provider-flagd/v0.1.4) (2024-03-26)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: openfeature-provider-flagd
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: OpenFeature provider for the flagd flag evaluation engine
5
5
  Project-URL: Homepage, https://github.com/open-feature/python-sdk-contrib
6
6
  Author-email: OpenFeature <openfeature-core@groups.io>
@@ -211,8 +211,12 @@ Classifier: Programming Language :: Python
211
211
  Classifier: Programming Language :: Python :: 3
212
212
  Requires-Python: >=3.8
213
213
  Requires-Dist: grpcio>=1.60.0
214
+ Requires-Dist: mmh3>=4.1.0
214
215
  Requires-Dist: openfeature-sdk>=0.4.0
216
+ Requires-Dist: panzi-json-logic>=1.0.1
215
217
  Requires-Dist: protobuf>=4.25.2
218
+ Requires-Dist: pyyaml>=6.0.1
219
+ Requires-Dist: semver<4,>=3
216
220
  Description-Content-Type: text/markdown
217
221
 
218
222
  # flagd Provider for OpenFeature
@@ -236,6 +240,19 @@ from openfeature.contrib.provider.flagd import FlagdProvider
236
240
  api.set_provider(FlagdProvider())
237
241
  ```
238
242
 
243
+ To use in-process evaluation in offline mode with a file as source:
244
+
245
+ ```python
246
+ from openfeature import api
247
+ from openfeature.contrib.provider.flagd import FlagdProvider
248
+ from openfeature.contrib.provider.flagd.config import ResolverType
249
+
250
+ api.set_provider(FlagdProvider(
251
+ resolver_type=ResolverType.IN_PROCESS,
252
+ offline_flag_source_path="my-flag.json",
253
+ ))
254
+ ```
255
+
239
256
  ### Configuration options
240
257
 
241
258
  The default options can be defined in the FlagdProvider constructor.
@@ -19,6 +19,19 @@ from openfeature.contrib.provider.flagd import FlagdProvider
19
19
  api.set_provider(FlagdProvider())
20
20
  ```
21
21
 
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
+
22
35
  ### Configuration options
23
36
 
24
37
  The default options can be defined in the FlagdProvider constructor.
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "openfeature-provider-flagd"
8
- version = "0.1.4"
8
+ version = "0.1.5"
9
9
  description = "OpenFeature provider for the flagd flag evaluation engine"
10
10
  readme = "README.md"
11
11
  authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }]
@@ -20,6 +20,10 @@ dependencies = [
20
20
  "openfeature-sdk>=0.4.0",
21
21
  "grpcio>=1.60.0",
22
22
  "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",
23
27
  ]
24
28
  requires-python = ">=3.8"
25
29
 
@@ -32,6 +36,7 @@ Homepage = "https://github.com/open-feature/python-sdk-contrib"
32
36
  dependencies = [
33
37
  "coverage[toml]>=6.5",
34
38
  "pytest",
39
+ "pytest-bdd",
35
40
  ]
36
41
  post-install-commands = [
37
42
  "./scripts/gen_protos.sh"
@@ -42,6 +47,7 @@ test = "pytest {args:tests}"
42
47
  test-cov = "coverage run -m pytest {args:tests}"
43
48
  cov-report = [
44
49
  "coverage xml",
50
+ "coverage html",
45
51
  ]
46
52
  cov = [
47
53
  "test-cov",
@@ -61,4 +67,5 @@ packages = ["src/openfeature"]
61
67
  omit = [
62
68
  # exclude generated files
63
69
  "src/openfeature/contrib/provider/flagd/proto/*",
70
+ "tests/**",
64
71
  ]
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import typing
3
+ from enum import Enum
3
4
 
4
5
  T = typing.TypeVar("T")
5
6
 
@@ -17,13 +18,21 @@ def env_or_default(
17
18
  return val if cast is None else cast(val)
18
19
 
19
20
 
21
+ class ResolverType(Enum):
22
+ GRPC = "grpc"
23
+ IN_PROCESS = "in-process"
24
+
25
+
20
26
  class Config:
21
- def __init__(
27
+ def __init__( # noqa: PLR0913
22
28
  self,
23
29
  host: typing.Optional[str] = None,
24
30
  port: typing.Optional[int] = None,
25
31
  tls: typing.Optional[bool] = None,
26
32
  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,
27
36
  ):
28
37
  self.host = env_or_default("FLAGD_HOST", "localhost") if host is None else host
29
38
  self.port = (
@@ -33,3 +42,18 @@ class Config:
33
42
  env_or_default("FLAGD_TLS", False, cast=str_to_bool) if tls is None else tls
34
43
  )
35
44
  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
+ )
@@ -0,0 +1,136 @@
1
+ """
2
+ # This is a Python Provider to interact with flagd
3
+ #
4
+ # -- Usage --
5
+ # open_feature_api.set_provider(flagd_provider.FlagdProvider())
6
+ # flag_value = open_feature_client.get_string_value(
7
+ # key="foo",
8
+ # default_value="missingflag"
9
+ # )
10
+ # print(f"Flag Value is: {flag_value}")
11
+ # OR the more verbose option
12
+ # flag = open_feature_client.get_string_details(key="foo", default_value="missingflag")
13
+ # print(f"Flag is: {flag.value}")
14
+ # OR
15
+ # print(f"Flag Details: {vars(flag)}"")
16
+ #
17
+ # -- Customisation --
18
+ # Follows flagd defaults: 'http' protocol on 'localhost' on port '8013'
19
+ # But can be overridden:
20
+ # provider = open_feature_api.get_provider()
21
+ # provider.initialise(schema="https",endpoint="example.com",port=1234,timeout=10)
22
+ """
23
+
24
+ import typing
25
+
26
+ from openfeature.evaluation_context import EvaluationContext
27
+ from openfeature.flag_evaluation import FlagResolutionDetails
28
+ from openfeature.provider.metadata import Metadata
29
+ from openfeature.provider.provider import AbstractProvider
30
+
31
+ from .config import Config, ResolverType
32
+ from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver
33
+
34
+ T = typing.TypeVar("T")
35
+
36
+
37
+ class FlagdProvider(AbstractProvider):
38
+ """Flagd OpenFeature Provider"""
39
+
40
+ def __init__( # noqa: PLR0913
41
+ self,
42
+ host: typing.Optional[str] = None,
43
+ port: typing.Optional[int] = None,
44
+ tls: typing.Optional[bool] = None,
45
+ 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,
49
+ ):
50
+ """
51
+ Create an instance of the FlagdProvider
52
+
53
+ :param host: the host to make requests to
54
+ :param port: the port the flagd service is available on
55
+ :param tls: enable/disable secure TLS connectivity
56
+ :param timeout: the maximum to wait before a request times out
57
+ """
58
+ self.config = Config(
59
+ host=host,
60
+ port=port,
61
+ tls=tls,
62
+ 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,
66
+ )
67
+
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
+ )
79
+
80
+ def shutdown(self) -> None:
81
+ if self.resolver:
82
+ self.resolver.shutdown()
83
+
84
+ def get_metadata(self) -> Metadata:
85
+ """Returns provider metadata"""
86
+ return Metadata(name="FlagdProvider")
87
+
88
+ def resolve_boolean_details(
89
+ self,
90
+ key: str,
91
+ default_value: bool,
92
+ evaluation_context: typing.Optional[EvaluationContext] = None,
93
+ ) -> FlagResolutionDetails[bool]:
94
+ return self.resolver.resolve_boolean_details(
95
+ key, default_value, evaluation_context
96
+ )
97
+
98
+ def resolve_string_details(
99
+ self,
100
+ key: str,
101
+ default_value: str,
102
+ evaluation_context: typing.Optional[EvaluationContext] = None,
103
+ ) -> FlagResolutionDetails[str]:
104
+ return self.resolver.resolve_string_details(
105
+ key, default_value, evaluation_context
106
+ )
107
+
108
+ def resolve_float_details(
109
+ self,
110
+ key: str,
111
+ default_value: float,
112
+ evaluation_context: typing.Optional[EvaluationContext] = None,
113
+ ) -> FlagResolutionDetails[float]:
114
+ return self.resolver.resolve_float_details(
115
+ key, default_value, evaluation_context
116
+ )
117
+
118
+ def resolve_integer_details(
119
+ self,
120
+ key: str,
121
+ default_value: int,
122
+ evaluation_context: typing.Optional[EvaluationContext] = None,
123
+ ) -> FlagResolutionDetails[int]:
124
+ return self.resolver.resolve_integer_details(
125
+ key, default_value, evaluation_context
126
+ )
127
+
128
+ def resolve_object_details(
129
+ self,
130
+ key: str,
131
+ default_value: typing.Union[dict, list],
132
+ evaluation_context: typing.Optional[EvaluationContext] = None,
133
+ ) -> FlagResolutionDetails[typing.Union[dict, list]]:
134
+ return self.resolver.resolve_object_details(
135
+ key, default_value, evaluation_context
136
+ )
@@ -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"]
@@ -1,26 +1,3 @@
1
- """
2
- # This is a Python Provider to interact with flagd
3
- #
4
- # -- Usage --
5
- # open_feature_api.set_provider(flagd_provider.FlagdProvider())
6
- # flag_value = open_feature_client.get_string_value(
7
- # key="foo",
8
- # default_value="missingflag"
9
- # )
10
- # print(f"Flag Value is: {flag_value}")
11
- # OR the more verbose option
12
- # flag = open_feature_client.get_string_details(key="foo", default_value="missingflag")
13
- # print(f"Flag is: {flag.value}")
14
- # OR
15
- # print(f"Flag Details: {vars(flag)}"")
16
- #
17
- # -- Customisation --
18
- # Follows flagd defaults: 'http' protocol on 'localhost' on port '8013'
19
- # But can be overridden:
20
- # provider = open_feature_api.get_provider()
21
- # provider.initialise(schema="https",endpoint="example.com",port=1234,timeout=10)
22
- """
23
-
24
1
  import typing
25
2
 
26
3
  import grpc
@@ -35,52 +12,26 @@ from openfeature.exception import (
35
12
  TypeMismatchError,
36
13
  )
37
14
  from openfeature.flag_evaluation import FlagResolutionDetails
38
- from openfeature.provider.metadata import Metadata
39
- from openfeature.provider.provider import AbstractProvider
40
15
 
41
- from .config import Config
42
- from .flag_type import FlagType
43
- from .proto.schema.v1 import schema_pb2, schema_pb2_grpc
16
+ from ..config import Config
17
+ from ..flag_type import FlagType
18
+ from ..proto.schema.v1 import schema_pb2, schema_pb2_grpc
44
19
 
45
20
  T = typing.TypeVar("T")
46
21
 
47
22
 
48
- class FlagdProvider(AbstractProvider):
49
- """Flagd OpenFeature Provider"""
50
-
51
- def __init__(
52
- self,
53
- host: typing.Optional[str] = None,
54
- port: typing.Optional[int] = None,
55
- tls: typing.Optional[bool] = None,
56
- timeout: typing.Optional[int] = None,
57
- ):
58
- """
59
- Create an instance of the FlagdProvider
60
-
61
- :param host: the host to make requests to
62
- :param port: the port the flagd service is available on
63
- :param tls: enable/disable secure TLS connectivity
64
- :param timeout: the maximum to wait before a request times out
65
- """
66
- self.config = Config(
67
- host=host,
68
- port=port,
69
- tls=tls,
70
- timeout=timeout,
23
+ class GrpcResolver:
24
+ def __init__(self, config: Config):
25
+ self.config = config
26
+ channel_factory = (
27
+ grpc.secure_channel if self.config.tls else grpc.insecure_channel
71
28
  )
72
-
73
- channel_factory = grpc.secure_channel if tls else grpc.insecure_channel
74
29
  self.channel = channel_factory(f"{self.config.host}:{self.config.port}")
75
30
  self.stub = schema_pb2_grpc.ServiceStub(self.channel)
76
31
 
77
32
  def shutdown(self) -> None:
78
33
  self.channel.close()
79
34
 
80
- def get_metadata(self) -> Metadata:
81
- """Returns provider metadata"""
82
- return Metadata(name="FlagdProvider")
83
-
84
35
  def resolve_boolean_details(
85
36
  self,
86
37
  key: str,
@@ -0,0 +1,122 @@
1
+ import time
2
+ import typing
3
+
4
+ from json_logic import builtins, jsonLogic # type: ignore[import-untyped]
5
+
6
+ from openfeature.evaluation_context import EvaluationContext
7
+ from openfeature.exception import FlagNotFoundError, ParseError
8
+ from openfeature.flag_evaluation import FlagResolutionDetails, Reason
9
+ from openfeature.provider.provider import AbstractProvider
10
+
11
+ from ..config import Config
12
+ from .process.custom_ops import ends_with, fractional, sem_ver, starts_with
13
+ from .process.file_watcher import FileWatcherFlagStore
14
+
15
+ T = typing.TypeVar("T")
16
+
17
+
18
+ class InProcessResolver:
19
+ OPERATORS: typing.ClassVar[dict] = {
20
+ **builtins.BUILTINS,
21
+ "fractional": fractional,
22
+ "starts_with": starts_with,
23
+ "ends_with": ends_with,
24
+ "sem_ver": sem_ver,
25
+ }
26
+
27
+ def __init__(self, config: Config, provider: AbstractProvider):
28
+ self.config = config
29
+ self.provider = provider
30
+ if not self.config.offline_flag_source_path:
31
+ raise ValueError(
32
+ "offline_flag_source_path must be provided when using in-process resolver"
33
+ )
34
+ self.flag_store = FileWatcherFlagStore(
35
+ self.config.offline_flag_source_path,
36
+ self.provider,
37
+ self.config.offline_poll_interval_seconds,
38
+ )
39
+
40
+ def shutdown(self) -> None:
41
+ self.flag_store.shutdown()
42
+
43
+ def resolve_boolean_details(
44
+ self,
45
+ key: str,
46
+ default_value: bool,
47
+ evaluation_context: typing.Optional[EvaluationContext] = None,
48
+ ) -> FlagResolutionDetails[bool]:
49
+ return self._resolve(key, default_value, evaluation_context)
50
+
51
+ def resolve_string_details(
52
+ self,
53
+ key: str,
54
+ default_value: str,
55
+ evaluation_context: typing.Optional[EvaluationContext] = None,
56
+ ) -> FlagResolutionDetails[str]:
57
+ return self._resolve(key, default_value, evaluation_context)
58
+
59
+ def resolve_float_details(
60
+ self,
61
+ key: str,
62
+ default_value: float,
63
+ evaluation_context: typing.Optional[EvaluationContext] = None,
64
+ ) -> FlagResolutionDetails[float]:
65
+ return self._resolve(key, default_value, evaluation_context)
66
+
67
+ def resolve_integer_details(
68
+ self,
69
+ key: str,
70
+ default_value: int,
71
+ evaluation_context: typing.Optional[EvaluationContext] = None,
72
+ ) -> FlagResolutionDetails[int]:
73
+ return self._resolve(key, default_value, evaluation_context)
74
+
75
+ def resolve_object_details(
76
+ self,
77
+ key: str,
78
+ default_value: typing.Union[dict, list],
79
+ evaluation_context: typing.Optional[EvaluationContext] = None,
80
+ ) -> FlagResolutionDetails[typing.Union[dict, list]]:
81
+ return self._resolve(key, default_value, evaluation_context)
82
+
83
+ def _resolve(
84
+ self,
85
+ key: str,
86
+ default_value: T,
87
+ evaluation_context: typing.Optional[EvaluationContext] = None,
88
+ ) -> FlagResolutionDetails[T]:
89
+ flag = self.flag_store.get_flag(key)
90
+ if not flag:
91
+ raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")
92
+
93
+ if flag.state == "DISABLED":
94
+ return FlagResolutionDetails(default_value, reason=Reason.DISABLED)
95
+
96
+ if not flag.targeting:
97
+ variant, value = flag.default
98
+ return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC)
99
+
100
+ json_logic_context = evaluation_context.attributes if evaluation_context else {}
101
+ json_logic_context["$flagd"] = {"flagKey": key, "timestamp": int(time.time())}
102
+ json_logic_context["targetingKey"] = (
103
+ evaluation_context.targeting_key if evaluation_context else None
104
+ )
105
+ variant = jsonLogic(flag.targeting, json_logic_context, self.OPERATORS)
106
+ if variant is None:
107
+ variant, value = flag.default
108
+ return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT)
109
+ if not isinstance(variant, (str, bool)):
110
+ raise ParseError(
111
+ "Parsed JSONLogic targeting did not return a string or bool"
112
+ )
113
+
114
+ variant, value = flag.get_variant(variant)
115
+ if not value:
116
+ raise ParseError(f"Resolved variant {variant} not in variants config.")
117
+
118
+ return FlagResolutionDetails(
119
+ value,
120
+ variant=variant,
121
+ reason=Reason.TARGETING_MATCH,
122
+ )
@@ -0,0 +1,126 @@
1
+ import logging
2
+ import typing
3
+
4
+ import mmh3
5
+ import semver
6
+
7
+ JsonPrimitive = typing.Union[str, bool, float, int]
8
+ JsonLogicArg = typing.Union[JsonPrimitive, typing.Sequence[JsonPrimitive]]
9
+
10
+ logger = logging.getLogger("openfeature.contrib")
11
+
12
+
13
+ def fractional(data: dict, *args: JsonLogicArg) -> typing.Optional[str]:
14
+ if not args:
15
+ logger.error("No arguments provided to fractional operator.")
16
+ return None
17
+
18
+ bucket_by = None
19
+ if isinstance(args[0], str):
20
+ bucket_by = args[0]
21
+ args = args[1:]
22
+ else:
23
+ seed = data.get("$flagd", {}).get("flagKey", "")
24
+ targeting_key = data.get("targetingKey")
25
+ if not targeting_key:
26
+ logger.error("No targetingKey provided for fractional shorthand syntax.")
27
+ return None
28
+ bucket_by = seed + targeting_key
29
+
30
+ if not bucket_by:
31
+ logger.error("No hashKey value resolved")
32
+ return None
33
+
34
+ hash_ratio = abs(mmh3.hash(bucket_by)) / (2**31 - 1)
35
+ bucket = int(hash_ratio * 100)
36
+
37
+ for arg in args:
38
+ if (
39
+ not isinstance(arg, (tuple, list))
40
+ or len(arg) != 2
41
+ or not isinstance(arg[0], str)
42
+ or not isinstance(arg[1], int)
43
+ ):
44
+ logger.error("Fractional variant weights must be (str, int) tuple")
45
+ return None
46
+ variant_weights: typing.Tuple[typing.Tuple[str, int]] = args # type: ignore[assignment]
47
+
48
+ range_end = 0
49
+ for variant, weight in variant_weights:
50
+ range_end += weight
51
+ if bucket < range_end:
52
+ return variant
53
+
54
+ return None
55
+
56
+
57
+ def starts_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]:
58
+ def f(s1: str, s2: str) -> bool:
59
+ return s1.startswith(s2)
60
+
61
+ return string_comp(f, data, *args)
62
+
63
+
64
+ def ends_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]:
65
+ def f(s1: str, s2: str) -> bool:
66
+ return s1.endswith(s2)
67
+
68
+ return string_comp(f, data, *args)
69
+
70
+
71
+ def string_comp(
72
+ comparator: typing.Callable[[str, str], bool], data: dict, *args: JsonLogicArg
73
+ ) -> typing.Optional[bool]:
74
+ if not args:
75
+ logger.error("No arguments provided to string_comp operator.")
76
+ return None
77
+ if len(args) != 2:
78
+ logger.error("Exactly 2 args expected for string_comp operator.")
79
+ return None
80
+ arg1, arg2 = args
81
+ if not isinstance(arg1, str):
82
+ logger.debug(f"incorrect argument for first argument, expected string: {arg1}")
83
+ return False
84
+ if not isinstance(arg2, str):
85
+ logger.debug(f"incorrect argument for second argument, expected string: {arg2}")
86
+ return False
87
+
88
+ return comparator(arg1, arg2)
89
+
90
+
91
+ def sem_ver(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]: # noqa: C901
92
+ if not args:
93
+ logger.error("No arguments provided to sem_ver operator.")
94
+ return None
95
+ if len(args) != 3:
96
+ logger.error("Exactly 3 args expected for sem_ver operator.")
97
+ return None
98
+
99
+ arg1, op, arg2 = args
100
+
101
+ try:
102
+ v1 = semver.Version.parse(str(arg1))
103
+ v2 = semver.Version.parse(str(arg2))
104
+ except ValueError as e:
105
+ logger.exception(e)
106
+ return None
107
+
108
+ if op == "=":
109
+ return v1 == v2
110
+ elif op == "!=":
111
+ return v1 != v2
112
+ elif op == "<":
113
+ return v1 < v2
114
+ elif op == "<=":
115
+ return v1 <= v2
116
+ elif op == ">":
117
+ return v1 > v2
118
+ elif op == ">=":
119
+ return v1 >= v2
120
+ elif op == "^":
121
+ return v1.major == v2.major
122
+ elif op == "~":
123
+ return v1.major == v2.major and v1.minor == v2.minor
124
+ else:
125
+ logger.error(f"Op not supported by sem_ver: {op}")
126
+ return None