From 03f7ea8b10a5224f1fddc384e5a4e192dc2bba62 Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Fri, 2 May 2025 11:24:51 +0200 Subject: [PATCH 1/9] Narrow `REgexValidator.regex` type because it cast to a pattern in init --- django-stubs/core/validators.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django-stubs/core/validators.pyi b/django-stubs/core/validators.pyi index 644106429..62a92fc38 100644 --- a/django-stubs/core/validators.pyi +++ b/django-stubs/core/validators.pyi @@ -14,7 +14,7 @@ _Regex: TypeAlias = str | Pattern[str] _ValidatorCallable: TypeAlias = Callable[[Any], None] # noqa: PYI047 class RegexValidator(_Deconstructible): - regex: _Regex # Pattern[str] on instance, but may be str on class definition + regex: Pattern[str] message: _StrOrPromise code: str inverse_match: bool From d7f9a5461184aa45663d0087572dd68730fe8a84 Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Fri, 2 May 2025 20:21:44 +0200 Subject: [PATCH 2/9] Use a descriptor to describe the type --- django-stubs/core/validators.pyi | 15 +++++++++++++-- tests/assert_type/core/test_validators.py | 11 +++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 tests/assert_type/core/test_validators.py diff --git a/django-stubs/core/validators.pyi b/django-stubs/core/validators.pyi index 62a92fc38..37e4f20ed 100644 --- a/django-stubs/core/validators.pyi +++ b/django-stubs/core/validators.pyi @@ -1,7 +1,7 @@ from collections.abc import Callable, Collection, Sequence, Sized from decimal import Decimal from re import Pattern, RegexFlag -from typing import Any, TypeAlias +from typing import Any, Generic, TypeAlias, TypeVar, overload, type_check_only from django.core.files.base import File from django.utils.deconstruct import _Deconstructible @@ -13,8 +13,19 @@ _Regex: TypeAlias = str | Pattern[str] _ValidatorCallable: TypeAlias = Callable[[Any], None] # noqa: PYI047 +_ClassT = TypeVar("_ClassT") +_InstanceT = TypeVar("_InstanceT") + +@type_check_only +class ClassOrInstanceAttribute(Generic[_ClassT, _InstanceT]): + @overload + def __get__(self, obj: None, owner: type[object]) -> _ClassT: ... + @overload + def __get__(self, obj: object, owner: type[object]) -> _InstanceT: ... + def __set__(self, obj: object, value: _InstanceT) -> None: ... + class RegexValidator(_Deconstructible): - regex: Pattern[str] + regex: ClassOrInstanceAttribute[_Regex, Pattern[str]] message: _StrOrPromise code: str inverse_match: bool diff --git a/tests/assert_type/core/test_validators.py b/tests/assert_type/core/test_validators.py new file mode 100644 index 000000000..141584b15 --- /dev/null +++ b/tests/assert_type/core/test_validators.py @@ -0,0 +1,11 @@ +from re import Pattern +from typing import assert_type + +from django.contrib.auth.validators import UnicodeUsernameValidator +from django.core.validators import RegexValidator + +assert_type(RegexValidator.regex, str | Pattern[str]) +assert_type(RegexValidator().regex, Pattern[str]) + +assert_type(UnicodeUsernameValidator.regex, str | Pattern[str]) +assert_type(UnicodeUsernameValidator().regex, Pattern[str]) From e86f563195f466605f6d3d9cd06f8906674a4bc8 Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Fri, 2 May 2025 23:54:42 +0200 Subject: [PATCH 3/9] Fix after CR --- django-stubs/core/validators.pyi | 10 +++++----- tests/assert_type/core/test_validators.py | 7 +++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/django-stubs/core/validators.pyi b/django-stubs/core/validators.pyi index 37e4f20ed..50747d04e 100644 --- a/django-stubs/core/validators.pyi +++ b/django-stubs/core/validators.pyi @@ -17,15 +17,15 @@ _ClassT = TypeVar("_ClassT") _InstanceT = TypeVar("_InstanceT") @type_check_only -class ClassOrInstanceAttribute(Generic[_ClassT, _InstanceT]): +class _ClassOrInstanceAttribute(Generic[_ClassT, _InstanceT]): @overload - def __get__(self, obj: None, owner: type[object]) -> _ClassT: ... + def __get__(self, instance: None, owner: type[object]) -> _ClassT: ... @overload - def __get__(self, obj: object, owner: type[object]) -> _InstanceT: ... - def __set__(self, obj: object, value: _InstanceT) -> None: ... + def __get__(self, instance: object, owner: type[object]) -> _InstanceT: ... + def __set__(self, instance: object, value: _InstanceT) -> None: ... class RegexValidator(_Deconstructible): - regex: ClassOrInstanceAttribute[_Regex, Pattern[str]] + regex: _ClassOrInstanceAttribute[_Regex, Pattern[str]] message: _StrOrPromise code: str inverse_match: bool diff --git a/tests/assert_type/core/test_validators.py b/tests/assert_type/core/test_validators.py index 141584b15..80e7433e2 100644 --- a/tests/assert_type/core/test_validators.py +++ b/tests/assert_type/core/test_validators.py @@ -1,3 +1,4 @@ +import re from re import Pattern from typing import assert_type @@ -6,6 +7,12 @@ assert_type(RegexValidator.regex, str | Pattern[str]) assert_type(RegexValidator().regex, Pattern[str]) +RegexValidator().regex = re.compile("") +RegexValidator().regex = "" # type: ignore[assignment] # expect "Pattern[str]" +RegexValidator.regex = "anything fails here" # type: ignore[assignment] # expect "_ClassOrInstanceAttribute[Union[str, Pattern[str]], Pattern[str]]" assert_type(UnicodeUsernameValidator.regex, str | Pattern[str]) assert_type(UnicodeUsernameValidator().regex, Pattern[str]) +UnicodeUsernameValidator().regex = re.compile("") +UnicodeUsernameValidator().regex = "" # type: ignore[assignment] # expect "Pattern[str]" +UnicodeUsernameValidator.regex = "anything fails here" # type: ignore[assignment] # expect "_ClassOrInstanceAttribute[Union[str, Pattern[str]], Pattern[str]]" From 4647a3e90d6d5462fe33815b2a6fdc47b9a200f1 Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Sat, 3 May 2025 00:01:19 +0200 Subject: [PATCH 4/9] Amend the allowlist --- scripts/stubtest/allowlist.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/stubtest/allowlist.txt b/scripts/stubtest/allowlist.txt index 401d9e586..30e78a885 100644 --- a/scripts/stubtest/allowlist.txt +++ b/scripts/stubtest/allowlist.txt @@ -550,3 +550,9 @@ django.contrib.gis.forms.BaseModelFormSet.save_m2m # Dynamically generated in https://github.com/django/django/blob/0ee06c04e0256094270db3ffe8b5dafa6a8457a3/django/core/mail/backends/locmem.py#L24 django.core.mail.outbox + +# We use a trick using a descriptor class to represent an attribute with different type at the class and instance level. +# Here to narrow `str | Pattern[str]` at the class level to `Pattern[str]` at the instance level. +django.core.validators.RegexValidator.regex +django.contrib.auth.validators.ASCIIUsernameValidator.regex +django.contrib.auth.validators.UnicodeUsernameValidator.regex From af7948ecb035a2ab6e5b106c70c5599db80f5107 Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Sat, 3 May 2025 00:08:00 +0200 Subject: [PATCH 5/9] Add pyright ignores too + use `typing_extensions` --- tests/assert_type/core/test_validators.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/assert_type/core/test_validators.py b/tests/assert_type/core/test_validators.py index 80e7433e2..da6bb7f5d 100644 --- a/tests/assert_type/core/test_validators.py +++ b/tests/assert_type/core/test_validators.py @@ -1,18 +1,22 @@ import re from re import Pattern -from typing import assert_type from django.contrib.auth.validators import UnicodeUsernameValidator from django.core.validators import RegexValidator +from typing_extensions import assert_type assert_type(RegexValidator.regex, str | Pattern[str]) assert_type(RegexValidator().regex, Pattern[str]) RegexValidator().regex = re.compile("") -RegexValidator().regex = "" # type: ignore[assignment] # expect "Pattern[str]" -RegexValidator.regex = "anything fails here" # type: ignore[assignment] # expect "_ClassOrInstanceAttribute[Union[str, Pattern[str]], Pattern[str]]" assert_type(UnicodeUsernameValidator.regex, str | Pattern[str]) assert_type(UnicodeUsernameValidator().regex, Pattern[str]) UnicodeUsernameValidator().regex = re.compile("") -UnicodeUsernameValidator().regex = "" # type: ignore[assignment] # expect "Pattern[str]" -UnicodeUsernameValidator.regex = "anything fails here" # type: ignore[assignment] # expect "_ClassOrInstanceAttribute[Union[str, Pattern[str]], Pattern[str]]" + +# expect "Pattern[str]" +RegexValidator().regex = "" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] +UnicodeUsernameValidator().regex = "" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] + +# expect "_ClassOrInstanceAttribute[Union[str, Pattern[str]], Pattern[str]]" +RegexValidator.regex = "anything fails here" # type: ignore[assignment] +UnicodeUsernameValidator.regex = "anything fails here" # type: ignore[assignment] From ec1a145004fcc4671c582d14c5fd12c775bf1770 Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Sat, 3 May 2025 00:11:49 +0200 Subject: [PATCH 6/9] Add missing pyright ignores --- tests/assert_type/core/test_validators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/assert_type/core/test_validators.py b/tests/assert_type/core/test_validators.py index da6bb7f5d..984845555 100644 --- a/tests/assert_type/core/test_validators.py +++ b/tests/assert_type/core/test_validators.py @@ -18,5 +18,5 @@ UnicodeUsernameValidator().regex = "" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] # expect "_ClassOrInstanceAttribute[Union[str, Pattern[str]], Pattern[str]]" -RegexValidator.regex = "anything fails here" # type: ignore[assignment] -UnicodeUsernameValidator.regex = "anything fails here" # type: ignore[assignment] +RegexValidator.regex = "anything fails here" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] +UnicodeUsernameValidator.regex = "anything fails here" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] From 0ca8419db4f15730f66fb10de58040b0ea26e637 Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Sat, 3 May 2025 10:16:19 +0200 Subject: [PATCH 7/9] Add test cases --- tests/assert_type/core/test_validators.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/assert_type/core/test_validators.py b/tests/assert_type/core/test_validators.py index 984845555..2ca433800 100644 --- a/tests/assert_type/core/test_validators.py +++ b/tests/assert_type/core/test_validators.py @@ -13,6 +13,7 @@ assert_type(UnicodeUsernameValidator().regex, Pattern[str]) UnicodeUsernameValidator().regex = re.compile("") + # expect "Pattern[str]" RegexValidator().regex = "" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] UnicodeUsernameValidator().regex = "" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] @@ -20,3 +21,11 @@ # expect "_ClassOrInstanceAttribute[Union[str, Pattern[str]], Pattern[str]]" RegexValidator.regex = "anything fails here" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] UnicodeUsernameValidator.regex = "anything fails here" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] + + +class RegexSubtype(RegexValidator): + regex = re.compile("abc") + + +class StrSubtype(RegexValidator): + regex = "abc" From 31b693905728dcf6ade1ecfcb971390bc2dcc02a Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Sat, 3 May 2025 15:26:49 +0200 Subject: [PATCH 8/9] Remove descriptor approach, It doesn't handle subclassing well --- django-stubs/core/validators.pyi | 15 ++------------- scripts/stubtest/allowlist.txt | 5 +++-- tests/assert_type/core/test_validators.py | 17 +++++++++-------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/django-stubs/core/validators.pyi b/django-stubs/core/validators.pyi index 50747d04e..62a92fc38 100644 --- a/django-stubs/core/validators.pyi +++ b/django-stubs/core/validators.pyi @@ -1,7 +1,7 @@ from collections.abc import Callable, Collection, Sequence, Sized from decimal import Decimal from re import Pattern, RegexFlag -from typing import Any, Generic, TypeAlias, TypeVar, overload, type_check_only +from typing import Any, TypeAlias from django.core.files.base import File from django.utils.deconstruct import _Deconstructible @@ -13,19 +13,8 @@ _Regex: TypeAlias = str | Pattern[str] _ValidatorCallable: TypeAlias = Callable[[Any], None] # noqa: PYI047 -_ClassT = TypeVar("_ClassT") -_InstanceT = TypeVar("_InstanceT") - -@type_check_only -class _ClassOrInstanceAttribute(Generic[_ClassT, _InstanceT]): - @overload - def __get__(self, instance: None, owner: type[object]) -> _ClassT: ... - @overload - def __get__(self, instance: object, owner: type[object]) -> _InstanceT: ... - def __set__(self, instance: object, value: _InstanceT) -> None: ... - class RegexValidator(_Deconstructible): - regex: _ClassOrInstanceAttribute[_Regex, Pattern[str]] + regex: Pattern[str] message: _StrOrPromise code: str inverse_match: bool diff --git a/scripts/stubtest/allowlist.txt b/scripts/stubtest/allowlist.txt index 30e78a885..397e61485 100644 --- a/scripts/stubtest/allowlist.txt +++ b/scripts/stubtest/allowlist.txt @@ -551,8 +551,9 @@ django.contrib.gis.forms.BaseModelFormSet.save_m2m # Dynamically generated in https://github.com/django/django/blob/0ee06c04e0256094270db3ffe8b5dafa6a8457a3/django/core/mail/backends/locmem.py#L24 django.core.mail.outbox -# We use a trick using a descriptor class to represent an attribute with different type at the class and instance level. -# Here to narrow `str | Pattern[str]` at the class level to `Pattern[str]` at the instance level. +# The type system does not allow to represent an attribute with different type at the class level (`str | Pattern[str]`) +# and instance (`Pattern[str]`) level. But in order to have more precise type at the instance level, we restrict the types +# allowed at the class level to a subset. In an ideal world, these should probably have different attribute names. django.core.validators.RegexValidator.regex django.contrib.auth.validators.ASCIIUsernameValidator.regex django.contrib.auth.validators.UnicodeUsernameValidator.regex diff --git a/tests/assert_type/core/test_validators.py b/tests/assert_type/core/test_validators.py index 2ca433800..60ff17aae 100644 --- a/tests/assert_type/core/test_validators.py +++ b/tests/assert_type/core/test_validators.py @@ -5,27 +5,28 @@ from django.core.validators import RegexValidator from typing_extensions import assert_type -assert_type(RegexValidator.regex, str | Pattern[str]) assert_type(RegexValidator().regex, Pattern[str]) RegexValidator().regex = re.compile("") -assert_type(UnicodeUsernameValidator.regex, str | Pattern[str]) assert_type(UnicodeUsernameValidator().regex, Pattern[str]) UnicodeUsernameValidator().regex = re.compile("") - # expect "Pattern[str]" RegexValidator().regex = "" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] UnicodeUsernameValidator().regex = "" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] -# expect "_ClassOrInstanceAttribute[Union[str, Pattern[str]], Pattern[str]]" -RegexValidator.regex = "anything fails here" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] -UnicodeUsernameValidator.regex = "anything fails here" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] - class RegexSubtype(RegexValidator): regex = re.compile("abc") +# We would like to improve on these, it should allow "str | Pattern[str]": +assert_type(RegexValidator.regex, Pattern[str]) +assert_type(UnicodeUsernameValidator.regex, Pattern[str]) + +RegexValidator.regex = "" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] +UnicodeUsernameValidator.regex = "" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] + + class StrSubtype(RegexValidator): - regex = "abc" + regex = "abc" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] From 1d4eff0a52272748c0eb3cc2c6d1057a5fa75d88 Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Sat, 3 May 2025 17:15:48 +0200 Subject: [PATCH 9/9] Correct pyright ignore --- tests/assert_type/core/test_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/assert_type/core/test_validators.py b/tests/assert_type/core/test_validators.py index 60ff17aae..71e293ae2 100644 --- a/tests/assert_type/core/test_validators.py +++ b/tests/assert_type/core/test_validators.py @@ -29,4 +29,4 @@ class RegexSubtype(RegexValidator): class StrSubtype(RegexValidator): - regex = "abc" # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] + regex = "abc" # type: ignore[assignment] # pyright: ignore[reportAssignmentType]