Skip to content

mypy thinks properties on a class (not an instance) are callable #18490

New issue

Have a question about this project? No Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “No Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? No Sign in to your account

Closed
injust opened this issue Jan 20, 2025 · 10 comments
Closed

mypy thinks properties on a class (not an instance) are callable #18490

injust opened this issue Jan 20, 2025 · 10 comments
Labels
bug mypy got something wrong topic-descriptors Properties, class vs. instance attributes

Comments

@injust
Copy link

injust commented Jan 20, 2025

Bug Report

To Reproduce

from typing import TYPE_CHECKING


class Foo:
    something = 1

    @property
    def bar(self) -> bool:
        return True


if TYPE_CHECKING:
    reveal_type(Foo.bar)  # note: Revealed type is "def (self: __main__.Foo) -> builtins.bool
    reveal_type(Foo.bar(Foo()))  # note: Revealed type is "builtins.bool"

Foo.bar(Foo())  # TypeError: 'property' object is not callable

https://mypy-play.net/?mypy=latest&python=3.13&gist=9f317c32db027f53db148d0b6899be82

Expected Behavior

The code should not type check.

Actual Behavior

main.py:13: note: Revealed type is "def (self: __main__.Foo) -> builtins.bool"
main.py:14: note: Revealed type is "builtins.bool"
Success: no issues found in 1 source file

Your Environment

  • Mypy version used: 1.14.1
  • Python version used: 3.13
@injust injust added the bug mypy got something wrong label Jan 20, 2025
@delfick
Copy link

delfick commented Jan 20, 2025

hi @injust !

That's because the property decorator returns an object that has a __get__ method on it, which implements the Descriptor methods.

Here is an alternative version of your example:

from typing import TYPE_CHECKING, cast


class Foo:
    something = 1

    @property
    def bar(self) -> bool:
        return True


if TYPE_CHECKING:
    reveal_type(
        # bar is effectively a boolean attribute on instances of Foo
        cast(Foo, None).bar
    )  # Note: Revealed type is "builtins.bool"

# This is effectively what python does when you access a descriptor on an instance
# and in fact, as a nice side note, is how instance methods actually work!
Foo.bar.__get__(Foo())

@injust
Copy link
Author

injust commented Jan 20, 2025

from typing import TYPE_CHECKING, cast


class Foo:
    something = 1

    @property
    def bar(self) -> bool:
        return True

reveal_type(Foo.bar)  # note: Revealed type is "def (self: test.Foo) -> builtins.bool"
reveal_type(cast(Foo, None).bar)  # note: Revealed type is "builtins.bool"

This is confusing to me -- why is Foo.bar different from cast(Foo, None).bar? Surely something casted to Foo should behave like an actual Foo for type checking purposes...right?

@injust
Copy link
Author

injust commented Jan 20, 2025

class Foo:
    something = 1

    @property
    def bar(self) -> bool:
        return True


print(Foo.bar.__get__(Foo()))  # True
print(Foo.bar(Foo()))  # TypeError: 'property' object is not callable

Foo.bar.__get__(Foo()) doesn't actually behave the same as Foo.bar(Foo()) at runtime, though.

@delfick
Copy link

delfick commented Jan 20, 2025

@injust

So, the cast(Foo, None) is a trick that only makes sense at static time. It essentially convinces mypy that you have an instance of Foo.

The way that works is cast(SomeType, some_variable) will make it so that mypy believes some_variable is of the type SomeType. So in this case we're using cast to pass around None as if it's an instance of Foo.

It means if you have a class that takes in parameters, you don't have to worry about what those parameters are or how to make them when you're in an if TYPE_CHECKING block and want to test the behaviour of an instance of something.

In this case Foo doesn't take any parameters so it's equivalent to saying Foo()

So at runtime, you'd want to say Foo().bar to have something equivalent to cast(Foo, None).bar that makes sense.

Foo.bar(Foo())

Whilst a simplification (and without type annotations for brevity), the property decorator effectively means your Foo class looks like this

class BarDescriptor:
    def __init__(self):
        def func(instance):
            return True
        self.func = func

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.func(instance)

class Foo:
    bar = BarDescriptor()

Essentially Foo.bar is a class attribute that is an instance of a descriptor class.

So when you have an instance of Foo and you access bar on it, python will see find that instance of the descriptor class and call it's __get__ method with your instance of Foo.

So when you're doing Foo().bar(...) you're trying to access BarDescriptor's __call__ method, but it doesn't have one, which is why that doesn't work.

@injust
Copy link
Author

injust commented Jan 20, 2025

This is confusing to me -- why is Foo.bar different from cast(Foo, None).bar? Surely something casted to Foo should behave like an actual Foo for type checking purposes...right?

I realized only after I wrote this that cast(Foo, ...) casts ... to an instance of Foo, not the type itself.

@delfick
Copy link

delfick commented Jan 20, 2025

Also, specifically for print(Foo.bar(Foo()))

When you access bar on the class, you're getting back the instance of the descriptor rather than getting the result of calling it's dunder get method.

@injust
Copy link
Author

injust commented Jan 20, 2025

So when you're doing Foo().bar(...) you're trying to access BarDescriptor's __call__ method, but it doesn't have one, which is why that doesn't work.

When you access bar on the class, you're getting back the instance of the descriptor rather than getting the result of calling it's dunder get method.

This makes sense to me, and it explains why Foo.bar(Foo()) raises TypeError at runtime. Thank you!

But I think mypy should be able to understand this and raise a type checking error, no?

@delfick
Copy link

delfick commented Jan 20, 2025

But I think mypy should be able to understand this and raise a type checking error, no?

mypy is giving the correct errors in all of these examples.

@injust
Copy link
Author

injust commented Jan 20, 2025

mypy is giving the correct errors in all of these examples.

Foo.bar(Foo()) raises an error at runtime.

If I take my original example and run it through Pyright, it says that Type of "Foo.bar" is "property", which isn't callable. That aligns with the runtime behaviour.

But mypy thinks everything is correct and does not show any errors. Am I missing something obvious here?

@delfick
Copy link

delfick commented Jan 20, 2025

@injust ah, I see, I missed that. You are correct :)

@injust injust changed the title mypy thinks properties are callable mypy thinks properties on a class (not an instance) are callable Jan 20, 2025
@sterliakov sterliakov added the topic-descriptors Properties, class vs. instance attributes label Jan 21, 2025
No Sign up for free to join this conversation on GitHub. Already have an account? No Sign in to comment
Labels
bug mypy got something wrong topic-descriptors Properties, class vs. instance attributes
Projects
None yet
Development

No branches or pull requests

3 participants