Skip to content

[1.16 regression] descriptor not working in protocol #19054

Closed as not planned
Closed as not planned

Description

@JukkaL

After #18831 this example started generating an error, which looks like a regression:

import typing

A = typing.TypeVar('A')

class Attribute(typing.Generic[A]):
    @typing.overload
    def __get__(self, instance: None, owner: typing.Any) -> Attribute[A]: ...
    @typing.overload
    def __get__(self, instance: object, owner: typing.Any) -> A: ...
    def __get__(self, instance, owner): ...

class PathArg(typing.Protocol):
    path: Attribute[str]

def f(arg: PathArg) -> None:
    # No error expected
    s: str = arg.path  # error: Expression has type "Attribute[str]", variable has type "str'

If PathArg is a regular class, there is no error.

cc @ilevkivskyi as the author of the PR

Activity

  1. ilevkivskyi commented on May 8, 2025

    @ilevkivskyi
    Member

    This was an intentional change, to make descriptor access more consistent, see this PR comment for more motivation #18831 (comment). IMO the code in the example is a bug, such pattern should be reserved for when the actual attribute type is a descriptor, otherwise one should just use path: str in the protocol.

  2. JukkaL commented on May 8, 2025

    @JukkaL
    CollaboratorAuthor

    Ok, let me play around with this a little. The impact to the Dropbox internal codebase (where I found this) is very minor, so if using path: str works, we can close this issue.

  3. JukkaL commented on May 9, 2025

    @JukkaL
    CollaboratorAuthor

    I couldn't find a way to make a concrete class that uses the descriptor to be compatible with the protocol:

    import typing
    
    A = typing.TypeVar('A')
    
    class Attribute(typing.Generic[A]):
        @typing.overload
        def __get__(self, instance: None, owner: typing.Any) -> Attribute[A]: ...
        @typing.overload
        def __get__(self, instance: object, owner: typing.Any) -> A: ...
        def __get__(self, instance, owner): ...
    
    class PathArg(typing.Protocol):
        @property  # Attempt to use @property since the descriptor is read only
        def path(self) -> str: ...
    
    class C:
        path = Attribute[str]()
    
    def f(arg: PathArg) -> None:
        s: str = arg.path
    
    def g(c: C) -> None:
        reveal_type(c.path)  # "str", as expected
        f(c)  # error: C is not compatible with PathArg <<-- this should be fine?

    Is there a way to modify the old example so that it works using the new semantics?

  4. JukkaL commented on May 9, 2025

    @JukkaL
    CollaboratorAuthor

    Another use case where the new behavior may cause issues is when using type[PathArg] -- here path: Attribute[str] in the protocol makes a difference:

    import typing
    
    A = typing.TypeVar('A')
    
    class Attribute(typing.Generic[A]):
        @typing.overload
        def __get__(self, instance: None, owner: typing.Any) -> Attribute[A]: ...
        @typing.overload
        def __get__(self, instance: object, owner: typing.Any) -> A: ...
        def __get__(self, instance, owner): ...
    
    class PathArg(typing.Protocol):
        path: Attribute[str]   # no error with Attribute[str], but error when using str
    
    class C:
        path = Attribute[str]()
    
    def f(arg: type[PathArg]) -> None:
        reveal_type(arg.path)  # Attribute[str]
    
    def g() -> None:
        f(C)  # error if using "path: str" in PathArg
  5. ilevkivskyi commented on May 9, 2025

    @ilevkivskyi
    Member

    OK, I understand what happened (i.e. why someone defined such weird protocol). The problem is that protocols never worked with descriptors, see #5481, so someone found this workaround, and now this workaround doesn't work anymore.

    I think the best way forward is to just wait for the proper descriptor support added in #18943. I you are really worried about this, you can revert it in the release branch. But I wouldn't bother, because IMO this is really niche workaround.

  6. JukkaL commented on May 9, 2025

    @JukkaL
    CollaboratorAuthor

    This only impacts two files in the Dropbox codebase, so it's not a big deal. As you said, the code is kind of questionable anyway, so this shouldn't block the release.

  7. eltoder commented on Jun 9, 2025

    @eltoder

    This is a big issue for us. We have a system where we use dataclasses with a special descriptor field to define schemas. Since there's no common base class, we use a protocol to type "generic schema".

  8. ilevkivskyi commented on Jun 9, 2025

    @ilevkivskyi
    Member

    @eltoder I have two questions:

    • Did you try to play with current master?
    • Can you share some code for me to understand what exactly you are doing?
  9. eltoder commented on Jun 10, 2025

    @eltoder

    @ilevkivskyi

    from __future__ import annotations
    
    # Framework code
    from typing import Any, Protocol, overload
    
    class DescInfo: ...
    
    class Desc:
        @overload
        def __get__(self, instance: None, owner: Any) -> Desc: ...
        @overload
        def __get__(self, instance: object, owner: Any) -> Callable[[], DescInfo]: ...
        def __get__(self, instance, owner):
            if instance is None: return self
            return lambda: DescInfo()  # construct from self and instance
    
    class HasDesc(Protocol):
        desc: Desc
    
    def write_schema(schema: HasDesc) -> None:
        print(schema.desc())  # do something with DescInfo
    
    # User code
    import dataclasses
    
    @dataclasses.dataclass
    class MySchema:
        desc = Desc()
        name: str
        value: int
    
    write_schema(MySchema("foo", 123))
  10. changed the title [-]Regression: descriptor not working in protocol[/-] [+][1.16 regression] descriptor not working in protocol[/+] on Jun 10, 2025
  11. ilevkivskyi commented on Jun 10, 2025

    @ilevkivskyi
    Member

    @eltoder Then you should use this instead

    class HasDesc(Protocol):
        desc: Callable[[], DescInfo]

    to correctly specify the interface. This works on current master. This means however that you may need to wait for the next release for this to be available. Anyway, I don't think we need to keep this open, the old behavior was wrong.

  12. eltoder commented on Jun 10, 2025

    @eltoder

    @ilevkivskyi That's simply wrong. It does not work when using the type. For example:

    ST = TypeVar("ST", bound=HasDesc)
    
    def read_schema(cls: type[ST]) -> ST:
        print(cls.desc)  # use desc to read

    TBH I don't see how the current behavior is defensible. It makes Protocols inconsistent with regular classes and it contradicts the runtime. Please fix.

  13. ilevkivskyi commented on Jun 10, 2025

    @ilevkivskyi
    Member

    @eltoder It is not for you to decide. Also please watch your tone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions