Sachaa-Thanasius / defer-imports

Lazy imports with regular syntax in pure Python.
MIT License
8 stars 0 forks source link

============= defer-imports

.. image:: https://img.shields.io/github/license/Sachaa-Thanasius/defer-imports.svg :target: https://opensource.org/licenses/MIT :alt: License: MIT

.. image:: https://img.shields.io/pypi/v/defer-imports.svg :target: https://pypi.org/project/defer-imports :alt: PyPI version info

.. image:: https://img.shields.io/pypi/pyversions/defer-imports.svg :target: https://pypi.org/project/defer-imports :alt: PyPI supported Python versions

A library that implements PEP 690_–esque lazy imports in pure Python.

Note: This is still in development.

.. contents:: :local: :depth: 2

Installation

defer-imports requires Python 3.9+.

This can be installed via pip::

python -m pip install defer-imports

Usage

See the docstrings and comments in the codebase for more details.

Setup

To do its work, defer-imports must hook into the Python import system. Include the following call somewhere such that it will be executed before your code:

.. code-block:: python

import defer_imports

# For all usage, import statements *within the module the hook is installed from* 
# are not affected. In this case, that would be this module.

defer_imports.install_import_hook()

import your_code

The function call's result can be used as a context manager, which makes sense when passing in configuration arguments. That way, unrelated code or usage of this library isn't polluted:

.. code-block:: python

import defer_imports

with defer_imports.install_import_hook(module_names=(__name__,)) as hook_ctx:
    import your_code

Making this call without arguments allows user code with imports contained within the defer_imports.until_use context manager to be deferred until referenced. However, its several configuration parameters allow toggling global instrumentation (affecting all import statements) and adjusting the granularity of that global instrumentation.

WARNING: Avoid using the hook as anything other than a context manager when passing in module-specific configuration; otherwise, the explicit (or default) configuration will persist and may cause other packages using defer_imports to behave differently than expected.

.. code-block:: python

import defer_imports

# Ex 1. Henceforth, instrument all import statements in other pure-Python modules
# so that they are deferred. Off by default. If on, it has priority over any other
# configuration passed in alongside it.
#
# Better suited for applications.
defer_imports.install_import_hook(apply_all=True)

# Ex 2. Henceforth, instrument all import statements *only* in modules whose names
# are in the given sequence of strings.
#
# Better suited for libraries.
with defer_imports.install_import_hook(module_names=(__name__,)):
    ...

# Ex 3. Henceforth, instrument all import statements *only* in modules whose names
# are in the given sequence *or* whose names indicate they are submodules of any
# of the sequence members.
#
# In this case, the discord, discord.types, and discord.abc.other modules would all
# be affected.
#
# Better suited for libraries.
with defer_imports.install_import_hook(module_names=("discord",), recursive=True):
    ...

Example

Assuming the path hook was registered normally (i.e. without providing any configuration), you can use the defer_imports.until_use context manager to decide which imports should be deferred. For instance:

.. code-block:: python

import defer_imports

with defer_imports.until_use:
    import inspect
    from typing import Final

# inspect and Final won't be imported until referenced.

WARNING: If the context manager is not used as defer_imports.until_use, it will not be instrumented properly. until_use by itself, aliases of it, and the like are currently not supported.

If the path hook was registered with configuration, then within the affected modules, most module-level import statements will be instrumented. There are two supported exceptions: import statements within try-except-else-finally blocks and within non- defer_imports.until_use with blocks. Such imports are still performed eagerly. These "escape hatches" mostly match those described in PEP 690.

Use Cases

Features

Caveats

Why?

Lazy imports alleviate several of Python's current pain points. Because of that, PEP 690_ was put forth to integrate lazy imports into CPython; see that proposal and the surrounding discussions for more information about the history, implementations, benefits, and costs of lazy imports.

Though that proposal was rejected, there are well-established third-party libraries that provide lazy import mechanisms, albeit with more constraints. Most do not have APIs as integrated or ergonomic as PEP 690's, but that makes sense; most predate the PEP and were not created with that goal in mind.

Existing libraries that do intentionally inject or emulate PEP 690's semantics and API don't fill my needs for one reason or another. For example, |slothy|_ (currently) limits itself to specific Python implementations by relying on the existence of call stack frames. I wanted to create something similar that relies on public implementation-agnostic APIs as much as possible.

How?

The core of this package is quite simple: when import statments are executed, the resulting values are special proxies representing the delayed import, which are then saved in the local namespace with special keys instead of normal string keys. When a user requests the normal string key corresponding to the import, the relevant import is executed and both the special key and the proxy replace themselves with the correct string key and import result. Everything stems from this.

The defer_imports.until_use context manager is what causes the proxies to be returned by the import statements: it temporarily replaces builtins.__import__ with a version that will give back proxies that store the arguments needed to execute the actual import at a later time.

Those proxies don't use those stored __import__ arguments themselves, though; the aforementioned special keys are what use the proxy's stored arguments to trigger the late import. These keys are aware of the namespace, the dictionary, they live in, are aware of the proxy they are the key for, and have overriden their __eq__ and __hash__ methods so that they know when they've been queried. In a sense, they're like descriptors, but instead of "owning the dot", they're "owning the brackets". Once such a key has been matched (i.e. someone uses the name of the import), it can use its corresponding proxy's stored arguments to execute the late import and replace itself and the proxy in the local namespace. That way, as soon as the name of the deferred import is referenced, all a user sees in the local namespace is a normal string key and the result of the resolved import.

The missing intermediate step is making sure these special proxies are stored with these special keys in the namespace. After all, Python name binding semantics only allow regular strings to be used as variable names/namespace keys; how can this be bypassed? defer-imports's answer is a little compile-time instrumentation. When a user calls defer_imports.install_import_hook() to set up the library machinery (see "Setup" above), what they are doing is installing an import hook that will modify the code of any given Python file that uses the defer_imports.until_use context manager. Using AST transformation, it adds a few lines of code around imports within that context manager to reassign the returned proxies to special keys in the local namespace (via locals()).

With this methodology, we can avoid using implementation-specific hacks like frame manipulation to modify the locals. We can even avoid changing the contract of builtins.__import__, which specifically says it does not modify the global or local namespaces that are passed into it. We may modify and replace members of it, but at no point do we change its size while within __import__ by removing or adding anything.

Benchmarks

There are currently a few ways of measuring activation and/or import time:

Acknowledgements

The design of this library was inspired by the following:

Without them, this would not exist.

.. Common/formatted hyperlinks

.. _PEP 690: https://peps.python.org/pep-0690/

.. |timeit| replace:: timeit .. _timeit: https://docs.python.org/3/library/timeit.html

.. |python -B| replace:: python -B .. _python -B: https://docs.python.org/3/using/cmdline.html#cmdoption-B

.. |python -X importtime| replace:: python -X importtime .. _python -X importtime: https://docs.python.org/3/using/cmdline.html#cmdoption-X

.. |typeguard| replace:: typeguard .. _typeguard: https://github.com/agronholm/typeguard

.. |beartype| replace:: beartype .. _beartype: https://github.com/beartype/beartype

.. |jaxtyping| replace:: jaxtyping .. _jaxtyping: https://github.com/patrick-kidger/jaxtyping

.. |torchtyping| replace:: torchtyping .. _torchtyping: https://github.com/patrick-kidger/torchtyping

.. |pyximport| replace:: pyximport .. _pyximport: https://github.com/cython/cython/tree/master/pyximport

.. |demandimport| replace:: demandimport .. _demandimport: https://github.com/bwesterb/py-demandimport

.. |apipkg| replace:: apipkg .. _apipkg: https://github.com/pytest-dev/apipkg

.. |metamodule| replace:: metamodule .. _metamodule: https://github.com/njsmith/metamodule

.. |modutil| replace:: modutil .. _modutil: https://github.com/brettcannon/modutil

.. |lazy-loader| replace:: lazy-loader .. _lazy-loader: https://github.com/scientific-python/lazy-loader

.. |slothy| replace:: slothy .. _slothy: https://github.com/bswck/slothy

.. |ideas| replace:: ideas .. _ideas: https://github.com/aroberge/ideas