Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

DaelonSuzuka
Copy link
Contributor

@DaelonSuzuka DaelonSuzuka commented Dec 10, 2022

For some reason, QtGui and QtWidgets are importing QtOpenGL and QtOpenGLWidgets, respectively. Attempting to import these here forces applications to depend on OpenGL even if they don't need it.

My use case for this change is that I distribute Qt/Python applications by creating a single-folder bundle with PyInstaller, and then zipping that bundle to create a portable distribution, or compiling it into an installer with a tool like InnoSetup. I was looking to reduce the size of my distributions, and Qt is supposed to be able to run with only a few of the dlls present, so I started deleting the biggest ones from my bundle. opengl32sw.dll is 20MB and QtOpenGL.pyd is 10MB, so omitting them is a non-trivial reduction in final size.

Am I missing a reason for these imports?

If removing these is okay, I can also audit the rest of the package for similar cross-contaminations (and I promise it won't spiral into a multi-month project like last time >.>).

@dalthviz
Copy link
Member

Hi @DaelonSuzuka! The reason to do those imports is to bring compatibility with the Qt5 modules layout. For example, QOpenGLWidget is available from QtWidgets on Qt 5 while is in its own module for Qt 6.

However, maybe we could somehow condition making these imports with a flag (something like QTOPENGL_QT5_LAYOUT)? What do you think @CAM-Gerlach @ccordoba12 ?

@dalthviz dalthviz changed the title Improve import modularity PR: Improve import modularity between QtGui, QtWidgets and QtOpenGL* related modules Dec 12, 2022
@ccordoba12
Copy link
Member

However, maybe we could somehow condition making these imports with a flag (something like QTOPENGL_QT5_LAYOUT)? What do you think @CAM-Gerlach @ccordoba12 ?

Good idea, I was thinking along those lines too.

@dalthviz dalthviz added this to the v2.3.1 milestone Dec 12, 2022
@CAM-Gerlach
Copy link
Member

I guess that seems reasonable to me, a bit ugly but I'm not sure there's really another alternative, other than making it a lazy import or breaking backward compatibility.

If we add a flag, we'd want it to documented in the README like the others. For backward compat, it should presumably by on by default in QtPy 2.x, though we could revisit that in QtPy 3.x.

@dalthviz dalthviz modified the milestones: v2.3.1, v2.4.0 Feb 8, 2023
@CAM-Gerlach
Copy link
Member

Thinking about it more, ISTM both the simplest and best approach here is try/except the QtOpenGL import and if it fails, define a module-level __getattr__ (supported in Python 3.7+) that raises a QtModuleNotInstalledError with the package name (instead of a AttributeError) if any of the missing QtOpenGL attributes are accessed. i.e. something like:

MISSING_OPENGL_NAMES = {}
# ...
if PYQT6:
    # ...
    try:
        from PyQt6.QtOpenGLWidgets import QOpenGLWidget
    except ModuleNotFoundError:
        MISSING_OPENGL_NAMES.update("QOpenGLWidget")
# ...
def __getattr__(name):
    if name in MISSING_OPENGL_NAMES:
        raise QtModuleNotInstalledError(
            name="PyQt6.QtOpenGLWidgets", missing_package="pyopengl")
    else:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

That way, it would work gracefully right now without a hacky manual flag, while immediately raising a clearer and more specific/helpful error if it is not installed. The same approach could be applied to other optional modules as well, where they don't coincide exactly with a top-level QtPy (i.e. PyQt, PySide) submodule. Plus, it's shorter, simpler and can easily be re-used and extended to handle other missing optional modules.

We could also use the same strategy (with a single __getattr__ taking some data structure of the various things supported on different bindings/versions and raising the appropriate helpful QtPy errors if they are not found, rather than just a generic Attribute Error. That way we can easily handle and maintain a lot of cases without a bunch of bespoke code, and in a declarative and machine-readable way, which could potentially even be supported by static type checkers using our existing mechanism.

@DaelonSuzuka
Copy link
Contributor Author

module-level __getattr__

Wow, I did not know that was an option.

code

That's a beautiful mechanism. I'll definitely try this out today.

@CAM-Gerlach
Copy link
Member

Wow, I did not know that was an option.

Yeah; see PEP 562 for more details.

I'll definitely try this out today.

👍

@DaelonSuzuka DaelonSuzuka force-pushed the improve-import-hygiene branch from deb3a80 to 3c107df Compare March 22, 2023 04:04
@DaelonSuzuka
Copy link
Contributor Author

Sorry for the conversation spam, I had to made some modifications to the mechanism and I wanted to explain the rationale.

QtGui.py and QtWidgets.py have slightly different implementations because I thought of a possible refactor while I was writing the first set of comments. I changed only QtWidgets.py to get feedback on both versions. The winning style will replace the losing one.

The Real Problem

How on earth do I write tests for this? I manually tested it in a project of mine by:

  • uninstall qtpy
  • install qtpy from local repo
  • delete build/ and dist/
  • run pyinstaller to make a single-folder bundle
  • run bundled exe to make sure it works
  • open project/dist/Application/PySide6
  • delete the OpenGL files
  • run bundled exe again to make sure it still works

Something tells me there isn't a pytest helper for this.

Copy link
Member

@CAM-Gerlach CAM-Gerlach left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for implementing this, @DaelonSuzuka !

I didn't think about it before, but we need to set __cause__ on the error, so we can:

  • Actually indicate that that it was an attribute access that was the proximate cause of the error, and inform the user which attribute required PyOpenGL
  • Give the details of the ImportError/ModuleNotFound error that happened on import, giving them useful information about what actually happened rather than leaving them (and us or whomever they report it to) guessing whether the module is missing, there's a DLL issue, it was installed/uninstalled incorrectly, it was pip/conda mixing, etc.

Therefore, after a bunch of experimentation, since this added a lot of logic to every call site, I ended up putting this in a helper utility function and and calling that in __getattr__.

I was originally going to make this as suggestions, but it turned out to be rather complicated, and prevent putting the common logic into a utility function somewhere else. Thus, I ended up just pushing it to a branch on my fork you can pull with

git remote add cam-gerlach https://github.com/CAM-Gerlach/qtpy.git
git fetch cam-gerlach
git pull cam-gerlach improve-import-hygiene

Or, I can push it to your branch directly, if you prefer. Thanks!

@CAM-Gerlach
Copy link
Member

Seems like there's a merge conflict to solve, but we can deal with it after the current batch of changes (to avoid invalidating them).

Sorry for the conversation spam, I had to made some modifications to the mechanism and I wanted to explain the rationale.

NB, if you want to avoid others getting with with notifications for every comment, you can just do the comments as part of a review of your own PR, which will mark the as pending and then only send one notification once you submit it.

QtGui.py and QtWidgets.py have slightly different implementations because I thought of a possible refactor while I was writing the first set of comments. I changed only QtWidgets.py to get feedback on both versions. The winning style will replace the losing one.

Ah, I see. I preferred your second, but I ended up refactoring it further and posted a patch/branch with it.

How on earth do I write tests for this? I manually tested it in a project of mine by:

Our eventual goal is to have CI test environments with and without the optional deps, see #383 , which would allow testing this fully integrated, but for now to test this you should be able to patch PyQt6/PySide6.QtOpenGLWidgets with a Mock to always raise ImportError, to ensure it doesn't do so, and test that the behavior is correct in each case.

@DaelonSuzuka
Copy link
Contributor Author

I think I know how to do that. Hopefully I can get it done today.

Narrator: He did not, in fact, know how to do that.

I'll have to try again after a sleep.

@DaelonSuzuka
Copy link
Contributor Author

Alright, I have to admit defeat: I cannot figure out how to test this. I thought I had it working for PySide6 with monkeypatching, but PyQt6 structures their imports differently and the same technique doesn't do anything there. It also only works if it's the first test that's run, which is obviously not correct.

@CAM-Gerlach pls halp.

@CAM-Gerlach
Copy link
Member

Okay, sorry about that!

While I previously thought it was possible, at least in some cases, as far as I can tell there's no obvious way to either get the Conda or the PyPI packages without OpenGL, as it is a core part of even the cut-down PySide6-Essentials package (aside from, of course, horrible manual hackery as you describe) which, though, does somewhat limit the usefulness of this here for this particular case, though the same code/technique will certainly be useful for others that are more "optional".

For starters, one thing that we should certainly add regardless here is add tests for the OpenGL classes in QtGui and QtWidgets, to actually verify that we're compatible here, and (that would presumably xfail if OpenGL was not present)—I assuming that's something you've done already locally.

Beyond that, one thing we could do is just add a special matrix job where after installing but before running the test suite, we delete the OpenGL files from the installed package e.g.

for file in Path(PySide6.__file__).parent.glob(*OpenGL*):
    file.unlink()

Then run the test suite, or a specific set of tests via a custom marker, checking an environment variable to xfail (with the correct error) or xpass the tests that should fail/pass based on the OpenGL install status.

I'm not sure if that complexity (and an extra matrix job) is worth it for what seems to be a niche case; we have a plan to have separate jobs for scenarios with all the optional deps installed, and only the essentials, to test that things still work as expected, which would would presumably focus on what can be installed/uninstalled normally via pip/Conda, but would at least test the basic logic here.

@dalthviz , what do you think here?

@DaelonSuzuka
Copy link
Contributor Author

For starters, one thing that we should certainly add regardless here is add tests for the OpenGL classes in QtGui and QtWidgets,

This, I can definitely do.

Beyond that, one thing we could do is just add a special matrix job where after installing but before running the test suite, we delete the OpenGL files from the installed package e.g.

This was the only idea I had left, but I couldn't tell if it was crazy or not.

@dalthviz
Copy link
Member

dalthviz commented Apr 3, 2023

add tests for the OpenGL classes in QtGui and QtWidgets, to actually verify that we're compatible here, and (that would presumably xfail if OpenGL was not present)

I think that should be enough 👍

Beyond that, one thing we could do is just add a special matrix job where after installing but before running the test suite, we delete the OpenGL files from the installed package e.g.

As you said it seems complex and covers quite a niche case where basically you are removing things manually from the base bindings package, right?

Maybe what could be worthy is to add some tests for the utility functions added and maybe add a test checking that the approach implemented with QtOpenGL to handle stuff missing works? So not directly testing QtOpenGL but a dummy module only available for the test like DummyOpenGL or something like that?

@CAM-Gerlach
Copy link
Member

As you said it seems complex and covers quite a niche case where basically you are removing things manually from the base bindings package, right?

That's what it kinda seems like to me, yeah...

Maybe what could be worthy is to add some tests for the utility functions added and maybe add a test checking that the approach implemented with QtOpenGL to handle stuff missing works? So not directly testing QtOpenGL but a dummy module only available for the test like DummyOpenGL or something like that?

👍

@DaelonSuzuka
Copy link
Contributor Author

DaelonSuzuka commented Apr 4, 2023

I think the new tests are working correctly. Thank you @dalthviz for the recommendation.

I manually tested this latest version on one of my work applications and I can strip opengl32sw.dll, QtOpenGL.pyd, and Qt6OpenGL.dll without issue.

@dalthviz
Copy link
Member

dalthviz commented Apr 4, 2023

Awesome! 🎉 I think we need to solve the conflict in QtGui.py so the PR will be able to run the checks but other than that this LGTM 👍 Thanks for all the work here @DaelonSuzuka and @CAM-Gerlach !

Copy link
Member

@CAM-Gerlach CAM-Gerlach left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking pretty good so far; just some relatively minor suggestions. Thanks!

@DaelonSuzuka
Copy link
Contributor Author

That's the first merge conflict I've resolved using GitHub's editor, I'm crossing my fingers that I didn't mess it up.

CAM-Gerlach

This comment was marked as duplicate.

Copy link
Member

@CAM-Gerlach CAM-Gerlach left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, except for a problem with the merge resolution (see comment).

Co-authored-by: C.A.M. Gerlach <[email protected]>
@DaelonSuzuka
Copy link
Contributor Author

LGTM, except for a problem with the merge resolution (see comment).

Typical. I'm gonna blame github and their no-highlighting merge editor. Thanks for catching that.

Copy link
Member

@CAM-Gerlach CAM-Gerlach left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM from my end, thanks @DaelonSuzuka !

Copy link
Member

@ccordoba12 ccordoba12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some small suggestions for you @DaelonSuzuka, otherwise looks good to me.

…score from _getattr_missing_optional_dep

Co-authored-by: Carlos Cordoba <[email protected]>
@DaelonSuzuka
Copy link
Contributor Author

Some small suggestions for you @DaelonSuzuka, otherwise looks good to me.

I like both of those changes, thanks for suggesting them!

Copy link
Member

@ccordoba12 ccordoba12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work here @DaelonSuzuka!

Copy link
Member

@dalthviz dalthviz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all the work here @DaelonSuzuka !

@dalthviz dalthviz merged commit df95964 into spyder-ide:master Apr 6, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants