From: Aleš Mrázek Date: Mon, 16 Feb 2026 21:59:26 +0000 (+0100) Subject: python: modeling: types: added inspect module to help with types annotations X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=cf6dd9f45a08117c809612bfb1a07e32f6441278;p=thirdparty%2Fknot-resolver.git python: modeling: types: added inspect module to help with types annotations --- diff --git a/python/knot_resolver/utils/modeling/types/inspect.py b/python/knot_resolver/utils/modeling/types/inspect.py new file mode 100644 index 000000000..e7ffc3641 --- /dev/null +++ b/python/knot_resolver/utils/modeling/types/inspect.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import inspect +import sys +from typing import Any, Dict, List, Literal, Tuple, Union, get_args, get_origin + +from knot_resolver.utils.modeling.errors import DataAnnotationError +from knot_resolver.utils.modeling.types.base_custom_type import BaseCustomType +from knot_resolver.utils.modeling.types.base_generic_custom_types import ( + BaseGenericCustomTypeWrapper, + ListOrItem, + Transformed, +) + +# TODO(amrazek): condition can be removed from Python 3.10+ +if sys.version_info >= (3, 10): + from inspect import get_annotations + from types import UnionType + +NoneType = type(None) + + +def is_transformed(obj: Any) -> bool: + if get_origin(obj) is Transformed: + return True + # TODO(amrazek): can be removed + # when typing_extensions are not used for Layered/Annotated + return "Annotated" in str(obj) + + +def get_transformed_input_type(obj: Any) -> Any: + if get_origin(obj) is Transformed: + return get_args(obj)[1] + # TODO(amrazek): condition can be removed + # when typing_extensions are not used for Layered/Annotated + if "Annotated" in str(obj): + return getattr(obj, "__metadata__", ())[0] + msg = f"expected 'Layered/Annotated' type, got {obj}" + raise DataAnnotationError(msg) + + +def get_transformed_result_type(obj: Any) -> Any: + if get_origin(obj) is Transformed: + return get_args(obj)[0] + # TODO(amrazek): condition can be removed + # when typing_extensions are not used for Layered/Annotated + if "Annotated" in str(obj): + return get_args(obj)[0] + msg = f"expected 'Layered/Annotated' type, got {obj}" + raise DataAnnotationError(msg) + + +def get_annotations(obj: Any) -> dict[Any, Any]: + if sys.version_info >= (3, 10): + return get_annotations(obj) + # TODO(amrazek): can be removed from Python 3.10+ + return obj.__dict__.get("__annotations__", {}) + + +def get_arg(typ: Any) -> Any: + args = get_args(typ) + if len(args) == 1: + return args[0] + msg = f"expected one generic type argument, got {len(args)}" + raise DataAnnotationError(msg) + + +def is_base_generic_type_wrapper(typ: Any) -> bool: + origin = get_origin(typ) + return inspect.isclass(origin) and issubclass(origin, BaseGenericCustomTypeWrapper) + + +def get_base_generic_type_wrapper_argument(typ: type[BaseGenericCustomTypeWrapper[Any]]) -> Any: + origin = get_origin(typ) + if not hasattr(origin, "__orig_bases__"): + msg = f"expected '{BaseGenericCustomTypeWrapper}' type, got {typ}" + raise DataAnnotationError(msg) + + arg = get_arg(typ) + orig_bases: list[Any] = getattr(origin, "__orig_bases__", [])[0] + return get_arg(orig_bases[arg]) + + +def is_base_custom_type(typ: Any) -> bool: + return inspect.isclass(typ) and issubclass(typ, BaseCustomType) + + +def is_dict(typ: Any) -> bool: + return get_origin(typ) in (Dict, dict) + + +def is_list(typ: Any) -> bool: + return get_origin(typ) in (List, list) + + +def is_list_or_item(typ: Any) -> bool: + origin = get_origin(typ) + return inspect.isclass(origin) and issubclass(origin, ListOrItem) + + +def is_literal(typ: Any) -> bool: + return get_origin(typ) == Literal + + +def is_none_type(typ: Any) -> bool: + return typ is None or typ == NoneType + + +def is_optional(typ: Any) -> bool: + args = get_args(typ) + return is_union(typ) and NoneType in args + + +def is_tuple(typ: Any) -> bool: + return get_origin(typ) in (Tuple, tuple) + + +def is_union(typ: Any) -> bool: + origin = get_origin(typ) + # TODO(amrazek): condition can be removed from Python 3.10+ + if sys.version_info >= (3, 10): + return origin in (Union, UnionType) + return origin == Union + + +def get_optional_inner_type(optional: Any) -> Any: + if is_optional(optional): + args = get_args(optional) + for arg in args: + if not is_none_type(arg): + return arg + msg = "failed to get inner optional type" + raise DataAnnotationError(msg) + + +def getattr_type(obj: Any, attr_name: str) -> Any: + annot = get_annotations(type(obj)) + if hasattr(annot, attr_name): + return annot[attr_name] + msg = "attribute name is missing in data annotations" + raise DataAnnotationError(msg) + + +def is_attr_name_private(attr_name: str) -> bool: + return attr_name.startswith("_") diff --git a/tests/python/knot_resolver/utils/modeling/types/test_inspect.py b/tests/python/knot_resolver/utils/modeling/types/test_inspect.py new file mode 100644 index 000000000..c11ec2df5 --- /dev/null +++ b/tests/python/knot_resolver/utils/modeling/types/test_inspect.py @@ -0,0 +1,111 @@ +import sys +from typing import Any, Dict, List, Literal, Optional, Tuple, Union + +import pytest + +from knot_resolver.utils.modeling.types.base_custom_type import BaseCustomType +from knot_resolver.utils.modeling.types.base_generic_custom_types import ( + ListOrItem, + Transformed, +) +from knot_resolver.utils.modeling.types.inspect import ( + NoneType, + is_base_custom_type, + is_base_generic_type_wrapper, + is_dict, + is_list, + is_list_or_item, + is_literal, + is_none_type, + is_optional, + is_transformed, + is_tuple, + is_union, +) + +types = [ + Any, + bool, + int, + float, + str, + BaseCustomType, +] + +literals = [ + Literal[Any], + Literal[True, False], + Literal[0], + Literal[1, 2, 3], + Literal["literal"], + Literal["literal1", "literal2", "literal3"], +] + + +def test_is_base_custom_type() -> None: + class TestType(BaseCustomType): + pass + + assert is_base_custom_type(TestType) + + +@pytest.mark.parametrize("typ", types) +def test_is_base_generic_custom_type_wrapper(typ: Any) -> None: + assert is_base_generic_type_wrapper(ListOrItem[typ]) + + +@pytest.mark.parametrize("typ", types) +def test_is_dict(typ: Any) -> None: + assert is_dict(Dict[typ, Any]) + + +def test_is_transformed(): + typ = Transformed[int, float] + assert is_transformed(typ) + + +@pytest.mark.parametrize("typ", types) +def test_is_list(typ: Any) -> None: + assert is_list(List[typ]) + if sys.version_info >= (3, 10): + assert is_list(list[typ]) + + +@pytest.mark.parametrize("typ", types) +def test_is_list_or_item(typ: Any) -> None: + assert is_list_or_item(ListOrItem[typ]) + + +@pytest.mark.parametrize("typ", literals) +def test_is_literal(typ: Any) -> None: + assert is_literal(typ) + + +@pytest.mark.parametrize("typ", [None, NoneType]) +def test_is_none_type(typ: Any) -> None: + assert is_none_type(typ) + + +@pytest.mark.parametrize("typ", types) +def test_is_optional(typ: Any) -> None: + assert is_optional(Optional[typ]) + if sys.version_info >= (3, 10): + assert is_optional(typ | None) + assert is_optional(None | typ) + + +@pytest.mark.parametrize("typ", types) +def test_is_tuple(typ: Any) -> None: + assert is_tuple(Tuple[typ]) + if sys.version_info >= (3, 10): + assert is_tuple(tuple[typ]) + + +@pytest.mark.parametrize("typ", types) +def test_is_union(typ: Any) -> None: + assert is_union(Optional[typ]) + assert is_union(Union[typ, None]) + assert is_union(Union[None, typ]) + if sys.version_info >= (3, 10): + assert is_union(typ | None) + assert is_union(None | typ)