diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e77c1f..3356adbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# Unreleased + +- Raise `TypeError` when attempting to subclass `typing_extensions.ParamSpec` on + Python 3.9. The `typing` implementation has always raised an error, and the + `typing_extensions` implementation has raised an error on Python 3.10+ since + `typing_extensions` v4.6.0. Patch by Brian Schubert. + # Release 4.15.0 (August 25, 2025) No user-facing changes since 4.15.0rc1. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 0986427c..551579dc 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -531,6 +531,14 @@ def test_pickle(self): pickled = pickle.dumps(self.bottom_type, protocol=proto) self.assertIs(self.bottom_type, pickle.loads(pickled)) + @skipUnless(TYPING_3_10_0, "PEP 604 has yet to be") + def test_or(self): + self.assertEqual(self.bottom_type | int, Union[self.bottom_type, int]) + self.assertEqual(int | self.bottom_type, Union[int, self.bottom_type]) + + self.assertEqual(get_args(self.bottom_type | int), (self.bottom_type, int)) + self.assertEqual(get_args(int | self.bottom_type), (int, self.bottom_type)) + class NoReturnTests(BottomTypeTestsMixin, BaseTestCase): bottom_type = NoReturn @@ -2210,6 +2218,39 @@ def test_or_and_ror(self): Union[typing_extensions.Generator, typing.Deque] ) + def test_setattr(self): + origin = collections.abc.Generator + alias = typing_extensions.Generator + original_name = alias._name + + def cleanup(): + for obj in origin, alias: + for attr in 'foo', '__dunder__': + try: + delattr(obj, attr) + except Exception: + pass + try: + alias._name = original_name + except Exception: + pass + + self.addCleanup(cleanup) + + # Attribute assignment on generic alias sets attribute on origin + alias.foo = 1 + self.assertEqual(alias.foo, 1) + self.assertEqual(origin.foo, 1) + # Except for dunders... + alias.__dunder__ = 2 + self.assertEqual(alias.__dunder__, 2) + self.assertRaises(AttributeError, lambda: origin.__dunder__) + + # ...and certain known attributes + alias._name = "NewName" + self.assertEqual(alias._name, "NewName") + self.assertRaises(AttributeError, lambda: origin._name) + class OtherABCTests(BaseTestCase): @@ -2379,6 +2420,16 @@ def test_error_message_when_subclassing(self): class ProUserId(UserId): ... + def test_module_with_incomplete_sys(self): + def does_not_exist(*args): + raise AttributeError + with ( + patch("sys._getframemodulename", does_not_exist, create=True), + patch("sys._getframe", does_not_exist, create=True), + ): + X = NewType("X", int) + self.assertEqual(X.__module__, None) + class Coordinate(Protocol): x: int @@ -5297,6 +5348,17 @@ class A(TypedDict): def test_dunder_dict(self): self.assertIsInstance(TypedDict.__dict__, dict) + @skipUnless(TYPING_3_10_0, "PEP 604 has yet to be") + def test_or(self): + class TD(TypedDict): + a: int + + self.assertEqual(TD | int, Union[TD, int]) + self.assertEqual(int | TD, Union[int, TD]) + + self.assertEqual(get_args(TD | int), (TD, int)) + self.assertEqual(get_args(int | TD), (int, TD)) + class AnnotatedTests(BaseTestCase): def test_repr(self): @@ -5519,6 +5581,19 @@ def barfoo3(x: BA2): ... BA2 ) + @skipUnless(TYPING_3_11_0, "TODO: evaluate nested forward refs in Python < 3.11") + def test_get_type_hints_genericalias(self): + def foobar(x: list['X']): ... + X = Annotated[int, (1, 10)] + self.assertEqual( + get_type_hints(foobar, globals(), locals()), + {'x': list[int]} + ) + self.assertEqual( + get_type_hints(foobar, globals(), locals(), include_extras=True), + {'x': list[Annotated[int, (1, 10)]]} + ) + def test_get_type_hints_refs(self): Const = Annotated[T, "Const"] @@ -5973,6 +6048,11 @@ def run(): # The actual test: self.assertEqual(result1, result2) + def test_subclass(self): + with self.assertRaises(TypeError): + class MyParamSpec(ParamSpec): + pass + class ConcatenateTests(BaseTestCase): def test_basics(self): @@ -6335,6 +6415,14 @@ def test_pickle(self): pickled = pickle.dumps(LiteralString, protocol=proto) self.assertIs(LiteralString, pickle.loads(pickled)) + @skipUnless(TYPING_3_10_0, "PEP 604 has yet to be") + def test_or(self): + self.assertEqual(LiteralString | int, Union[LiteralString, int]) + self.assertEqual(int | LiteralString, Union[int, LiteralString]) + + self.assertEqual(get_args(LiteralString | int), (LiteralString, int)) + self.assertEqual(get_args(int | LiteralString), (int, LiteralString)) + class SelfTests(BaseTestCase): def test_basics(self): @@ -6382,6 +6470,14 @@ def test_pickle(self): pickled = pickle.dumps(Self, protocol=proto) self.assertIs(Self, pickle.loads(pickled)) + @skipUnless(TYPING_3_10_0, "PEP 604 has yet to be") + def test_or(self): + self.assertEqual(Self | int, Union[Self, int]) + self.assertEqual(int | Self, Union[int, Self]) + + self.assertEqual(get_args(Self | int), (Self, int)) + self.assertEqual(get_args(int | Self), (int, Self)) + class UnpackTests(BaseTestCase): def test_basic_plain(self): @@ -7711,42 +7807,61 @@ class A(Generic[T, P, U]): ... self.assertEqual(A[float, [range], int].__args__, (float, (range,), int)) -class NoDefaultTests(BaseTestCase): +class SentinelTestsMixin: @skip_if_py313_beta_1 def test_pickling(self): for proto in range(pickle.HIGHEST_PROTOCOL + 1): - s = pickle.dumps(NoDefault, proto) + s = pickle.dumps(self.sentinel_type, proto) loaded = pickle.loads(s) - self.assertIs(NoDefault, loaded) + self.assertIs(self.sentinel_type, loaded) @skip_if_py313_beta_1 def test_doc(self): - self.assertIsInstance(NoDefault.__doc__, str) + self.assertIsInstance(self.sentinel_type.__doc__, str) def test_constructor(self): - self.assertIs(NoDefault, type(NoDefault)()) + self.assertIs(self.sentinel_type, type(self.sentinel_type)()) with self.assertRaises(TypeError): - type(NoDefault)(1) - - def test_repr(self): - self.assertRegex(repr(NoDefault), r'typing(_extensions)?\.NoDefault') + type(self.sentinel_type)(1) def test_no_call(self): with self.assertRaises(TypeError): - NoDefault() + self.sentinel_type() @skip_if_py313_beta_1 def test_immutable(self): with self.assertRaises(AttributeError): - NoDefault.foo = 'bar' + self.sentinel_type.foo = 'bar' with self.assertRaises(AttributeError): - NoDefault.foo + self.sentinel_type.foo # TypeError is consistent with the behavior of NoneType with self.assertRaises(TypeError): - type(NoDefault).foo = 3 + type(self.sentinel_type).foo = 3 with self.assertRaises(AttributeError): - type(NoDefault).foo + type(self.sentinel_type).foo + + +class NoDefaultTests(SentinelTestsMixin, BaseTestCase): + sentinel_type = NoDefault + + def test_repr(self): + if hasattr(typing, 'NoDefault'): + mod_name = 'typing' + else: + mod_name = "typing_extensions" + self.assertEqual(repr(NoDefault), f"{mod_name}.NoDefault") + + +class NoExtraItemsTests(SentinelTestsMixin, BaseTestCase): + sentinel_type = NoExtraItems + + def test_repr(self): + if hasattr(typing, 'NoExtraItems'): + mod_name = 'typing' + else: + mod_name = "typing_extensions" + self.assertEqual(repr(NoExtraItems), f"{mod_name}.NoExtraItems") class TypeVarInferVarianceTests(BaseTestCase): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c2ecc2fc..38592935 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -555,7 +555,9 @@ def _is_dunder(attr): class _SpecialGenericAlias(typing._SpecialGenericAlias, _root=True): - def __init__(self, origin, nparams, *, inst=True, name=None, defaults=()): + def __init__(self, origin, nparams, *, defaults, inst=True, name=None): + assert nparams > 0, "`nparams` must be a positive integer" + assert defaults, "Must always specify a non-empty sequence for `defaults`" super().__init__(origin, nparams, inst=inst, name=name) self._defaults = defaults @@ -573,20 +575,14 @@ def __getitem__(self, params): msg = "Parameters to generic types must be types." params = tuple(typing._type_check(p, msg) for p in params) if ( - self._defaults - and len(params) < self._nparams + len(params) < self._nparams and len(params) + len(self._defaults) >= self._nparams ): params = (*params, *self._defaults[len(params) - self._nparams:]) actual_len = len(params) if actual_len != self._nparams: - if self._defaults: - expected = f"at least {self._nparams - len(self._defaults)}" - else: - expected = str(self._nparams) - if not self._nparams: - raise TypeError(f"{self} is not a generic class") + expected = f"at least {self._nparams - len(self._defaults)}" raise TypeError( f"Too {'many' if actual_len > self._nparams else 'few'}" f" arguments for {self};" @@ -1960,6 +1956,9 @@ def __reduce__(self): def __call__(self, *args, **kwargs): pass + def __init_subclass__(cls) -> None: + raise TypeError(f"type '{__name__}.ParamSpec' is not an acceptable base type") + # 3.9 if not hasattr(typing, 'Concatenate'):