Skip to content

Commit

Permalink
Merge pull request #27 from DavidCEllis/capturing_importer
Browse files Browse the repository at this point in the history
Add a context manager that captures import statements to populate a LazyImporter.
  • Loading branch information
DavidCEllis authored Nov 12, 2024
2 parents 16c9560 + 56daa5f commit bfece4d
Show file tree
Hide file tree
Showing 22 changed files with 828 additions and 22 deletions.
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,9 @@ __all__ = [..., "useful_tool"]

laz = LazyImporter(
[FromImport(".submodule", "useful_tool")],
globs=globals(), # If relative imports are used, globals() must be provided.
globs=globals(), # globals() is used for relative imports, LazyImporter will attempt to infer it if not provided
)
__getattr__, __dir__ = get_module_funcs(laz, __name__)
__getattr__, __dir__ = get_module_funcs(laz, __name__) # __name__ will also be inferred if not given
```

## The import classes ##
Expand Down Expand Up @@ -252,6 +252,50 @@ except ImportError:

when provided to a LazyImporter.

## Experimental import statement capture ##

There is an **experimental** mode that can capture import statements within a context block.

This is currently in a separate 'capture' submodule but may be merged (or lazily imported itself) in the future.

```python
from ducktools.lazyimporter import LazyImporter, get_importer_state
from ducktools.lazyimporter.capture import capture_imports

laz = LazyImporter()

with capture_imports(laz, auto_export=True):
# Inside this block, imports are captured and converted to lazy imports on laz
import functools
from collections import namedtuple as nt

print(get_importer_state(laz))

# Note that the captured imports are *not* available in the module namespace
try:
functools
except NameError:
print("functools is not here")
```

Imports are placed on the lazy importer object as with the explicit syntax. Unlike the regular
syntax, these imports are exported by default.

This works by replacing and restoring the builtin `__import__` function that is called by the
import statement while in the block.

### Context Manager Caveats ###

* This only supports Module imports and From imports
* The actual statement executes immediately and returns a placeholder, so a try/except can't work.
* Imports triggered inside functions or classes while within the block will still occur eagerly
* Imports triggered in other modules while within the block will still occur eagerly
* The context manager must be used at the module level
* It will error if you use it inside a class or function scope
* If other modules are also replacing `__import__` **simultaneously** this will probably fail.
* In a library you may not be able to guarantee this.
* Hopefully this will be resolvable.

## Environment Variables ##

There are two environment variables that can be used to modify the behaviour for
Expand Down
30 changes: 30 additions & 0 deletions docs/capture_imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Capturing import statements #

There is now an experimental method to capture import statements. This works by replacing the
`__import__` function within the block and restoring it afterwards. Currently this is in a
separate submodule.

```python
from ducktools.lazyimporter import LazyImporter, get_importer_state
from ducktools.lazyimporter.capture import capture_imports

laz = LazyImporter()

with capture_imports(laz):
# Inside this block, imports are captured and converted to lazy imports on laz
import functools
from collections import namedtuple as nt

print(get_importer_state(laz))

# Note that the captured imports are *not* available in the module namespace
try:
functools
except NameError:
print("functools is not here")
```

The replaced `__import__` function wraps the original `builtins.__import__` function outside
of the module it is being executed in. If you call a functiion inside the capturing block
that performs an import in another module that **should** work as expected.

1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ caption: "Contents:"
hidden: true
---
import_classes
capture_imports
examples
importer_state
extending
Expand Down
75 changes: 68 additions & 7 deletions src/ducktools/lazyimporter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import os
import sys

__version__ = "v0.6.0"
__version__ = "v0.7.0"
__all__ = [
"LazyImporter",
"ModuleImport",
Expand Down Expand Up @@ -636,7 +636,7 @@ class LazyImporter:

_importers = _ImporterGrouper()

def __init__(self, imports, *, globs=None, eager_process=None, eager_import=None):
def __init__(self, imports=None, *, globs=None, eager_process=None, eager_import=None):
"""
Create a LazyImporter to import modules and objects when they are accessed
on this importer object.
Expand All @@ -649,7 +649,7 @@ def __init__(self, imports, *, globs=None, eager_process=None, eager_import=None
overridden by providing arguments here.
:param imports: list of imports
:type imports: list[ImportBase]
:type imports: Optional[list[ImportBase]]
:param globs: globals object for relative imports
:type globs: dict[str, typing.Any]
:param eager_process: filter and check the imports eagerly
Expand All @@ -658,13 +658,27 @@ def __init__(self, imports, *, globs=None, eager_process=None, eager_import=None
:type eager_import: Optional[bool]
"""
# Keep original imports for __repr__
self._imports = imports
self._imports = imports if imports is not None else []

self._globals = globs
if self._globals is None:
try:
# Try to get globals through frame if possible
self._globals = sys._getframe(1).f_globals
except (AttributeError, ValueError):
pass

self._eager_import = eager_import or (EAGER_IMPORT and eager_import is None)
self._eager_process = (
eager_process
or self._eager_import
or (EAGER_PROCESS and eager_process is None)
)

if eager_process or (EAGER_PROCESS and eager_process is None):
if self._eager_process:
_ = self._importers

if eager_import or (EAGER_IMPORT and eager_import is None):
if self._eager_import:
force_imports(self)

def __getattr__(self, name):
Expand Down Expand Up @@ -731,7 +745,8 @@ def get_module_funcs(importer, module_name=None):
If a module name is provided, attributes from the module will appear in the
__dir__ function and __getattr__ will set the attributes on the module when
they are first accessed.
they are first accessed. If they are not provided, if the implementation
provides frame inspection it will be inferred.
If a module already has __dir__ and/or __getattr__ functions it is probably
better to use the result of dir(importer) and getattr(importer, name) to
Expand All @@ -746,6 +761,15 @@ def get_module_funcs(importer, module_name=None):
:return: __getattr__ and __dir__ functions
:rtype: tuple[types.FunctionType, types.FunctionType]
"""
# Try to get module name from the frame
if module_name is None:
try:
module_name = sys._getframemodulename(1) or "__main__"
except AttributeError:
try:
module_name = sys._getframe(1).f_globals.get("__name__", "__main__")
except (AttributeError, ValueError):
pass

if module_name:
mod = sys.modules[module_name]
Expand Down Expand Up @@ -785,3 +809,40 @@ def force_imports(importer):
"""
for attrib_name in dir(importer):
getattr(importer, attrib_name)


# noinspection PyProtectedMember
def extend_imports(importer, imports):
"""
Add additional importers to a LazyImporter.
:param importer: LazyImporter to add imports
:param imports: Additional imports to add to the lazyimporter
"""
# Delete current imports - dir only gives import names
redo_imports = []
for key in dir(importer):
try:
delattr(importer, key)
except AttributeError:
pass
else:
redo_imports.append(key)

# Clear out the importers cache
try:
del importer._importers
except AttributeError:
pass

# Add the new imports and do any necessary processing
importer._imports.extend(imports)

if importer._eager_process:
_ = importer._importers

if importer._eager_import:
force_imports(importer)
else:
for key in redo_imports:
getattr(importer, key)
12 changes: 9 additions & 3 deletions src/ducktools/lazyimporter/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,19 @@ class _ImporterGrouper:
@overload
def __get__(
self, inst: LazyImporter, cls: type[LazyImporter] | None = ...
) -> list[ImportBase]: ...
) -> dict[str, ImportBase]: ...
@staticmethod
def group_importers(inst: LazyImporter) -> list[ImportBase]: ...
def group_importers(inst: LazyImporter) -> dict[str, ImportBase]: ...

class LazyImporter:
_imports: list[ImportBase]
_globals: dict | None

_importers: _ImporterGrouper

def __init__(
self,
imports: list[ImportBase],
imports: list[ImportBase] | None = ...,
*,
globs: dict[str, Any] | None = ...,
eager_process: bool | None = ...,
Expand All @@ -170,3 +175,4 @@ def get_module_funcs(
module_name: str | None = ...,
) -> tuple[types.FunctionType, types.FunctionType]: ...
def force_imports(importer: LazyImporter) -> None: ...
def extend_imports(importer: LazyImporter, imports: list[ImportBase]) -> None: ...
Loading

0 comments on commit bfece4d

Please sign in to comment.