[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.
This commit is contained in:
David Peter 2025-10-08 17:46:47 +02:00 committed by GitHub
parent 697998f836
commit 150ea92d03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -820,22 +820,30 @@ reveal_type(C().c) # revealed: int
### Inheritance of class/instance attributes ### Inheritance of class/instance attributes
#### Instance variable defined in a base class
```py ```py
class Base: class Base:
declared_in_body: int | None = 1 attribute: int | None = 1
base_class_attribute_1: str | None redeclared_with_same_type: str | None
base_class_attribute_2: str | None redeclared_with_narrower_type: str | None
base_class_attribute_3: str | None redeclared_with_wider_type: str | None
overwritten_in_subclass_body: str
overwritten_in_subclass_method: str
undeclared = "base"
def __init__(self) -> None: 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): class Intermediate(Base):
# Redeclaring base class attributes with the *same *type is fine: # 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 # Redeclaring them with a *narrower type* is unsound, because modifications
# through a `Base` reference could violate that constraint. # through a `Base` reference could violate that constraint.
@ -847,22 +855,70 @@ class Intermediate(Base):
# enabled by default can still be discussed. # enabled by default can still be discussed.
# #
# TODO: This should be an error # 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. # Redeclaring attributes with a *wider type* directly violates LSP.
# #
# In this case, both mypy and pyright report an error. # In this case, both mypy and pyright report an error.
# #
# TODO: This should be 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): ... 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 ## Accessing attributes on class objects