From 0d3cf219c68e1689a29d82c9994c9a3f8197a355 Mon Sep 17 00:00:00 2001 From: Peter Schutt Date: Tue, 26 Sep 2023 14:44:52 +1000 Subject: [PATCH] feat: add `ParameterView` and tests. --- tests/test_parameter_view.py | 36 +++++++++++++++++++++ type_lens/__init__.py | 4 +-- type_lens/exc.py | 17 ++++++++++ type_lens/parameter_view.py | 63 ++++++++++++++++++++++++++++++++++++ type_lens/types.py | 6 +++- type_lens/utils.py | 2 +- 6 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 tests/test_parameter_view.py create mode 100644 type_lens/exc.py create mode 100644 type_lens/parameter_view.py diff --git a/tests/test_parameter_view.py b/tests/test_parameter_view.py new file mode 100644 index 0000000..e896e07 --- /dev/null +++ b/tests/test_parameter_view.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from inspect import Parameter + +import pytest + +from type_lens.exc import ParameterViewError +from type_lens.parameter_view import ParameterView +from type_lens.types import Empty + + +def test_param_view() -> None: + """Test ParameterView.""" + param = Parameter("foo", Parameter.POSITIONAL_OR_KEYWORD, annotation=int) + param_view = ParameterView.from_parameter(param, {"foo": int}) + assert param_view.name == "foo" + assert param_view.default is Empty + assert param_view.type_view.annotation is int + + +def test_param_view_raises_improperly_configured_if_no_annotation() -> None: + """Test ParameterView raises ImproperlyConfigured if no annotation.""" + param = Parameter("foo", Parameter.POSITIONAL_OR_KEYWORD) + with pytest.raises(ParameterViewError): + ParameterView.from_parameter(param, {}) + + +def test_param_view_has_default_predicate() -> None: + """Test ParameterView.has_default.""" + param = Parameter("foo", Parameter.POSITIONAL_OR_KEYWORD, annotation=int) + param_view = ParameterView.from_parameter(param, {"foo": int}) + assert param_view.has_default is False + + param = Parameter("foo", Parameter.POSITIONAL_OR_KEYWORD, annotation=int, default=42) + param_view = ParameterView.from_parameter(param, {"foo": int}) + assert param_view.has_default is True diff --git a/type_lens/__init__.py b/type_lens/__init__.py index 9735336..3fb91cb 100644 --- a/type_lens/__init__.py +++ b/type_lens/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from .type_lens import TypeLens +from .type_view import TypeView -__all__ = ("TypeLens",) +__all__ = ("TypeView",) diff --git a/type_lens/exc.py b/type_lens/exc.py new file mode 100644 index 0000000..9b26852 --- /dev/null +++ b/type_lens/exc.py @@ -0,0 +1,17 @@ +__all__ = ( + "TypeLensError", + "TypeViewError", + "ParameterViewError", +) + + +class TypeLensError(Exception): + """Base class for library exceptions.""" + + +class TypeViewError(TypeLensError): + """Base class for TypeView exceptions.""" + + +class ParameterViewError(TypeLensError): + """Base class for ParameterView exceptions.""" diff --git a/type_lens/parameter_view.py b/type_lens/parameter_view.py new file mode 100644 index 0000000..4825c3a --- /dev/null +++ b/type_lens/parameter_view.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass +from inspect import Signature +from typing import TYPE_CHECKING + +from type_lens.exc import ParameterViewError +from type_lens.type_view import TypeView +from type_lens.types import Empty + +__all__ = ("ParameterView",) + + +if TYPE_CHECKING: + from inspect import Parameter + from typing import Any, Self + + +@dataclass(frozen=True) +class ParameterView: + """Represents the parameters of a callable.""" + + __slots__ = ( + "name", + "default", + "type_view", + ) + + name: str + """The name of the parameter.""" + default: Any | Empty + """The default value of the parameter.""" + type_view: TypeView + """View of the parameter's annotation type.""" + + @property + def has_default(self) -> bool: + """Whether the parameter has a default value or not.""" + return self.default is not Empty + + @classmethod + def from_parameter(cls, parameter: Parameter, fn_type_hints: dict[str, Any]) -> Self: + """Initialize ParsedSignatureParameter. + + Args: + parameter: inspect.Parameter + fn_type_hints: mapping of names to types. Should be result of ``get_type_hints()``, preferably via the + :attr:``get_fn_type_hints() <.utils.signature_parsing.get_fn_type_hints>` helper. + + Returns: + ParsedSignatureParameter. + """ + try: + annotation = fn_type_hints[parameter.name] + except KeyError as err: + msg = f"No annotation found for '{parameter.name}'" + raise ParameterViewError(msg) from err + + return cls( + name=parameter.name, + default=Empty if parameter.default is Signature.empty else parameter.default, + type_view=TypeView(annotation), + ) diff --git a/type_lens/types.py b/type_lens/types.py index 2b7f1ed..9c5a9c9 100644 --- a/type_lens/types.py +++ b/type_lens/types.py @@ -3,7 +3,7 @@ import sys from typing import Union -__all__ = ["UNION_TYPES", "NoneType"] +__all__ = ["UNION_TYPES", "Empty", "NoneType"] if sys.version_info >= (3, 10): from types import UnionType @@ -13,3 +13,7 @@ UNION_TYPES = {Union} NoneType: type[None] = type(None) + + +class Empty: + """A sentinel class used as placeholder.""" diff --git a/type_lens/utils.py b/type_lens/utils.py index 762eefb..4d9c6f5 100644 --- a/type_lens/utils.py +++ b/type_lens/utils.py @@ -17,7 +17,7 @@ _WRAPPER_TYPES: te.Final = {te.Annotated, te.Required, te.NotRequired} """Types that always contain a wrapped type annotation as their first arg.""" -_GENERIC_ORIGIN_MAP: te.Final = { +_GENERIC_ORIGIN_MAP: te.Final[dict[t.Any, t.Any]] = { set: t.AbstractSet, defaultdict: t.DefaultDict, deque: t.Deque,