<!--
SPDX-FileCopyrightText: 2026 Knitli Inc.

SPDX-License-Identifier: MIT OR Apache-2.0
-->

# lateimport

Lazy import utilities for Python — defer module loading until an imported object is actually used.

[![PyPI](https://img.shields.io/pypi/v/lateimport)](https://pypi.org/project/lateimport/)
[![Python](https://img.shields.io/pypi/pyversions/lateimport)](https://pypi.org/project/lateimport/)
[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](LICENSE-MIT)
[![CI](https://github.com/knitli/lateimport/actions/workflows/ci.yml/badge.svg)](https://github.com/knitli/lateimport/actions/workflows/ci.yml)

## Overview

`lateimport` provides two complementary tools:

- **`lateimport`** — an explicit proxy for deferring imports in application code
- **`create_late_getattr`** — a factory for the `__getattr__` hook in package `__init__.py` files, for use with dispatch-table-based lazy loading (e.g. as generated by [Exportify](https://github.com/knitli/exportify))

Both are stdlib-only. No dependencies.

## Requirements

Python 3.12+

## Installation

```bash
pip install lateimport
```

## Usage

### Explicit lazy imports

Use `lateimport` when you want to defer a heavy import in application code. The module is not imported until the returned proxy is actually *used* — called, subscripted, passed to `isinstance`, etc.

```python
from lateimport import lateimport

# No import happens here
numpy = lateimport("numpy")
pandas = lateimport("pandas", "DataFrame")  # defers pandas.DataFrame

# Import triggered on first use
result = numpy.array([1, 2, 3])
df = pandas({"a": [1, 2]})
```

Proxies are thread-safe and cache the resolved object after first access.

```python
proxy = lateimport("heavy.module", "ExpensiveClass")

proxy.is_resolved()   # False
instance = proxy()    # heavy.module imported, ExpensiveClass resolved
proxy.is_resolved()   # True
```

### Type annotations

`LateImport[T]` is a generic class. Annotating a variable with the resolved type gives you full type-checking and autocompletion downstream — the type checker knows that calling or accessing the proxy produces a `T`.

```python
from __future__ import annotations

from typing import TYPE_CHECKING

from lateimport import LateImport, lateimport

if TYPE_CHECKING:
    import numpy as np
    from pandas import DataFrame

# Type checker knows ndarray() returns np.ndarray
ndarray: LateImport[np.ndarray] = lateimport("numpy", "ndarray")
arr = ndarray([1, 2, 3])  # arr: np.ndarray

# type[DataFrame] means calling the proxy produces a DataFrame instance
DataFrame: LateImport[type[DataFrame]] = lateimport("pandas", "DataFrame")
df = DataFrame({"col": [1, 2, 3]})  # df: DataFrame
```

The `TYPE_CHECKING` guard keeps the imports from executing at runtime — you get static type information without the import cost, which is the whole point.

For types that are always available no guard is needed:

```python
from pathlib import Path
from lateimport import LateImport, lateimport

MkPath: LateImport[type[Path]] = lateimport("pathlib", "Path")
p = MkPath("/tmp/out")  # p: Path
```

### Package-level lazy `__getattr__`

For packages using a dispatch-table pattern (e.g. generated by Exportify), `create_late_getattr` creates a `__getattr__` hook that imports attributes on demand:

```python
# mypackage/__init__.py
from types import MappingProxyType
from lateimport import create_late_getattr
# NOTE: for IDE support, import any dynamic imports in a TYPE_CHECKING block:
from typing import TYPE_CHECKING:
    from mypackage.core.models import MyClass
    from mypackage.utils.helpers import my_function
    from mypackage import SubModule

_dynamic_imports = MappingProxyType({
    "MyClass":      ("mypackage.core",   "models"),
    "my_function":  ("mypackage.utils",  "helpers"),
    # for the current package you can just use __spec__.parent:
    "SubModule":    (__spec__.parent,        "__module__"),
})

__getattr__ = create_late_getattr(_dynamic_imports, globals(), __name__)

__all__ = ("MyClass", "my_function", "SubModule")

# Make sure dir calls use __all__ and not globals:
__dir__ = lambda: list(__all__)
```

The dispatch tuple is `(package, submodule)`:
- **Normal attributes**: imports `package.submodule` and returns `getattr(submodule, attr_name)`
- **`"__module__"`**: imports the submodule itself as the attribute (`import_module(f".{attr_name}", package=package)`)

Resolved attributes are cached in `globals()` so subsequent accesses are direct.

## API

### `lateimport(module_name, *attrs) -> LateImport[T]`

Create a lazy proxy for `module_name`. Optional `attrs` are an attribute chain traversed after import. The function is generic — annotate the variable as `LateImport[T]` to propagate the resolved type to callers.

```python
lateimport("os")                    # proxy for the os module
lateimport("os.path", "join")       # proxy for os.path.join
lateimport("mypackage", "A", "B")   # proxy for mypackage.A.B
```

### `class LateImport[T]`

The generic proxy class returned by `lateimport`. Annotate the variable with `LateImport[T]` to tell the type checker what type the proxy resolves to — `__call__` and `_resolve()` are typed to return `T`. Supports call, attribute access, `dir()`, and `repr()`. Thread-safe.

| Method | Description |
|--------|-------------|
| `is_resolved()` | `True` if the import has been triggered |
| `_resolve()` | Force immediate resolution and return the object (typed as `T`) |

### `create_late_getattr(dynamic_imports, module_globals, module_name)`

Create a `__getattr__` function for package-level lazy loading.

| Parameter | Type | Description |
|-----------|------|-------------|
| `dynamic_imports` | `MappingProxyType[str, tuple[str, str]]` | Dispatch table |
| `module_globals` | `dict[str, object]` | `globals()` of the calling module |
| `module_name` | `str` | `__name__` of the calling module |

### `INTROSPECTION_ATTRIBUTES`

`frozenset` of dunder attribute names that are resolved immediately rather than proxied (e.g. `__doc__`, `__name__`, `__module__`). Available for reference when implementing custom proxy patterns.

## License

`MIT OR Apache-2.0` — see [LICENSE-MIT](LICENSES/MIT.txt) and [LICENSE-Apache-2.0](LICENSES/Apache-2.0.txt).
