-
Notifications
You must be signed in to change notification settings - Fork 0
importcost 0.1.0 #1
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
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| name: ci | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
|
|
||
| # Nothing here needs write access to anything. | ||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| python: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] | ||
| steps: | ||
| # Actions are pinned to commit SHAs. A tag can be moved; a SHA cannot, | ||
| # and this repository has a workflow that can publish to PyPI. | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 | ||
| with: | ||
| enable-cache: true | ||
| - name: install | ||
| run: uv sync --python ${{ matrix.python }} | ||
| - name: test | ||
| run: uv run --python ${{ matrix.python }} pytest -q | ||
|
|
||
| runtime: | ||
| # The lazy-import verification only means anything on 3.15, so give it a job | ||
| # of its own where a failure is unambiguous. | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 | ||
| - run: uv python install 3.15 | ||
| - run: uv sync --python 3.15 | ||
| - run: uv run --python 3.15 pytest -q tests/test_runtime.py | ||
|
|
||
| lint: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 | ||
| - run: uv sync | ||
| - run: uv run ruff check . | ||
| - run: uv run ruff format --check . | ||
| - run: uv run mypy | ||
|
|
||
| budget: | ||
| # importcost has to stay inside its own budget. Pinned to one interpreter, | ||
| # because the standard library's module set moves between releases and | ||
| # import.lock records which one it was written on. | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 | ||
| - run: uv sync --python 3.12 | ||
| - run: uv run --python 3.12 importcost check | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| name: release | ||
|
|
||
| on: | ||
| push: | ||
| tags: ["v*"] | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| build: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 | ||
| - name: the tag has to match the version being built | ||
| run: | | ||
| tag="${GITHUB_REF_NAME#v}" | ||
| version="$(uv run --no-project --with tomli python -c \ | ||
| 'import tomli,pathlib;print(tomli.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" | ||
| if [ "$tag" != "$version" ]; then | ||
| echo "tag $GITHUB_REF_NAME would publish version $version" >&2 | ||
| exit 1 | ||
| fi | ||
| - run: uv build | ||
| - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | ||
| with: | ||
| name: dist | ||
| path: dist/ | ||
|
|
||
| publish: | ||
| needs: build | ||
| runs-on: ubuntu-latest | ||
| environment: pypi | ||
| permissions: | ||
| id-token: write | ||
| steps: | ||
| - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | ||
| with: | ||
| name: dist | ||
| path: dist/ | ||
| - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| # Changelog | ||
|
|
||
| ## 0.1.0 | ||
|
|
||
| First release. | ||
|
|
||
| - `profile` reports import cost with the interpreter baseline subtracted, as a table or a tree. | ||
| - `audit` combines static candidate detection with a runtime pass on Python 3.15, so every | ||
| import gets a verdict of safe, no-win, or unsafe, with a measured saving attached. Failures | ||
| under a test command are bisected to find which module is responsible. | ||
| - `apply` rewrites imports as `lazy import` or as a `__lazy_modules__` set, gated on measured | ||
| saving. | ||
| - `check` enforces `[tool.importcost]` budgets and diffs the imported module set against a | ||
| committed `import.lock`. | ||
| - A `pytest` fixture, `import_budget`, for asserting the same limits from a test. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| # importcost | ||
|
|
||
| Find out which imports are actually costing you startup time, defer the ones that can be | ||
| deferred, and stop the slow ones from coming back. | ||
|
|
||
| Python 3.15 adds `lazy import` ([PEP 810](https://peps.python.org/pep-0810/)). That's the easy | ||
| part. The hard part is knowing which of your imports are worth deferring, which ones get loaded | ||
| a millisecond later anyway, and which ones quietly break something because they had a side | ||
| effect you forgot about. importcost answers all three by running your code, not by reading it. | ||
|
|
||
| ```shell | ||
| pip install importcost | ||
| ``` | ||
|
|
||
| ## Where is my startup time going | ||
|
|
||
| ```text | ||
| $ importcost profile "import mypkg" | ||
| 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 | ||
| ``` | ||
|
|
||
| `--tree` gives you the nesting if you need to know who pulled in what. `--json` if you want to | ||
| pipe it somewhere. | ||
|
|
||
| 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. | ||
|
|
||
| ## Which imports should be lazy | ||
|
|
||
| ```text | ||
| $ importcost audit src --target "import mypkg" --test "pytest -q" | ||
|
|
||
| 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; something depends on | ||
| its import side effect | ||
| - safe tomllib | ||
|
|
||
| 418.3 ms of imports today. Deferring the safe ones skips 137 module(s), worth about 276.0 ms | ||
| at what they cost now. | ||
| 3 import(s) clear the 0 ms bar. | ||
| ``` | ||
|
|
||
| The `no-win` and `unsafe` rows are the whole point. Static analysis will happily tell you all | ||
| five of those imports can be deferred. Two of them can't, and you'd only find out from a | ||
| production traceback. | ||
|
|
||
| Here's how each verdict is reached: | ||
|
|
||
| - **safe**: the module stayed unloaded for the entire run, and the test command still passed | ||
| with it deferred. The saving is what the modules it avoided actually cost in the ordinary | ||
| profile, so it's measured rather than guessed at. | ||
| - **no-win**: `sys.set_lazy_imports_filter` approved the deferral, but the module ended up in | ||
| `sys.modules` before the process exited. Something else on the startup path needed it. You'd | ||
| be adding a keyword for nothing. | ||
| - **unsafe**: the test command passes normally and fails with this module deferred. When the | ||
| whole set fails, importcost bisects to find which modules are responsible rather than making | ||
| you delete entries one at a time. | ||
|
|
||
| The runtime checks need a 3.15 interpreter. `uv python install 3.15` and pass `--python`. | ||
| Without one you get static analysis only, and it says so. | ||
|
|
||
| ## Make the change | ||
|
|
||
| ```text | ||
| $ importcost apply src --target "import mypkg" --min-saving-ms 5 --write | ||
| updated src/mypkg/io.py: pandas | ||
| updated src/mypkg/http.py: requests | ||
| ``` | ||
|
|
||
| Prints a diff by default; `--write` edits in place. Only touches imports that cleared | ||
| `--min-saving-ms`, so you don't end up with forty `lazy` keywords that buy you 3 ms total. | ||
|
|
||
| Two output styles: | ||
|
|
||
| `--style lazy` writes `lazy import pandas`. Needs 3.15. | ||
|
|
||
| `--style lazy-modules` writes a `__lazy_modules__` set above the imports and leaves the import | ||
| statements alone. That's PEP 810's own migration shim: it's ordinary syntax on 3.9, and it only | ||
| does anything on 3.15+. Use it for a library that still supports old Pythons. The set comes out | ||
| sorted and deduplicated so [flake8-lazy](https://pypi.org/project/flake8-lazy/) doesn't complain | ||
| about it. | ||
|
|
||
| ## Keep it from creeping back | ||
|
|
||
| The reason import time regresses isn't usually a bad commit. It's a dependency upgrade that adds | ||
| an at-import metadata fetch, or a new logging integration that costs 50 ms on load. Nothing in | ||
| that shows up in code review. | ||
|
|
||
| ```toml | ||
| [tool.importcost] | ||
| target = "import mypkg" | ||
| max_import_ms = 150 | ||
| max_modules = 200 | ||
| ``` | ||
|
|
||
| ```text | ||
| $ importcost check | ||
| ok import mypkg 118.4 ms, 173 modules | ||
| ``` | ||
|
|
||
| `importcost check --update` also writes an `import.lock` next to your pyproject.toml recording | ||
| exactly which modules get imported. Commit it. After that, a dependency that starts pulling in | ||
| something new fails the check with a diff: | ||
|
|
||
| ```text | ||
| $ importcost check | ||
| 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.hazmat, ... +13 more). Run 'importcost check --update' if this is intended. | ||
| + cryptography, cryptography.fernet, cryptography.hazmat | ||
| ``` | ||
|
|
||
| Times vary by machine, so only the numeric budgets are machine-dependent; the module set isn't, | ||
| which is why that's the part that gets 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 | ||
| ``` | ||
|
|
||
| In GitHub Actions: | ||
|
|
||
| ```yaml | ||
| - run: pip install importcost | ||
| - run: importcost check | ||
| ``` | ||
|
|
||
| Or as a normal test, if you'd rather keep it with everything else: | ||
|
|
||
| ```python | ||
| def test_import_stays_cheap(import_budget): | ||
| import_budget("import mypkg", max_ms=150, max_modules=200) | ||
| ``` | ||
|
|
||
| ## How this relates to the other tools | ||
|
|
||
| [`flake8-lazy`](https://pypi.org/project/flake8-lazy/) is a linter and a good one. It finds | ||
| imports that are unused at module scope and writes `__lazy_modules__` for them. It doesn't | ||
| measure anything or run your code, which its author is upfront about. Keep using it. importcost | ||
| reads and writes the same `__lazy_modules__` convention, and adds the measurement, the runtime | ||
| verification, and the CI guard. | ||
|
|
||
| `-X importtime` and [`tuna`](https://pypi.org/project/tuna/) show you where the time goes and | ||
| leave the rest to you. | ||
|
|
||
| ## Notes and caveats | ||
|
|
||
| `profile` and `check` work on 3.10+. `audit` and `apply` need a 3.15 interpreter for the runtime | ||
| pass; below that they fall back to static analysis and warn. | ||
|
|
||
| The audit's safety check is only as good as the command you give `--test`. If your test suite | ||
| doesn't touch the code path that relies 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 single | ||
| import, and that overhead is about the same size as the saving on a small target, so | ||
| subtracting the two just gives you noise. Counting the modules that were genuinely skipped, at what they cost when | ||
| they ran, is both stable and closer to what you'll see after the change lands. | ||
|
|
||
| Verification is scoped to the file the import came from, not to the module name globally. | ||
| Writing `lazy import x` in one file doesn't defer `x` for the rest of the program, and neither | ||
| does the check. Otherwise auditing a package would defer the package itself and cheerfully | ||
| report that everything got faster. | ||
|
|
||
| Imports inside `if TYPE_CHECKING:` are skipped: they already cost nothing. Wildcard imports, | ||
| `__future__` imports, and imports inside `try`/`except ImportError` are skipped because PEP 810 | ||
| doesn't allow deferring them. Names in `__all__` are skipped as a conservative default. | ||
|
|
||
| Annotations count as import-time uses unless the file has `from __future__ import annotations`. | ||
| On 3.14+ with PEP 649 that's stricter than it needs to be; pass the flag if it's costing you | ||
| candidates. | ||
|
|
||
| importcost has no runtime dependencies on 3.11+ and enforces its own import budget in CI. A | ||
| startup-time tool that takes 200 ms to start isn't a good look. | ||
|
|
||
| MIT. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "version": 1, | ||
| "targets": { | ||
| "import importcost": { | ||
| "modules": [ | ||
| "__future__", | ||
| "importcost" | ||
| ], | ||
| "import_ms": 0.2, | ||
| "python": "3.12" | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.