Skip to content
New issue

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

By clicking “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? Sign in to your account

Dynamically wrapped composed methods have no type hints #3023

Open
Antyos opened this issue May 10, 2024 · 5 comments
Open

Dynamically wrapped composed methods have no type hints #3023

Antyos opened this issue May 10, 2024 · 5 comments

Comments

@Antyos
Copy link

Antyos commented May 10, 2024

Short description

Starting in pyqtgraph 0.13.5, dynamically wrapped class properties/methods raise errors in type checkers such as Pylance/Pyright. Examples of this include GraphicsLayoutWidget, ImageView, and PlotItem.

Code to reproduce

import pyqtgraph as pg

if __name__ == "__main__":
    app = pg.mkQApp()
    w = pg.GraphicsLayoutWidget()
    w.nextRow()
    # ^^^^^^^^^  Cannot access attribute "nextRow" for class "GraphicsLayoutWidget"
    #              Attribute "nextRow" is unknown (Pylance)
    w.show()
    app.exec()

Behavior

nextRow() is not originally defined in GraphicsLayoutWidget, but is rather copied over from the internal GraphicsLayout at runtime.

self.ci = GraphicsLayout(**kargs)
for n in ['nextRow', 'nextCol', 'nextColumn', 'addPlot', 'addViewBox', 'addItem', 'getItem', 'addLayout', 'addLabel', 'removeItem', 'itemIndex', 'clear']:
setattr(self, n, getattr(self.ci, n))

In the past, I suspect Pylance never compained about this issue because it was unable to make type inferences without the Qt type hints.
I suspect that this is with the addition of Qt type hints (a much appreciated addition), Pylance is properly trace type inheritance, and can figure out that the classes appear to be missing these methods.

Suggestions

I tried adding type hinted versions of __getattr__() to proxy specific methods, but I didn't have any luck. It seems that once a class defines a __getattr__() method without type hints (or use easily inferable types), most type checkers "give up" and say it returns Unknown or Any.

I have a few suggestions for potential ways forward:

  1. Manually redefine each method with arguments that pass to the composed object. This seems like a lot of work and adds another layer of maintenance/point of failure.

  2. A slightly better implementation of the method 1, could be to use *args and **kwargs in method redefinitions and use a decorator to copy over the type signature with something like this: Type hints and tooltips for decorators/wrappers/proxy objects microsoft/pyright#4426 (reply in thread)

  3. Use @property on methods for pass-through. This does screw up syntax highlighting for certain color schemes a bit, as the methods are seen as callable properties instead of actual methods.

class GraphicsLayoutWidget:
    # ...
    @property
    def nextRow(self):
        return self.ci.nextRow
  1. Quick and dirty approach to allow __getattr__() to return Any. It loses some type checking utility, but at least it doesn't show errors.
from typing import TYPE_CHECKING

class GraphicsLayoutWidget:
    # ...
    if TYPE_CHECKING:
        def __getattr__(self, name: str) -> Any: ...

Tested environment(s)

  • PyQtGraph version: 0.13.5 and 0.13.7
  • Qt Python binding: PyQt6
  • Python version: 3.12.2
  • NumPy version: 1.26.4
  • Operating system: Windows 10
  • Installation method: Poetry
@j9ac9k
Copy link
Member

j9ac9k commented May 11, 2024

I've low-key hated those dynamically allocated methods for a variety of reasons, they're tough to document, auto-completion in editors doesn't work and of course type annotations don't work either.

Of the suggestions proposed, I would imagine the @property method would be "best", seems to address all the issues above, but having another callable as a property certainly seems "weird"

That said, I'm curious how matplotlib handles this, I think they started type-annotating their code, and the same methods are used in a variety of objects.

@Antyos
Copy link
Author

Antyos commented May 12, 2024

I agree that calling a property feels weird, even if it is the most straight forward.

I can't find an instance of Matplotlib copying a function definition, but it's a dense library, so I may have missed it. It does seem like they use a docstring decorator to copy over function docstrings, but nothing about type hints.

I know Plotly uses a lot of copied definitions between functions, but I think they mostly use **kwargs and leave it up to the docstring to provide the necessary information. I think they generate most of the code used in the library with other scripts based on the state of Plotly.js, so it may not be a fair comparison.

typing.Unpack could be useful, but it seems like it's probably more trouble than it's worth for small functions.

Here's a thread I opened about a similar issue in Xarray (but hasn't received any new comments): pydata/xarray#8136. One approach they suggest is to add a .pyi file, however, I think that would be another place to maintain type hints, which sort of defeats the purpose.

I messed around with the signature copier decorator, but I didn't have much luck getting it to work. Maybe using @property is the best thing to do for now until "proper" signature copying is implemented into the language.

@ksunden
Copy link
Contributor

ksunden commented May 13, 2024

Copying my reply from discord here (note have not fully read this thread yet):

Yeah... in general dynamic behavior is not conducive to static type checking...

In mpl, we do have some aliasing that is handled by a class decorator, such as set_ha which is the short form of set_horizontalalignment (among many others). We have opted to not type hint those things that are aliased dynamically like that. In our case the official guidance is to use the canonical name in the type checked setting, which is type hinted.

In mpl's case, we technically have the ability to put the type hints in the .pyi files, but I chose not to since it seems silly to expand out the class decorator, and it would make it harder for us to inline type hints should we ever choose to do so (no active plans though on that front)

As for CI, we have a file with specific ignores (for stubtest, which is mypy's program to validate .pyi files against implementation), which actually includes almost everything except the aliases and such, because we generate the most common cases with a script, so that individual PR authors don't have to think about the stubtest exceptions as often:

In this case, I think perhaps rewriting these so that they are more static friendly may be possible...

Not finding the obvious solution immediately, but feels possible, I think one step would be unrolling the loop, which I think will help static checkers...

In mpl we have a similar pass through implented as _axis_method_wrapper for various things that are wrapped in Axes that are actually canonically held in the x/y Axis. However in this case I just typed them in pyi

@sneakers-the-rat
Copy link

@sneakers-the-rat
Copy link

oh wow sorry i was just doing more dynamic typing stuff and came back and realized i really misread what was going on. i thought the problem was for general dynamically created methods, not for the case in OP where the methods are known beforehand.

Not sure if y'all were looking to like actually change the dynamically assigned attrs and handle that in a different way, but here's another option that involves no changes to the runtime code. It's a little uh verbose (dynamically assigning in a loop doesn't work for the same reason pyright can't infer from the setattr/getattr combo) but it is simple and works:

from typing import TYPE_CHECKING

# ...

class GraphicsLayoutWidget(GraphicsView):
    # ...
    def __init__(self, ...):
        # ...
        
    if TYPE_CHECKING:
        nextRow = GraphicsLayout.nextRow
        nextCol = GraphicsLayout.nextCol
        nextColumn = GraphicsLayout.nextColumn
        addPlot = GraphicsLayout.addPlot
        addViewBox = GraphicsLayout.addViewBox
        addItem = GraphicsLayout.addItem
        getItem = GraphicsLayout.getItem
        addLayout = GraphicsLayout.addLayout
        addLabel = GraphicsLayout.addLabel
        removeItem = GraphicsLayout.removeItem
        itemIndex = GraphicsLayout.itemIndex
        clear = GraphicsLayout.clear    
        

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants