diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 78f757a6bb..5ac36c4fb9 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -357,7 +357,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -387,7 +387,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -502,7 +502,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -529,7 +529,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -557,7 +557,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -591,7 +591,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -627,7 +627,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -651,7 +651,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -678,7 +678,7 @@ with 1: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -707,7 +707,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -751,7 +751,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -793,7 +793,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -826,7 +826,7 @@ class C[U](Generic[T]): ... Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -865,7 +865,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -900,7 +900,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -934,7 +934,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1052,7 +1052,8 @@ Checks for invalidly defined `NamedTuple` classes. **Why is this bad?** An invalidly defined `NamedTuple` class may lead to the type checker -drawing incorrect conclusions. It may also lead to `TypeError`s at runtime. +drawing incorrect conclusions. It may also lead to `TypeError`s or +`AttributeError`s at runtime. **Examples** @@ -1067,13 +1068,34 @@ in a class's bases list. TypeError: can only inherit from a NamedTuple type and Generic ``` +Further, `NamedTuple` field names cannot start with an underscore: + +```pycon +>>> from typing import NamedTuple +>>> class Foo(NamedTuple): +... _bar: int +ValueError: Field names cannot start with an underscore: '_bar' +``` + +`NamedTuple` classes also have certain synthesized attributes (like `_asdict`, `_make`, +`_replace`, etc.) that cannot be overwritten. Attempting to assign to these attributes +without a type annotation will raise an `AttributeError` at runtime. + +```pycon +>>> from typing import NamedTuple +>>> class Foo(NamedTuple): +... x: int +... _asdict = 42 +AttributeError: Cannot overwrite NamedTuple attribute _asdict +``` + ## `invalid-newtype` Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -1103,7 +1125,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1153,7 +1175,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1179,7 +1201,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1244,7 +1266,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1293,7 +1315,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1318,7 +1340,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1376,7 +1398,7 @@ TODO #14889 Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1403,7 +1425,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -1450,7 +1472,7 @@ Bar[int] # error: too few arguments Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1480,7 +1502,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1510,7 +1532,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1544,7 +1566,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1578,7 +1600,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1613,7 +1635,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1638,7 +1660,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1671,7 +1693,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1700,7 +1722,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1724,7 +1746,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1750,7 +1772,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -1783,7 +1805,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1810,7 +1832,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -1868,7 +1890,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1898,7 +1920,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1927,7 +1949,7 @@ class B(A): ... # Error raised here Default level: error · Preview (since 0.0.1-alpha.30) · Related issues · -View source +View source @@ -1961,7 +1983,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1988,7 +2010,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2016,7 +2038,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2062,7 +2084,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2089,7 +2111,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2117,7 +2139,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2142,7 +2164,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2167,7 +2189,7 @@ print(x) # NameError: name 'x' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2204,7 +2226,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2232,7 +2254,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2386,7 +2408,7 @@ a = 20 / 0 # type: ignore Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2446,7 +2468,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2478,7 +2500,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2505,7 +2527,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2529,7 +2551,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -2587,7 +2609,7 @@ def g(): Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -2626,7 +2648,7 @@ class D(C): ... # error: [unsupported-base] Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2713,7 +2735,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index a34e115117..3d1a0ec544 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -500,3 +500,113 @@ class Bar(NamedTuple): class Baz(Bar): _whatever: str # `Baz` is not a NamedTuple class, so this is fine ``` + +## Prohibited NamedTuple attributes + +`NamedTuple` classes have certain synthesized attributes that cannot be overwritten. Attempting to +assign to these attributes (without type annotations) will raise an `AttributeError` at runtime. + +```py +from typing import NamedTuple + +class F(NamedTuple): + x: int + + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `_asdict`" + _asdict = 42 + + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `_make`" + _make = "foo" + + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `_replace`" + _replace = lambda self: self + + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `_fields`" + _fields = () + + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `_field_defaults`" + _field_defaults = {} + + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `__new__`" + __new__ = None + + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `__init__`" + __init__ = None + + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `__getnewargs__`" + __getnewargs__ = None +``` + +However, other attributes (including those starting with underscores) can be assigned without error: + +```py +from typing import NamedTuple + +class G(NamedTuple): + x: int + + # These are fine (not prohibited attributes) + _custom = 42 + __custom__ = "ok" + regular_attr = "value" +``` + +Note that type-annotated attributes become NamedTuple fields, not attribute overrides. They are not +flagged as prohibited attribute overrides (though field names starting with `_` are caught by the +underscore field name check): + +```py +from typing import NamedTuple + +class H(NamedTuple): + x: int + # This is a field declaration, not an override. It's not flagged as an override, + # but is flagged because field names cannot start with underscores. + # error: [invalid-named-tuple] "NamedTuple field `_asdict` cannot start with an underscore" + _asdict: int = 0 +``` + +The check also applies to assignments within conditional blocks: + +```py +from typing import NamedTuple + +class I(NamedTuple): + x: int + + if True: + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `_asdict`" + _asdict = 42 +``` + +Method definitions with prohibited names are also flagged: + +```py +from typing import NamedTuple + +class J(NamedTuple): + x: int + + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `_asdict`" + def _asdict(self): + return {} + + @classmethod + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `_make`" + def _make(cls, iterable): + return cls(*iterable) +``` + +Classes that inherit from a `NamedTuple` class (but don't directly inherit from `NamedTuple`) are +not subject to these restrictions: + +```py +from typing import NamedTuple + +class Base(NamedTuple): + x: int + +class Child(Base): + # This is fine - Child is not directly a NamedTuple + _asdict = 42 +``` diff --git a/crates/ty_python_semantic/resources/mdtest/override.md b/crates/ty_python_semantic/resources/mdtest/override.md index c1ab23d790..386db9b340 100644 --- a/crates/ty_python_semantic/resources/mdtest/override.md +++ b/crates/ty_python_semantic/resources/mdtest/override.md @@ -283,8 +283,7 @@ class MyNamedTuple(NamedTuple): x: int @override - # TODO: this raises an exception at runtime (which we should emit a diagnostic for). - # It shouldn't be an `invalid-explicit-override` diagnostic, however. + # error: [invalid-named-tuple] "Cannot overwrite NamedTuple attribute `_asdict`" def _asdict(self, /) -> dict[str, Any]: ... class MyNamedTupleParent(NamedTuple): diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index f4f62a0f0b..5a5c990ab1 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -545,7 +545,8 @@ declare_lint! { /// /// ## Why is this bad? /// An invalidly defined `NamedTuple` class may lead to the type checker - /// drawing incorrect conclusions. It may also lead to `TypeError`s at runtime. + /// drawing incorrect conclusions. It may also lead to `TypeError`s or + /// `AttributeError`s at runtime. /// /// ## Examples /// A class definition cannot combine `NamedTuple` with other base classes @@ -558,6 +559,27 @@ declare_lint! { /// >>> class Foo(NamedTuple, object): ... /// TypeError: can only inherit from a NamedTuple type and Generic /// ``` + /// + /// Further, `NamedTuple` field names cannot start with an underscore: + /// + /// ```pycon + /// >>> from typing import NamedTuple + /// >>> class Foo(NamedTuple): + /// ... _bar: int + /// ValueError: Field names cannot start with an underscore: '_bar' + /// ``` + /// + /// `NamedTuple` classes also have certain synthesized attributes (like `_asdict`, `_make`, + /// `_replace`, etc.) that cannot be overwritten. Attempting to assign to these attributes + /// without a type annotation will raise an `AttributeError` at runtime. + /// + /// ```pycon + /// >>> from typing import NamedTuple + /// >>> class Foo(NamedTuple): + /// ... x: int + /// ... _asdict = 42 + /// AttributeError: Cannot overwrite NamedTuple attribute _asdict + /// ``` pub(crate) static INVALID_NAMED_TUPLE = { summary: "detects invalid `NamedTuple` class definitions", status: LintStatus::stable("0.0.1-alpha.19"), diff --git a/crates/ty_python_semantic/src/types/overrides.rs b/crates/ty_python_semantic/src/types/overrides.rs index 517878202a..a56f04d1db 100644 --- a/crates/ty_python_semantic/src/types/overrides.rs +++ b/crates/ty_python_semantic/src/types/overrides.rs @@ -11,20 +11,40 @@ use crate::{ Db, lint::LintId, place::Place, - semantic_index::{place_table, scope::ScopeId, symbol::ScopedSymbolId, use_def_map}, + semantic_index::{ + definition::DefinitionKind, place_table, scope::ScopeId, symbol::ScopedSymbolId, + use_def_map, + }, types::{ ClassBase, ClassLiteral, ClassType, KnownClass, Type, class::CodeGeneratorKind, context::InferContext, diagnostic::{ - INVALID_EXPLICIT_OVERRIDE, INVALID_METHOD_OVERRIDE, OVERRIDE_OF_FINAL_METHOD, - report_invalid_method_override, report_overridden_final_method, + INVALID_EXPLICIT_OVERRIDE, INVALID_METHOD_OVERRIDE, INVALID_NAMED_TUPLE, + OVERRIDE_OF_FINAL_METHOD, report_invalid_method_override, + report_overridden_final_method, }, function::{FunctionDecorators, FunctionType, KnownFunction}, ide_support::{MemberWithDefinition, all_declarations_and_bindings}, }, }; +/// Prohibited `NamedTuple` attributes that cannot be overwritten. +/// See for the list. +const PROHIBITED_NAMEDTUPLE_ATTRS: &[&str] = &[ + "__new__", + "__init__", + "__slots__", + "__getnewargs__", + "_fields", + "_field_defaults", + "_field_types", + "_make", + "_replace", + "_asdict", + "_source", +]; + pub(super) fn check_class<'db>(context: &InferContext<'db, '_>, class: ClassLiteral<'db>) { let db = context.db(); let configuration = OverrideRulesConfig::from(context); @@ -126,6 +146,27 @@ fn check_class_declaration<'db>( let (literal, specialization) = class.class_literal(db); let class_kind = CodeGeneratorKind::from_class(db, literal, specialization); + // Check for prohibited `NamedTuple` attribute overrides. + // + // `NamedTuple` classes have certain synthesized attributes (like `_asdict`, `_make`, etc.) + // that cannot be overwritten. Attempting to assign to these attributes (without type + // annotations) or define methods with these names will raise an `AttributeError` at runtime. + if class_kind == Some(CodeGeneratorKind::NamedTuple) + && configuration.check_prohibited_named_tuple_attrs() + && PROHIBITED_NAMEDTUPLE_ATTRS.contains(&member.name.as_str()) + && !matches!(definition.kind(db), DefinitionKind::AnnotatedAssignment(_)) + && let Some(builder) = context.report_lint( + &INVALID_NAMED_TUPLE, + definition.focus_range(db, context.module()), + ) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Cannot overwrite NamedTuple attribute `{}`", + &member.name + )); + diagnostic.info("This will cause the class creation to fail at runtime"); + } + let mut subclass_overrides_superclass_declaration = false; let mut has_dynamic_superclass = false; let mut has_typeddict_in_mro = false; @@ -349,6 +390,7 @@ bitflags! { const LISKOV_METHODS = 1 << 0; const EXPLICIT_OVERRIDE = 1 << 1; const FINAL_METHOD_OVERRIDDEN = 1 << 2; + const PROHIBITED_NAMED_TUPLE_ATTR = 1 << 3; } } @@ -368,6 +410,9 @@ impl From<&InferContext<'_, '_>> for OverrideRulesConfig { if rule_selection.is_enabled(LintId::of(&OVERRIDE_OF_FINAL_METHOD)) { config |= OverrideRulesConfig::FINAL_METHOD_OVERRIDDEN; } + if rule_selection.is_enabled(LintId::of(&INVALID_NAMED_TUPLE)) { + config |= OverrideRulesConfig::PROHIBITED_NAMED_TUPLE_ATTR; + } config } @@ -385,4 +430,8 @@ impl OverrideRulesConfig { const fn check_final_method_overridden(self) -> bool { self.contains(OverrideRulesConfig::FINAL_METHOD_OVERRIDDEN) } + + const fn check_prohibited_named_tuple_attrs(self) -> bool { + self.contains(OverrideRulesConfig::PROHIBITED_NAMED_TUPLE_ATTR) + } } diff --git a/ty.schema.json b/ty.schema.json index e2325d6ac4..5630ec353c 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -645,7 +645,7 @@ }, "invalid-named-tuple": { "title": "detects invalid `NamedTuple` class definitions", - "description": "## What it does\nChecks for invalidly defined `NamedTuple` classes.\n\n## Why is this bad?\nAn invalidly defined `NamedTuple` class may lead to the type checker\ndrawing incorrect conclusions. It may also lead to `TypeError`s at runtime.\n\n## Examples\nA class definition cannot combine `NamedTuple` with other base classes\nin multiple inheritance; doing so raises a `TypeError` at runtime. The sole\nexception to this rule is `Generic[]`, which can be used alongside `NamedTuple`\nin a class's bases list.\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple, object): ...\nTypeError: can only inherit from a NamedTuple type and Generic\n```", + "description": "## What it does\nChecks for invalidly defined `NamedTuple` classes.\n\n## Why is this bad?\nAn invalidly defined `NamedTuple` class may lead to the type checker\ndrawing incorrect conclusions. It may also lead to `TypeError`s or\n`AttributeError`s at runtime.\n\n## Examples\nA class definition cannot combine `NamedTuple` with other base classes\nin multiple inheritance; doing so raises a `TypeError` at runtime. The sole\nexception to this rule is `Generic[]`, which can be used alongside `NamedTuple`\nin a class's bases list.\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple, object): ...\nTypeError: can only inherit from a NamedTuple type and Generic\n```\n\nFurther, `NamedTuple` field names cannot start with an underscore:\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple):\n... _bar: int\nValueError: Field names cannot start with an underscore: '_bar'\n```\n\n`NamedTuple` classes also have certain synthesized attributes (like `_asdict`, `_make`,\n`_replace`, etc.) that cannot be overwritten. Attempting to assign to these attributes\nwithout a type annotation will raise an `AttributeError` at runtime.\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple):\n... x: int\n... _asdict = 42\nAttributeError: Cannot overwrite NamedTuple attribute _asdict\n```", "default": "error", "oneOf": [ {