diff --git a/.gitmodules b/.gitmodules index d063bd57..ac0d737c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,10 +2,10 @@ path = providers/openfeature-provider-flagd/openfeature/schemas url = https://github.com/open-feature/schemas branch = protobuf-v0.6.1 -[submodule "providers/openfeature-provider-flagd/test-harness"] - path = providers/openfeature-provider-flagd/openfeature/test-harness - url = git@github.com:open-feature/flagd-testbed.git - branch = v2.5.0 [submodule "providers/openfeature-provider-flagd/spec"] path = providers/openfeature-provider-flagd/openfeature/spec url = https://github.com/open-feature/spec +[submodule "providers/openfeature-provider-flagd/openfeature/test-harness"] + path = providers/openfeature-provider-flagd/openfeature/test-harness + url = https://github.com/open-feature/flagd-testbed.git + branch = v2.7.0 diff --git a/providers/openfeature-provider-flagd/openfeature/test-harness b/providers/openfeature-provider-flagd/openfeature/test-harness index 9d35a07f..dee690a1 160000 --- a/providers/openfeature-provider-flagd/openfeature/test-harness +++ b/providers/openfeature-provider-flagd/openfeature/test-harness @@ -1 +1 @@ -Subproject commit 9d35a07f43c6b5e1810a5e83029aae62a5dbd494 +Subproject commit dee690a1d274000e0cb6c36d569299f6237daebf diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py index 1e614773..4b70afdd 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py @@ -17,6 +17,23 @@ T = typing.TypeVar("T") +def _merge_metadata( + flag_metadata: typing.Optional[ + typing.Mapping[str, typing.Union[float, int, str, bool]] + ], + flag_set_metadata: typing.Optional[ + typing.Mapping[str, typing.Union[float, int, str, bool]] + ], +) -> typing.Mapping[str, typing.Union[float, int, str, bool]]: + metadata = {} if flag_set_metadata is None else dict(flag_set_metadata) + + if flag_metadata is not None: + for key, value in flag_metadata.items(): + metadata[key] = value + + return metadata + + class InProcessResolver: def __init__( self, @@ -103,18 +120,26 @@ def _resolve( if not flag: raise FlagNotFoundError(f"Flag with key {key} not present in flag store.") + metadata = _merge_metadata(flag.metadata, self.flag_store.flag_set_metadata) + if flag.state == "DISABLED": - return FlagResolutionDetails(default_value, reason=Reason.DISABLED) + return FlagResolutionDetails( + default_value, flag_metadata=metadata, reason=Reason.DISABLED + ) if not flag.targeting: variant, value = flag.default - return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC) + return FlagResolutionDetails( + value, variant=variant, flag_metadata=metadata, reason=Reason.STATIC + ) variant = targeting(flag.key, flag.targeting, evaluation_context) if variant is None: variant, value = flag.default - return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT) + return FlagResolutionDetails( + value, variant=variant, flag_metadata=metadata, reason=Reason.DEFAULT + ) if not isinstance(variant, (str, bool)): raise ParseError( "Parsed JSONLogic targeting did not return a string or bool" @@ -128,4 +153,5 @@ def _resolve( value, variant=variant, reason=Reason.TARGETING_MATCH, + flag_metadata=metadata, ) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/connector/file_watcher.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/connector/file_watcher.py index eaf2b6d3..6149dc1d 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/connector/file_watcher.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/connector/file_watcher.py @@ -14,7 +14,7 @@ from openfeature.contrib.provider.flagd.resolvers.process.flags import FlagStore from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEventDetails -from openfeature.exception import ParseError, ProviderNotReadyError +from openfeature.exception import ErrorCode, ParseError, ProviderNotReadyError logger = logging.getLogger("openfeature.contrib") @@ -76,8 +76,15 @@ def safe_load_data(self) -> None: self.handle_error("Could not parse JSON flag data from file") except yaml.error.YAMLError: self.handle_error("Could not parse YAML flag data from file") - except ParseError: - self.handle_error("Could not parse flag data using flagd syntax") + except ParseError as e: + self.handle_error( + "Could not parse flag data using flagd syntax: " + + ( + "no error message provided" + if e is None or e.error_message is None + else e.error_message + ) + ) except Exception: self.handle_error("Could not read flags from file") @@ -104,4 +111,8 @@ def _load_data(self, modified_time: typing.Optional[float] = None) -> None: def handle_error(self, error_message: str) -> None: logger.exception(error_message) self.should_emit_ready_on_success = True - self.emit_provider_error(ProviderEventDetails(message=error_message)) + self.emit_provider_error( + ProviderEventDetails( + message=error_message, error_code=ErrorCode.PARSE_ERROR + ) + ) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py index 638535b4..9559761e 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py @@ -7,6 +7,21 @@ from openfeature.exception import ParseError +def _validate_metadata(key: str, value: typing.Union[float, int, str, bool]) -> None: + if key is None: + raise ParseError("Metadata key must be set") + elif not isinstance(key, str): + raise ParseError(f"Metadata key {key} must be of type str, but is {type(key)}") + elif not key: + raise ParseError("key must not be empty") + if value is None: + raise ParseError(f"Metadata value for key {key} must be set") + elif not isinstance(value, (float, int, str, bool)): + raise ParseError( + f"Metadata value {value} for key {key} must be of type float, int, str or bool, but is {type(value)}" + ) + + class FlagStore: def __init__( self, @@ -16,12 +31,16 @@ def __init__( ): self.emit_provider_configuration_changed = emit_provider_configuration_changed self.flags: typing.Mapping[str, Flag] = {} + self.flag_set_metadata: typing.Mapping[ + str, typing.Union[float, int, str, bool] + ] = {} def get_flag(self, key: str) -> typing.Optional["Flag"]: return self.flags.get(key) def update(self, flags_data: dict) -> None: flags = flags_data.get("flags", {}) + metadata = flags_data.get("metadata", {}) evaluators: typing.Optional[dict] = flags_data.get("$evaluators") if evaluators: transposed = json.dumps(flags) @@ -33,10 +52,18 @@ def update(self, flags_data: dict) -> None: if not isinstance(flags, dict): raise ParseError("`flags` key of configuration must be a dictionary") + if not isinstance(metadata, dict): + raise ParseError("`metadata` key of configuration must be a dictionary") + for key, value in metadata.items(): + _validate_metadata(key, value) + self.flags = {key: Flag.from_dict(key, data) for key, data in flags.items()} + self.flag_set_metadata = metadata self.emit_provider_configuration_changed( - ProviderEventDetails(flags_changed=list(self.flags.keys())) + ProviderEventDetails( + flags_changed=list(self.flags.keys()), metadata=metadata + ) ) @@ -47,6 +74,9 @@ class Flag: variants: typing.Mapping[str, typing.Any] default_variant: typing.Union[bool, str] targeting: typing.Optional[dict] = None + metadata: typing.Optional[ + typing.Mapping[str, typing.Union[float, int, str, bool]] + ] = None def __post_init__(self) -> None: if not self.state or not isinstance(self.state, str): @@ -66,6 +96,12 @@ def __post_init__(self) -> None: if self.default_variant not in self.variants: raise ParseError("Default variant does not match set of variants") + if self.metadata: + if not isinstance(self.metadata, dict): + raise ParseError("Flag metadata is not a valid json object") + for key, value in self.metadata.items(): + _validate_metadata(key, value) + @classmethod def from_dict(cls, key: str, data: dict) -> "Flag": if "defaultVariant" in data: @@ -77,6 +113,8 @@ def from_dict(cls, key: str, data: dict) -> "Flag": try: flag = cls(key=key, **data) return flag + except ParseError as parseError: + raise parseError except Exception as err: raise ParseError from err diff --git a/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py b/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py index 77ca44b1..de3cb850 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py +++ b/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py @@ -4,7 +4,7 @@ from tests.e2e.testfilter import TestFilter resolver = ResolverType.RPC -feature_list = ["~targetURI", "~unixsocket", "~sync"] +feature_list = ["~targetURI", "~unixsocket", "~sync", "~metadata"] def pytest_collection_modifyitems(config, items): diff --git a/providers/openfeature-provider-flagd/tests/e2e/step/event_steps.py b/providers/openfeature-provider-flagd/tests/e2e/step/event_steps.py index ccd0e9ce..7aec3648 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/step/event_steps.py +++ b/providers/openfeature-provider-flagd/tests/e2e/step/event_steps.py @@ -44,7 +44,7 @@ def handler(event): def assert_handlers(handles, event_type: str, max_wait: int = 2): - poll_interval = 1 + poll_interval = 0.2 while max_wait > 0: found = any(h["type"] == event_type for h in handles) if not found: diff --git a/providers/openfeature-provider-flagd/tests/e2e/step/flag_step.py b/providers/openfeature-provider-flagd/tests/e2e/step/flag_step.py index cc4386e4..e0ff97ce 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/step/flag_step.py +++ b/providers/openfeature-provider-flagd/tests/e2e/step/flag_step.py @@ -94,3 +94,16 @@ def resolve_details_reason( reason: str, ): assert_equal(details.reason, Reason(reason)) + + +@then(parsers.cfparse("the resolved metadata should contain")) +def metadata_contains(details: FlagEvaluationDetails[JsonPrimitive], datatable): + assert_equal(len(details.flag_metadata), len(datatable) - 1) # skip table header + for i in range(1, len(datatable)): + key, metadata_type, expected = datatable[i] + assert_equal(details.flag_metadata[key], type_cast[metadata_type](expected)) + + +@then("the resolved metadata is empty") +def empty_metadata(details: FlagEvaluationDetails[JsonPrimitive]): + assert_equal(len(details.flag_metadata), 0) diff --git a/providers/openfeature-provider-flagd/tests/e2e/step/provider_steps.py b/providers/openfeature-provider-flagd/tests/e2e/step/provider_steps.py index 4286bc17..3d8d5195 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/step/provider_steps.py +++ b/providers/openfeature-provider-flagd/tests/e2e/step/provider_steps.py @@ -31,6 +31,7 @@ class TestProviderType(Enum): UNSTABLE = "unstable" SSL = "ssl" SOCKET = "socket" + METADATA = "metadata" @given("a provider is registered", target_fixture="client") @@ -43,7 +44,7 @@ def setup_provider_old( def get_default_options_for_provider( - provider_type: str, resolver_type: ResolverType, container + provider_type: str, resolver_type: ResolverType, container, option_values: dict ) -> tuple[dict, bool]: launchpad = "default" t = TestProviderType(provider_type) @@ -68,11 +69,20 @@ def get_default_options_for_provider( launchpad = "ssl" elif t == TestProviderType.SOCKET: return options, True + elif t == TestProviderType.METADATA: + launchpad = "metadata" if resolver_type == ResolverType.FILE: - options["offline_flag_source_path"] = os.path.join( - container.flagDir.name, "allFlags.json" - ) + if "selector" in option_values: + path = option_values["selector"] + path = path.replace("rawflags/", "") + options["offline_flag_source_path"] = os.path.join( + Path(__file__).parents[3], "openfeature", "test-harness", "flags", path + ) + else: + options["offline_flag_source_path"] = os.path.join( + container.flagDir.name, "allFlags.json" + ) requests.post( f"{container.get_launchpad_url()}/start?config={launchpad}", timeout=1 @@ -91,7 +101,7 @@ def setup_provider( option_values: dict, ) -> OpenFeatureClient: default_options, wait = get_default_options_for_provider( - provider_type, resolver_type, container + provider_type, resolver_type, container, option_values ) combined_options = {**default_options, **option_values} @@ -120,7 +130,8 @@ def flagd_restart( resolver_type: ResolverType, ): requests.post( - f"{container.get_launchpad_url()}/restart?seconds={seconds}", timeout=2 + f"{container.get_launchpad_url()}/restart?seconds={seconds}", + timeout=float(seconds) + 2, ) pass diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-combined-metadata.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-combined-metadata.json new file mode 100644 index 00000000..75016630 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-combined-metadata.json @@ -0,0 +1,29 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {}, + "metadata": { + "string": "a", + "integer": 1, + "float": 1.2, + "bool": true + } + } + }, + "metadata": { + "string": "b", + "integer": 2, + "float": 2.2, + "bool": false, + "flag-set-string": "c", + "flag-set-integer": 3, + "flag-set-float": 3.2, + "flag-set-bool": false + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-metadata.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-metadata.json new file mode 100644 index 00000000..208bd18c --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-metadata.json @@ -0,0 +1,19 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {}, + "metadata": { + "string": "a", + "integer": 1, + "float": 1.2, + "bool": true + } + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-set-metadata.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-set-metadata.json new file mode 100644 index 00000000..ed111e0a --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-set-metadata.json @@ -0,0 +1,19 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {} + } + }, + "metadata": { + "string": "a", + "integer": 1, + "float": 1.2, + "bool": true + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-flag-metadata-list.json b/providers/openfeature-provider-flagd/tests/flags/invalid-flag-metadata-list.json new file mode 100644 index 00000000..89176889 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-flag-metadata-list.json @@ -0,0 +1,14 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {}, + "metadata": ["a"] + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-flag-metadata.json b/providers/openfeature-provider-flagd/tests/flags/invalid-flag-metadata.json new file mode 100644 index 00000000..bbbd6144 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-flag-metadata.json @@ -0,0 +1,24 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {}, + "metadata": { + "string": { + "a": "a" + }, + "integer": 1, + "float": 1.2, + "bool": true + } + } + }, + "metadata": { + "bool": true + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-flag-set-metadata-list.json b/providers/openfeature-provider-flagd/tests/flags/invalid-flag-set-metadata-list.json new file mode 100644 index 00000000..61950ce7 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-flag-set-metadata-list.json @@ -0,0 +1,14 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {} + } + }, + "metadata": ["a"] +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-flag-set-metadata.json b/providers/openfeature-provider-flagd/tests/flags/invalid-flag-set-metadata.json new file mode 100644 index 00000000..800733a8 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-flag-set-metadata.json @@ -0,0 +1,21 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {} + } + }, + "metadata": { + "string": { + "a": "a" + }, + "integer": 1, + "float": 1.2, + "bool": true + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/test_errors.py b/providers/openfeature-provider-flagd/tests/test_errors.py index 15ea2d8c..872994c8 100644 --- a/providers/openfeature-provider-flagd/tests/test_errors.py +++ b/providers/openfeature-provider-flagd/tests/test_errors.py @@ -108,5 +108,5 @@ def fail(*args, **kwargs): ) elapsed = time.time() - t - assert abs(elapsed - wait * 0.001) < 0.11 + assert abs(elapsed - wait * 0.001) < 0.15 assert init_failed diff --git a/providers/openfeature-provider-flagd/tests/test_file_store.py b/providers/openfeature-provider-flagd/tests/test_file_store.py index dea890ec..7af4ccfd 100644 --- a/providers/openfeature-provider-flagd/tests/test_file_store.py +++ b/providers/openfeature-provider-flagd/tests/test_file_store.py @@ -44,3 +44,36 @@ def test_file_load(file_name: str): assert flag is not None assert isinstance(flag, Flag) + + flag_set_metadata = flag_store.flag_set_metadata + + assert flag_set_metadata is not None + assert isinstance(flag_set_metadata, dict) + assert len(flag_set_metadata) == 0 + + +def test_file_load_metadata(): + emit_provider_configuration_changed = Mock() + emit_provider_ready = Mock() + emit_provider_error = Mock() + flag_store = FlagStore(emit_provider_configuration_changed) + path = os.path.abspath(os.path.join(os.path.dirname(__file__), "./flags/")) + file_watcher = FileWatcher( + Config( + offline_flag_source_path=f"{path}/basic-flag-set-metadata.json", + ), + flag_store, + emit_provider_ready, + emit_provider_error, + ) + file_watcher.initialize(None) + + flag_set_metadata = flag_store.flag_set_metadata + + assert flag_set_metadata is not None + assert isinstance(flag_set_metadata, dict) + assert len(flag_set_metadata) == 4 + assert flag_set_metadata["string"] == "a" + assert flag_set_metadata["integer"] == 1 + assert flag_set_metadata["float"] == 1.2 + assert flag_set_metadata["bool"] diff --git a/providers/openfeature-provider-flagd/tests/test_metadata.py b/providers/openfeature-provider-flagd/tests/test_metadata.py new file mode 100644 index 00000000..939af96f --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/test_metadata.py @@ -0,0 +1,148 @@ +import os +import time +from time import sleep + +import pytest + +from openfeature import api +from openfeature.contrib.provider.flagd import FlagdProvider +from openfeature.contrib.provider.flagd.config import ResolverType +from openfeature.contrib.provider.flagd.resolvers.process.flags import ( + _validate_metadata, +) +from openfeature.event import EventDetails, ProviderEvent +from openfeature.exception import ErrorCode, ParseError + + +def create_client(file_name): + path = os.path.abspath(os.path.join(os.path.dirname(__file__), "./flags/")) + provider = FlagdProvider( + resolver_type=ResolverType.FILE, + offline_flag_source_path=f"{path}/{file_name}", + ) + + api.set_provider(provider) + return api.get_client() + + +def test_should_load_flag_set_metadata(): + client = create_client("basic-flag-set-metadata.json") + res = client.get_boolean_details("basic-flag", False) + + assert res.flag_metadata is not None + assert isinstance(res.flag_metadata, dict) + assert len(res.flag_metadata) == 4 + assert res.flag_metadata["string"] == "a" + assert res.flag_metadata["integer"] == 1 + assert res.flag_metadata["float"] == 1.2 + assert res.flag_metadata["bool"] + + +def test_should_load_flag_metadata(): + client = create_client("basic-flag-metadata.json") + res = client.get_boolean_details("basic-flag", False) + + assert res.flag_metadata is not None + assert isinstance(res.flag_metadata, dict) + assert len(res.flag_metadata) == 4 + assert res.flag_metadata["string"] == "a" + assert res.flag_metadata["integer"] == 1 + assert res.flag_metadata["float"] == 1.2 + assert res.flag_metadata["bool"] + + +def test_should_load_flag_combined_metadata(): + client = create_client("basic-flag-combined-metadata.json") + res = client.get_boolean_details("basic-flag", False) + + assert res.flag_metadata is not None + assert isinstance(res.flag_metadata, dict) + assert len(res.flag_metadata) == 8 + assert res.flag_metadata["string"] == "a" + assert res.flag_metadata["integer"] == 1 + assert res.flag_metadata["float"] == 1.2 + assert res.flag_metadata["bool"] + assert res.flag_metadata["flag-set-string"] == "c" + assert res.flag_metadata["flag-set-integer"] == 3 + assert res.flag_metadata["flag-set-float"] == 3.2 + assert not res.flag_metadata["flag-set-bool"] + + +class Channel: + parse_error_received = False + + +def create_error_handler(): + channel = Channel() + + def error_handler(details: EventDetails): + nonlocal channel + if details.error_code == ErrorCode.PARSE_ERROR: + channel.parse_error_received = True + + return error_handler, channel + + +@pytest.mark.parametrize( + "file_name", + [ + "invalid-flag-set-metadata.json", + "invalid-flag-set-metadata-list.json", + "invalid-flag-metadata.json", + "invalid-flag-metadata-list.json", + ], +) +def test_invalid_flag_set_metadata(file_name): + error_handler, channel = create_error_handler() + + client = create_client(file_name) + client.add_handler(ProviderEvent.PROVIDER_ERROR, error_handler) + + # keep the test thread alive + max_timeout = 2 + start = time.time() + while not channel.parse_error_received: + now = time.time() + if now - start > max_timeout: + raise AssertionError() + sleep(0.01) + + +def test_validate_metadata_with_none_key(): + try: + _validate_metadata(None, "a") + except ParseError: + return + raise AssertionError() + + +def test_validate_metadata_with_empty_key(): + try: + _validate_metadata("", "a") + except ParseError: + return + raise AssertionError() + + +def test_validate_metadata_with_non_string_key(): + try: + _validate_metadata(1, "a") + except ParseError: + return + raise AssertionError() + + +def test_validate_metadata_with_non_string_value(): + try: + _validate_metadata("a", []) + except ParseError: + return + raise AssertionError() + + +def test_validate_metadata_with_none_value(): + try: + _validate_metadata("a", None) + except ParseError: + return + raise AssertionError()