From 2435520e8144f2566891f17ef79e1dc93e37a9b7 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 20 Oct 2022 15:02:17 +0100 Subject: [PATCH 1/6] Proof of concept for exploring --- traitlets/traitlets.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index 7489d019..b2b5d480 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -514,7 +514,10 @@ def instance_init(self, obj): pass -class TraitType(BaseDescriptor): +G = t.TypeVar('G') +S = t.TypeVar('S') + +class TraitType(BaseDescriptor, t.Generic[G, S]): """A base class for all trait types.""" metadata: t.Dict[str, t.Any] = {} @@ -672,7 +675,7 @@ def get(self, obj, cls=None): else: return value - def __get__(self, obj, cls=None): + def __get__(self, obj, cls=None) -> G: """Get the value of the trait by self.name for the instance. Default values are instantiated when :meth:`HasTraits.__new__` @@ -703,7 +706,7 @@ def set(self, obj, value): # comparison above returns something other than True/False obj._notify_trait(self.name, old_value, new_value) - def __set__(self, obj, value): + def __set__(self, obj, value: S): """Set the value of the trait by self.name for the instance. Values pass through a validation stage where errors are raised when @@ -2460,7 +2463,7 @@ def validate(self, obj, value): self.error(obj, value) -class Bool(TraitType): +class Bool(TraitType[str, t.Union[bool, int]]): """A boolean (True, False) trait.""" default_value = False @@ -2488,7 +2491,7 @@ def from_string(self, s): raise ValueError("%r is not 1, 0, true, or false") -class CBool(Bool): +class CBool(Bool, TraitType[bool, t.Any]): """A casting version of the boolean trait.""" def validate(self, obj, value): From e4a581909cf87dd4f4cf8b379ab4d150e5905bbc Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 20 Oct 2022 17:02:38 +0100 Subject: [PATCH 2/6] Add some workable typings, and some tests Typings can still be improved, and more tests would be needed, but this demonstrates the concept. --- pyproject.toml | 2 +- traitlets/tests/test_typing.py | 27 +++++++++++++++++ traitlets/traitlets.py | 55 +++++++++++++++++----------------- 3 files changed, 56 insertions(+), 28 deletions(-) create mode 100644 traitlets/tests/test_typing.py diff --git a/pyproject.toml b/pyproject.toml index 993179a9..e0b8ed86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ requires-python = ">=3.7" dynamic = ["description", "version"] [project.optional-dependencies] -test = ["pytest", "pre-commit"] +test = ["pytest", "pre-commit", "pytest-mypy-testing"] docs = [ "myst-parser", "pydata-sphinx-theme", diff --git a/traitlets/tests/test_typing.py b/traitlets/tests/test_typing.py new file mode 100644 index 00000000..93324d8f --- /dev/null +++ b/traitlets/tests/test_typing.py @@ -0,0 +1,27 @@ + +from lib2to3 import pytree +from typing_extensions import reveal_type +import pytest + +from traitlets import ( + Bool, + HasTraits, + TCPAddress, +) + +@pytest.mark.mypy_testing +def mypy_bool_typing(): + class T(HasTraits): + b = Bool() + t = T() + reveal_type(t.b) # R: Union[builtins.bool, None] + # we would expect this to be Optional[Union[bool, int]], but... + t.b = 'foo' # E: Incompatible types in assignment (expression has type "str", variable has type "Optional[int]") [assignment] + +@pytest.mark.mypy_testing +def mypy_tcp_typing(): + class T(HasTraits): + tcp = TCPAddress() + t = T() + reveal_type(t.tcp) # R: Union[Tuple[builtins.str, builtins.int], None] + t.tcp = 'foo' # E: Incompatible types in assignment (expression has type "str", variable has type "Optional[Tuple[str, int]]") [assignment] diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index b2b5d480..bcfe372b 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -39,6 +39,7 @@ # Adapted from enthought.traits, Copyright (c) Enthought, Inc., # also under the terms of the Modified BSD License. +import collections.abc import contextlib import enum import inspect @@ -643,9 +644,9 @@ def init_default_value(self, obj): obj._trait_values[self.name] = value return value - def get(self, obj, cls=None): + def get(self, obj: 'HasTraits', cls: t.Any = None) -> t.Optional[G]: try: - value = obj._trait_values[self.name] + value = obj._trait_values[self.name] # type: ignore except KeyError: # Check for a dynamic initializer. default = obj.trait_defaults(self.name) @@ -659,7 +660,7 @@ def get(self, obj, cls=None): ) with obj.cross_validation_lock: value = self._validate(obj, default) - obj._trait_values[self.name] = value + obj._trait_values[self.name] = value # type: ignore obj._notify_observers( Bunch( name=self.name, @@ -668,14 +669,14 @@ def get(self, obj, cls=None): type="default", ) ) - return value + return value # type: ignore except Exception: # This should never be reached. raise TraitError("Unexpected error in TraitType: default value not set properly") else: - return value + return value # type: ignore - def __get__(self, obj, cls=None) -> G: + def __get__(self, obj: 'HasTraits', cls: t.Any = None) -> t.Optional[G]: """Get the value of the trait by self.name for the instance. Default values are instantiated when :meth:`HasTraits.__new__` @@ -706,7 +707,7 @@ def set(self, obj, value): # comparison above returns something other than True/False obj._notify_trait(self.name, old_value, new_value) - def __set__(self, obj, value: S): + def __set__(self, obj: 'HasTraits', value: t.Optional[S]) -> None: """Set the value of the trait by self.name for the instance. Values pass through a validation stage where errors are raised when @@ -1862,7 +1863,7 @@ def trait_events(cls, name=None): # ----------------------------------------------------------------------------- -class ClassBasedTraitType(TraitType): +class ClassBasedTraitType(TraitType[t.Any, t.Any]): """ A trait with error reporting and string -> type resolution for Type, Instance and This. @@ -2120,7 +2121,7 @@ def validate(self, obj, value): self.error(obj, value) -class Union(TraitType): +class Union(TraitType[t.Any, t.Any]): """A trait type representing a Union type.""" def __init__(self, trait_types, **kwargs): @@ -2208,7 +2209,7 @@ def from_string(self, s): # ----------------------------------------------------------------------------- -class Any(TraitType): +class Any(TraitType[t.Optional[t.Any], t.Optional[t.Any]]): """A trait which allows any value.""" default_value: t.Optional[t.Any] = None @@ -2242,7 +2243,7 @@ def _validate_bounds(trait, obj, value): return value -class Int(TraitType): +class Int(TraitType[int, int]): """An int trait.""" default_value = 0 @@ -2264,7 +2265,7 @@ def from_string(self, s): return int(s) -class CInt(Int): +class CInt(Int, TraitType[int, t.Any]): """A casting version of the int trait.""" def validate(self, obj, value): @@ -2279,7 +2280,7 @@ def validate(self, obj, value): Integer = Int -class Float(TraitType): +class Float(TraitType[float, int | float]): """A float trait.""" default_value = 0.0 @@ -2303,7 +2304,7 @@ def from_string(self, s): return float(s) -class CFloat(Float): +class CFloat(Float, TraitType[float, t.Any]): """A casting version of the float trait.""" def validate(self, obj, value): @@ -2314,7 +2315,7 @@ def validate(self, obj, value): return _validate_bounds(self, obj, value) -class Complex(TraitType): +class Complex(TraitType[complex, complex | tuple[float, int]]): """A trait for complex numbers.""" default_value = 0.0 + 0.0j @@ -2333,7 +2334,7 @@ def from_string(self, s): return complex(s) -class CComplex(Complex): +class CComplex(Complex, TraitType[complex, t.Any]): """A casting version of the complex number trait.""" def validate(self, obj, value): @@ -2346,7 +2347,7 @@ def validate(self, obj, value): # We should always be explicit about whether we're using bytes or unicode, both # for Python 3 conversion and for reliable unicode behaviour on Python 2. So # we don't have a Str type. -class Bytes(TraitType): +class Bytes(TraitType[bytes, bytes]): """A trait for byte strings.""" default_value = b"" @@ -2375,7 +2376,7 @@ def from_string(self, s): return s.encode("utf8") -class CBytes(Bytes): +class CBytes(Bytes, TraitType[bytes, t.Any]): """A casting version of the byte string trait.""" def validate(self, obj, value): @@ -2385,7 +2386,7 @@ def validate(self, obj, value): self.error(obj, value) -class Unicode(TraitType): +class Unicode(TraitType[str, str | bytes]): """A trait for unicode strings.""" default_value = "" @@ -2420,7 +2421,7 @@ def from_string(self, s): return s -class CUnicode(Unicode): +class CUnicode(Unicode, TraitType[str, t.Any]): """A casting version of the unicode trait.""" def validate(self, obj, value): @@ -2430,7 +2431,7 @@ def validate(self, obj, value): self.error(obj, value) -class ObjectName(TraitType): +class ObjectName(TraitType[str, str]): """A string holding a valid object name in this version of Python. This does not check that the name exists in any scope.""" @@ -2463,7 +2464,7 @@ def validate(self, obj, value): self.error(obj, value) -class Bool(TraitType[str, t.Union[bool, int]]): +class Bool(TraitType[bool, t.Union[bool, int]]): """A boolean (True, False) trait.""" default_value = False @@ -2501,7 +2502,7 @@ def validate(self, obj, value): self.error(obj, value) -class Enum(TraitType): +class Enum(TraitType[t.Any, t.Any]): """An enum whose value must be in a given sequence.""" def __init__(self, values, default_value=Undefined, **kwargs): @@ -3326,7 +3327,7 @@ def item_from_string(self, s): return {key: value} -class TCPAddress(TraitType): +class TCPAddress(TraitType[tuple[str, int], tuple[str, int]]): """A trait for an (ip, port) tuple. This allows for both IPv4 IP addresses as well as hostnames. @@ -3354,7 +3355,7 @@ def from_string(self, s): return (ip, port) -class CRegExp(TraitType): +class CRegExp(TraitType[re.Pattern[t.Any], re.Pattern[t.Any] | str]): """A casting compiled regular expression trait. Accepts both strings and compiled regular expressions. The resulting @@ -3369,7 +3370,7 @@ def validate(self, obj, value): self.error(obj, value) -class UseEnum(TraitType): +class UseEnum(TraitType[t.Any, t.Any]): """Use a Enum class as model for the data type description. Note that if no default-value is provided, the first enum-value is used as default-value. @@ -3466,7 +3467,7 @@ def info_rst(self): return self._info(as_rst=True) -class Callable(TraitType): +class Callable(TraitType[collections.abc.Callable[..., t.Any], collections.abc.Callable[..., t.Any]]): """A trait which is callable. Notes From 7bca016b82a10da98a2fe2e5fed6aa77729d7c06 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 20 Oct 2022 16:16:29 +0000 Subject: [PATCH 3/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- CHANGELOG.md | 10 +++++----- traitlets/tests/test_typing.py | 18 +++++++++--------- traitlets/traitlets.py | 15 +++++++++------ 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4fb02d..7cf1cc93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ ## 5.5.0 -* Clean up application typing -* Update tests and docs to use non-deprecated functions -* Clean up version handling -* Prep for jupyter releaser -* Format the changelog +- Clean up application typing +- Update tests and docs to use non-deprecated functions +- Clean up version handling +- Prep for jupyter releaser +- Format the changelog diff --git a/traitlets/tests/test_typing.py b/traitlets/tests/test_typing.py index 93324d8f..e96617e8 100644 --- a/traitlets/tests/test_typing.py +++ b/traitlets/tests/test_typing.py @@ -1,27 +1,27 @@ - from lib2to3 import pytree -from typing_extensions import reveal_type + import pytest +from typing_extensions import reveal_type + +from traitlets import Bool, HasTraits, TCPAddress -from traitlets import ( - Bool, - HasTraits, - TCPAddress, -) @pytest.mark.mypy_testing def mypy_bool_typing(): class T(HasTraits): b = Bool() + t = T() reveal_type(t.b) # R: Union[builtins.bool, None] # we would expect this to be Optional[Union[bool, int]], but... - t.b = 'foo' # E: Incompatible types in assignment (expression has type "str", variable has type "Optional[int]") [assignment] + t.b = "foo" # E: Incompatible types in assignment (expression has type "str", variable has type "Optional[int]") [assignment] + @pytest.mark.mypy_testing def mypy_tcp_typing(): class T(HasTraits): tcp = TCPAddress() + t = T() reveal_type(t.tcp) # R: Union[Tuple[builtins.str, builtins.int], None] - t.tcp = 'foo' # E: Incompatible types in assignment (expression has type "str", variable has type "Optional[Tuple[str, int]]") [assignment] + t.tcp = "foo" # E: Incompatible types in assignment (expression has type "str", variable has type "Optional[Tuple[str, int]]") [assignment] diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index bcfe372b..2f5cad4d 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -515,8 +515,9 @@ def instance_init(self, obj): pass -G = t.TypeVar('G') -S = t.TypeVar('S') +G = t.TypeVar("G") +S = t.TypeVar("S") + class TraitType(BaseDescriptor, t.Generic[G, S]): """A base class for all trait types.""" @@ -644,7 +645,7 @@ def init_default_value(self, obj): obj._trait_values[self.name] = value return value - def get(self, obj: 'HasTraits', cls: t.Any = None) -> t.Optional[G]: + def get(self, obj: "HasTraits", cls: t.Any = None) -> t.Optional[G]: try: value = obj._trait_values[self.name] # type: ignore except KeyError: @@ -676,7 +677,7 @@ def get(self, obj: 'HasTraits', cls: t.Any = None) -> t.Optional[G]: else: return value # type: ignore - def __get__(self, obj: 'HasTraits', cls: t.Any = None) -> t.Optional[G]: + def __get__(self, obj: "HasTraits", cls: t.Any = None) -> t.Optional[G]: """Get the value of the trait by self.name for the instance. Default values are instantiated when :meth:`HasTraits.__new__` @@ -707,7 +708,7 @@ def set(self, obj, value): # comparison above returns something other than True/False obj._notify_trait(self.name, old_value, new_value) - def __set__(self, obj: 'HasTraits', value: t.Optional[S]) -> None: + def __set__(self, obj: "HasTraits", value: t.Optional[S]) -> None: """Set the value of the trait by self.name for the instance. Values pass through a validation stage where errors are raised when @@ -3467,7 +3468,9 @@ def info_rst(self): return self._info(as_rst=True) -class Callable(TraitType[collections.abc.Callable[..., t.Any], collections.abc.Callable[..., t.Any]]): +class Callable( + TraitType[collections.abc.Callable[..., t.Any], collections.abc.Callable[..., t.Any]] +): """A trait which is callable. Notes From 3d079c328c43483ac8624b813f1174212ff21f9f Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 20 Oct 2022 17:17:13 +0100 Subject: [PATCH 4/6] Fix complex type --- traitlets/traitlets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index 2f5cad4d..0d2649e0 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -2316,7 +2316,7 @@ def validate(self, obj, value): return _validate_bounds(self, obj, value) -class Complex(TraitType[complex, complex | tuple[float, int]]): +class Complex(TraitType[complex, complex | float | int]): """A trait for complex numbers.""" default_value = 0.0 + 0.0j From 4fb7293c688ec934015515fc88b5a835164ef6b1 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 27 Oct 2022 16:25:29 +0100 Subject: [PATCH 5/6] Currently failing tests --- traitlets/tests/test_typing.py | 3 ++- traitlets/traitlets.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/traitlets/tests/test_typing.py b/traitlets/tests/test_typing.py index e96617e8..b40b4b7e 100644 --- a/traitlets/tests/test_typing.py +++ b/traitlets/tests/test_typing.py @@ -9,12 +9,13 @@ @pytest.mark.mypy_testing def mypy_bool_typing(): class T(HasTraits): - b = Bool() + b = Bool().tag(sync=True) t = T() reveal_type(t.b) # R: Union[builtins.bool, None] # we would expect this to be Optional[Union[bool, int]], but... t.b = "foo" # E: Incompatible types in assignment (expression has type "str", variable has type "Optional[int]") [assignment] + T.b.tag(foo=True) @pytest.mark.mypy_testing diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index 0d2649e0..bc609c5b 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -518,6 +518,7 @@ def instance_init(self, obj): G = t.TypeVar("G") S = t.TypeVar("S") +Self = t.TypeVar("Self", bound="TraitType") # Holdover waiting for typings.Self in Python 3.11 class TraitType(BaseDescriptor, t.Generic[G, S]): """A base class for all trait types.""" @@ -855,7 +856,7 @@ def set_metadata(self, key, value): warn("Deprecated in traitlets 4.1, " + msg, DeprecationWarning, stacklevel=2) self.metadata[key] = value - def tag(self, **metadata): + def tag(self, **metadata) -> "TraitType[G, S]": """Sets metadata and returns self. This allows convenient metadata tagging when initializing the trait, such as: From 0d88902a22d3654454f50e8a38aaae007e91bef0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 27 Oct 2022 15:26:06 +0000 Subject: [PATCH 6/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- traitlets/traitlets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index bc609c5b..6b9bbece 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -520,6 +520,7 @@ def instance_init(self, obj): Self = t.TypeVar("Self", bound="TraitType") # Holdover waiting for typings.Self in Python 3.11 + class TraitType(BaseDescriptor, t.Generic[G, S]): """A base class for all trait types."""