openfeature-provider-flagd 0.2.2__tar.gz → 0.2.3__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.2.2 → openfeature_provider_flagd-0.2.3}/CHANGELOG.md +20 -0
  2. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/PKG-INFO +1 -1
  3. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/pyproject.toml +1 -1
  4. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/provider.py +10 -10
  5. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/in_process.py +29 -3
  6. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/process/connector/file_watcher.py +15 -4
  7. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py +39 -1
  8. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/rpc/conftest.py +1 -1
  9. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/step/event_steps.py +1 -1
  10. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/step/flag_step.py +13 -0
  11. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/step/provider_steps.py +17 -6
  12. openfeature_provider_flagd-0.2.3/tests/flags/basic-flag-combined-metadata.json +29 -0
  13. openfeature_provider_flagd-0.2.3/tests/flags/basic-flag-metadata.json +19 -0
  14. openfeature_provider_flagd-0.2.3/tests/flags/basic-flag-set-metadata.json +19 -0
  15. openfeature_provider_flagd-0.2.3/tests/flags/invalid-flag-metadata-list.json +14 -0
  16. openfeature_provider_flagd-0.2.3/tests/flags/invalid-flag-metadata.json +24 -0
  17. openfeature_provider_flagd-0.2.3/tests/flags/invalid-flag-set-metadata-list.json +14 -0
  18. openfeature_provider_flagd-0.2.3/tests/flags/invalid-flag-set-metadata.json +21 -0
  19. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/test_errors.py +1 -1
  20. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/test_file_store.py +33 -0
  21. openfeature_provider_flagd-0.2.3/tests/test_metadata.py +148 -0
  22. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/.gitignore +0 -0
  23. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/LICENSE +0 -0
  24. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/README.md +0 -0
  25. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/pytest.ini +0 -0
  26. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/__init__.py +0 -0
  27. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/config.py +0 -0
  28. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/flag_type.py +0 -0
  29. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/__init__.py +0 -0
  30. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/grpc.py +0 -0
  31. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/process/connector/__init__.py +0 -0
  32. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/process/connector/grpc_watcher.py +0 -0
  33. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py +0 -0
  34. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/process/targeting.py +0 -0
  35. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/resolvers/protocol.py +0 -0
  36. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/contrib/provider/flagd/sync_metadata_hook.py +0 -0
  37. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2.py +0 -0
  38. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2.pyi +0 -0
  39. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2_grpc.py +0 -0
  40. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2_grpc.pyi +0 -0
  41. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2.py +0 -0
  42. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2.pyi +0 -0
  43. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2_grpc.py +0 -0
  44. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2_grpc.pyi +0 -0
  45. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/schema/v1/schema_pb2.py +0 -0
  46. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/schema/v1/schema_pb2.pyi +0 -0
  47. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/schema/v1/schema_pb2_grpc.py +0 -0
  48. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/schema/v1/schema_pb2_grpc.pyi +0 -0
  49. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/sync/v1/sync_service_pb2.py +0 -0
  50. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/sync/v1/sync_service_pb2.pyi +0 -0
  51. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/sync/v1/sync_service_pb2_grpc.py +0 -0
  52. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/src/openfeature/schemas/protobuf/sync/v1/sync_service_pb2_grpc.pyi +0 -0
  53. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/__init__.py +0 -0
  54. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/conftest.py +0 -0
  55. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/__init__.py +0 -0
  56. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/conftest.py +0 -0
  57. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/file/__init__.py +0 -0
  58. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/file/conftest.py +0 -0
  59. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/file/test_flaqd.py +0 -0
  60. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/flagd_container.py +0 -0
  61. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/inprocess/__init__.py +0 -0
  62. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/inprocess/conftest.py +0 -0
  63. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/inprocess/test_flaqd.py +0 -0
  64. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/parsers.py +0 -0
  65. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/paths.py +0 -0
  66. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/rpc/__init__.py +0 -0
  67. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/rpc/test_flaqd.py +0 -0
  68. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/step/_utils.py +0 -0
  69. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/step/config_steps.py +0 -0
  70. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/step/context_steps.py +0 -0
  71. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/e2e/testfilter.py +0 -0
  72. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag-broken-default.json +0 -0
  73. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag-broken-state.json +0 -0
  74. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag-broken-targeting.json +0 -0
  75. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag-broken-variants.json +0 -0
  76. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag-disabled.json +0 -0
  77. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag-invalid.not-json +0 -0
  78. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag-no-state.json +0 -0
  79. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag-wrong-structure.json +0 -0
  80. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag-wrong-variant.json +0 -0
  81. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag.json +0 -0
  82. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/basic-flag.yaml +0 -0
  83. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/invalid-fractional-args-wrong-content.json +0 -0
  84. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/invalid-fractional-args.json +0 -0
  85. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/invalid-fractional-weights-strings.json +0 -0
  86. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/invalid-fractional-weights.json +0 -0
  87. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/invalid-semver-args.json +0 -0
  88. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/invalid-semver-op.json +0 -0
  89. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/flags/invalid-stringcomp-args.json +0 -0
  90. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/test_config.py +0 -0
  91. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/test_flagd.py +0 -0
  92. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/test_in_process.py +0 -0
  93. {openfeature_provider_flagd-0.2.2 → openfeature_provider_flagd-0.2.3}/tests/test_targeting.py +0 -0
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.3](https://github.com/open-feature/python-sdk-contrib/compare/openfeature-provider-flagd/v0.2.2...openfeature-provider-flagd/v0.2.3) (2025-04-11)
4
+
5
+
6
+ ### 🐛 Bug Fixes
7
+
8
+ * **flagd:** fix parameter name inconsistency with SDK version 0.8.1 ([#232](https://github.com/open-feature/python-sdk-contrib/issues/232)) ([55ee420](https://github.com/open-feature/python-sdk-contrib/commit/55ee42087bd9a948a130b08671395138baa33621))
9
+
10
+
11
+ ### ✨ New Features
12
+
13
+ * add support for flagd flag metadata ([#215](https://github.com/open-feature/python-sdk-contrib/issues/215)) ([6dc72c0](https://github.com/open-feature/python-sdk-contrib/commit/6dc72c0e16b01cd40e6c103884a2d457e95871d1))
14
+
15
+
16
+ ### 🧹 Chore
17
+
18
+ * **deps:** update dependency providers/openfeature-provider-flagd/openfeature/test-harness to v2.7.3 ([#226](https://github.com/open-feature/python-sdk-contrib/issues/226)) ([9a0971c](https://github.com/open-feature/python-sdk-contrib/commit/9a0971c1fd67998259903a3ea4b42772413fc259))
19
+ * **deps:** update providers/openfeature-provider-flagd/openfeature/spec digest to 130df3e ([#222](https://github.com/open-feature/python-sdk-contrib/issues/222)) ([fa7f429](https://github.com/open-feature/python-sdk-contrib/commit/fa7f4293e060a3d56b204db851c19a668076e7a7))
20
+ * **deps:** update providers/openfeature-provider-flagd/openfeature/spec digest to 27e4461 ([#223](https://github.com/open-feature/python-sdk-contrib/issues/223)) ([9bf2e42](https://github.com/open-feature/python-sdk-contrib/commit/9bf2e421e52922516afa2e5b8648b52a035a038d))
21
+ * **deps:** update providers/openfeature-provider-flagd/openfeature/spec digest to aad6193 ([#194](https://github.com/open-feature/python-sdk-contrib/issues/194)) ([277ad0e](https://github.com/open-feature/python-sdk-contrib/commit/277ad0e744764bbc4eb5c5ebb773287bc2607ac3))
22
+
3
23
  ## [0.2.2](https://github.com/open-feature/python-sdk-contrib/compare/openfeature-provider-flagd/v0.2.1...openfeature-provider-flagd/v0.2.2) (2025-03-18)
4
24
 
5
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openfeature-provider-flagd
3
- Version: 0.2.2
3
+ Version: 0.2.3
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>
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "openfeature-provider-flagd"
8
- version = "0.2.2"
8
+ version = "0.2.3"
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" }]
@@ -158,52 +158,52 @@ class FlagdProvider(AbstractProvider):
158
158
 
159
159
  def resolve_boolean_details(
160
160
  self,
161
- key: str,
161
+ flag_key: str,
162
162
  default_value: bool,
163
163
  evaluation_context: typing.Optional[EvaluationContext] = None,
164
164
  ) -> FlagResolutionDetails[bool]:
165
165
  return self.resolver.resolve_boolean_details(
166
- key, default_value, evaluation_context
166
+ flag_key, default_value, evaluation_context
167
167
  )
168
168
 
169
169
  def resolve_string_details(
170
170
  self,
171
- key: str,
171
+ flag_key: str,
172
172
  default_value: str,
173
173
  evaluation_context: typing.Optional[EvaluationContext] = None,
174
174
  ) -> FlagResolutionDetails[str]:
175
175
  return self.resolver.resolve_string_details(
176
- key, default_value, evaluation_context
176
+ flag_key, default_value, evaluation_context
177
177
  )
178
178
 
179
179
  def resolve_float_details(
180
180
  self,
181
- key: str,
181
+ flag_key: str,
182
182
  default_value: float,
183
183
  evaluation_context: typing.Optional[EvaluationContext] = None,
184
184
  ) -> FlagResolutionDetails[float]:
185
185
  return self.resolver.resolve_float_details(
186
- key, default_value, evaluation_context
186
+ flag_key, default_value, evaluation_context
187
187
  )
188
188
 
189
189
  def resolve_integer_details(
190
190
  self,
191
- key: str,
191
+ flag_key: str,
192
192
  default_value: int,
193
193
  evaluation_context: typing.Optional[EvaluationContext] = None,
194
194
  ) -> FlagResolutionDetails[int]:
195
195
  return self.resolver.resolve_integer_details(
196
- key, default_value, evaluation_context
196
+ flag_key, default_value, evaluation_context
197
197
  )
198
198
 
199
199
  def resolve_object_details(
200
200
  self,
201
- key: str,
201
+ flag_key: str,
202
202
  default_value: typing.Union[dict, list],
203
203
  evaluation_context: typing.Optional[EvaluationContext] = None,
204
204
  ) -> FlagResolutionDetails[typing.Union[dict, list]]:
205
205
  return self.resolver.resolve_object_details(
206
- key, default_value, evaluation_context
206
+ flag_key, default_value, evaluation_context
207
207
  )
208
208
 
209
209
  def emit_provider_ready_with_context(
@@ -17,6 +17,23 @@ from .process.targeting import targeting
17
17
  T = typing.TypeVar("T")
18
18
 
19
19
 
20
+ def _merge_metadata(
21
+ flag_metadata: typing.Optional[
22
+ typing.Mapping[str, typing.Union[float, int, str, bool]]
23
+ ],
24
+ flag_set_metadata: typing.Optional[
25
+ typing.Mapping[str, typing.Union[float, int, str, bool]]
26
+ ],
27
+ ) -> typing.Mapping[str, typing.Union[float, int, str, bool]]:
28
+ metadata = {} if flag_set_metadata is None else dict(flag_set_metadata)
29
+
30
+ if flag_metadata is not None:
31
+ for key, value in flag_metadata.items():
32
+ metadata[key] = value
33
+
34
+ return metadata
35
+
36
+
20
37
  class InProcessResolver:
21
38
  def __init__(
22
39
  self,
@@ -103,18 +120,26 @@ class InProcessResolver:
103
120
  if not flag:
104
121
  raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")
105
122
 
123
+ metadata = _merge_metadata(flag.metadata, self.flag_store.flag_set_metadata)
124
+
106
125
  if flag.state == "DISABLED":
107
- return FlagResolutionDetails(default_value, reason=Reason.DISABLED)
126
+ return FlagResolutionDetails(
127
+ default_value, flag_metadata=metadata, reason=Reason.DISABLED
128
+ )
108
129
 
109
130
  if not flag.targeting:
110
131
  variant, value = flag.default
111
- return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC)
132
+ return FlagResolutionDetails(
133
+ value, variant=variant, flag_metadata=metadata, reason=Reason.STATIC
134
+ )
112
135
 
113
136
  variant = targeting(flag.key, flag.targeting, evaluation_context)
114
137
 
115
138
  if variant is None:
116
139
  variant, value = flag.default
117
- return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT)
140
+ return FlagResolutionDetails(
141
+ value, variant=variant, flag_metadata=metadata, reason=Reason.DEFAULT
142
+ )
118
143
  if not isinstance(variant, (str, bool)):
119
144
  raise ParseError(
120
145
  "Parsed JSONLogic targeting did not return a string or bool"
@@ -128,4 +153,5 @@ class InProcessResolver:
128
153
  value,
129
154
  variant=variant,
130
155
  reason=Reason.TARGETING_MATCH,
156
+ flag_metadata=metadata,
131
157
  )
@@ -14,7 +14,7 @@ from openfeature.contrib.provider.flagd.resolvers.process.connector import (
14
14
  from openfeature.contrib.provider.flagd.resolvers.process.flags import FlagStore
15
15
  from openfeature.evaluation_context import EvaluationContext
16
16
  from openfeature.event import ProviderEventDetails
17
- from openfeature.exception import ParseError, ProviderNotReadyError
17
+ from openfeature.exception import ErrorCode, ParseError, ProviderNotReadyError
18
18
 
19
19
  logger = logging.getLogger("openfeature.contrib")
20
20
 
@@ -76,8 +76,15 @@ class FileWatcher(FlagStateConnector):
76
76
  self.handle_error("Could not parse JSON flag data from file")
77
77
  except yaml.error.YAMLError:
78
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")
79
+ except ParseError as e:
80
+ self.handle_error(
81
+ "Could not parse flag data using flagd syntax: "
82
+ + (
83
+ "no error message provided"
84
+ if e is None or e.error_message is None
85
+ else e.error_message
86
+ )
87
+ )
81
88
  except Exception:
82
89
  self.handle_error("Could not read flags from file")
83
90
 
@@ -104,4 +111,8 @@ class FileWatcher(FlagStateConnector):
104
111
  def handle_error(self, error_message: str) -> None:
105
112
  logger.exception(error_message)
106
113
  self.should_emit_ready_on_success = True
107
- self.emit_provider_error(ProviderEventDetails(message=error_message))
114
+ self.emit_provider_error(
115
+ ProviderEventDetails(
116
+ message=error_message, error_code=ErrorCode.PARSE_ERROR
117
+ )
118
+ )
@@ -7,6 +7,21 @@ from openfeature.event import ProviderEventDetails
7
7
  from openfeature.exception import ParseError
8
8
 
9
9
 
10
+ def _validate_metadata(key: str, value: typing.Union[float, int, str, bool]) -> None:
11
+ if key is None:
12
+ raise ParseError("Metadata key must be set")
13
+ elif not isinstance(key, str):
14
+ raise ParseError(f"Metadata key {key} must be of type str, but is {type(key)}")
15
+ elif not key:
16
+ raise ParseError("key must not be empty")
17
+ if value is None:
18
+ raise ParseError(f"Metadata value for key {key} must be set")
19
+ elif not isinstance(value, (float, int, str, bool)):
20
+ raise ParseError(
21
+ f"Metadata value {value} for key {key} must be of type float, int, str or bool, but is {type(value)}"
22
+ )
23
+
24
+
10
25
  class FlagStore:
11
26
  def __init__(
12
27
  self,
@@ -16,12 +31,16 @@ class FlagStore:
16
31
  ):
17
32
  self.emit_provider_configuration_changed = emit_provider_configuration_changed
18
33
  self.flags: typing.Mapping[str, Flag] = {}
34
+ self.flag_set_metadata: typing.Mapping[
35
+ str, typing.Union[float, int, str, bool]
36
+ ] = {}
19
37
 
20
38
  def get_flag(self, key: str) -> typing.Optional["Flag"]:
21
39
  return self.flags.get(key)
22
40
 
23
41
  def update(self, flags_data: dict) -> None:
24
42
  flags = flags_data.get("flags", {})
43
+ metadata = flags_data.get("metadata", {})
25
44
  evaluators: typing.Optional[dict] = flags_data.get("$evaluators")
26
45
  if evaluators:
27
46
  transposed = json.dumps(flags)
@@ -33,10 +52,18 @@ class FlagStore:
33
52
 
34
53
  if not isinstance(flags, dict):
35
54
  raise ParseError("`flags` key of configuration must be a dictionary")
55
+ if not isinstance(metadata, dict):
56
+ raise ParseError("`metadata` key of configuration must be a dictionary")
57
+ for key, value in metadata.items():
58
+ _validate_metadata(key, value)
59
+
36
60
  self.flags = {key: Flag.from_dict(key, data) for key, data in flags.items()}
61
+ self.flag_set_metadata = metadata
37
62
 
38
63
  self.emit_provider_configuration_changed(
39
- ProviderEventDetails(flags_changed=list(self.flags.keys()))
64
+ ProviderEventDetails(
65
+ flags_changed=list(self.flags.keys()), metadata=metadata
66
+ )
40
67
  )
41
68
 
42
69
 
@@ -47,6 +74,9 @@ class Flag:
47
74
  variants: typing.Mapping[str, typing.Any]
48
75
  default_variant: typing.Union[bool, str]
49
76
  targeting: typing.Optional[dict] = None
77
+ metadata: typing.Optional[
78
+ typing.Mapping[str, typing.Union[float, int, str, bool]]
79
+ ] = None
50
80
 
51
81
  def __post_init__(self) -> None:
52
82
  if not self.state or not isinstance(self.state, str):
@@ -66,6 +96,12 @@ class Flag:
66
96
  if self.default_variant not in self.variants:
67
97
  raise ParseError("Default variant does not match set of variants")
68
98
 
99
+ if self.metadata:
100
+ if not isinstance(self.metadata, dict):
101
+ raise ParseError("Flag metadata is not a valid json object")
102
+ for key, value in self.metadata.items():
103
+ _validate_metadata(key, value)
104
+
69
105
  @classmethod
70
106
  def from_dict(cls, key: str, data: dict) -> "Flag":
71
107
  if "defaultVariant" in data:
@@ -77,6 +113,8 @@ class Flag:
77
113
  try:
78
114
  flag = cls(key=key, **data)
79
115
  return flag
116
+ except ParseError as parseError:
117
+ raise parseError
80
118
  except Exception as err:
81
119
  raise ParseError from err
82
120
 
@@ -4,7 +4,7 @@ from openfeature.contrib.provider.flagd.config import ResolverType
4
4
  from tests.e2e.testfilter import TestFilter
5
5
 
6
6
  resolver = ResolverType.RPC
7
- feature_list = ["~targetURI", "~unixsocket", "~sync"]
7
+ feature_list = ["~targetURI", "~unixsocket", "~sync", "~metadata"]
8
8
 
9
9
 
10
10
  def pytest_collection_modifyitems(config, items):
@@ -44,7 +44,7 @@ def add_event_handler(client: OpenFeatureClient, event_type: str, event_handles:
44
44
 
45
45
 
46
46
  def assert_handlers(handles, event_type: str, max_wait: int = 2):
47
- poll_interval = 1
47
+ poll_interval = 0.2
48
48
  while max_wait > 0:
49
49
  found = any(h["type"] == event_type for h in handles)
50
50
  if not found:
@@ -94,3 +94,16 @@ def resolve_details_reason(
94
94
  reason: str,
95
95
  ):
96
96
  assert_equal(details.reason, Reason(reason))
97
+
98
+
99
+ @then(parsers.cfparse("the resolved metadata should contain"))
100
+ def metadata_contains(details: FlagEvaluationDetails[JsonPrimitive], datatable):
101
+ assert_equal(len(details.flag_metadata), len(datatable) - 1) # skip table header
102
+ for i in range(1, len(datatable)):
103
+ key, metadata_type, expected = datatable[i]
104
+ assert_equal(details.flag_metadata[key], type_cast[metadata_type](expected))
105
+
106
+
107
+ @then("the resolved metadata is empty")
108
+ def empty_metadata(details: FlagEvaluationDetails[JsonPrimitive]):
109
+ assert_equal(len(details.flag_metadata), 0)
@@ -31,6 +31,7 @@ class TestProviderType(Enum):
31
31
  UNSTABLE = "unstable"
32
32
  SSL = "ssl"
33
33
  SOCKET = "socket"
34
+ METADATA = "metadata"
34
35
 
35
36
 
36
37
  @given("a provider is registered", target_fixture="client")
@@ -43,7 +44,7 @@ def setup_provider_old(
43
44
 
44
45
 
45
46
  def get_default_options_for_provider(
46
- provider_type: str, resolver_type: ResolverType, container
47
+ provider_type: str, resolver_type: ResolverType, container, option_values: dict
47
48
  ) -> tuple[dict, bool]:
48
49
  launchpad = "default"
49
50
  t = TestProviderType(provider_type)
@@ -68,11 +69,20 @@ def get_default_options_for_provider(
68
69
  launchpad = "ssl"
69
70
  elif t == TestProviderType.SOCKET:
70
71
  return options, True
72
+ elif t == TestProviderType.METADATA:
73
+ launchpad = "metadata"
71
74
 
72
75
  if resolver_type == ResolverType.FILE:
73
- options["offline_flag_source_path"] = os.path.join(
74
- container.flagDir.name, "allFlags.json"
75
- )
76
+ if "selector" in option_values:
77
+ path = option_values["selector"]
78
+ path = path.replace("rawflags/", "")
79
+ options["offline_flag_source_path"] = os.path.join(
80
+ Path(__file__).parents[3], "openfeature", "test-harness", "flags", path
81
+ )
82
+ else:
83
+ options["offline_flag_source_path"] = os.path.join(
84
+ container.flagDir.name, "allFlags.json"
85
+ )
76
86
 
77
87
  requests.post(
78
88
  f"{container.get_launchpad_url()}/start?config={launchpad}", timeout=1
@@ -91,7 +101,7 @@ def setup_provider(
91
101
  option_values: dict,
92
102
  ) -> OpenFeatureClient:
93
103
  default_options, wait = get_default_options_for_provider(
94
- provider_type, resolver_type, container
104
+ provider_type, resolver_type, container, option_values
95
105
  )
96
106
 
97
107
  combined_options = {**default_options, **option_values}
@@ -120,7 +130,8 @@ def flagd_restart(
120
130
  resolver_type: ResolverType,
121
131
  ):
122
132
  requests.post(
123
- f"{container.get_launchpad_url()}/restart?seconds={seconds}", timeout=2
133
+ f"{container.get_launchpad_url()}/restart?seconds={seconds}",
134
+ timeout=float(seconds) + 2,
124
135
  )
125
136
  pass
126
137
 
@@ -0,0 +1,29 @@
1
+ {
2
+ "flags": {
3
+ "basic-flag": {
4
+ "state": "ENABLED",
5
+ "variants": {
6
+ "true": true,
7
+ "false": false
8
+ },
9
+ "defaultVariant": "false",
10
+ "targeting": {},
11
+ "metadata": {
12
+ "string": "a",
13
+ "integer": 1,
14
+ "float": 1.2,
15
+ "bool": true
16
+ }
17
+ }
18
+ },
19
+ "metadata": {
20
+ "string": "b",
21
+ "integer": 2,
22
+ "float": 2.2,
23
+ "bool": false,
24
+ "flag-set-string": "c",
25
+ "flag-set-integer": 3,
26
+ "flag-set-float": 3.2,
27
+ "flag-set-bool": false
28
+ }
29
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "flags": {
3
+ "basic-flag": {
4
+ "state": "ENABLED",
5
+ "variants": {
6
+ "true": true,
7
+ "false": false
8
+ },
9
+ "defaultVariant": "false",
10
+ "targeting": {},
11
+ "metadata": {
12
+ "string": "a",
13
+ "integer": 1,
14
+ "float": 1.2,
15
+ "bool": true
16
+ }
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "flags": {
3
+ "basic-flag": {
4
+ "state": "ENABLED",
5
+ "variants": {
6
+ "true": true,
7
+ "false": false
8
+ },
9
+ "defaultVariant": "false",
10
+ "targeting": {}
11
+ }
12
+ },
13
+ "metadata": {
14
+ "string": "a",
15
+ "integer": 1,
16
+ "float": 1.2,
17
+ "bool": true
18
+ }
19
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "flags": {
3
+ "basic-flag": {
4
+ "state": "ENABLED",
5
+ "variants": {
6
+ "true": true,
7
+ "false": false
8
+ },
9
+ "defaultVariant": "false",
10
+ "targeting": {},
11
+ "metadata": ["a"]
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "flags": {
3
+ "basic-flag": {
4
+ "state": "ENABLED",
5
+ "variants": {
6
+ "true": true,
7
+ "false": false
8
+ },
9
+ "defaultVariant": "false",
10
+ "targeting": {},
11
+ "metadata": {
12
+ "string": {
13
+ "a": "a"
14
+ },
15
+ "integer": 1,
16
+ "float": 1.2,
17
+ "bool": true
18
+ }
19
+ }
20
+ },
21
+ "metadata": {
22
+ "bool": true
23
+ }
24
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "flags": {
3
+ "basic-flag": {
4
+ "state": "ENABLED",
5
+ "variants": {
6
+ "true": true,
7
+ "false": false
8
+ },
9
+ "defaultVariant": "false",
10
+ "targeting": {}
11
+ }
12
+ },
13
+ "metadata": ["a"]
14
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "flags": {
3
+ "basic-flag": {
4
+ "state": "ENABLED",
5
+ "variants": {
6
+ "true": true,
7
+ "false": false
8
+ },
9
+ "defaultVariant": "false",
10
+ "targeting": {}
11
+ }
12
+ },
13
+ "metadata": {
14
+ "string": {
15
+ "a": "a"
16
+ },
17
+ "integer": 1,
18
+ "float": 1.2,
19
+ "bool": true
20
+ }
21
+ }
@@ -108,5 +108,5 @@ def test_grpc_sync_fail_deadline(wait: int):
108
108
  )
109
109
 
110
110
  elapsed = time.time() - t
111
- assert abs(elapsed - wait * 0.001) < 0.11
111
+ assert abs(elapsed - wait * 0.001) < 0.15
112
112
  assert init_failed
@@ -44,3 +44,36 @@ def test_file_load(file_name: str):
44
44
 
45
45
  assert flag is not None
46
46
  assert isinstance(flag, Flag)
47
+
48
+ flag_set_metadata = flag_store.flag_set_metadata
49
+
50
+ assert flag_set_metadata is not None
51
+ assert isinstance(flag_set_metadata, dict)
52
+ assert len(flag_set_metadata) == 0
53
+
54
+
55
+ def test_file_load_metadata():
56
+ emit_provider_configuration_changed = Mock()
57
+ emit_provider_ready = Mock()
58
+ emit_provider_error = Mock()
59
+ flag_store = FlagStore(emit_provider_configuration_changed)
60
+ path = os.path.abspath(os.path.join(os.path.dirname(__file__), "./flags/"))
61
+ file_watcher = FileWatcher(
62
+ Config(
63
+ offline_flag_source_path=f"{path}/basic-flag-set-metadata.json",
64
+ ),
65
+ flag_store,
66
+ emit_provider_ready,
67
+ emit_provider_error,
68
+ )
69
+ file_watcher.initialize(None)
70
+
71
+ flag_set_metadata = flag_store.flag_set_metadata
72
+
73
+ assert flag_set_metadata is not None
74
+ assert isinstance(flag_set_metadata, dict)
75
+ assert len(flag_set_metadata) == 4
76
+ assert flag_set_metadata["string"] == "a"
77
+ assert flag_set_metadata["integer"] == 1
78
+ assert flag_set_metadata["float"] == 1.2
79
+ assert flag_set_metadata["bool"]
@@ -0,0 +1,148 @@
1
+ import os
2
+ import time
3
+ from time import sleep
4
+
5
+ import pytest
6
+
7
+ from openfeature import api
8
+ from openfeature.contrib.provider.flagd import FlagdProvider
9
+ from openfeature.contrib.provider.flagd.config import ResolverType
10
+ from openfeature.contrib.provider.flagd.resolvers.process.flags import (
11
+ _validate_metadata,
12
+ )
13
+ from openfeature.event import EventDetails, ProviderEvent
14
+ from openfeature.exception import ErrorCode, ParseError
15
+
16
+
17
+ def create_client(file_name):
18
+ path = os.path.abspath(os.path.join(os.path.dirname(__file__), "./flags/"))
19
+ provider = FlagdProvider(
20
+ resolver_type=ResolverType.FILE,
21
+ offline_flag_source_path=f"{path}/{file_name}",
22
+ )
23
+
24
+ api.set_provider(provider)
25
+ return api.get_client()
26
+
27
+
28
+ def test_should_load_flag_set_metadata():
29
+ client = create_client("basic-flag-set-metadata.json")
30
+ res = client.get_boolean_details("basic-flag", False)
31
+
32
+ assert res.flag_metadata is not None
33
+ assert isinstance(res.flag_metadata, dict)
34
+ assert len(res.flag_metadata) == 4
35
+ assert res.flag_metadata["string"] == "a"
36
+ assert res.flag_metadata["integer"] == 1
37
+ assert res.flag_metadata["float"] == 1.2
38
+ assert res.flag_metadata["bool"]
39
+
40
+
41
+ def test_should_load_flag_metadata():
42
+ client = create_client("basic-flag-metadata.json")
43
+ res = client.get_boolean_details("basic-flag", False)
44
+
45
+ assert res.flag_metadata is not None
46
+ assert isinstance(res.flag_metadata, dict)
47
+ assert len(res.flag_metadata) == 4
48
+ assert res.flag_metadata["string"] == "a"
49
+ assert res.flag_metadata["integer"] == 1
50
+ assert res.flag_metadata["float"] == 1.2
51
+ assert res.flag_metadata["bool"]
52
+
53
+
54
+ def test_should_load_flag_combined_metadata():
55
+ client = create_client("basic-flag-combined-metadata.json")
56
+ res = client.get_boolean_details("basic-flag", False)
57
+
58
+ assert res.flag_metadata is not None
59
+ assert isinstance(res.flag_metadata, dict)
60
+ assert len(res.flag_metadata) == 8
61
+ assert res.flag_metadata["string"] == "a"
62
+ assert res.flag_metadata["integer"] == 1
63
+ assert res.flag_metadata["float"] == 1.2
64
+ assert res.flag_metadata["bool"]
65
+ assert res.flag_metadata["flag-set-string"] == "c"
66
+ assert res.flag_metadata["flag-set-integer"] == 3
67
+ assert res.flag_metadata["flag-set-float"] == 3.2
68
+ assert not res.flag_metadata["flag-set-bool"]
69
+
70
+
71
+ class Channel:
72
+ parse_error_received = False
73
+
74
+
75
+ def create_error_handler():
76
+ channel = Channel()
77
+
78
+ def error_handler(details: EventDetails):
79
+ nonlocal channel
80
+ if details.error_code == ErrorCode.PARSE_ERROR:
81
+ channel.parse_error_received = True
82
+
83
+ return error_handler, channel
84
+
85
+
86
+ @pytest.mark.parametrize(
87
+ "file_name",
88
+ [
89
+ "invalid-flag-set-metadata.json",
90
+ "invalid-flag-set-metadata-list.json",
91
+ "invalid-flag-metadata.json",
92
+ "invalid-flag-metadata-list.json",
93
+ ],
94
+ )
95
+ def test_invalid_flag_set_metadata(file_name):
96
+ error_handler, channel = create_error_handler()
97
+
98
+ client = create_client(file_name)
99
+ client.add_handler(ProviderEvent.PROVIDER_ERROR, error_handler)
100
+
101
+ # keep the test thread alive
102
+ max_timeout = 2
103
+ start = time.time()
104
+ while not channel.parse_error_received:
105
+ now = time.time()
106
+ if now - start > max_timeout:
107
+ raise AssertionError()
108
+ sleep(0.01)
109
+
110
+
111
+ def test_validate_metadata_with_none_key():
112
+ try:
113
+ _validate_metadata(None, "a")
114
+ except ParseError:
115
+ return
116
+ raise AssertionError()
117
+
118
+
119
+ def test_validate_metadata_with_empty_key():
120
+ try:
121
+ _validate_metadata("", "a")
122
+ except ParseError:
123
+ return
124
+ raise AssertionError()
125
+
126
+
127
+ def test_validate_metadata_with_non_string_key():
128
+ try:
129
+ _validate_metadata(1, "a")
130
+ except ParseError:
131
+ return
132
+ raise AssertionError()
133
+
134
+
135
+ def test_validate_metadata_with_non_string_value():
136
+ try:
137
+ _validate_metadata("a", [])
138
+ except ParseError:
139
+ return
140
+ raise AssertionError()
141
+
142
+
143
+ def test_validate_metadata_with_none_value():
144
+ try:
145
+ _validate_metadata("a", None)
146
+ except ParseError:
147
+ return
148
+ raise AssertionError()