]> git.ipfire.org Git - thirdparty/knot-resolver.git/commitdiff
python: modeling: types: added inspect module to help with types annotations
authorAleš Mrázek <ales.mrazek@nic.cz>
Mon, 16 Feb 2026 21:59:26 +0000 (22:59 +0100)
committerAleš Mrázek <ales.mrazek@nic.cz>
Mon, 20 Apr 2026 22:25:58 +0000 (00:25 +0200)
python/knot_resolver/utils/modeling/types/inspect.py [new file with mode: 0644]
tests/python/knot_resolver/utils/modeling/types/test_inspect.py [new file with mode: 0644]

diff --git a/python/knot_resolver/utils/modeling/types/inspect.py b/python/knot_resolver/utils/modeling/types/inspect.py
new file mode 100644 (file)
index 0000000..e7ffc36
--- /dev/null
@@ -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 (file)
index 0000000..c11ec2d
--- /dev/null
@@ -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)