basedpyright icon indicating copy to clipboard operation
basedpyright copied to clipboard

allow non-descriptor subtypes

Open choucavalier opened this issue 5 months ago • 3 comments

Description

consider the following example, where we want to define a Protocol for any object that has system: str and a code: str attributes:

from typing import Protocol, runtime_checkable

from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass


class Base(MappedAsDataclass, DeclarativeBase):
    pass


@runtime_checkable
class ConceptProto(Protocol):
    @property
    def system(self) -> str: ...

    @property
    def code(self) -> str: ...


class Concept(Base):
    __tablename__ = "concept"

    system: Mapped[str]
    code: Mapped[str]


def fun(c: ConceptProto):
    print(c.system, c.code)


fun(Concept(system="SNOMED", code="123456789"))

this currently fails with basedpyright:

#########################/proto.py
  #########################/proto.py:30:5 - error: Argument of type "Concept" cannot be assigned to parameter "c" of type "ConceptProto" in function "fun"
    "Concept" is incompatible with protocol "ConceptProto"
      "system" is an incompatible type
        "Mapped[str]" is not assignable to "str"
      "code" is an incompatible type
        "Mapped[str]" is not assignable to "str" (reportArgumentType)
1 error, 0 warnings, 0 notes

it would be nice if sqlalchemy models could be used within that context, and manipulated by duck-typed libs without ever having to import sqlalchemy

you can see the initial discussion with the maintainer of sqlalchemy here: https://github.com/sqlalchemy/sqlalchemy/discussions/12889

choucavalier avatar Oct 10 '25 10:10 choucavalier

i think the issue here is that a regular attribute should be considered a valid subtype of an attribute that uses descriptors as long as it's compatible with the descriptor type:

from typing import Protocol


class Foo[T]:
    def __get__(self, instance: object, owner: type[object]) -> T: ...
    def __set__(self, instance: object, value: T): ...


class Bar(Protocol):
    a: int

class Baz:
    a: Foo[int] = Foo()

baz = Baz()

baz.a = 2

foo: Bar = baz # error: "Foo[int]" is not assignable to "int"

would have to think about this some more but at first glance it seems safe to not report an error here

DetachHead avatar Oct 10 '25 12:10 DetachHead

@DetachHead thanks for your always-amazing reactivity and detailed answer

Do you want me to open an issue upstream?

choucavalier avatar Oct 10 '25 13:10 choucavalier

you can if you want. i have a feeling it will be rejected though

DetachHead avatar Oct 10 '25 13:10 DetachHead

Just hit a similar problem.

from __future__ import annotations

from typing import Protocol

# PROTOCOL
class ServiceProto(Protocol):
    @property
    def value(self) -> int: ...

# DESCRIPTOR
class Descriptor:
    def __get__(self, instance: ServiceImpl, owner: type[ServiceImpl]) -> int:
        return 5

# IMPLEMENTATION
class ServiceImpl:
    value = Descriptor()

class App[ServiceT: ServiceProto]:
    def __init__(self, service: ServiceT) -> None:
        self.service = service

def bootstrap() -> App[ServiceImpl]:
    return App(ServiceImpl())

Both lines of the bootstrap function are flagged by basedpyright (Descriptor is not assignable to int). It seems like it may be the same issue, but maybe not. Let me know if I need to open a different issue.

For reference, mypy handles this somewhat inconsistently (as of 1.18): if the bootstrap function is in the same file no errors, but if in a different file, mypy fails too (https://github.com/python/mypy/issues/20317).

thomas-mckay avatar Nov 28 '25 18:11 thomas-mckay