diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0876c1e7..69791e42 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,4 +19,4 @@ repos: rev: v1.18.1 hooks: - id: mypy - files: openfeature + files: openfeature|tests/typechecking diff --git a/openfeature/evaluation_context/__init__.py b/openfeature/evaluation_context/__init__.py index 666abb5e..a72e324a 100644 --- a/openfeature/evaluation_context/__init__.py +++ b/openfeature/evaluation_context/__init__.py @@ -10,24 +10,23 @@ __all__ = ["EvaluationContext", "get_evaluation_context", "set_evaluation_context"] # https://openfeature.dev/specification/sections/evaluation-context#requirement-312 -EvaluationContextAttributes = typing.Mapping[ +EvaluationContextAttribute = typing.Union[ + bool, + int, + float, str, - typing.Union[ - bool, - int, - float, - str, - datetime, - Sequence["EvaluationContextAttributes"], - "EvaluationContextAttributes", - ], + datetime, + Sequence["EvaluationContextAttribute"], + typing.Mapping[str, "EvaluationContextAttribute"], ] @dataclass class EvaluationContext: targeting_key: typing.Optional[str] = None - attributes: EvaluationContextAttributes = field(default_factory=dict) + attributes: typing.Mapping[str, EvaluationContextAttribute] = field( + default_factory=dict + ) def merge(self, ctx2: EvaluationContext) -> EvaluationContext: if not (self and ctx2): diff --git a/openfeature/hook/__init__.py b/openfeature/hook/__init__.py index e21a263f..a66181ed 100644 --- a/openfeature/hook/__init__.py +++ b/openfeature/hook/__init__.py @@ -70,19 +70,18 @@ def __setattr__(self, key: str, value: typing.Any) -> None: # https://openfeature.dev/specification/sections/hooks/#requirement-421 -HookHints = typing.Mapping[ +HookHintValue = typing.Union[ + bool, + int, + float, str, - typing.Union[ - bool, - int, - float, - str, - datetime, - Sequence["HookHints"], - typing.Mapping[str, "HookHints"], - ], + datetime, + Sequence["HookHintValue"], + typing.Mapping[str, "HookHintValue"], ] +HookHints = typing.Mapping[str, HookHintValue] + class Hook: def before( diff --git a/pyproject.toml b/pyproject.toml index 66070c62..6656e30c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ module-root = "" namespace = true [tool.mypy] -files = "openfeature" +files = "openfeature,tests/typechecking" python_version = "3.9" # should be identical to the minimum supported version namespace_packages = true diff --git a/tests/typechecking/README.md b/tests/typechecking/README.md new file mode 100644 index 00000000..ae19055a --- /dev/null +++ b/tests/typechecking/README.md @@ -0,0 +1,8 @@ +# Type Checking Tests + +This directory contains type checking validation files that are designed to be checked by **type checkers only**, not executed by pytest. + +## Purpose + +These files validate that the type annotations work correctly through static analysis. +They ensure type safety for complex type definitions like `EvaluationContextAttribute` and other typed interfaces. diff --git a/tests/typechecking/evaluation_context.py b/tests/typechecking/evaluation_context.py new file mode 100644 index 00000000..9fd9999f --- /dev/null +++ b/tests/typechecking/evaluation_context.py @@ -0,0 +1,90 @@ +"""Requirement 3.1.2""" + +from datetime import datetime + +from openfeature.evaluation_context import EvaluationContext + +# positive +EvaluationContext( + targeting_key="key", + attributes={"bool": True}, +) + +EvaluationContext( + targeting_key="key", + attributes={"int": 42}, +) + +EvaluationContext( + targeting_key="key", + attributes={"float": 3.14}, +) + +EvaluationContext( + targeting_key="key", + attributes={"str": "value"}, +) + +EvaluationContext( + targeting_key="key", + attributes={"date": datetime.now()}, +) + +EvaluationContext( + targeting_key="key", + attributes={ + "bool_list": [True, False], + "int_list": [1, 2], + "float_list": [1.1, 2.2], + "date_list": [datetime.now(), datetime.now()], + "str_list": ["a", "b"], + "list_list": [ + ["a", "b"], + ["c", "d"], + ], + "dict_list": [ + {"int": 42}, + {"str": "value"}, + ], + }, +) + +EvaluationContext( + targeting_key="key", + attributes={ + "user": { + "id": 12345, + "name": "John Doe", + "active": True, + "last_login": datetime.now(), + "permissions": ["read", "write", "admin"], + "metadata": { + "source": "api", + "version": 2.1, + "tags": ["premium", "beta"], + "config": { + "nested_deeply": [ + {"item": 1, "enabled": True}, + {"item": 2, "enabled": False}, + ] + }, + }, + }, + }, +) + +# negative +EvaluationContext( + targeting_key="key", + attributes={"null": None}, # type: ignore[dict-item] +) + +EvaluationContext( + targeting_key="key", + attributes={"complex": -4.5j}, # type: ignore[dict-item] +) + +EvaluationContext( + targeting_key="key", + attributes={42: 42}, # type: ignore[dict-item] +) diff --git a/tests/typechecking/hook_data.py b/tests/typechecking/hook_data.py new file mode 100644 index 00000000..0a26f043 --- /dev/null +++ b/tests/typechecking/hook_data.py @@ -0,0 +1,34 @@ +"""Requirement 4.6.1""" + +from openfeature.hook import HookData + +# positive +any_hook_data: HookData = { + "user": { + "id": 12345, + "name": "John Doe", + "active": True, + "permissions": ["read", "write", "admin"], + "metadata": { + "source": "api", + "version": 2.1, + "tags": ["premium", "beta"], + "config": { + "nested_deeply": [ + {"item": 1, "enabled": True}, + {"item": 2, "enabled": False}, + ] + }, + }, + }, +} + + +class ExampleClass: + pass + + +class_hook_data: HookData = {"example": ExampleClass} + +# negative +int_key_hook_data: HookData = {42: 42} # type: ignore[dict-item] diff --git a/tests/typechecking/hook_hints.py b/tests/typechecking/hook_hints.py new file mode 100644 index 00000000..bfa14028 --- /dev/null +++ b/tests/typechecking/hook_hints.py @@ -0,0 +1,60 @@ +"""Requirement 4.1.2""" + +from datetime import datetime + +from openfeature.hook import HookHints + +# positive +bool_hook_hint: HookHints = {"bool": True} + +int_hook_hint: HookHints = {"int": 42} + +float_hook_hint: HookHints = {"float": 3.14} + +str_hook_hint: HookHints = {"str": "value"} + +date_hook_hint: HookHints = {"date": datetime.now()} + +list_hook_hint: HookHints = { + "bool_list": [True, False], + "int_list": [1, 2], + "float_list": [1.1, 2.2], + "date_list": [datetime.now(), datetime.now()], + "str_list": ["a", "b"], + "list_list": [ + ["a", "b"], + ["c", "d"], + ], + "dict_list": [ + {"int": 42}, + {"str": "value"}, + ], +} + +dict_hook_hint: HookHints = { + "user": { + "id": 12345, + "name": "John Doe", + "active": True, + "last_login": datetime.now(), + "permissions": ["read", "write", "admin"], + "metadata": { + "source": "api", + "version": 2.1, + "tags": ["premium", "beta"], + "config": { + "nested_deeply": [ + {"item": 1, "enabled": True}, + {"item": 2, "enabled": False}, + ] + }, + }, + }, +} + +# negative +null_hook_hint: HookHints = {"null": None} # type: ignore[dict-item] + +complex_hook_hint: HookHints = {"complex": -4.5j} # type: ignore[dict-item] + +int_key_hook_hint: HookHints = {42: 42} # type: ignore[dict-item]