Metadata-Version: 2.5
Name: importcost
Version: 0.1.1
Summary: Measure what your imports actually cost, make the safe ones lazy (PEP 810), and stop regressions in CI.
Project-URL: Homepage, https://github.com/aviseth/importcost
Project-URL: Repository, https://github.com/aviseth/importcost
Project-URL: Changelog, https://github.com/aviseth/importcost/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/aviseth/importcost/issues
Author-email: Avi Seth <avi@crispa.ai>
License-Expression: MIT
License-File: LICENSE
Keywords: ci,cold-start,import,importtime,lazy-imports,pep810,performance,profiling,startup
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: System :: Benchmark
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: tomli>=2.0; python_version < '3.11'
Description-Content-Type: text/markdown

# importcost

Measure what your imports cost, defer the safe ones, and stop the slow ones coming back.

[![PyPI](https://img.shields.io/pypi/v/importcost.svg)](https://pypi.org/project/importcost/)
[![Python](https://img.shields.io/pypi/pyversions/importcost.svg)](https://pypi.org/project/importcost/)
[![CI](https://github.com/aviseth/importcost/actions/workflows/ci.yml/badge.svg)](https://github.com/aviseth/importcost/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Python 3.15 adds `lazy import` ([PEP 810](https://peps.python.org/pep-0810/)), which defers a
module until something touches it. Deciding what to apply it to is the hard part: you cannot tell
by reading code whether deferring an import saves anything, and you cannot tell whether it breaks
something. importcost answers both by running your code.

## Installation

```shell
pip install importcost
```

Requires Python 3.10+. `audit` and `apply` need a Python 3.15 interpreter for their runtime pass
and fall back to static analysis below that, with a warning.

## Quick start

```shell
importcost profile "import mypkg"
```

```text
import mypkg  418.3 ms of imports across 261 modules (wall 471.2 ms, median of 5)

self ms  cumul ms  module
   61.4     181.9  pandas
   38.2      38.2  pandas._libs.tslibs.timestamps
   22.7      94.1  requests
   19.8      19.8  numpy.core._multiarray_umath
    9.1      31.4  rich.console
```

This is `-X importtime` with the interpreter's own baseline subtracted, run five times and
median-ed, because a single run of anything on a laptop is noise. `--tree` shows the nesting,
`--json` pipes it somewhere.

A target can be an import statement, a module, a script, or a console script:

```shell
importcost profile "import pandas"
importcost profile -m mypkg.cli
importcost profile ./scripts/run.py
importcost profile mypy
```

## Deciding what to defer

```shell
importcost audit src --target "import mypkg" --test "pytest -q"
```

```text
saves ms  verdict  module          why
   181.9  safe     pandas
    94.1  safe     requests
       -  no-win   rich.console    loaded anyway during startup, so deferring it changes nothing
       -  unsafe   mypkg.plugins   deferring this breaks the test command
       -  safe     tomllib

418.3 ms of imports today. Deferring the safe ones skips 137 modules, worth about 276.0 ms.
3 imports clear the 0 ms bar.
```

| Verdict | How it is reached |
| --- | --- |
| `safe` | The module stayed unloaded for the whole run and the test command still passed with it deferred |
| `no-win` | The filter approved the deferral but the module ended up in `sys.modules` anyway, so something else on the startup path needed it |
| `unsafe` | The test command passes normally and fails with this module deferred |
| `not-imported` | Never imported on this path, so there is nothing to win |

The `no-win` and `unsafe` rows are the point. Static analysis will happily tell you all five of
those imports can be deferred. Two of them cannot, and you would find out from a production
traceback.

When the whole proposed set fails, importcost bisects to find which modules are responsible, so
the report names them rather than making you delete entries one at a time.

## Applying it

```shell
importcost apply src --target "import mypkg" --min-saving-ms 5 --write
```

Prints a diff by default. Only touches imports that cleared `--min-saving-ms`, so you do not end
up with forty `lazy` keywords buying 3 ms in total.

| Style | Output | Requires |
| --- | --- | --- |
| `--style lazy` | `lazy import pandas` | Python 3.15 |
| `--style lazy-modules` | A `__lazy_modules__` set above the imports | Nothing; valid syntax on 3.9+, active on 3.15+ |

`lazy-modules` is PEP 810's own migration shim, for a library that still supports old Pythons. The
set comes out sorted and deduplicated so [flake8-lazy](https://pypi.org/project/flake8-lazy/) stays
quiet about it.

## Budgets in CI

Import time rarely regresses from a bad commit. It regresses when a dependency upgrade adds an
at-import metadata fetch, and nothing about that shows up in review.

```toml
[tool.importcost]
target = "import mypkg"
max_import_ms = 150
max_modules = 200
```

```shell
importcost check          # ok   import mypkg  118.4 ms, 173 modules
importcost check --update # also writes import.lock
```

`import.lock` records exactly which modules get imported. Commit it. After that, a dependency that
starts pulling in something new fails the check with a diff:

```text
fail import mypkg  204.7 ms, 189 modules
     import time 204.7 ms is over the 150 ms budget by 54.7 ms
     imported modules no longer match import.lock (16 new: cryptography, cryptography.fernet, ...)
     + cryptography, cryptography.fernet, cryptography.hazmat
```

Times vary by machine; the module set does not, which is why that is the part pinned.

Several entry points with different budgets:

```toml
[tool.importcost]
trials = 7

[[tool.importcost.budget]]
target = "import mypkg"
max_import_ms = 150

[[tool.importcost.budget]]
target = "-m mypkg.cli"
max_import_ms = 400
```

Or as an ordinary test:

```python
def test_import_stays_cheap(import_budget):
    import_budget("import mypkg", max_ms=150, max_modules=200)
```

## Configuration

Keys under `[tool.importcost]` in `pyproject.toml`.

| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `target` | string | none | What to measure, for the single-budget shorthand |
| `max_import_ms` | number | none | Fail `check` above this import time |
| `max_modules` | integer | none | Fail `check` above this module count |
| `trials` | integer | `5` | Runs per measurement, median taken |
| `python` | string | current | Interpreter to measure with |
| `lock` | string | `"import.lock"` | Path to the lock file |
| `[[tool.importcost.budget]]` | array of tables | none | Several targets, each with its own limits |

## Command reference

| Command | What it does |
| --- | --- |
| `importcost profile <target>` | Show where import time goes, as a table or `--tree` |
| `importcost audit <paths> --target T` | Give every import a verdict, with `--test` to prove safety |
| `importcost apply <paths> --target T` | Rewrite the imports the audit approved, `--write` to edit |
| `importcost check` | Enforce budgets and diff against `import.lock` |

Every command takes `--json`.

## How it compares

| Tool | Measures cost | Finds candidates | Verifies the win | Verifies safety | Applies | CI guard |
| --- | --- | --- | --- | --- | --- | --- |
| `-X importtime` | yes | no | no | no | no | no |
| `tuna` | yes | no | no | no | no | no |
| `flake8-lazy` | no | yes | no | no | yes | no |
| importcost | yes | yes | yes | yes | yes | yes |

[`flake8-lazy`](https://pypi.org/project/flake8-lazy/) is a good linter and worth keeping.
importcost reads and writes the same `__lazy_modules__` convention and adds the measurement, the
runtime verification and the CI guard.

## Notes

The audit's safety check is only as good as the command given to `--test`. If your suite does not
touch the code path relying on an import side effect, neither will importcost.

Savings are priced from the eager profile rather than by subtracting the two runs. Verifying a
proposal means running the interpreter with a Python-level filter callback on every import, and on
a small target that overhead is the same size as the saving, so subtracting gives noise. Counting
the modules genuinely skipped, at what they cost when they ran, is stable and closer to what you
will see.

Verification is scoped to the file an import came from, not to the module name globally. Writing
`lazy import x` in one file does not defer `x` for the rest of the program, and neither does the
check. Otherwise auditing a package would defer the package itself and report that everything got
faster.

Imports inside `if TYPE_CHECKING:` are skipped since they already cost nothing. Wildcard imports,
`__future__` imports and imports inside `try`/`except ImportError` are skipped because PEP 810
does not allow deferring them. Names in `__all__` are skipped as a conservative default. So is
`import a, b`, because the statement can only be deferred as a unit but only the first module
would be verified.

Annotations count as import-time uses unless the file has `from __future__ import annotations`. On
3.14+ with PEP 649 that is stricter than necessary; `--assume-lazy-annotations` relaxes it.

Module names are worked out by walking up through `__init__.py` files, which comes out short under
a namespace package. When every candidate returns `not-imported` but the target demonstrably
imports some of them, importcost says so rather than reporting a confident zero.

No runtime dependencies on 3.11+, and CI runs `importcost check` on importcost.

## Contributing

Bug reports and pull requests are welcome. `uv sync` then `uv run pytest`. The runtime tests need
Python 3.15: `uv python install 3.15`.

## License

MIT.
