From 150ea92d03ec4e1861b661ef6f3723bc61db3780 Mon Sep 17 00:00:00 2001 From: David Peter Date: Wed, 8 Oct 2025 17:46:47 +0200 Subject: [PATCH] [ty] Add tests for instance attributes in class hierarchies (#20767) ## Summary This adds a couple of new test cases related to https://github.com/astral-sh/ty/issues/1067 and beyond that. For now, they are just documenting the current (problematic) behavior. Since the topic has some subtleties, I'd like to merge this prior to the actual bugfix(es) in order to evaluate the changes in an easier way. --- .../resources/mdtest/attributes.md | 82 ++++++++++++++++--- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 1c0c2da772..60aaa68306 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -820,22 +820,30 @@ reveal_type(C().c) # revealed: int ### Inheritance of class/instance attributes -#### Instance variable defined in a base class - ```py class Base: - declared_in_body: int | None = 1 + attribute: int | None = 1 - base_class_attribute_1: str | None - base_class_attribute_2: str | None - base_class_attribute_3: str | None + redeclared_with_same_type: str | None + redeclared_with_narrower_type: str | None + redeclared_with_wider_type: str | None + + overwritten_in_subclass_body: str + overwritten_in_subclass_method: str + + undeclared = "base" def __init__(self) -> None: - self.defined_in_init: str | None = "value in base" + self.pure_attribute: str | None = "value in base" + + self.pure_overwritten_in_subclass_body: str = "value in base" + self.pure_overwritten_in_subclass_method: str = "value in base" + + self.pure_undeclared = "base" class Intermediate(Base): # Redeclaring base class attributes with the *same *type is fine: - base_class_attribute_1: str | None = None + redeclared_with_same_type: str | None = None # Redeclaring them with a *narrower type* is unsound, because modifications # through a `Base` reference could violate that constraint. @@ -847,22 +855,70 @@ class Intermediate(Base): # enabled by default can still be discussed. # # TODO: This should be an error - base_class_attribute_2: str + redeclared_with_narrower_type: str # Redeclaring attributes with a *wider type* directly violates LSP. # # In this case, both mypy and pyright report an error. # # TODO: This should be an error - base_class_attribute_3: str | int | None + redeclared_with_wider_type: str | int | None + + # TODO: This should be an `invalid-assignment` error + overwritten_in_subclass_body = None + + # TODO: This should be an `invalid-assignment` error + pure_overwritten_in_subclass_body = None + + undeclared = "intermediate" + + def set_attributes(self) -> None: + # TODO: This should be an `invalid-assignment` error + self.overwritten_in_subclass_method = None + + # TODO: This should be an `invalid-assignment` error + self.pure_overwritten_in_subclass_method = None + + self.pure_undeclared = "intermediate" class Derived(Intermediate): ... -reveal_type(Derived.declared_in_body) # revealed: int | None +reveal_type(Derived.attribute) # revealed: int | None +reveal_type(Derived().attribute) # revealed: int | None -reveal_type(Derived().declared_in_body) # revealed: int | None +reveal_type(Derived.redeclared_with_same_type) # revealed: str | None +reveal_type(Derived().redeclared_with_same_type) # revealed: str | None -reveal_type(Derived().defined_in_init) # revealed: str | None +# TODO: It would probably be more consistent if these were `str | None` +reveal_type(Derived.redeclared_with_narrower_type) # revealed: str +reveal_type(Derived().redeclared_with_narrower_type) # revealed: str + +# TODO: It would probably be more consistent if these were `str | None` +reveal_type(Derived.redeclared_with_wider_type) # revealed: str | int | None +reveal_type(Derived().redeclared_with_wider_type) # revealed: str | int | None + +# TODO: Both of these should be `str` +reveal_type(Derived.overwritten_in_subclass_body) # revealed: Unknown | None +reveal_type(Derived().overwritten_in_subclass_body) # revealed: Unknown | None | str + +# TODO: Both of these should be `str` +reveal_type(Derived.overwritten_in_subclass_method) # revealed: str +reveal_type(Derived().overwritten_in_subclass_method) # revealed: str | Unknown | None + +reveal_type(Derived().pure_attribute) # revealed: str | None + +# TODO: This should be `str` +reveal_type(Derived().pure_overwritten_in_subclass_body) # revealed: Unknown | None | str + +# TODO: This should be `str` +reveal_type(Derived().pure_overwritten_in_subclass_method) # revealed: Unknown | None + +# TODO: Both of these should be `Unknown | Literal["intermediate", "base"]` +reveal_type(Derived.undeclared) # revealed: Unknown | Literal["intermediate"] +reveal_type(Derived().undeclared) # revealed: Unknown | Literal["intermediate"] + +# TODO: This should be `Unknown | Literal["intermediate", "base"]` +reveal_type(Derived().pure_undeclared) # revealed: Unknown | Literal["intermediate"] ``` ## Accessing attributes on class objects