# How Pyrefly Handles Attribute Access and Property Types

> Learn how Pyrefly handles attribute access and property types using its three phase pipeline: MRO resolution, getattr fallback, and descriptor protocol semantics for accurate type checking.

- Repository: [Meta/pyrefly](https://github.com/facebook/pyrefly)
- Tags: internals
- Published: 2026-05-21

---

**Pyrefly implements Python’s attribute lookup rules through a three-phase pipeline that combines static MRO resolution, dynamic fallback via `__getattr__`, and strict descriptor protocol semantics to accurately type-check property accesses.**

The **facebook/pyrefly** type checker models Python’s complex attribute resolution mechanism exactly as the language specification defines, enabling precise static analysis of both static class attributes and dynamic property descriptors. Understanding how Pyrefly resolves attribute access is essential for interpreting type errors related to missing attributes, read-only properties, or incompatible setter types. This article examines the core resolution logic, descriptor handling, and property type inference implemented in the Rust source code.

## The Three Phases of Attribute Resolution

Pyrefly’s attribute resolution follows CPython’s canonical lookup order, implemented primarily in the **[`lsp_attributes.rs`](https://github.com/facebook/pyrefly/blob/main/lsp_attributes.rs)** module. The system evaluates each attribute access through distinct static and dynamic phases before applying descriptor semantics.

### Static Attribute Resolution

When an attribute name is known at analysis time, Pyrefly initiates **static resolution** by walking the class’s Method Resolution Order (MRO). The entry point for this operation is the **`resolve_attribute_definition`** function defined in [`pyrefly/lib/state/lsp_attributes.rs`](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/state/lsp_attributes.rs) at approximately line 1485.

This function receives the expression handle, attribute name, and definition context, then traverses the class hierarchy to locate the attribute definition. If found, it returns the definition object; if the lookup fails, it returns `None`, signaling either a static error or the need for dynamic fallback. Static resolution covers explicitly annotated class attributes, methods, and imported symbols.

### Dynamic Fallback via `__getattr__` and `__getattribute__`

When static resolution fails to locate an attribute, Pyrefly checks for **dynamic attribute handlers**. If the class defines `__getattr__` or `__getattribute__`, the resolver treats the attribute as implicitly defined, deriving its type from the return annotation of these dunder methods.

This logic is implemented immediately following the static lookup in [`lsp_attributes.rs`](https://github.com/facebook/pyrefly/blob/main/lsp_attributes.rs). The test suite in [`pyrefly/lib/test/attributes.rs`](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/test/attributes.rs) validates this behavior through cases like `test_object_getattr`, demonstrating that accesses on classes with custom `__getattr__` implementations receive the method’s declared return type rather than triggering "attribute missing" errors.

### Descriptor Protocol and Property Types

Once an attribute is located—either statically or dynamically—Pyrefly applies **descriptor protocol** semantics. The core helper **`resolve_descriptor`** (in the same file) examines whether the attribute value implements `__get__`, `__set__`, or `__delete__`.

For **`property`** objects specifically, Pyrefly extracts the type signatures from the getter, setter, and deleter:
- The **getter’s return type** becomes the attribute’s read type
- The **setter’s argument type** becomes the write type
- Missing getters mark the property as write-only; missing setters mark it read-only

## Data vs. Non-Data Descriptors

Pyrefly strictly enforces Python’s precedence rules for **data descriptors** versus **non-data descriptors**. Data descriptors (defining both `__get__` and either `__set__` or `__delete__`) take precedence over instance dictionary entries, while non-data descriptors are shadowed by instance attributes.

This precedence logic lives in **`resolve_attribute_access`** inside [`pyrefly/lib/report/pysa/call_graph.rs`](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/report/pysa/call_graph.rs) at approximately line 2632. The LSP server and Pysa call-graph builder reuse this function to ensure consistent behavior across type-checking, "go-to-definition" navigation, and security analysis. When Pyrefly encounters a property (which is a data descriptor), it always consults the property’s methods before checking the instance `__dict__`.

## Property Type Inference in Practice

For properties lacking explicit annotations, Pyrefly’s **solver phase** (`pyrefly/lib/solver`) propagates inferred types through the data-flow graph. If a property getter is a lambda or unannotated function, Pyrefly infers the return type from how the property is used in subsequent operations.

The following examples demonstrate how these resolution phases apply to real Python code:

```python

# Static resolution from class annotations

class Point:
    x: int
    y: int

p = Point()
reveal_type(p.x)  # int: resolved via static MRO lookup

```

```python

# Property descriptor with typed getter

class Circle:
    _radius: float

    @property
    def radius(self) -> float:
        return self._radius

c = Circle()
reveal_type(c.radius)  # float: extracted from property.__get__ return type

```

```python

# Property with setter enforcing write types

class Counter:
    _value: int = 0

    @property
    def value(self) -> int:
        return self._value

    @value.setter
    def value(self, v: int) -> None:
        self._value = v

ctr = Counter()
ctr.value = 42       # Valid: matches setter parameter type

ctr.value = "error"  # Type error: incompatible with int

```

## LSP Integration and Call Graph Construction

The attribute resolution engine powers Pyrefly’s language server features. In [`pyrefly/lib/lsp/wasm/completion.rs`](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/lsp/wasm/completion.rs) at line 421, the LSP completion provider invokes `resolve_attribute_definition` to populate autocomplete suggestions with accurate type information.

Similarly, the Pysa security analyzer reuses `resolve_attribute_access` in [`pyrefly/lib/report/pysa/call_graph.rs`](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/report/pysa/call_graph.rs) to build call graphs that include attribute-based method calls. This ensures that taint analysis correctly tracks flows through properties and dynamic attributes defined via `__getattr__`.

## Summary

- **Static resolution** via `resolve_attribute_definition` (line 1485 in [`lsp_attributes.rs`](https://github.com/facebook/pyrefly/blob/main/lsp_attributes.rs)) walks the MRO to locate attributes in the class hierarchy.
- **Dynamic fallback** activates when `__getattr__` or `__getattribute__` is present, using the dunder method’s return type as the attribute type.
- **Descriptor protocol** handling in `resolve_descriptor` extracts read and write types from property getters and setters.
- **Data vs. non-data descriptor** precedence is enforced in `resolve_attribute_access` (line 2632 in [`call_graph.rs`](https://github.com/facebook/pyrefly/blob/main/call_graph.rs)), ensuring properties override instance dictionary values.
- **Type inference** for unannotated properties occurs in the solver phase, propagating types through usage patterns.
- **LSP features** rely on the same resolution engine, providing consistent type information across editors and security analysis tools.

## Frequently Asked Questions

### How does Pyrefly handle properties without type annotations?

When a property getter lacks a return annotation, Pyrefly’s **solver** infers the type from the function’s return statements and usage context. If the setter lacks annotations, Pyrefly attempts to infer the write type from assigned values, though this may result in broader types or `Any` if insufficient context exists.

### What is the difference between `__getattr__` and `__getattribute__` in Pyrefly's analysis?

Pyrefly treats **`__getattribute__`** as the primary attribute access hook that intercepts all attribute lookups, while **`__getattr__`** serves as a fallback only when normal lookup fails. If `__getattribute__` is defined, Pyrefly assumes all attribute accesses are handled by this method; if only `__getattr__` is present, Pyrefly first attempts static resolution before falling back to the dynamic method’s return type.

### How does Pyrefly distinguish between data and non-data descriptors?

Pyrefly checks for the presence of both `__get__` and either `__set__` or `__delete__` to classify a descriptor as a **data descriptor**. If only `__get__` is present, it is treated as a non-data descriptor. This classification determines whether the descriptor’s value takes precedence over instance dictionary entries during attribute access resolution.

### Can Pyrefly infer types for dynamically added attributes?

Pyrefly can infer types for attributes added dynamically only if the class defines **`__getattr__`** or **`__setattr__`** with appropriate return type annotations. For arbitrary runtime attribute assignment (e.g., `obj.new_attr = value`), Pyrefly typically treats the attribute as missing or falling back to `Any`, depending on the configuration and presence of `__dict__` manipulation.