facebook / facebook/pyrefly

Pyrefly incorrectly rejects Protocol with descriptor attributes

Open
#2,135 10 comments 0 reactions 1 assignee Assigned to @stroxler View on GitHub
needs-discussion typechecking
Dominant language
Rust
Stars
7k
Forks
516
PR merge metrics
No merged PRs in 30d

Description

## Description

Pyrefly rejects classes with descriptor attributes when used with Protocol bounds, even when the descriptor types match exactly. This affects common patterns like SQLAlchemy ORM models where columns are defined using `Mapped[T]` descriptors.

## Minimal Reproducible Example

```python
from typing import TypeVar, Protocol, Generic, overload

T = TypeVar('T')

class Mapped(Generic[T]):
@overload
def __get__(self, obj: None, objtype: type) -> 'Mapped[T]': ...
@overload
def __get__(self, obj: object, objtype: type) -> T: ...
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, f'_{self._name}')

def __set__(self, obj, value: T) -> None:
setattr(obj, f'_{self._name}', value)

def __set_name__(self, owner, name):
self._name = name

class HasID(Protocol):
@property
def id(self) -> int: ...

def __init__(self, id: int) -> None: ...

TModel = TypeVar('TModel', bound=HasID)

class User:
id: Mapped[int] = Mapped[int]()

def __init__(self, id: int):
self.id = id

def get_by_id(cls: type[TModel], id_value: int) -> TModel:
return cls(id_value)

user = get_by_id(User, 42)
print(user.id)
```

[Sandbox Link](https://pyrefly.org/sandbox/?project=N4IgZglgNgpgziAXKOBDAdgEwEYHsAeAdAA4CeS4ATrgLYAEALqcROgOZ0Q3G6UN0AVZjABqqSgBo6ABWoNcAY1xQpAcRjoYlCAqm4Ablqi5UmADroLFgXQC8g4WMoAKAOQDXASiuX0CqKhwcHQAsqjExDCYzuqa2goA2gIAup6IFnSZdAACBkYm5uhZdJgwYHQA%2BhVsMAxVznAwUGB62ABWiHQAcriarW1MkZ2DMJ50ALQAfHSuYRFRScmunYSrGVm5hpTGpuuZpeVVNXUVDU0tdLjtnVdtMAoM-SPDwmNTgitrRVkHldW19UazX6T2Eth6mjSe2KnHKt04wQhMHS3xhMMotQArpQikCwNDihiGNiisdUAwGC5blIwK4KsA8YQKuhUDQYABfLzQ6G-KqNE5nYGXdpSfSoKCY5GCN7TJEotGZfnkynOal0Wn0xnM1kc1yi8WS7youg8sp-fnatmA856ADucSkLLZUONxS1TpgdjoHp8Fn8gWCAAlAgBJAAizlkuHkSigLuK2WI1EifFIpvKEGieJlnHQDE%2BhG5xt5FVYEAFeKkmc6rAYObldFWhd81hCuFKUC9QkiTjcAjbHb1dDwmKwtmDcHDRp8-qCdAAqo1KPKstXQuFIpgErXkl65pvt3nks4jcV038yxWbZxMDW8-GFYzM17M76sGbjhVsKRS9F-HAXkiJIByaZIq0wCoxQlKVaxzft2yaFdMiJEk6H-ZxM0gg1RjfTEly9T9v1-ZxFy0KQABYACYjSTWtnDwrRCEzTwQAkEBMQYaA4BIchEBAABiBdOKgctSHVUcHggXo4B8X4wF4GhyWZTEaGwLRnHwO86wmaY4EpJC6BQnF1TMEAuhUtTlzoYB8HZUyLFYkAyAxMAoFIQh5BoKAKEE6RSBcty6DQLA8HwNDekgNhsXJKT0GbQSAGUYE9AALCliAAgB6TLnLKNzCF4NhMo0TLMEUOBMqUdBIuizjeky9VeDoVAxWgVBsFgcLqogKLKBi3pLmIOr0G4iwyAYFLenGLY4Fir1TIAZkIABGSj7PQEB2TY1BJMMAAxaAYAoYKcAIHjNqAA)

## Expected Behavior

A class with `id: Mapped[int]` should be assignable to a Protocol that also defines `id: Mapped[int]`, since the types match structurally at the class level.

Mypy accepts this pattern without issues.

## Actual Behavior

Pyrefly produces the error:
```
ERROR sandbox.py:45:17-27: `User` is not assignable to upper bound `HasID` of type variable `TModel` [[bad-specialization](https://pyrefly.org/en/docs/error-kinds/#bad-specialization)]
```

## Additional Context

This pattern is commonly used with SQLAlchemy 2.0+ where ORM models use `Mapped[T]` descriptors:

```python
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase

class HasID(Protocol):
id: Mapped[int]

class User(DeclarativeBase):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.