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

implement lazy imports #2584

Closed
wants to merge 42 commits into from
Closed

implement lazy imports #2584

wants to merge 42 commits into from

Conversation

KRRT7
Copy link

@KRRT7 KRRT7 commented May 20, 2024

adds a lazy import mechanism using a module-level getattr function. This applies both to:

  1. toga-core (in toga/init.py)
  2. toga_cocoa/factory.py)

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

core/src/toga/__init__.py Outdated Show resolved Hide resolved
cocoa/src/toga_cocoa/factory.py Outdated Show resolved Hide resolved
core/src/toga/__init__.py Outdated Show resolved Hide resolved
@mhsmith
Copy link
Member

mhsmith commented May 20, 2024

There are some flake8 errors (find "pre-commit checks" below and click "Details" on the right), mostly like this:

cocoa/src/toga_cocoa/factory.py:58:1: F822 undefined name 'App' in `__all__`.

These can be suppressed by adding a noqa marker:

__all__ = [  # noqa: F822

However, I'm not sure whether toga_cocoa/factory.py even needs an __all__ list anymore, because it's an internal module which should never be used with import *. The list was only needed before to suppress the "unused import" warnings, which won't happen anymore with lazy import.

toga/__init__.py does need an __all__, but I think it should be possible to auto-generate it from the list of lazy import names, as shown in #2547 (comment). This could be done while generating the "dict which maps from name to module", as mentioned above.

core/src/toga/__init__.py Outdated Show resolved Hide resolved
cocoa/src/toga_cocoa/factory.py Outdated Show resolved Hide resolved
changes/2547.feature.rst Outdated Show resolved Hide resolved
cocoa/src/toga_cocoa/factory.py Outdated Show resolved Hide resolved
core/src/toga/__init__.py Outdated Show resolved Hide resolved
core/src/toga/__init__.py Outdated Show resolved Hide resolved
core/src/toga/__init__.py Outdated Show resolved Hide resolved
@mhsmith
Copy link
Member

mhsmith commented May 21, 2024

The core unit tests (scroll through the checks below and click "Details") are now failing with this error:

../.tox/py-cov/lib/python3.8/site-packages/toga_dummy/widgets/slider.py:6: in <module>
    class Slider(Widget, toga.widgets.slider.SliderImpl):
E   AttributeError: module 'toga.widgets' has no attribute 'slider'

This is a problem with the tests, so it isn't your fault, but you'll still have to fix it before the PR can be merged. The problem is that the tests assume that all the toga submodules have already been imported, which is no longer true now that the imports are lazy.

This can be fixed in that file by changing import toga to import toga.widgets.slider. There are probably a few other places which will have to be updated in the same way. But user code in apps shouldn't be affected, because they should only import the top-level toga module.

To run the tests on your own machine, install Toga into your environment and then follow these instructions.

@KRRT7
Copy link
Author

KRRT7 commented May 23, 2024

The core unit tests (scroll through the checks below and click "Details") are now failing with this error:

../.tox/py-cov/lib/python3.8/site-packages/toga_dummy/widgets/slider.py:6: in <module>
    class Slider(Widget, toga.widgets.slider.SliderImpl):
E   AttributeError: module 'toga.widgets' has no attribute 'slider'

This is a problem with the tests, so it isn't your fault, but you'll still have to fix it before the PR can be merged. The problem is that the tests assume that all the toga submodules have already been imported, which is no longer true now that the imports are lazy.

This can be fixed in that file by changing import toga to import toga.widgets.slider. There are probably a few other places which will have to be updated in the same way. But user code in apps shouldn't be affected, because they should only import the top-level toga module.

To run the tests on your own machine, install Toga into your environment and then follow these instructions.

all the tests pass on my end.

@freakboy3742
Copy link
Member

all the tests pass on my end.

Does this include both the core API tests and the testbed tests? Since the testbed tests are failing on every platform (except iOS) in CI, it suggests you're only running the core API tests locally.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

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

I've cleaned up some import usage and the changenote; but the bigger question is about the changes to the Cocoa factory module.

Lazy loading in the backends there would definitely make sense... but why has only the cocoa backend been updated in this way? (And, you'll note, it's then the backend that fails testing, because it's lazily loading the core classes, not the backend classes).

@mhsmith
Copy link
Member

mhsmith commented May 29, 2024

why has only the cocoa backend been updated in this way?

Each backend can do this independently, so they don't all need to be done in the same PR. I've removed the "fixes" keyword from the top comment to reflect this.

@freakboy3742
Copy link
Member

why has only the cocoa backend been updated in this way?

Each backend can do this independently, so they don't all need to be done in the same PR. I've removed the "fixes" keyword from the top comment to reflect this.

Well... sure... but the logic for each backend will be identical, so I'm not sure why we wouldn't duplicate across all the backends (or, at the very least, the desktop, mobile and dummy backends).

On top of that, via Discord we found out that the one platform the reporter doesn't have access to is macOS, so it seems a weird choice to start there.

freakboy3742 and others added 3 commits June 2, 2024 22:40
Update factory.py

Cam Lock

sorted lazy mobile

lazy mobiles

final final stretch

final stretch

pre-commit

coverage

fix up the fixup of the fixups

fixup the fixups

fixup

precommit

fixup

fixup

not_impl fn

hiccup

precommit
Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

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

A couple of minor stylistic tweaks inline; however, the bigger issue for me is the code duplication. The logic in each backend's factory module is now identical, except for the name of the module, and the list of widgets that are in the mapping; the former can be easily parameterised, and the latter is detail that can be determined programmatically by trying to import a name and succeding/failing.

That leads me to question whether the factory modules are needed at all.
The Factory module was needed previously because it included literal imports; but if we're replacing all those literal imports with programmatic ones, it seems to me that we should be replacing all the factory.py modules with a single Factory class that is instantiated in toga.core.platform, whose __getattr__ implementation does the dynamic submodule lookup in the way it's being done here for each module. The lookup mechanism will be almost identical - just parameterised on the module name, and with an extra layer of ImportError handling.

testbed/tests/test_factory.py Outdated Show resolved Hide resolved
) from None
else:
module = importlib.import_module(module_name)
value = getattr(module, name) if has_dot else module
Copy link
Member

Choose a reason for hiding this comment

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

This logic works, but it's a bit convoluted - it involves evaluating has_dot twice. You can get the same effect in less lines of code using rsplit() and iteration:

        base_name, *extra = module_name.rsplit('.', 1)
        value = importlib.import_module(base_name)
        for name in extra:
            value = getattr(value, name)

Co-authored-by: Russell Keith-Magee <russell@keith-magee.com>
@mhsmith
Copy link
Member

mhsmith commented Jun 4, 2024

if we're replacing all those literal imports with programmatic ones, it seems to me that we should be replacing all the factory.py modules with a single Factory class that is instantiated in toga.core.platform, whose __getattr__ implementation does the dynamic submodule lookup

That's a good idea, but I think it can be made even simpler, at the cost of changing all the code that uses the factory. The factory takes a perfectly usable multi-level namespace from the backend package, and creates a new single-level namespace out of it for no significant benefit. What if we instead make the interface layer access the backend module structure directly?

For example, where we currently write:

self.factory.Camera

We would instead have:

import_backend("hardware.camera.Camera")

Which on macOS would translate into an import of toga_cocoa.hardware.camera, and a getattr of Camera.

That way, the names of the backend modules and classes would be distributed throughout the interface layer in the places each of them is used, rather than centralized in one list.

@mhsmith
Copy link
Member

mhsmith commented Jun 4, 2024

Places which use the same name multiple times can be refactored slightly. For example, in Icon.__init__ we currently have self.factory = get_platform_factory() followed by a number of uses of self.factory.Icon. This could be replaced by IconBackend = import_backend("icons.Icon") followed by a number of uses of IconBackend.

@freakboy3742
Copy link
Member

That's a good idea, but I think it can be made even simpler, at the cost of changing all the code that uses the factory. The factory takes a perfectly usable multi-level namespace from the backend package, and creates a new single-level namespace out of it for no significant benefit. What if we instead make the interface layer access the backend module structure directly?
...
That way, the names of the backend modules and classes would be distributed throughout the interface layer in the places each of them is used, rather than centralized in one list.

I guess that's true: there's no reason to use a single "Factory" namespace, as long as we can clearly specify that path inside the backend module that will contain the class we want to use.

I guess the only question I'd have is whether we include the class name in the import calls (e.g., import_backend('icons.Icon')). If we do the class lookup outside the method, you'd end up with import_backend("icons").Icon - which means import_backend() is a really lightweight wrapper around importlib.import_module() that injects the name of the current backend module - potentially honouring all the argument semantics of import_module as well.

@mhsmith
Copy link
Member

mhsmith commented Jun 5, 2024

If we do the class lookup outside the method, you'd end up with import_backend("icons").Icon - which means import_backend() is a really lightweight wrapper around importlib.import_module() that injects the name of the current backend module

Yes, that would be a bit cleaner. And in most cases, each interface module only uses one backend module, so we could adopt the pattern of calling it backend and putting it at the module level below the import statements:

backend = import_backend("hardware.camera")

and then self.factory.Camera becomes backend.Camera.

potentially honouring all the argument semantics of import_module as well

The only other argument of import_module is package, and it wouldn't make any sense to accept that because the package should always be the same. Neither would it make sense to require relative module syntax, because the number of leading dots could never be anything other than 1.

@freakboy3742
Copy link
Member

If we do the class lookup outside the method, you'd end up with import_backend("icons").Icon - which means import_backend() is a really lightweight wrapper around importlib.import_module() that injects the name of the current backend module

Yes, that would be a bit cleaner. And in most cases, each interface module only uses one backend module, so we could adopt the pattern of calling it backend and putting it at the module level below the import statements:

backend = import_backend("hardware.camera")

and then self.factory.Camera becomes backend.Camera.

Agreed this would be nice, provided there's no circular import or other dependency issues lurking.

potentially honouring all the argument semantics of import_module as well

The only other argument of import_module is package, and it wouldn't make any sense to accept that because the package should always be the same. Neither would it make sense to require relative module syntax, because the number of leading dots could never be anything other than 1.
👍

@mhsmith
Copy link
Member

mhsmith commented Jun 7, 2024

In that case, how about we reduce this PR to just doing lazy imports in the core, and record a separate issue for the factory change ideas?

@freakboy3742
Copy link
Member

In that case, how about we reduce this PR to just doing lazy imports in the core, and record a separate issue for the factory change ideas?

I'd be OK with that - but either way, this PR will need to be modified (either adding the new imports, or removing the dynamic import implementation on the platform backends being used here); which way we go depends on @KRRT7's interest level.

@mhsmith
Copy link
Member

mhsmith commented Jun 10, 2024

Agreed this would be nice, provided there's no circular import or other dependency issues lurking.

Good point: there are a few places where the backend needs to access things from the frontend at module level, like toga.widgets.slider.SliderImpl. These should be reviewed before committing to an approach.

@KRRT7
Copy link
Author

KRRT7 commented Jul 1, 2024

reduce this PR to just doing lazy imports in the core

let's do this, and let's open the factory change ideas and once you guys have come to a decision on what to do, i'll give it a look and discuss how best to implement it.

@mhsmith
Copy link
Member

mhsmith commented Jul 1, 2024

KRRT7 added a commit to KRRT7/toga that referenced this pull request Jul 3, 2024
This list should be in alphabetical order by module and then by name, as in toga/__init__.py.
beeware#2584 (comment)
including  preserving the historical sort order (i.e., sorted by the full import path of the original module), with interleaved comments highlighting that ordering.
beeware#2686 (comment)
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

Successfully merging this pull request may close these issues.

3 participants