ruff/crates/ty_python_semantic/resources/mdtest/call/getattr_static.md
Carl Meyer 62975b3ab2
[ty] eliminate is_fully_static (#18799)
## Summary

Having a recursive type method to check whether a type is fully static
is inefficient, unnecessary, and makes us overly strict about subtyping
relations.

It's inefficient because we end up re-walking the same types many times
to check for fully-static-ness.

It's unnecessary because we can check relations involving the dynamic
type appropriately, depending whether the relation is subtyping or
assignability.

We use the subtyping relation to simplify unions and intersections. We
can usefully consider that `S <: T` for gradual types also, as long as
it remains true that `S | T` is equivalent to `T` and `S & T` is
equivalent to `S`.

One conservative definition (implemented here) that satisfies this
requirement is that we consider `S <: T` if, for every possible pair of
materializations `S'` and `T'`, `S' <: T'`. Or put differently the top
materialization of `S` (`S+` -- the union of all possible
materializations of `S`) is a subtype of the bottom materialization of
`T` (`T-` -- the intersection of all possible materializations of `T`).
In the most basic cases we can usefully say that `Any <: object` and
that `Never <: Any`, and we can handle more complex cases inductively
from there.

This definition of subtyping for gradual subtypes is not reflexive
(`Any` is not a subtype of `Any`).

As a corollary, we also remove `is_gradual_equivalent_to` --
`is_equivalent_to` now has the meaning that `is_gradual_equivalent_to`
used to have. If necessary, we could restore an
`is_fully_static_equivalent_to` or similar (which would not do an
`is_fully_static` pre-check of the types, but would instead pass a
relation-kind enum down through a recursive equivalence check, similar
to `has_relation_to`), but so far this doesn't appear to be necessary.

Credit to @JelleZijlstra for the observation that `is_fully_static` is
unnecessary and overly restrictive on subtyping.

There is another possible definition of gradual subtyping: instead of
requiring that `S+ <: T-`, we could instead require that `S+ <: T+` and
`S- <: T-`. In other words, instead of requiring all materializations of
`S` to be a subtype of every materialization of `T`, we just require
that every materialization of `S` be a subtype of _some_ materialization
of `T`, and that every materialization of `T` be a supertype of some
materialization of `S`. This definition also preserves the core
invariant that `S <: T` implies that `S | T = T` and `S & T = S`, and it
restores reflexivity: under this definition, `Any` is a subtype of
`Any`, and for any equivalent types `S` and `T`, `S <: T` and `T <: S`.
But unfortunately, this definition breaks transitivity of subtyping,
because nominal subclasses in Python use assignability ("consistent
subtyping") to define acceptable overrides. This means that we may have
a class `A` with `def method(self) -> Any` and a subtype `B(A)` with
`def method(self) -> int`, since `int` is assignable to `Any`. This
means that if we have a protocol `P` with `def method(self) -> Any`, we
would have `B <: A` (from nominal subtyping) and `A <: P` (`Any` is a
subtype of `Any`), but not `B <: P` (`int` is not a subtype of `Any`).
Breaking transitivity of subtyping is not tenable, so we don't use this
definition of subtyping.

## Test Plan

Existing tests (modified in some cases to account for updated
semantics.)

Stable property tests pass at a million iterations:
`QUICKCHECK_TESTS=1000000 cargo test -p ty_python_semantic -- --ignored
types::property_tests::stable`

### Changes to property test type generation

Since we no longer have a method of categorizing built types as
fully-static or not-fully-static, I had to add a previously-discussed
feature to the property tests so that some tests can build types that
are known by construction to be fully static, because there are still
properties that only apply to fully-static types (for example,
reflexiveness of subtyping.)

## Changes to handling of `*args, **kwargs` signatures

This PR "discovered" that, once we allow non-fully-static types to
participate in subtyping under the above definitions, `(*args: Any,
**kwargs: Any) -> Any` is now a subtype of `() -> object`. This is true,
if we take a literal interpretation of the former signature: all
materializations of the parameters `*args: Any, **kwargs: Any` can
accept zero arguments, making the former signature a subtype of the
latter. But the spec actually says that `*args: Any, **kwargs: Any`
should be interpreted as equivalent to `...`, and that makes a
difference here: `(...) -> Any` is not a subtype of `() -> object`,
because (unlike a literal reading of `(*args: Any, **kwargs: Any)`),
`...` can materialize to _any_ signature, including a signature with
required positional arguments.

This matters for this PR because it makes the "any two types are both
assignable to their union" property test fail if we don't implement the
equivalence to `...`. Because `FunctionType.__call__` has the signature
`(*args: Any, **kwargs: Any) -> Any`, and if we take that at face value
it's a subtype of `() -> object`, making `FunctionType` a subtype of `()
-> object)` -- but then a function with a required argument is also a
subtype of `FunctionType`, but not a subtype of `() -> object`. So I
went ahead and implemented the equivalence to `...` in this PR.

## Ecosystem analysis

* Most of the ecosystem report are cases of improved union/intersection
simplification. For example, we can now simplify a union like `bool |
(bool & Unknown) | Unknown` to simply `bool | Unknown`, because we can
now observe that every possible materialization of `bool & Unknown` is
still a subtype of `bool` (whereas before we would set aside `bool &
Unknown` as a not-fully-static type.) This is clearly an improvement.
* The `possibly-unresolved-reference` errors in sockeye, pymongo,
ignite, scrapy and others are true positives for conditional imports
that were formerly silenced by bogus conflicting-declarations (which we
currently don't issue a diagnostic for), because we considered two
different declarations of `Unknown` to be conflicting (we used
`is_equivalent_to` not `is_gradual_equivalent_to`). In this PR that
distinction disappears and all equivalence is gradual, so a declaration
of `Unknown` no longer conflicts with a declaration of `Unknown`, which
then results in us surfacing the possibly-unbound error.
* We will now issue "redundant cast" for casting from a typevar with a
gradual bound to the same typevar (the hydra-zen diagnostic). This seems
like an improvement.
* The new diagnostics in bandersnatch are interesting. For some reason
primer in CI seems to be checking bandersnatch on Python 3.10 (not yet
sure why; this doesn't happen when I run it locally). But bandersnatch
uses `enum.StrEnum`, which doesn't exist on 3.10. That makes the `class
SimpleDigest(StrEnum)` a class that inherits from `Unknown` (and
bypasses our current TODO handling for accessing attributes on enum
classes, since we don't recognize it as an enum class at all). This PR
improves our understanding of assignability to classes that inherit from
`Any` / `Unknown`, and we now recognize that a string literal is not
assignable to a class inheriting `Any` or `Unknown`.
2025-06-24 18:02:05 -07:00

4.2 KiB

inspect.getattr_static

Basic usage

inspect.getattr_static is a function that returns attributes of an object without invoking the descriptor protocol (for caveats, see the official documentation).

Consider the following example:

import inspect

class Descriptor:
    def __get__(self, instance, owner) -> str:
        return "a"

class C:
    normal: int = 1
    descriptor: Descriptor = Descriptor()

If we access attributes on an instance of C as usual, the descriptor protocol is invoked, and we get a type of str for the descriptor attribute:

c = C()

reveal_type(c.normal)  # revealed: int
reveal_type(c.descriptor)  # revealed: str

However, if we use inspect.getattr_static, we can see the underlying Descriptor type:

reveal_type(inspect.getattr_static(c, "normal"))  # revealed: int
reveal_type(inspect.getattr_static(c, "descriptor"))  # revealed: Descriptor

For non-existent attributes, a default value can be provided:

reveal_type(inspect.getattr_static(C, "normal", "default-arg"))  # revealed: int
reveal_type(inspect.getattr_static(C, "non_existent", "default-arg"))  # revealed: Literal["default-arg"]

When a non-existent attribute is accessed without a default value, the runtime raises an AttributeError. We could emit a diagnostic for this case, but that is currently not supported:

# TODO: we could emit a diagnostic here
reveal_type(inspect.getattr_static(C, "non_existent"))  # revealed: Never

We can access attributes on objects of all kinds:

import sys

reveal_type(inspect.getattr_static(sys, "dont_write_bytecode"))  # revealed: bool
# revealed: def getattr_static(obj: object, attr: str, default: Any | None = ellipsis) -> Any
reveal_type(inspect.getattr_static(inspect, "getattr_static"))

reveal_type(inspect.getattr_static(1, "real"))  # revealed: property

(Implicit) instance attributes can also be accessed through inspect.getattr_static:

class D:
    def __init__(self) -> None:
        self.instance_attr: int = 1

reveal_type(inspect.getattr_static(D(), "instance_attr"))  # revealed: int

And attributes on metaclasses can be accessed when probing the class:

class Meta(type):
    attr: int = 1

class E(metaclass=Meta): ...

reveal_type(inspect.getattr_static(E, "attr"))  # revealed: int

Metaclass attributes can not be added when probing an instance of the class:

reveal_type(inspect.getattr_static(E(), "attr", "non_existent"))  # revealed: Literal["non_existent"]

Error cases

We can only infer precise types if the attribute is a literal string. In all other cases, we fall back to Any:

import inspect

class C:
    x: int = 1

def _(attr_name: str):
    reveal_type(inspect.getattr_static(C(), attr_name))  # revealed: Any
    reveal_type(inspect.getattr_static(C(), attr_name, 1))  # revealed: Any

But we still detect errors in the number or type of arguments:

# error: [missing-argument] "No arguments provided for required parameters `obj`, `attr` of function `getattr_static`"
inspect.getattr_static()

# error: [missing-argument] "No argument provided for required parameter `attr`"
inspect.getattr_static(C())

# error: [invalid-argument-type] "Argument to function `getattr_static` is incorrect: Expected `str`, found `Literal[1]`"
inspect.getattr_static(C(), 1)

# error: [too-many-positional-arguments] "Too many positional arguments to function `getattr_static`: expected 3, got 4"
inspect.getattr_static(C(), "x", "default-arg", "one too many")

Possibly unbound attributes

import inspect

def _(flag: bool):
    class C:
        if flag:
            x: int = 1

    reveal_type(inspect.getattr_static(C, "x", "default"))  # revealed: int | Literal["default"]

Gradual types

import inspect
from typing import Any

def _(a: Any, tuple_of_any: tuple[Any]):
    reveal_type(inspect.getattr_static(a, "x", "default"))  # revealed: Any | Literal["default"]

    # revealed: def index(self, value: Any, start: SupportsIndex = Literal[0], stop: SupportsIndex = int, /) -> int
    reveal_type(inspect.getattr_static(tuple_of_any, "index", "default"))