From 7db3ff53735940e8b2efda287d4be733fd599307 Mon Sep 17 00:00:00 2001 From: Avi Seth Date: Mon, 24 Aug 2026 02:20:53 +0200 Subject: [PATCH 1/5] lazybudget 0.1.0 Measures what imports actually cost, verifies which ones can be deferred under PEP 810, applies the change, and guards the result in CI. The four commands: profile runs a target under -X importtime with the interpreter baseline subtracted, medianed over several trials, as a table or a tree. audit pairs static candidate detection with a runtime pass on Python 3.15. Every import comes back safe, no-win (it loads anyway, so deferring buys nothing), or unsafe (the test command fails with it deferred). Failures are bisected so the report names the module responsible. apply rewrites imports as `lazy import` or as a __lazy_modules__ set, gated on measured saving rather than on what a parser thinks. check enforces [tool.lazybudget] budgets and diffs the imported module set against a committed import.lock, so a dependency that starts pulling in something new shows up in the pull request. Verification is scoped to (importing module, imported module) pairs rather than bare module names, so it reflects what the codemod would actually do. Savings are priced from the eager profile. Running the interpreter with a filter callback on every import costs about as much as the saving on a small target, so subtracting the two runs reports noise. No runtime dependencies on 3.11+, and it runs its own budget check in CI. --- .github/workflows/ci.yml | 53 +++ .github/workflows/release.yml | 30 ++ CHANGELOG.md | 15 + README.md | 197 ++++++++++ import.lock | 13 + pyproject.toml | 80 ++++ src/lazybudget/__init__.py | 22 ++ src/lazybudget/__main__.py | 6 + src/lazybudget/audit.py | 307 ++++++++++++++++ src/lazybudget/check.py | 122 +++++++ src/lazybudget/cli.py | 381 +++++++++++++++++++ src/lazybudget/codemod.py | 155 ++++++++ src/lazybudget/config.py | 121 +++++++ src/lazybudget/importtime.py | 83 +++++ src/lazybudget/lock.py | 94 +++++ src/lazybudget/measure.py | 185 ++++++++++ src/lazybudget/py.typed | 0 src/lazybudget/pytest_plugin.py | 49 +++ src/lazybudget/report.py | 93 +++++ src/lazybudget/runtime.py | 323 +++++++++++++++++ src/lazybudget/static.py | 282 +++++++++++++++ src/lazybudget/targets.py | 83 +++++ tests/test_check.py | 76 ++++ tests/test_cli.py | 72 ++++ tests/test_codemod.py | 98 +++++ tests/test_config.py | 61 ++++ tests/test_importtime.py | 44 +++ tests/test_lock.py | 38 ++ tests/test_measure.py | 45 +++ tests/test_runtime.py | 162 +++++++++ tests/test_static.py | 144 ++++++++ tests/test_targets.py | 42 +++ uv.lock | 623 ++++++++++++++++++++++++++++++++ 33 files changed, 4099 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 import.lock create mode 100644 pyproject.toml create mode 100644 src/lazybudget/__init__.py create mode 100644 src/lazybudget/__main__.py create mode 100644 src/lazybudget/audit.py create mode 100644 src/lazybudget/check.py create mode 100644 src/lazybudget/cli.py create mode 100644 src/lazybudget/codemod.py create mode 100644 src/lazybudget/config.py create mode 100644 src/lazybudget/importtime.py create mode 100644 src/lazybudget/lock.py create mode 100644 src/lazybudget/measure.py create mode 100644 src/lazybudget/py.typed create mode 100644 src/lazybudget/pytest_plugin.py create mode 100644 src/lazybudget/report.py create mode 100644 src/lazybudget/runtime.py create mode 100644 src/lazybudget/static.py create mode 100644 src/lazybudget/targets.py create mode 100644 tests/test_check.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_codemod.py create mode 100644 tests/test_config.py create mode 100644 tests/test_importtime.py create mode 100644 tests/test_lock.py create mode 100644 tests/test_measure.py create mode 100644 tests/test_runtime.py create mode 100644 tests/test_static.py create mode 100644 tests/test_targets.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4573307 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +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: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@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@v4 + - uses: astral-sh/setup-uv@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@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy + + budget: + # lazybudget has to stay inside its own budget. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync + - run: uv run lazybudget check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..32a64a7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: release + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a718904 --- /dev/null +++ b/CHANGELOG.md @@ -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.lazybudget]` 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1e041f --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# lazybudget + +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. lazybudget answers all three by running your code, not by reading it. + +``` +pip install lazybudget +``` + +## Where is my startup time going + +``` +$ lazybudget 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 + +``` +$ lazybudget 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, lazybudget 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 + +``` +$ lazybudget 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.lazybudget] +target = "import mypkg" +max_import_ms = 150 +max_modules = 200 +``` + +``` +$ lazybudget check +ok import mypkg 118.4 ms, 173 modules +``` + +`lazybudget 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: + +``` +$ lazybudget 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 'lazybudget 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.lazybudget] +trials = 7 + +[[tool.lazybudget.budget]] +target = "import mypkg" +max_import_ms = 150 + +[[tool.lazybudget.budget]] +target = "-m mypkg.cli" +max_import_ms = 400 +``` + +In GitHub Actions: + +```yaml +- run: pip install lazybudget +- run: lazybudget 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. lazybudget +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 lazybudget. + +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. + +lazybudget 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. diff --git a/import.lock b/import.lock new file mode 100644 index 0000000..fd631bf --- /dev/null +++ b/import.lock @@ -0,0 +1,13 @@ +{ + "version": 1, + "targets": { + "import lazybudget": { + "modules": [ + "__future__", + "lazybudget" + ], + "import_ms": 0.2, + "python": "3.12" + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe02f53 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,80 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lazybudget" +version = "0.1.0" +description = "Measure what your imports actually cost, make the safe ones lazy (PEP 810), and stop regressions in CI." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "Avi Seth", email = "avi@crispa.ai" }] +keywords = [ + "import", + "importtime", + "lazy-imports", + "pep810", + "startup", + "cold-start", + "performance", + "profiling", + "ci", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Quality Assurance", + "Topic :: System :: Benchmark", + "Typing :: Typed", +] +dependencies = ["tomli>=2.0; python_version<'3.11'"] + +[project.urls] +Homepage = "https://github.com/aviseth/lazybudget" +Repository = "https://github.com/aviseth/lazybudget" +Changelog = "https://github.com/aviseth/lazybudget/blob/main/CHANGELOG.md" +Issues = "https://github.com/aviseth/lazybudget/issues" + +[project.scripts] +lazybudget = "lazybudget.cli:main" + +[project.entry-points.pytest11] +lazybudget = "lazybudget.pytest_plugin" + +[dependency-groups] +dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.7", "mypy>=1.11"] + +[tool.hatch.build.targets.wheel] +packages = ["src/lazybudget"] + +[tool.ruff] +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "PTH", "C4"] + +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.lazybudget] +# lazybudget guards itself: importing the package must stay cheap. +target = "import lazybudget" +max_import_ms = 10 +max_modules = 6 diff --git a/src/lazybudget/__init__.py b/src/lazybudget/__init__.py new file mode 100644 index 0000000..01e38cf --- /dev/null +++ b/src/lazybudget/__init__.py @@ -0,0 +1,22 @@ +"""Measure what your imports cost, defer the safe ones, and keep them from creeping back. + +The public API is intentionally tiny; everything else lives behind the ``lazybudget`` +command-line interface. Importing this package must stay cheap -- it is the tool's own +first test case -- so nothing here imports a submodule at module scope. +""" + +from __future__ import annotations + +__all__ = ["__version__", "measure"] + +__version__ = "0.1.0" + + +def measure(target: str, *, trials: int = 3, python: str | None = None): # type: ignore[no-untyped-def] + """Measure the import cost of ``target``. See :mod:`lazybudget.measure`. + + Imported lazily so that ``import lazybudget`` stays under its own budget. + """ + from lazybudget.measure import measure as _measure + + return _measure(target, trials=trials, python=python) diff --git a/src/lazybudget/__main__.py b/src/lazybudget/__main__.py new file mode 100644 index 0000000..0a4267b --- /dev/null +++ b/src/lazybudget/__main__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from lazybudget.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/lazybudget/audit.py b/src/lazybudget/audit.py new file mode 100644 index 0000000..64e3341 --- /dev/null +++ b/src/lazybudget/audit.py @@ -0,0 +1,307 @@ +"""Put the static and runtime passes together and give every import a verdict. + +The output is the thing that is missing from every other lazy-import tool: not +"this import could be lazy", but "this import can be lazy, it saves 41 ms, and +the test suite still passes with it deferred" -- or one of the two ways that +sentence can go wrong. +""" + +from __future__ import annotations + +import tempfile +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path + +from lazybudget import runtime +from lazybudget.importtime import ImportNode, flatten +from lazybudget.measure import DEFAULT_TRIALS, Measurement, measure +from lazybudget.static import FileAnalysis, analyze_file +from lazybudget.targets import Target, resolve + +SAFE = "safe" +NO_WIN = "no-win" +UNSAFE = "unsafe" +UNUSED = "not-imported" + +_EXCLUDED_DIRS = { + ".git", + ".hg", + ".venv", + "venv", + "__pycache__", + "build", + "dist", + ".tox", + ".nox", + ".mypy_cache", + ".ruff_cache", + "node_modules", +} + + +@dataclass +class Verdict: + module: str + status: str + saving_ms: float + locations: list[str] = field(default_factory=list) + note: str = "" + + @property + def worth_it(self) -> bool: + return self.status == SAFE and self.saving_ms > 0 + + +@dataclass +class AuditReport: + target: Target + eager: Measurement + lazy: Measurement | None + verdicts: list[Verdict] + analyses: list[FileAnalysis] + tests_run: bool = False + test_output: str = "" + skipped_runtime: str = "" + + @property + def total_saving_ms(self) -> float: + """What deferring the safe imports is worth, in eager-run milliseconds. + + Deliberately *not* ``eager.import_ms - lazy.import_ms``. Verifying a + proposal means running the interpreter with a Python-level filter + callback on every import, and that costs a few microseconds a module. + On a small target that overhead is the same size as the saving being + measured, and the subtraction reports nonsense. Counting the modules + that were genuinely avoided, priced at what they cost in the ordinary + eager run, is both stable and closer to what you will actually see. + """ + return sum(v.saving_ms for v in self.verdicts if v.status == SAFE) + + @property + def avoided_modules(self) -> int: + if self.lazy is None: + return 0 + return max(0, len(self.eager.modules) - len(self.lazy.modules)) + + def accepted(self, min_saving_ms: float = 0.0) -> set[str]: + """Modules that earned the change.""" + return {v.module for v in self.verdicts if v.worth_it and v.saving_ms >= min_saving_ms} + + +def module_name(path: Path) -> str: + """Best guess at the dotted name a file will be imported under. + + Walks up while there are ``__init__.py`` files, which is what an installed + package looks like. Used to scope a proposed deferral to the file it came + from, so verification matches what the codemod would actually do. + """ + resolved = path.resolve() + parts = [resolved.stem] if resolved.stem != "__init__" else [] + directory = resolved.parent + while (directory / "__init__.py").is_file(): + parts.insert(0, directory.name) + directory = directory.parent + return ".".join(parts) or resolved.stem + + +def proposal(analyses: Sequence[FileAnalysis]) -> list[str]: + """Build the ``"importer|imported"`` entries the runtime filter matches on.""" + entries = { + f"{module_name(analysis.path)}|{candidate.module}" + for analysis in analyses + for candidate in analysis.candidates + if not candidate.is_relative + } + return sorted(entries) + + +def discover(paths: Sequence[Path]) -> list[Path]: + """Expand files and directories into the Python files we should look at.""" + found: list[Path] = [] + for path in paths: + if path.is_file() and path.suffix == ".py": + found.append(path) + elif path.is_dir(): + found.extend( + p for p in sorted(path.rglob("*.py")) if not _EXCLUDED_DIRS.intersection(p.parts) + ) + return found + + +def run( + paths: Sequence[Path], + target: str | Target, + *, + python: str | None = None, + trials: int = DEFAULT_TRIALS, + test_command: Sequence[str] | None = None, + assume_lazy_annotations: bool = False, +) -> AuditReport: + """Analyze, measure, and verify.""" + resolved = target if isinstance(target, Target) else resolve(target) + analyses = [ + analyze_file(path, assume_lazy_annotations=assume_lazy_annotations) + for path in discover(paths) + ] + locations: dict[str, list[str]] = {} + for analysis in analyses: + for candidate in analysis.candidates: + locations.setdefault(candidate.module, []).append(f"{analysis.path}:{candidate.lineno}") + entries = proposal(analyses) + modules = sorted(locations) + + eager = measure(resolved, trials=trials, python=python) + if eager.returncode != 0: + return AuditReport( + target=resolved, + eager=eager, + lazy=None, + verdicts=[], + analyses=analyses, + skipped_runtime=( + f"the target exited with status {eager.returncode}, so nothing was measured:\n" + + eager.stderr.strip()[-1500:] + ), + ) + if not modules: + return AuditReport(target=resolved, eager=eager, lazy=None, verdicts=[], analyses=analyses) + + lazy_python = runtime.find_lazy_python(python) + if lazy_python is None: + verdicts = [ + Verdict( + module=module, + status=SAFE, + saving_ms=0.0, + locations=locations[module], + note="static only: no Python 3.15 available to verify", + ) + for module in modules + ] + return AuditReport( + target=resolved, + eager=eager, + lazy=None, + verdicts=verdicts, + analyses=analyses, + skipped_runtime=( + "no interpreter with PEP 810 support was found, so nothing could be " + "verified against a running program. Install Python 3.15 " + "(uv python install 3.15) and re-run to get real numbers." + ), + ) + + reification = runtime.check_reification(resolved, entries, python=lazy_python) + lazy_measurement = _measure_lazy(resolved, entries, python=lazy_python, trials=trials) + + culprits: set[str] = set() + tests_run = False + test_output = "" + deferred_entries = [e for e in entries if e.split("|", 1)[1] in set(reification.deferred)] + if test_command and deferred_entries: + safety = runtime.check_safety(test_command, deferred_entries, python=lazy_python) + tests_run = True + if not safety.passed: + culprits = set(safety.culprits) + test_output = safety.output + + savings = _attribute(eager, lazy_measurement) + verdicts = [ + _verdict(module, reification, culprits, savings, locations[module]) for module in modules + ] + verdicts.sort(key=lambda v: (-v.saving_ms, v.module)) + + return AuditReport( + target=resolved, + eager=eager, + lazy=lazy_measurement, + verdicts=verdicts, + analyses=analyses, + tests_run=tests_run, + test_output=test_output, + ) + + +def _verdict( + module: str, + reification: runtime.Reification, + culprits: set[str], + savings: dict[str, float], + locations: list[str], +) -> Verdict: + if module in culprits: + return Verdict( + module, + UNSAFE, + 0.0, + locations, + "deferring this breaks the test command; something depends on its import side effect", + ) + if module in reification.never_imported: + return Verdict( + module, + UNUSED, + 0.0, + locations, + "never imported on this path, so there is nothing to win", + ) + if module in reification.reified: + return Verdict( + module, + NO_WIN, + 0.0, + locations, + "loaded anyway during startup, so deferring it changes nothing", + ) + return Verdict(module, SAFE, savings.get(module, 0.0), locations) + + +def _measure_lazy( + target: Target, + entries: Sequence[str], + *, + python: str, + trials: int, +) -> Measurement: + """Measure the target again, this time with the proposal in force.""" + with tempfile.TemporaryDirectory(prefix="lazybudget-") as tmp: + runtime.write_sitecustomize(Path(tmp)) + env = runtime.child_env(tmp, entries, result_path=Path(tmp) / "result.json", env=None) + return measure(target, trials=trials, python=python, env=env) + + +def _attribute(eager: Measurement, lazy: Measurement) -> dict[str, float]: + """Split the measured saving across the modules that caused it. + + Whatever the eager run imported and the lazy run did not is the saving. Each + avoided module is charged to the outermost deferred module that pulled it in, + so nothing is counted twice and the numbers add up to the real total. + """ + avoided = set(eager.modules) - set(lazy.modules) + if not avoided: + return {} + + nodes = flatten(eager.roots) + claimed: set[str] = set() + savings: dict[str, float] = {} + + def subtree(node: ImportNode) -> list[ImportNode]: + return list(node.walk()) + + # Largest subtrees first: a parent should claim its children, not the reverse. + roots = sorted( + (nodes[name] for name in avoided if name in nodes), + key=lambda n: n.cumulative_us, + reverse=True, + ) + for node in roots: + if node.name in claimed: + continue + total = 0 + for descendant in subtree(node): + if descendant.name in avoided and descendant.name not in claimed: + claimed.add(descendant.name) + total += descendant.self_us + savings[node.name] = total / 1000 + return savings diff --git a/src/lazybudget/check.py b/src/lazybudget/check.py new file mode 100644 index 0000000..b7dded6 --- /dev/null +++ b/src/lazybudget/check.py @@ -0,0 +1,122 @@ +"""Enforce budgets and report drift. This is the part that runs in CI.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from lazybudget import lock as lockfile +from lazybudget.config import Budget, Config +from lazybudget.measure import Measurement, measure + + +@dataclass +class Result: + budget: Budget + measurement: Measurement + drift: lockfile.Drift + failures: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.failures + + +@dataclass +class CheckReport: + results: list[Result] + lock_path: Path | None + lock_updated: bool = False + + @property + def ok(self) -> bool: + return all(result.ok for result in self.results) + + +def run( + config: Config, + *, + update: bool = False, + fail_on_drift: bool = True, + trials: int | None = None, +) -> CheckReport: + """Measure every configured target and compare it to its budget and lock entry.""" + lock = lockfile.read(config.lock_path) if config.lock_path else lockfile.Lock() + results: list[Result] = [] + + for budget in config.budgets: + measurement = measure( + budget.target, + trials=trials or config.trials, + python=config.python, + ) + drift = lockfile.compare( + budget.target, lock.targets.get(budget.target), measurement.modules + ) + result = Result(budget=budget, measurement=measurement, drift=drift) + + if measurement.returncode != 0: + result.failures.append( + f"target exited with status {measurement.returncode}; nothing was measured" + ) + else: + result.failures.extend(_violations(budget, measurement)) + if fail_on_drift and not update and drift.changed and budget.target in lock.targets: + result.failures.append(_drift_message(drift)) + + results.append(result) + if update and measurement.returncode == 0: + lock.targets[budget.target] = lockfile.LockEntry( + modules=measurement.modules, + import_ms=measurement.import_ms, + python=_python_tag(measurement.python), + ) + + report = CheckReport(results=results, lock_path=config.lock_path) + if update and config.lock_path is not None: + lockfile.write(config.lock_path, lock) + report.lock_updated = True + return report + + +def _violations(budget: Budget, measurement: Measurement) -> list[str]: + failures = [] + if budget.max_import_ms is not None and measurement.import_ms > budget.max_import_ms: + over = measurement.import_ms - budget.max_import_ms + failures.append( + f"import time {measurement.import_ms:.1f} ms is over the " + f"{budget.max_import_ms:.0f} ms budget by {over:.1f} ms" + ) + if budget.max_modules is not None and measurement.module_count > budget.max_modules: + over = measurement.module_count - budget.max_modules + failures.append( + f"{measurement.module_count} modules imported, " + f"{over} over the budget of {budget.max_modules}" + ) + return failures + + +def _drift_message(drift: lockfile.Drift) -> str: + bits = [] + if drift.added: + bits.append(f"{len(drift.added)} new: {_sample(drift.added)}") + if drift.removed: + bits.append(f"{len(drift.removed)} gone: {_sample(drift.removed)}") + return ( + "imported modules no longer match import.lock (" + + "; ".join(bits) + + "). Run 'lazybudget check --update' if this is intended." + ) + + +def _sample(names: list[str], limit: int = 5) -> str: + shown = ", ".join(names[:limit]) + return shown if len(names) <= limit else f"{shown}, +{len(names) - limit} more" + + +def _python_tag(python: str) -> str: + import subprocess + + probe = "import sys; print('.'.join(map(str, sys.version_info[:2])))" + completed = subprocess.run([python, "-c", probe], capture_output=True, text=True, check=False) + return completed.stdout.strip() or "unknown" diff --git a/src/lazybudget/cli.py b/src/lazybudget/cli.py new file mode 100644 index 0000000..9fb9d8f --- /dev/null +++ b/src/lazybudget/cli.py @@ -0,0 +1,381 @@ +"""Command line entry point.""" + +from __future__ import annotations + +import argparse +import json +import shlex +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from lazybudget import __version__ + +if TYPE_CHECKING: + from lazybudget.audit import AuditReport + +EPILOG = """\ +examples: + lazybudget profile "import pandas" + lazybudget profile -m mypkg.cli --tree + lazybudget audit src --target "import mypkg" --test "pytest -x -q" + lazybudget apply src --target "import mypkg" --min-saving-ms 5 --write + lazybudget check --update +""" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="lazybudget", + description="Measure what your imports cost and keep them honest.", + epilog=EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--version", action="version", version=f"lazybudget {__version__}") + sub = parser.add_subparsers(dest="command", required=True) + + profile = sub.add_parser("profile", help="show where import time goes") + profile.add_argument("target", nargs="+", help='what to run, e.g. "import pandas" or -m pkg') + profile.add_argument("-n", "--trials", type=int, default=5) + profile.add_argument("--python", help="interpreter to measure with") + profile.add_argument("--limit", type=int, default=25, help="rows to show (0 for all)") + profile.add_argument("--tree", action="store_true", help="show the import tree instead") + profile.add_argument("--min-ms", type=float, default=1.0, help="hide anything cheaper") + profile.add_argument("--json", action="store_true") + + audit = sub.add_parser("audit", help="decide which imports are worth deferring, and verify it") + audit.add_argument("paths", nargs="*", default=["."], type=Path) + audit.add_argument("--target", required=True, help="the startup path you care about") + audit.add_argument("--test", help="command to prove nothing broke, e.g. 'pytest -q'") + audit.add_argument("-n", "--trials", type=int, default=5) + audit.add_argument("--python", help="interpreter to verify with (needs 3.15)") + audit.add_argument("--min-saving-ms", type=float, default=0.0) + audit.add_argument( + "--all", + dest="show_all", + action="store_true", + help="also list imports that never run on this path", + ) + audit.add_argument("--json", action="store_true") + + apply_cmd = sub.add_parser("apply", help="rewrite the imports the audit approved") + apply_cmd.add_argument("paths", nargs="*", default=["."], type=Path) + apply_cmd.add_argument("--target", required=True) + apply_cmd.add_argument("--test") + apply_cmd.add_argument("-n", "--trials", type=int, default=5) + apply_cmd.add_argument("--python") + apply_cmd.add_argument("--min-saving-ms", type=float, default=1.0) + apply_cmd.add_argument( + "--style", + choices=["lazy", "lazy-modules"], + default="lazy", + help="'lazy' needs 3.15; 'lazy-modules' works on any version", + ) + apply_cmd.add_argument("--line-length", type=int, default=88) + apply_cmd.add_argument("-w", "--write", action="store_true", help="edit files in place") + + check = sub.add_parser("check", help="fail if a budget is blown or the module set drifted") + check.add_argument("--config", type=Path, help="path to pyproject.toml") + check.add_argument("-n", "--trials", type=int) + check.add_argument("--update", action="store_true", help="rewrite import.lock and pass") + check.add_argument("--no-drift", action="store_true", help="only enforce the numeric budgets") + check.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "profile": + return _profile(args) + if args.command == "audit": + return _audit(args) + if args.command == "apply": + return _apply(args) + if args.command == "check": + return _check(args) + except KeyboardInterrupt: # pragma: no cover + return 130 + except (ValueError, OSError) as error: + print(f"lazybudget: {error}", file=sys.stderr) + return 2 + return 2 + + +def _profile(args: argparse.Namespace) -> int: + from lazybudget.measure import measure + from lazybudget.report import style, table, tree + + spec = " ".join(args.target) + result = measure(spec, trials=args.trials, python=args.python) + if result.returncode != 0: + print(f"lazybudget: target exited with status {result.returncode}", file=sys.stderr) + print(result.stderr.strip()[-2000:], file=sys.stderr) + return 1 + + if args.json: + print( + json.dumps( + { + "target": result.target.spec, + "import_ms": round(result.import_ms, 2), + "wall_ms": round(result.wall_ms, 2), + "modules": result.modules, + "by_self_ms": [ + {"module": n.name, "self_ms": round(n.self_ms, 3)} + for n in result.attributed() + ], + }, + indent=2, + ) + ) + return 0 + + print( + f"{style(result.target.spec, 'bold')} " + f"{style(f'{result.import_ms:.1f} ms', 'cyan')} of imports across " + f"{result.module_count} modules " + f"{style(f'(wall {result.wall_ms:.1f} ms, median of {result.trials})', 'dim')}" + ) + print() + + if args.tree: + # Drop the interpreter's own startup trees; they are not yours to fix. + roots = [r for r in result.roots if r.name not in result.baseline_modules] + text = tree(roots, min_ms=args.min_ms) + print(text or f"nothing costs more than {args.min_ms} ms") + return 0 + + rows = [ + (f"{n.self_ms:.1f}", f"{n.cumulative_ms:.1f}", n.name) + for n in result.attributed() + if n.self_ms >= args.min_ms + ] + if args.limit: + rows = rows[: args.limit] + if not rows: + print(f"nothing costs more than {args.min_ms} ms") + return 0 + print(table(["self ms", "cumul ms", "module"], rows, aligns="rrl")) + return 0 + + +def _audit(args: argparse.Namespace) -> int: + report = _run_audit(args) + if args.json: + print( + json.dumps( + { + "target": report.target.spec, + "eager_import_ms": round(report.eager.import_ms, 2), + "lazy_import_ms": (round(report.lazy.import_ms, 2) if report.lazy else None), + "total_saving_ms": round(report.total_saving_ms, 2), + "verdicts": [ + { + "module": v.module, + "status": v.status, + "saving_ms": round(v.saving_ms, 2), + "locations": v.locations, + "note": v.note, + } + for v in report.verdicts + ], + }, + indent=2, + ) + ) + return 0 + _print_audit(report, args.min_saving_ms, show_all=args.show_all) + return 0 + + +def _run_audit(args: argparse.Namespace) -> AuditReport: + from lazybudget import audit as audit_mod + + paths = [Path(p) for p in args.paths] or [Path()] + return audit_mod.run( + paths, + args.target, + python=args.python, + trials=args.trials, + test_command=shlex.split(args.test) if args.test else None, + ) + + +def _print_audit(report: AuditReport, min_saving_ms: float, *, show_all: bool = False) -> None: + from lazybudget.audit import NO_WIN, SAFE, UNSAFE, UNUSED + from lazybudget.report import style, table + + if report.skipped_runtime: + print(style(report.skipped_runtime, "yellow")) + print() + if not report.verdicts: + print("no deferrable imports found: every module-level import is used at import time") + return + + colors = {SAFE: "green", NO_WIN: "dim", UNSAFE: "red", UNUSED: "dim"} + shown = report.verdicts if show_all else [v for v in report.verdicts if v.status != UNUSED] + hidden = len(report.verdicts) - len(shown) + rows = [ + ( + f"{v.saving_ms:.1f}" if v.saving_ms else "-", + style(v.status, colors.get(v.status, "")), + v.module, + v.note or (v.locations[0] if v.locations else ""), + ) + for v in shown + ] + if rows: + print(table(["saves ms", "verdict", "module", "why"], rows, aligns="rlll")) + if hidden: + print( + style( + f"{hidden} more import(s) never run on this startup path (--all to see them)", + "dim", + ) + ) + print() + + winners = [v for v in report.verdicts if v.worth_it and v.saving_ms >= min_saving_ms] + if report.lazy is not None: + print( + f"{report.eager.import_ms:.1f} ms of imports today. Deferring the safe ones " + f"skips {report.avoided_modules} module(s), worth " + + style(f"about {report.total_saving_ms:.1f} ms", "bold") + + " at what they cost now." + ) + if winners: + print(f"{len(winners)} import(s) clear the {min_saving_ms:g} ms bar.") + print("Run the same command with 'apply --write' to make the change.") + else: + print("Nothing clears the bar. Your imports are already paying for themselves.") + if report.tests_run and report.test_output: + print() + print(style("the test command failed with these deferred:", "red")) + print(report.test_output) + + +def _apply(args: argparse.Namespace) -> int: + from lazybudget.codemod import apply as apply_edit + from lazybudget.report import style + + report = _run_audit(args) + accepted = report.accepted(args.min_saving_ms) + if not accepted: + _print_audit(report, args.min_saving_ms) + return 0 + + changed = 0 + for analysis in report.analyses: + if not any(c.module in accepted for c in analysis.candidates): + continue + source = analysis.path.read_text(encoding="utf-8") + edit = apply_edit( + analysis, + source, + style=args.style, + only=accepted, + line_length=args.line_length, + ) + if not edit.changed: + continue + changed += 1 + if args.write: + analysis.path.write_text(edit.after, encoding="utf-8") + print(f"{style('updated', 'green')} {analysis.path}: {', '.join(edit.modules)}") + else: + print(_diff(edit.before, edit.after, analysis.path)) + + if changed == 0: + print("nothing to change") + elif not args.write: + print() + print( + style( + f"{changed} file(s) would change, saving about " + f"{report.total_saving_ms:.1f} ms. Re-run with --write to apply.", + "bold", + ) + ) + return 0 + + +def _diff(before: str, after: str, path: Path) -> str: + import difflib + + return "".join( + difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=str(path), + tofile=str(path), + ) + ) + + +def _check(args: argparse.Namespace) -> int: + from lazybudget import check as check_mod + from lazybudget.config import load + from lazybudget.report import style + + config = load(args.config) + if not config.budgets: + where = config.source or "pyproject.toml" + print( + f"lazybudget: no budgets configured. Add a [tool.lazybudget] section to {where}:\n\n" + " [tool.lazybudget]\n" + ' target = "import yourpackage"\n' + " max_import_ms = 150\n", + file=sys.stderr, + ) + return 2 + + report = check_mod.run( + config, + update=args.update, + fail_on_drift=not args.no_drift, + trials=args.trials, + ) + + if args.json: + print( + json.dumps( + { + "ok": report.ok, + "results": [ + { + "target": r.budget.target, + "import_ms": round(r.measurement.import_ms, 2), + "modules": r.measurement.module_count, + "added": r.drift.added, + "removed": r.drift.removed, + "failures": r.failures, + } + for r in report.results + ], + }, + indent=2, + ) + ) + return 0 if report.ok else 1 + + for result in report.results: + mark = style("ok ", "green") if result.ok else style("fail", "red") + print( + f"{mark} {result.budget.target} " + f"{result.measurement.import_ms:.1f} ms, {result.measurement.module_count} modules" + ) + for failure in result.failures: + print(f" {failure}") + if result.drift.added: + print(f" {style('+ ' + ', '.join(result.drift.added[:8]), 'yellow')}") + if result.drift.removed: + print(f" {style('- ' + ', '.join(result.drift.removed[:8]), 'dim')}") + + if report.lock_updated: + print(f"\nwrote {report.lock_path}") + return 0 if report.ok else 1 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/lazybudget/codemod.py b/src/lazybudget/codemod.py new file mode 100644 index 0000000..161f4e9 --- /dev/null +++ b/src/lazybudget/codemod.py @@ -0,0 +1,155 @@ +"""Rewrite imports, without disturbing anything else in the file. + +Two output styles, because two kinds of project need different things. + +``lazy`` + Writes ``lazy import x``. Requires Python 3.15 and reads the way you would + write it by hand. Right for applications and for libraries that have dropped + everything older. + +``lazy-modules`` + Writes a ``__lazy_modules__`` set above the imports and leaves the import + statements alone. This is PEP 810's own migration shim: it is ordinary + syntax on every Python back to 3.9 and only means anything on 3.15+. Right + for a library with a wide support range. The set is sorted and deduplicated + so ``flake8-lazy`` stays quiet about it. + +Edits are applied bottom-up on the original line list, so nothing has to be +reformatted and nothing else in the file moves. +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass +from pathlib import Path + +from lazybudget.static import Candidate, FileAnalysis, parse_source + +DUNDER = "__lazy_modules__" + + +@dataclass +class Edit: + path: Path + before: str + after: str + style: str + modules: list[str] + + @property + def changed(self) -> bool: + return self.before != self.after + + +def apply( + analysis: FileAnalysis, + source: str, + *, + style: str = "lazy", + only: set[str] | None = None, + line_length: int = 88, +) -> Edit: + """Produce the rewritten source for one file. + + ``only`` restricts the change to specific module names, which is how + measured evidence gets enforced: ``audit`` decides what earns its keep and + passes the list through. + """ + chosen = [c for c in analysis.candidates if only is None or c.module in only] + if style == "lazy": + after = _apply_keyword(source, chosen) + elif style == "lazy-modules": + after = _apply_dunder(source, chosen, line_length=line_length) + else: + raise ValueError(f"unknown style {style!r}; expected 'lazy' or 'lazy-modules'") + return Edit( + path=analysis.path, + before=source, + after=after, + style=style, + modules=sorted({c.module for c in chosen}), + ) + + +def _apply_keyword(source: str, candidates: list[Candidate]) -> str: + lines = source.splitlines(keepends=True) + for candidate in sorted(candidates, key=lambda c: c.lineno, reverse=True): + index = candidate.lineno - 1 + line = lines[index] + if line.lstrip().startswith("lazy "): + continue + col = candidate.col_offset + lines[index] = line[:col] + "lazy " + line[col:] + return "".join(lines) + + +def _apply_dunder(source: str, candidates: list[Candidate], *, line_length: int) -> str: + modules = {c.module for c in candidates if not c.is_relative} + if not modules: + return source + + tree = parse_source(source, Path("")) + lines = source.splitlines(keepends=True) + existing = _find_dunder(tree) + + if existing is not None: + node, current = existing + merged = sorted(modules | current) + block = _render(merged, line_length=line_length) + start, end = node.lineno - 1, (node.end_lineno or node.lineno) + return "".join([*lines[:start], block, *lines[end:]]) + + block = _render(sorted(modules), line_length=line_length) + insert_at = _insertion_point(tree) + prefix = lines[:insert_at] + suffix = lines[insert_at:] + # Keep one blank line between the declaration and whatever follows. + if suffix and suffix[0].strip(): + block += "\n" + if prefix and prefix[-1].strip(): + block = "\n" + block + return "".join([*prefix, block, *suffix]) + + +def _render(modules: list[str], *, line_length: int) -> str: + inline = f"{DUNDER} = {{" + ", ".join(f'"{m}"' for m in modules) + "}\n" + if len(inline) - 1 <= line_length: + return inline + body = "".join(f' "{m}",\n' for m in modules) + return f"{DUNDER} = {{\n{body}}}\n" + + +def _find_dunder(tree: ast.Module) -> tuple[ast.stmt, set[str]] | None: + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + if not any(isinstance(t, ast.Name) and t.id == DUNDER for t in node.targets): + continue + value = node.value + current: set[str] = set() + if isinstance(value, ast.Set | ast.List | ast.Tuple): + current = { + element.value + for element in value.elts + if isinstance(element, ast.Constant) and isinstance(element.value, str) + } + return node, current + return None + + +def _insertion_point(tree: ast.Module) -> int: + """Line index for the declaration: after the docstring and __future__, before imports.""" + index = 0 + for node in tree.body: + is_docstring = ( + isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ) + is_future = isinstance(node, ast.ImportFrom) and node.module == "__future__" + if is_docstring or is_future: + index = node.end_lineno or node.lineno + continue + break + return index diff --git a/src/lazybudget/config.py b/src/lazybudget/config.py new file mode 100644 index 0000000..d71e33a --- /dev/null +++ b/src/lazybudget/config.py @@ -0,0 +1,121 @@ +"""Read ``[tool.lazybudget]`` out of pyproject.toml. + +Two shapes are accepted. The short one, for the common case of a single thing +you care about:: + + [tool.lazybudget] + target = "import mypkg" + max_import_ms = 120 + max_modules = 200 + +And the long one, when a project has several entry points with different budgets:: + + [tool.lazybudget] + trials = 7 + + [[tool.lazybudget.budget]] + target = "import mypkg" + max_import_ms = 120 + + [[tool.lazybudget.budget]] + target = "-m mypkg.cli" + max_import_ms = 300 +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - exercised only on 3.10 + import tomli as tomllib # type: ignore[import-not-found] + +from lazybudget.measure import DEFAULT_TRIALS + + +class ConfigError(ValueError): + """pyproject.toml has a ``[tool.lazybudget]`` section we cannot use.""" + + +@dataclass +class Budget: + """One target and the limits it must stay inside.""" + + target: str + max_import_ms: float | None = None + max_modules: int | None = None + + @property + def slug(self) -> str: + """A filesystem- and JSON-safe key for this target.""" + return "".join(c if c.isalnum() or c in "-_." else "-" for c in self.target).strip("-") + + +@dataclass +class Config: + budgets: list[Budget] = field(default_factory=list) + trials: int = DEFAULT_TRIALS + python: str | None = None + lock_path: Path | None = None + source: Path | None = None + + +def find_pyproject(start: Path | None = None) -> Path | None: + """Walk up from ``start`` looking for a pyproject.toml.""" + current = (start or Path.cwd()).resolve() + for directory in (current, *current.parents): + candidate = directory / "pyproject.toml" + if candidate.is_file(): + return candidate + return None + + +def load(path: Path | None = None) -> Config: + """Load configuration, returning an empty :class:`Config` if there is none.""" + pyproject = path or find_pyproject() + if pyproject is None: + return Config() + + with pyproject.open("rb") as handle: + data = tomllib.load(handle) + section = data.get("tool", {}).get("lazybudget") + if not isinstance(section, dict): + return Config(source=pyproject) + + config = Config( + trials=int(section.get("trials", DEFAULT_TRIALS)), + python=section.get("python"), + source=pyproject, + ) + lock = section.get("lock") + config.lock_path = pyproject.parent / lock if lock else pyproject.parent / "import.lock" + config.budgets = _budgets(section, pyproject) + return config + + +def _budgets(section: dict[str, Any], pyproject: Path) -> list[Budget]: + entries = section.get("budget") + if entries is not None: + if not isinstance(entries, list): + raise ConfigError(f"{pyproject}: [[tool.lazybudget.budget]] must be a list of tables") + return [_budget(entry, pyproject) for entry in entries] + if "target" in section: + return [_budget(section, pyproject)] + return [] + + +def _budget(entry: dict[str, Any], pyproject: Path) -> Budget: + target = entry.get("target") + if not isinstance(target, str) or not target.strip(): + raise ConfigError(f"{pyproject}: every lazybudget budget needs a 'target' string") + max_ms = entry.get("max_import_ms") + max_modules = entry.get("max_modules") + return Budget( + target=target, + max_import_ms=float(max_ms) if max_ms is not None else None, + max_modules=int(max_modules) if max_modules is not None else None, + ) diff --git a/src/lazybudget/importtime.py b/src/lazybudget/importtime.py new file mode 100644 index 0000000..6386207 --- /dev/null +++ b/src/lazybudget/importtime.py @@ -0,0 +1,83 @@ +"""Parse the output of ``python -X importtime``. + +CPython writes one line per imported module to stderr, deepest-first:: + + import time: self [us] | cumulative | imported package + import time: 177 | 177 | _io + import time: 572 | 962 | _frozen_importlib_external + +The leading whitespace of the name encodes tree depth (one space at the root, +two more per level). Lines appear in post-order: a module is printed after +everything it imported. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterator +from dataclasses import dataclass, field + +_LINE = re.compile(r"^import time:\s*(\d+)\s*\|\s*(\d+)\s*\|(\s*)(\S.*)$") + + +@dataclass +class ImportNode: + """One imported module and everything it pulled in.""" + + name: str + self_us: int + cumulative_us: int + depth: int + children: list[ImportNode] = field(default_factory=list) + + @property + def self_ms(self) -> float: + return self.self_us / 1000 + + @property + def cumulative_ms(self) -> float: + return self.cumulative_us / 1000 + + def walk(self) -> Iterator[ImportNode]: + """Yield this node and every descendant, parents first.""" + yield self + for child in self.children: + yield from child.walk() + + +def parse(stderr: str) -> list[ImportNode]: + """Build the import forest from ``-X importtime`` output. + + Returns the root-level nodes in the order CPython finished importing them. + Lines that are not importtime records (a traceback, the program's own + stderr) are ignored, so this is safe to point at a noisy workload. + """ + stack: list[tuple[int, ImportNode]] = [] + + for line in stderr.splitlines(): + match = _LINE.match(line) + if match is None: + continue + self_us, cumulative_us, indent, name = match.groups() + # One space at depth 0, two additional spaces per level below that. + depth = max(0, (len(indent) - 1) // 2) + node = ImportNode( + name=name.strip(), + self_us=int(self_us), + cumulative_us=int(cumulative_us), + depth=depth, + ) + # Everything still on the stack deeper than us is one of our children. + children: list[ImportNode] = [] + while stack and stack[-1][0] > depth: + children.append(stack.pop()[1]) + children.reverse() + node.children = children + stack.append((depth, node)) + + return [node for _, node in stack] + + +def flatten(roots: list[ImportNode]) -> dict[str, ImportNode]: + """Map module name -> node. A module is only imported once, so names are unique.""" + return {node.name: node for root in roots for node in root.walk()} diff --git a/src/lazybudget/lock.py b/src/lazybudget/lock.py new file mode 100644 index 0000000..95266ce --- /dev/null +++ b/src/lazybudget/lock.py @@ -0,0 +1,94 @@ +"""The import lock file: a committed snapshot of *which* modules a target imports. + +Timings drift with the machine; the set of imported modules does not. That makes +the module set the honest thing to pin in version control. When a dependency +upgrade quietly adds an at-import metadata fetch, the lock diff says so in the +pull request -- which is the only place anyone would have caught it. + +Recorded timings are kept for context but are never used to pass or fail a run. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + +LOCK_VERSION = 1 + + +@dataclass +class LockEntry: + modules: list[str] + import_ms: float + python: str + + def to_json(self) -> dict[str, object]: + return { + "modules": self.modules, + "import_ms": round(self.import_ms, 1), + "python": self.python, + } + + +@dataclass +class Lock: + targets: dict[str, LockEntry] = field(default_factory=dict) + + def to_json(self) -> dict[str, object]: + return { + "version": LOCK_VERSION, + "targets": {name: entry.to_json() for name, entry in sorted(self.targets.items())}, + } + + +@dataclass +class Drift: + """What changed between the locked module set and the current one.""" + + target: str + added: list[str] + removed: list[str] + + @property + def changed(self) -> bool: + return bool(self.added or self.removed) + + +def read(path: Path) -> Lock: + """Read a lock file; a missing file is an empty lock, not an error.""" + if not path.is_file(): + return Lock() + data = json.loads(path.read_text(encoding="utf-8")) + version = data.get("version") + if version != LOCK_VERSION: + raise ValueError( + f"{path}: unsupported lock version {version!r}; " + f"delete it and re-run 'lazybudget check --update' to regenerate" + ) + targets = { + name: LockEntry( + modules=list(entry.get("modules", [])), + import_ms=float(entry.get("import_ms", 0.0)), + python=str(entry.get("python", "")), + ) + for name, entry in data.get("targets", {}).items() + } + return Lock(targets=targets) + + +def write(path: Path, lock: Lock) -> None: + """Write the lock file with a trailing newline, so diffs stay clean.""" + path.write_text(json.dumps(lock.to_json(), indent=2) + "\n", encoding="utf-8") + + +def compare(target: str, locked: LockEntry | None, current: list[str]) -> Drift: + """Diff a locked module set against what we just measured.""" + if locked is None: + return Drift(target=target, added=[], removed=[]) + before, after = set(locked.modules), set(current) + return Drift( + target=target, + added=sorted(after - before), + removed=sorted(before - after), + ) diff --git a/src/lazybudget/measure.py b/src/lazybudget/measure.py new file mode 100644 index 0000000..022c82b --- /dev/null +++ b/src/lazybudget/measure.py @@ -0,0 +1,185 @@ +"""Run a target under ``-X importtime`` and attribute the cost. + +Two numbers matter and they are not the same: + +``import_ms`` + Time spent importing modules *your* target pulled in, with the interpreter's + own unavoidable startup imports subtracted. This is the number a budget + should be set against, because it is the part you control. + +``wall_ms`` + Median wall time of the whole process, minus the median wall time of a bare + ``python -c ""``. Includes interpreter startup and anything the target does + after importing. Useful as a sanity check that ``import_ms`` is telling the + truth. + +Every measurement is the median of several trials. A single run of anything on a +laptop is noise. +""" + +from __future__ import annotations + +import os +import statistics +import subprocess +import sys +import time +from dataclasses import dataclass +from functools import lru_cache + +from lazybudget.importtime import ImportNode, flatten, parse +from lazybudget.targets import Target, resolve + +DEFAULT_TRIALS = 5 + + +@dataclass +class Trial: + wall_ms: float + roots: list[ImportNode] + returncode: int + stdout: str + stderr: str + + +@dataclass +class Measurement: + """The result of profiling one target.""" + + target: Target + python: str + trials: int + import_ms: float + wall_ms: float + modules: list[str] + roots: list[ImportNode] + baseline_modules: frozenset[str] + returncode: int + stderr: str + + @property + def module_count(self) -> int: + return len(self.modules) + + def attributed(self) -> list[ImportNode]: + """Nodes the target is responsible for, most expensive (self time) first.""" + nodes = [n for n in flatten(self.roots).values() if n.name not in self.baseline_modules] + return sorted(nodes, key=lambda n: n.self_us, reverse=True) + + +def measure( + target: str | Target, + *, + trials: int = DEFAULT_TRIALS, + python: str | None = None, + env: dict[str, str] | None = None, + extra_flags: list[str] | None = None, +) -> Measurement: + """Profile ``target``'s import cost.""" + resolved = target if isinstance(target, Target) else resolve(target) + interpreter = python or sys.executable + baseline_modules, baseline_wall = _baseline(interpreter, _env_key(env)) + + runs = [ + _run(interpreter, resolved.argv, env=env, extra_flags=extra_flags) for _ in range(trials) + ] + failed = next((r for r in runs if r.returncode != 0), None) + if failed is not None: + return Measurement( + target=resolved, + python=interpreter, + trials=trials, + import_ms=0.0, + wall_ms=0.0, + modules=[], + roots=[], + baseline_modules=baseline_modules, + returncode=failed.returncode, + stderr=failed.stderr, + ) + + # Median run, chosen by wall time, so the tree we report is a real run and + # not a Frankenstein average of several. + runs.sort(key=lambda r: r.wall_ms) + median = runs[len(runs) // 2] + + nodes = flatten(median.roots) + attributed = [name for name in nodes if name not in baseline_modules] + import_us = sum(nodes[name].self_us for name in attributed) + + return Measurement( + target=resolved, + python=interpreter, + trials=trials, + import_ms=import_us / 1000, + wall_ms=max(0.0, statistics.median(r.wall_ms for r in runs) - baseline_wall), + modules=sorted(attributed), + roots=median.roots, + baseline_modules=baseline_modules, + returncode=0, + stderr=median.stderr, + ) + + +def _env_key(env: dict[str, str] | None) -> frozenset[tuple[str, str]]: + """Hashable form of the environment overrides, for the baseline cache. + + The baseline has to be taken under the same environment as the measurement. + Otherwise a harness that sets PYTHONPATH to inject a sitecustomize charges + its own startup cost to the thing it is measuring, and a real saving comes + out looking like zero. + """ + return frozenset((env or {}).items()) + + +@lru_cache(maxsize=32) +def _baseline( + interpreter: str, env_key: frozenset[tuple[str, str]] = frozenset() +) -> tuple[frozenset[str], float]: + """Modules and wall time of a do-nothing interpreter, so we can subtract them.""" + env = dict(env_key) or None + runs = [_run(interpreter, ["-c", ""], env=env) for _ in range(DEFAULT_TRIALS)] + modules: set[str] = set() + for run in runs: + modules |= set(flatten(run.roots)) + return frozenset(modules), statistics.median(r.wall_ms for r in runs) + + +def _run( + interpreter: str, + argv: list[str], + *, + env: dict[str, str] | None = None, + extra_flags: list[str] | None = None, +) -> Trial: + command = [interpreter, "-X", "importtime", *(extra_flags or []), *argv] + full_env = {**os.environ, **(env or {})} + # Bytecode caching makes the first run of anything an outlier; let it happen + # normally rather than forcing -B, but do keep the child from writing to a + # random cwd's __pycache__ mid-measurement. + full_env.setdefault("PYTHONDONTWRITEBYTECODE", "") + start = time.perf_counter() + completed = subprocess.run( + command, + capture_output=True, + text=True, + env=full_env, + check=False, + ) + wall_ms = (time.perf_counter() - start) * 1000 + return Trial( + wall_ms=wall_ms, + roots=parse(completed.stderr), + returncode=completed.returncode, + stdout=completed.stdout, + # The importtime records are already parsed; what is left is the + # target's own stderr, which is the only part worth showing a human. + stderr=strip_importtime(completed.stderr), + ) + + +def strip_importtime(stderr: str) -> str: + """Drop the ``-X importtime`` records, keeping whatever the program itself printed.""" + return "\n".join( + line for line in stderr.splitlines() if not line.startswith("import time:") + ).strip() diff --git a/src/lazybudget/py.typed b/src/lazybudget/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/lazybudget/pytest_plugin.py b/src/lazybudget/pytest_plugin.py new file mode 100644 index 0000000..78259d6 --- /dev/null +++ b/src/lazybudget/pytest_plugin.py @@ -0,0 +1,49 @@ +"""A pytest fixture for asserting import cost, so a budget can live next to your tests. + + def test_cli_starts_fast(import_budget): + import_budget("import mypkg.cli", max_ms=120, max_modules=200) + +Pytest imports every installed plugin at startup, so this module deliberately +imports nothing of its own until the fixture is actually used. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + + +@pytest.fixture +def import_budget() -> Callable[..., object]: + """Return a callable that measures a target and fails the test if it is over budget.""" + + def assert_within( + target: str, + *, + max_ms: float | None = None, + max_modules: int | None = None, + trials: int = 5, + python: str | None = None, + ) -> object: + from lazybudget.measure import measure + + result = measure(target, trials=trials, python=python) + if result.returncode != 0: + pytest.fail( + f"{target!r} exited with status {result.returncode}:\n" + + result.stderr.strip()[-2000:] + ) + problems = [] + if max_ms is not None and result.import_ms > max_ms: + problems.append(f"{result.import_ms:.1f} ms of imports, budget is {max_ms:g} ms") + if max_modules is not None and result.module_count > max_modules: + problems.append(f"{result.module_count} modules imported, budget is {max_modules}") + if problems: + worst = ", ".join(n.name for n in result.attributed()[:5]) + pytest.fail( + f"{target!r} is over budget: " + "; ".join(problems) + f"\nheaviest: {worst}" + ) + return result + + return assert_within diff --git a/src/lazybudget/report.py b/src/lazybudget/report.py new file mode 100644 index 0000000..f5dd84a --- /dev/null +++ b/src/lazybudget/report.py @@ -0,0 +1,93 @@ +"""Terminal output. + +Deliberately hand-rolled rather than delegated to a rendering library. A tool +whose whole premise is that imports cost milliseconds has no business importing +something expensive to draw a table. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Iterable, Sequence + +from lazybudget.importtime import ImportNode + +_RESET = "\033[0m" +_STYLES = { + "bold": "\033[1m", + "dim": "\033[2m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "cyan": "\033[36m", +} + + +def color_enabled(stream: object | None = None) -> bool: + """Respect NO_COLOR, FORCE_COLOR, and whether we are actually on a terminal.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + target = stream or sys.stdout + return bool(getattr(target, "isatty", lambda: False)()) + + +def style(text: str, *names: str, enabled: bool | None = None) -> str: + if enabled is None: + enabled = color_enabled() + if not enabled or not names: + return text + return "".join(_STYLES.get(n, "") for n in names) + text + _RESET + + +def table(headers: Sequence[str], rows: Iterable[Sequence[str]], *, aligns: str = "") -> str: + """Render an aligned table. ``aligns`` is one char per column: 'l' or 'r'.""" + body = [list(map(str, row)) for row in rows] + if not body: + return "" + widths = [len(h) for h in headers] + for row in body: + for i, cell in enumerate(row): + widths[i] = max(widths[i], len(_plain(cell))) + aligns = (aligns + "l" * len(headers))[: len(headers)] + + def render(cells: Sequence[str], *, header: bool = False) -> str: + out = [] + for cell, width, align in zip(cells, widths, aligns, strict=False): + pad = width - len(_plain(cell)) + out.append(" " * pad + cell if align == "r" else cell + " " * pad) + line = " ".join(out).rstrip() + return style(line, "bold") if header else line + + lines = [render(headers, header=True), " ".join("-" * w for w in widths)] + lines.extend(render(row) for row in body) + return "\n".join(lines) + + +def _plain(text: str) -> str: + """Length of ``text`` ignoring ANSI escapes.""" + out, in_escape = [], False + for char in text: + if in_escape: + in_escape = char != "m" + continue + if char == "\033": + in_escape = True + continue + out.append(char) + return "".join(out) + + +def tree(nodes: list[ImportNode], *, min_ms: float = 1.0, indent: int = 0) -> str: + """An indented cumulative-time tree, pruned to what is worth reading.""" + lines: list[str] = [] + for node in sorted(nodes, key=lambda n: n.cumulative_us, reverse=True): + if node.cumulative_ms < min_ms: + continue + lines.append(f"{' ' * indent}{node.cumulative_ms:7.1f} ms {node.name}") + child = tree(node.children, min_ms=min_ms, indent=indent + 1) + if child: + lines.append(child) + return "\n".join(lines) diff --git a/src/lazybudget/runtime.py b/src/lazybudget/runtime.py new file mode 100644 index 0000000..0a0620c --- /dev/null +++ b/src/lazybudget/runtime.py @@ -0,0 +1,323 @@ +"""Check proposed lazy imports against a real interpreter. + +Static analysis can tell you an import *may* be deferred. It cannot tell you two +things you actually need to know: + +1. Whether deferring it wins anything. Plenty of imports look deferrable and are + pulled in a microsecond later by something else on the startup path, so the + module loads anyway and you have bought nothing. +2. Whether deferring it breaks you. Import side effects -- registering a codec, + populating a plugin registry, patching a third-party class -- are invisible to + a parser and obvious to a test suite. + +Both questions are answered here, by running the code under Python 3.15 with +``sys.set_lazy_imports_filter`` restricted to the modules we are proposing. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +from lazybudget.targets import Target, resolve + +RESULT_ENV = "LAZYBUDGET_RESULT" +LAZY_SET_ENV = "LAZYBUDGET_LAZY_MODULES" + +#: Injected ahead of the target so the filter is installed before user code runs. +#: Injected ahead of the target so the filter is installed before user code runs. +#: Kept to builtin modules only -- anything this imports would show up in the very +#: measurement it exists to take. +_SITECUSTOMIZE = '''\ +"""Installed by lazybudget for one measurement run. Not written to your project.""" +import atexit +import os +import sys + +# Entries are "importing.module|imported.module". Matching on the pair is what +# makes this faithful to the codemod: writing `lazy import x` in one file does +# not defer x for the rest of the program, and neither does this. +_wanted = set(filter(None, os.environ.get("LAZYBUDGET_LAZY_MODULES", "").split(","))) +_names = {pair.split("|", 1)[1] for pair in _wanted} +_result = os.environ.get("LAZYBUDGET_RESULT") +_registered = set() + + +def _filter(importer, name, fromlist): + if importer + "|" + name in _wanted: + _registered.add(name) + return True + return False + + +def _report(): + reified = sorted(n for n in _registered if n in sys.modules) + rows = [ + ("registered", sorted(_registered)), + ("reified", reified), + ("deferred", sorted(_registered - set(reified))), + ("never_imported", sorted(_names - _registered)), + ] + with open(_result, "w", encoding="utf-8") as handle: + for key, values in rows: + handle.write(key + "\\t" + ",".join(values) + "\\n") + + +if _result: + sys.set_lazy_imports_filter(_filter) + sys.set_lazy_imports("all") + atexit.register(_report) + +# Do not shadow a sitecustomize the project actually relies on. +_here = os.path.dirname(os.path.abspath(__file__)) +for _entry in sys.path: + try: + _same = os.path.samefile(_entry, _here) + except OSError: + _same = False + if _same: + continue + _next = os.path.join(_entry, "sitecustomize.py") + if os.path.isfile(_next): + with open(_next, encoding="utf-8") as _handle: + exec(compile(_handle.read(), _next, "exec"), globals()) + break +''' + + +def _parse_result(text: str) -> dict[str, list[str]]: + """Read the tab-separated report the injected sitecustomize wrote.""" + payload: dict[str, list[str]] = {} + for line in text.splitlines(): + key, _, values = line.partition("\t") + payload[key] = [v for v in values.split(",") if v] + return payload + + +class LazyUnsupported(RuntimeError): + """The interpreter we were pointed at predates PEP 810.""" + + +@dataclass +class Reification: + """Which of the proposed modules actually stayed unloaded.""" + + deferred: list[str] + reified: list[str] + never_imported: list[str] + returncode: int + stderr: str + + +@dataclass +class SafetyResult: + """Whether a command still passes with the proposed modules made lazy.""" + + passed: bool + culprits: list[str] + output: str + + +def supports_lazy(python: str) -> bool: + """True if ``python`` implements PEP 810. + + A path that does not exist, or is not an interpreter, is simply False -- + someone passing ``--python python3.15`` before installing it should get a + readable message, not a traceback from deep inside subprocess. + """ + probe = "import sys; raise SystemExit(0 if hasattr(sys, 'set_lazy_imports_filter') else 1)" + try: + completed = subprocess.run([python, "-c", probe], capture_output=True, check=False) + except OSError: + return False + return completed.returncode == 0 + + +def find_lazy_python(preferred: str | None = None) -> str | None: + """Locate an interpreter that can run the runtime checks.""" + for candidate in (preferred, sys.executable, "python3.15", "python3"): + if candidate is None: + continue + resolved = shutil.which(candidate) or candidate + if Path(resolved).exists() and supports_lazy(resolved): + return resolved + return None + + +def check_reification( + target: str | Target, + proposal: Sequence[str], + *, + python: str, + env: dict[str, str] | None = None, +) -> Reification: + """Run ``target`` with ``proposal`` made lazy and report what actually deferred. + + ``proposal`` holds ``"importing.module|imported.module"`` entries, as built by + :func:`lazybudget.audit.proposal`. + """ + if not supports_lazy(python): + raise LazyUnsupported(f"{python} does not support PEP 810 lazy imports") + resolved = target if isinstance(target, Target) else resolve(target) + completed, payload = _run_with_lazy(python, resolved.argv, proposal, env=env) + if payload is None: + names = sorted({entry.split("|", 1)[-1] for entry in proposal}) + return Reification([], [], names, completed.returncode, completed.stderr) + return Reification( + deferred=payload["deferred"], + reified=payload["reified"], + never_imported=payload["never_imported"], + returncode=completed.returncode, + stderr=completed.stderr, + ) + + +def check_safety( + command: Sequence[str], + proposal: Sequence[str], + *, + python: str, + env: dict[str, str] | None = None, + bisect: bool = True, +) -> SafetyResult: + """Run ``command`` (usually a test suite) with ``proposal`` lazy. + + If it fails, narrow down which modules are responsible by bisecting rather + than making you delete entries one at a time. Worst case that is a handful of + extra test runs; in exchange the report names the module that broke you. + """ + if not supports_lazy(python): + raise LazyUnsupported(f"{python} does not support PEP 810 lazy imports") + + baseline = _run_command(command, [], python=python, env=env) + if baseline.returncode != 0: + return SafetyResult( + passed=False, + culprits=[], + output=( + "the command already fails before anything is made lazy, " + "so its result says nothing about lazy imports:\n" + + _tail(baseline.stdout + baseline.stderr) + ), + ) + + attempt = _run_command(command, list(proposal), python=python, env=env) + if attempt.returncode == 0: + return SafetyResult(passed=True, culprits=[], output="") + if not bisect: + return SafetyResult( + passed=False, culprits=[], output=_tail(attempt.stdout + attempt.stderr) + ) + + culprits = _bisect(command, list(proposal), python=python, env=env) + return SafetyResult( + passed=False, + culprits=culprits, + output=_tail(attempt.stdout + attempt.stderr), + ) + + +def _bisect( + command: Sequence[str], + entries: list[str], + *, + python: str, + env: dict[str, str] | None, +) -> list[str]: + """Smallest set of proposal entries that still reproduces the failure.""" + culprits: list[str] = [] + remaining = list(entries) + while remaining: + if len(remaining) == 1: + culprits.append(remaining[0]) + break + mid = len(remaining) // 2 + left, right = remaining[:mid], remaining[mid:] + if _run_command(command, left, python=python, env=env).returncode != 0: + remaining = left + elif _run_command(command, right, python=python, env=env).returncode != 0: + remaining = right + else: + # Neither half fails alone: the interaction needs both. Report the + # whole remaining set rather than pretending we narrowed it. + culprits.extend(remaining) + break + return sorted({entry.split("|", 1)[-1] for entry in culprits}) + + +def _run_command( + command: Sequence[str], + modules: Sequence[str], + *, + python: str, + env: dict[str, str] | None, +) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory(prefix="lazybudget-") as tmp: + write_sitecustomize(Path(tmp)) + full_env = child_env(tmp, modules, result_path=None, env=env) + return subprocess.run( + list(command), + capture_output=True, + text=True, + env=full_env, + check=False, + ) + + +def _run_with_lazy( + python: str, + argv: Sequence[str], + modules: Sequence[str], + *, + env: dict[str, str] | None, +) -> tuple[subprocess.CompletedProcess[str], dict[str, list[str]] | None]: + with tempfile.TemporaryDirectory(prefix="lazybudget-") as tmp: + tmpdir = Path(tmp) + write_sitecustomize(tmpdir) + result_path = tmpdir / "result.json" + full_env = child_env(tmp, modules, result_path=result_path, env=env) + completed = subprocess.run( + [python, *argv], + capture_output=True, + text=True, + env=full_env, + check=False, + ) + if not result_path.is_file(): + return completed, None + return completed, _parse_result(result_path.read_text(encoding="utf-8")) + + +def write_sitecustomize(directory: Path) -> None: + (directory / "sitecustomize.py").write_text(_SITECUSTOMIZE, encoding="utf-8") + + +def child_env( + tmp: str, + modules: Sequence[str], + *, + result_path: Path | None, + env: dict[str, str] | None, +) -> dict[str, str]: + existing = os.environ.get("PYTHONPATH", "") + full = {**os.environ, **(env or {})} + full["PYTHONPATH"] = os.pathsep.join([tmp, existing]) if existing else tmp + full[LAZY_SET_ENV] = ",".join(modules) + if result_path is not None: + full[RESULT_ENV] = str(result_path) + else: + full.pop(RESULT_ENV, None) + # Without a result path the filter is inert, so switch it on explicitly. + full[RESULT_ENV] = str(Path(tmp) / "ignored.json") + return full + + +def _tail(text: str, lines: int = 40) -> str: + parts = text.strip().splitlines() + return "\n".join(parts[-lines:]) diff --git a/src/lazybudget/static.py b/src/lazybudget/static.py new file mode 100644 index 0000000..c42e986 --- /dev/null +++ b/src/lazybudget/static.py @@ -0,0 +1,282 @@ +"""Find module-level imports that could be deferred. + +An import is a candidate when nothing it binds is touched while the module is +still being executed. "While the module is still being executed" is doing a lot +of work in that sentence, so it is worth spelling out what counts as an +immediate use: + +* anything in the module body itself; +* decorators, default arguments, and base classes of top-level definitions -- + these run at ``def``/``class`` time, which is import time; +* the body of a top-level ``class``, which also runs at import time; +* annotations, *unless* the file has ``from __future__ import annotations`` + (or we have been told to assume PEP 649 semantics), in which case they are + never evaluated eagerly. + +Uses inside function bodies are not immediate -- that is exactly the case lazy +imports exist for. + +This module only decides what is *permissible* and *plausible*. Whether +deferring actually pays, and whether it actually works, is settled by +:mod:`lazybudget.runtime` against a running interpreter. +""" + +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass, field +from pathlib import Path + +#: A ``lazy`` import as PEP 810 spells it. Only 3.15 can parse one. +_LAZY_STATEMENT = re.compile(r"^(\s*)lazy (import|from)\b", re.MULTILINE) + +#: Reasons an import is disqualified, in the words we show the user. +SKIP_STAR = "wildcard imports cannot be lazy" +SKIP_FUTURE = "__future__ imports cannot be lazy" +SKIP_NESTED = "not at module top level" +SKIP_TYPE_CHECKING = "already free: only imported under TYPE_CHECKING" +SKIP_EXPORTED = "re-exported via __all__" + + +@dataclass +class Candidate: + """One import statement that may be deferrable.""" + + module: str + names: list[str] + lineno: int + end_lineno: int + col_offset: int + is_relative: bool + source: str + + @property + def key(self) -> str: + return f"{self.module}:{self.lineno}" + + +@dataclass +class Rejected: + module: str + lineno: int + reason: str + + +@dataclass +class FileAnalysis: + path: Path + candidates: list[Candidate] = field(default_factory=list) + rejected: list[Rejected] = field(default_factory=list) + eager: list[Rejected] = field(default_factory=list) + + @property + def lazy_modules(self) -> list[str]: + """Module names suitable for a ``__lazy_modules__`` set, sorted.""" + return sorted({c.module for c in self.candidates if not c.is_relative}) + + +def parse_source(source: str, path: Path) -> ast.Module: + """Parse ``source``, even if it already contains ``lazy import`` on an older Python. + + Running ``apply`` twice, or auditing a codebase that has already adopted PEP + 810, should not blow up just because the interpreter doing the analysis is + 3.13. When the parse fails on a ``lazy`` statement, the keyword is blanked + out with the same number of spaces and the file is parsed again -- so every + line number and column offset the analysis reports still points at the real + file. + """ + try: + return ast.parse(source, filename=str(path)) + except SyntaxError: + if not _LAZY_STATEMENT.search(source): + raise + without_keyword = _LAZY_STATEMENT.sub(r"\1\2", source) + return ast.parse(without_keyword, filename=str(path)) + + +def analyze_source( + source: str, path: Path, *, assume_lazy_annotations: bool = False +) -> FileAnalysis: + """Analyze one module's source text.""" + tree = parse_source(source, path) + lines = source.splitlines() + analysis = FileAnalysis(path=path) + + lazy_annotations = assume_lazy_annotations or _has_future_annotations(tree) + exported = _dunder_all(tree) + used = _ImmediateUses(lazy_annotations=lazy_annotations) + used.visit(tree) + + top_level_lines = { + node.lineno for node in tree.body if isinstance(node, ast.Import | ast.ImportFrom) + } + + for node in ast.walk(tree): + if not isinstance(node, ast.Import | ast.ImportFrom): + continue + module = _module_name(node) + if node.lineno not in top_level_lines: + reason = SKIP_TYPE_CHECKING if _under_type_checking(tree, node) else SKIP_NESTED + analysis.rejected.append(Rejected(module, node.lineno, reason)) + continue + if isinstance(node, ast.ImportFrom): + if node.module == "__future__": + analysis.rejected.append(Rejected(module, node.lineno, SKIP_FUTURE)) + continue + if any(alias.name == "*" for alias in node.names): + analysis.rejected.append(Rejected(module, node.lineno, SKIP_STAR)) + continue + + bound = _bound_names(node) + if bound & exported: + analysis.rejected.append(Rejected(module, node.lineno, SKIP_EXPORTED)) + continue + touched = sorted(bound & used.names) + if touched: + analysis.eager.append( + Rejected(module, node.lineno, f"used at import time: {', '.join(touched)}") + ) + continue + + analysis.candidates.append( + Candidate( + module=module, + names=sorted(bound), + lineno=node.lineno, + end_lineno=node.end_lineno or node.lineno, + col_offset=node.col_offset, + is_relative=isinstance(node, ast.ImportFrom) and bool(node.level), + source="\n".join(lines[node.lineno - 1 : (node.end_lineno or node.lineno)]), + ) + ) + analysis.candidates.sort(key=lambda c: c.lineno) + return analysis + + +def analyze_file(path: Path, *, assume_lazy_annotations: bool = False) -> FileAnalysis: + source = path.read_text(encoding="utf-8") + return analyze_source(source, path, assume_lazy_annotations=assume_lazy_annotations) + + +def _module_name(node: ast.Import | ast.ImportFrom) -> str: + if isinstance(node, ast.ImportFrom): + return "." * node.level + (node.module or "") + return node.names[0].name + + +def _bound_names(node: ast.Import | ast.ImportFrom) -> set[str]: + """The names an import statement puts into the namespace.""" + names = set() + for alias in node.names: + if alias.asname: + names.add(alias.asname) + elif isinstance(node, ast.Import): + # `import a.b.c` binds `a`. + names.add(alias.name.split(".")[0]) + else: + names.add(alias.name) + return names + + +def _has_future_annotations(tree: ast.Module) -> bool: + return any( + isinstance(node, ast.ImportFrom) + and node.module == "__future__" + and any(alias.name == "annotations" for alias in node.names) + for node in tree.body + ) + + +def _dunder_all(tree: ast.Module) -> set[str]: + for node in tree.body: + if not isinstance(node, ast.Assign | ast.AnnAssign): + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if not any(isinstance(t, ast.Name) and t.id == "__all__" for t in targets): + continue + value = node.value + if isinstance(value, ast.List | ast.Tuple | ast.Set): + return { + element.value + for element in value.elts + if isinstance(element, ast.Constant) and isinstance(element.value, str) + } + return set() + + +def _under_type_checking(tree: ast.Module, target: ast.stmt) -> bool: + for node in ast.walk(tree): + if not isinstance(node, ast.If): + continue + test = node.test + guard = (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or ( + isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING" + ) + if guard and any(target is stmt for stmt in ast.walk(node) if stmt is target): + return True + return False + + +class _ImmediateUses(ast.NodeVisitor): + """Collect names read while the module body is still executing.""" + + def __init__(self, *, lazy_annotations: bool) -> None: + self.names: set[str] = set() + self.lazy_annotations = lazy_annotations + self._immediate = True + + def visit_Name(self, node: ast.Name) -> None: + if self._immediate and isinstance(node.ctx, ast.Load): + self.names.add(node.id) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_function(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_function(node) + + def visit_Lambda(self, node: ast.Lambda) -> None: + self._visit_arguments(node.args) + self._deferred(node.body) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + # Decorators, bases and keywords run now; so does the class body. + for child in (*node.decorator_list, *node.bases, *node.keywords, *node.body): + self.visit(child) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + self.visit(node.target) + if node.value is not None: + self.visit(node.value) + self._annotation(node.annotation) + + def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + for decorator in node.decorator_list: + self.visit(decorator) + self._visit_arguments(node.args) + if node.returns is not None: + self._annotation(node.returns) + for statement in node.body: + self._deferred(statement) + + def _visit_arguments(self, args: ast.arguments) -> None: + for default in (*args.defaults, *(d for d in args.kw_defaults if d is not None)): + self.visit(default) + every = (*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg) + for arg in every: + if arg is not None and arg.annotation is not None: + self._annotation(arg.annotation) + + def _annotation(self, node: ast.expr) -> None: + if self.lazy_annotations: + self._deferred(node) + else: + self.visit(node) + + def _deferred(self, node: ast.AST) -> None: + previous, self._immediate = self._immediate, False + try: + self.visit(node) + finally: + self._immediate = previous diff --git a/src/lazybudget/targets.py b/src/lazybudget/targets.py new file mode 100644 index 0000000..4eb392a --- /dev/null +++ b/src/lazybudget/targets.py @@ -0,0 +1,83 @@ +"""Turn a human-written target string into an argv we can hand to the interpreter. + +A target is whatever you actually pay startup cost for. In practice that is one of +four things, and we accept all of them:: + + lazybudget profile "import pandas" # a bare import + lazybudget profile "-m http.server" # a module + lazybudget profile ./scripts/run.py # a script + lazybudget profile mypy # a console script on PATH +""" + +from __future__ import annotations + +import shlex +import shutil +from dataclasses import dataclass +from pathlib import Path + + +class TargetError(ValueError): + """The target string does not name anything we know how to run.""" + + +@dataclass(frozen=True) +class Target: + """A resolved, runnable target.""" + + spec: str + argv: list[str] + kind: str # "code" | "module" | "script" | "console-script" + + def __str__(self) -> str: + return self.spec + + +def resolve(spec: str) -> Target: + """Resolve ``spec`` into interpreter arguments. + + Raises :class:`TargetError` if the target cannot be found, rather than + letting the failure surface later as a confusing subprocess error. + """ + stripped = spec.strip() + if not stripped: + raise TargetError("empty target") + + if stripped.startswith(("import ", "from ")): + return Target(spec=stripped, argv=["-c", stripped], kind="code") + + parts = shlex.split(stripped) + head, rest = parts[0], parts[1:] + + if head == "-m": + if not rest: + raise TargetError("'-m' needs a module name, e.g. \"-m http.server\"") + return Target(spec=stripped, argv=["-m", *rest], kind="module") + + path = Path(head) + if path.is_file(): + return Target(spec=stripped, argv=[str(path), *rest], kind="script") + + found = shutil.which(head) + if found is not None and _is_python_script(Path(found)): + return Target(spec=stripped, argv=[found, *rest], kind="console-script") + + if found is not None: + raise TargetError( + f"{head!r} is on PATH but is not a Python script, so its imports " + f'cannot be measured. Point at the module instead, e.g. "-m {head}".' + ) + raise TargetError( + f"cannot resolve target {spec!r}: not an import statement, not '-m module', " + f"not a file, and not a console script on PATH" + ) + + +def _is_python_script(path: Path) -> bool: + """True if ``path`` is a text file whose shebang runs Python.""" + try: + with path.open("rb") as handle: + first = handle.readline(256) + except OSError: + return False + return first.startswith(b"#!") and b"python" in first diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..dcd88f5 --- /dev/null +++ b/tests/test_check.py @@ -0,0 +1,76 @@ +from lazybudget import check +from lazybudget.config import Budget, Config + + +def config(tmp_path, **kwargs): + return Config( + budgets=[Budget(target="import json", **kwargs)], + trials=2, + lock_path=tmp_path / "import.lock", + ) + + +def test_a_generous_budget_passes(tmp_path): + report = check.run(config(tmp_path, max_import_ms=10_000, max_modules=10_000)) + assert report.ok + + +def test_a_blown_time_budget_fails_with_the_overage(tmp_path): + report = check.run(config(tmp_path, max_import_ms=0.0001)) + assert not report.ok + assert "over the 0 ms budget by" in report.results[0].failures[0] + + +def test_a_blown_module_budget_fails(tmp_path): + report = check.run(config(tmp_path, max_modules=0)) + assert not report.ok + assert "over the budget of 0" in report.results[0].failures[0] + + +def test_update_writes_the_lock_and_passes(tmp_path): + conf = config(tmp_path, max_import_ms=10_000) + report = check.run(conf, update=True) + assert report.ok + assert report.lock_updated + assert conf.lock_path.is_file() + assert "json" in conf.lock_path.read_text() + + +def test_drift_fails_the_next_run(tmp_path): + conf = config(tmp_path, max_import_ms=10_000) + check.run(conf, update=True) + + from lazybudget import lock as lockfile + + lock = lockfile.read(conf.lock_path) + lock.targets["import json"].modules.append("a_module_that_went_away") + lockfile.write(conf.lock_path, lock) + + report = check.run(conf) + assert not report.ok + assert "no longer match import.lock" in report.results[0].failures[0] + assert report.results[0].drift.removed == ["a_module_that_went_away"] + + +def test_drift_can_be_ignored(tmp_path): + conf = config(tmp_path, max_import_ms=10_000) + check.run(conf, update=True) + + from lazybudget import lock as lockfile + + lock = lockfile.read(conf.lock_path) + lock.targets["import json"].modules.append("a_module_that_went_away") + lockfile.write(conf.lock_path, lock) + + assert check.run(conf, fail_on_drift=False).ok + + +def test_a_target_that_will_not_run_fails_loudly(tmp_path): + conf = Config( + budgets=[Budget(target="import definitely_not_a_module_9a8b7c")], + trials=1, + lock_path=tmp_path / "import.lock", + ) + report = check.run(conf) + assert not report.ok + assert "nothing was measured" in report.results[0].failures[0] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..3769de6 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,72 @@ +import json + +import pytest + +from lazybudget.cli import main + + +def test_version(capsys): + with pytest.raises(SystemExit) as exit_info: + main(["--version"]) + assert exit_info.value.code == 0 + assert "lazybudget" in capsys.readouterr().out + + +def test_profile_prints_a_table(capsys): + assert main(["profile", "import", "xml.etree.ElementTree", "-n", "2", "--min-ms", "0"]) == 0 + out = capsys.readouterr().out + assert "self ms" in out + assert "xml.etree.ElementTree" in out + + +def test_profile_json(capsys): + assert main(["profile", "import json", "-n", "2", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["target"] == "import json" + assert "json" in payload["modules"] + + +def test_profile_tree(capsys): + assert ( + main(["profile", "import xml.etree.ElementTree", "-n", "2", "--tree", "--min-ms", "0"]) == 0 + ) + assert "xml.etree.ElementTree" in capsys.readouterr().out + + +def test_profile_reports_a_broken_target(capsys): + code = main(["profile", "import definitely_not_a_module_9a8b7c", "-n", "1"]) + assert code == 1 + assert "ModuleNotFoundError" in capsys.readouterr().err + + +def test_profile_rejects_a_nonsense_target(capsys): + assert main(["profile", "definitely-not-a-command-9a8b7c"]) == 2 + assert "cannot resolve target" in capsys.readouterr().err + + +def test_check_without_configuration_explains_itself(tmp_path, capsys, monkeypatch): + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + monkeypatch.chdir(tmp_path) + assert main(["check"]) == 2 + assert "no budgets configured" in capsys.readouterr().err + + +def test_check_passes_and_fails(tmp_path, capsys, monkeypatch): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[tool.lazybudget]\ntarget = "import json"\nmax_import_ms = 10000\n') + monkeypatch.chdir(tmp_path) + assert main(["check", "-n", "2"]) == 0 + assert "ok" in capsys.readouterr().out + + pyproject.write_text('[tool.lazybudget]\ntarget = "import json"\nmax_import_ms = 0.0001\n') + assert main(["check", "-n", "2"]) == 1 + assert "fail" in capsys.readouterr().out + + +def test_check_json(tmp_path, capsys, monkeypatch): + (tmp_path / "pyproject.toml").write_text( + '[tool.lazybudget]\ntarget = "import json"\nmax_import_ms = 10000\n' + ) + monkeypatch.chdir(tmp_path) + assert main(["check", "-n", "2", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["ok"] is True diff --git a/tests/test_codemod.py b/tests/test_codemod.py new file mode 100644 index 0000000..c548b28 --- /dev/null +++ b/tests/test_codemod.py @@ -0,0 +1,98 @@ +from pathlib import Path + +import pytest + +from lazybudget.codemod import apply +from lazybudget.static import analyze_source + +PATH = Path("example.py") + +SOURCE = '''\ +"""Docstring.""" + +from __future__ import annotations + +import json +import zlib + + +def f(): + return json.dumps({}), zlib.crc32(b"") +''' + + +def edit(source: str, **kwargs): + return apply(analyze_source(source, PATH), source, **kwargs) + + +def test_lazy_keyword_prefixes_the_import(): + result = edit(SOURCE) + assert "lazy import json" in result.after + assert "lazy import zlib" in result.after + + +def test_lazy_keyword_leaves_everything_else_alone(): + result = edit(SOURCE) + assert result.after.count("\n") == SOURCE.count("\n") + assert result.after.startswith('"""Docstring."""') + + +def test_only_restricts_the_change(): + result = edit(SOURCE, only={"json"}) + assert "lazy import json" in result.after + assert "\nimport zlib" in result.after + + +def test_lazy_keyword_is_idempotent(): + once = edit(SOURCE).after + twice = edit(once).after + assert once == twice + + +def test_lazy_modules_goes_after_the_future_import(): + result = edit(SOURCE, style="lazy-modules") + lines = result.after.splitlines() + assert lines[2] == "from __future__ import annotations" + assert '__lazy_modules__ = {"json", "zlib"}' in lines + + +def test_lazy_modules_leaves_the_imports_untouched(): + result = edit(SOURCE, style="lazy-modules") + assert "\nimport json\n" in result.after + assert "lazy import" not in result.after + + +def test_lazy_modules_merges_into_an_existing_declaration(): + source = '__lazy_modules__ = {"zlib"}\nimport json\n\n\ndef f():\n return json\n' + result = apply(analyze_source(source, PATH), source, style="lazy-modules") + assert '__lazy_modules__ = {"json", "zlib"}' in result.after + assert result.after.count("__lazy_modules__") == 1 + + +def test_lazy_modules_wraps_when_the_line_would_be_too_long(): + source = "import json\nimport zlib\n\n\ndef f():\n return json, zlib\n" + result = apply(analyze_source(source, PATH), source, style="lazy-modules", line_length=20) + assert '__lazy_modules__ = {\n "json",\n "zlib",\n}' in result.after + + +def test_lazy_modules_skips_relative_imports(): + source = "from . import sibling\n\n\ndef f():\n return sibling\n" + result = apply(analyze_source(source, PATH), source, style="lazy-modules") + assert result.after == source + + +def test_no_candidates_means_no_change(): + source = "import json\n\nVALUE = json.dumps({})\n" + assert not edit(source).changed + + +def test_unknown_style_is_rejected(): + with pytest.raises(ValueError, match="unknown style"): + edit(SOURCE, style="nonsense") + + +def test_lazy_modules_declaration_stays_syntactically_valid_everywhere(): + import ast + + result = edit(SOURCE, style="lazy-modules") + ast.parse(result.after) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..dae5765 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,61 @@ +import pytest + +from lazybudget.config import ConfigError, load + + +def write(tmp_path, body): + path = tmp_path / "pyproject.toml" + path.write_text(body) + return path + + +def test_no_section_gives_no_budgets(tmp_path): + config = load(write(tmp_path, '[project]\nname = "x"\n')) + assert config.budgets == [] + + +def test_short_form(tmp_path): + path = write( + tmp_path, + '[tool.lazybudget]\ntarget = "import x"\nmax_import_ms = 120\nmax_modules = 40\n', + ) + config = load(path) + assert len(config.budgets) == 1 + assert config.budgets[0].target == "import x" + assert config.budgets[0].max_import_ms == 120 + assert config.budgets[0].max_modules == 40 + + +def test_long_form(tmp_path): + path = write( + tmp_path, + "[tool.lazybudget]\ntrials = 9\n\n" + '[[tool.lazybudget.budget]]\ntarget = "import x"\nmax_import_ms = 1\n\n' + '[[tool.lazybudget.budget]]\ntarget = "-m x.cli"\n', + ) + config = load(path) + assert config.trials == 9 + assert [b.target for b in config.budgets] == ["import x", "-m x.cli"] + assert config.budgets[1].max_import_ms is None + + +def test_lock_path_defaults_next_to_pyproject(tmp_path): + config = load(write(tmp_path, '[tool.lazybudget]\ntarget = "import x"\n')) + assert config.lock_path == tmp_path / "import.lock" + + +def test_lock_path_can_be_overridden(tmp_path): + path = write(tmp_path, '[tool.lazybudget]\ntarget = "import x"\nlock = "ci/imports.lock"\n') + assert load(path).lock_path == tmp_path / "ci" / "imports.lock" + + +def test_budget_without_a_target_is_an_error(tmp_path): + path = write(tmp_path, "[tool.lazybudget]\n[[tool.lazybudget.budget]]\nmax_import_ms = 1\n") + with pytest.raises(ConfigError, match="needs a 'target' string"): + load(path) + + +def test_budget_must_be_a_list_of_tables(tmp_path): + path = write(tmp_path, "[tool.lazybudget]\nbudget = 3\n") + with pytest.raises(ConfigError, match="list of tables"): + load(path) diff --git a/tests/test_importtime.py b/tests/test_importtime.py new file mode 100644 index 0000000..812eabd --- /dev/null +++ b/tests/test_importtime.py @@ -0,0 +1,44 @@ +from lazybudget.importtime import flatten, parse + +SAMPLE = """\ +import time: self [us] | cumulative | imported package +import time: 177 | 177 | _io +import time: 27 | 27 | marshal +import time: 572 | 962 | _frozen_importlib_external +import time: 31 | 31 | _codecs +import time: 219 | 250 | codecs +import time: 104 | 354 | encodings +""" + + +def test_parses_records(): + roots = parse(SAMPLE) + assert [r.name for r in roots] == ["_frozen_importlib_external", "encodings"] + + +def test_nests_by_indentation(): + roots = parse(SAMPLE) + external, encodings = roots + assert [c.name for c in external.children] == ["_io", "marshal"] + assert [c.name for c in encodings.children] == ["codecs"] + assert [c.name for c in encodings.children[0].children] == ["_codecs"] + + +def test_converts_to_milliseconds(): + node = flatten(parse(SAMPLE))["_frozen_importlib_external"] + assert node.self_ms == 0.572 + assert node.cumulative_ms == 0.962 + + +def test_ignores_lines_that_are_not_records(): + noisy = SAMPLE + "Traceback (most recent call last):\nValueError: nope\n" + assert len(flatten(parse(noisy))) == len(flatten(parse(SAMPLE))) + + +def test_empty_input_is_an_empty_forest(): + assert parse("") == [] + + +def test_walk_visits_every_descendant(): + encodings = parse(SAMPLE)[1] + assert sorted(n.name for n in encodings.walk()) == ["_codecs", "codecs", "encodings"] diff --git a/tests/test_lock.py b/tests/test_lock.py new file mode 100644 index 0000000..7bd55f2 --- /dev/null +++ b/tests/test_lock.py @@ -0,0 +1,38 @@ +import json + +import pytest + +from lazybudget import lock as lockfile + + +def test_missing_file_reads_as_empty(tmp_path): + assert lockfile.read(tmp_path / "nope.lock").targets == {} + + +def test_round_trip(tmp_path): + path = tmp_path / "import.lock" + original = lockfile.Lock(targets={"import a": lockfile.LockEntry(["a", "b"], 12.345, "3.13")}) + lockfile.write(path, original) + assert path.read_text().endswith("\n") + reloaded = lockfile.read(path) + assert reloaded.targets["import a"].modules == ["a", "b"] + assert reloaded.targets["import a"].import_ms == 12.3 + + +def test_unknown_version_is_an_error(tmp_path): + path = tmp_path / "import.lock" + path.write_text(json.dumps({"version": 99, "targets": {}})) + with pytest.raises(ValueError, match="unsupported lock version"): + lockfile.read(path) + + +def test_compare_reports_both_directions(): + locked = lockfile.LockEntry(["a", "b"], 1.0, "3.13") + drift = lockfile.compare("t", locked, ["b", "c"]) + assert drift.added == ["c"] + assert drift.removed == ["a"] + assert drift.changed + + +def test_compare_against_nothing_is_not_drift(): + assert not lockfile.compare("t", None, ["a"]).changed diff --git a/tests/test_measure.py b/tests/test_measure.py new file mode 100644 index 0000000..0142d9a --- /dev/null +++ b/tests/test_measure.py @@ -0,0 +1,45 @@ +import sys + +from lazybudget.measure import measure, strip_importtime + + +def test_measures_a_trivial_import(): + result = measure("import json", trials=3) + assert result.returncode == 0 + assert "json" in result.modules + assert result.import_ms > 0 + + +def test_subtracts_the_interpreter_baseline(): + nothing = measure("import sys", trials=3) + # `sys` is always already loaded, so a target that only imports it should + # come out at roughly zero rather than at the cost of interpreter startup. + assert nothing.import_ms < 5 + + +def test_a_heavier_import_costs_more_and_pulls_in_more(): + light = measure("import json", trials=3) + heavy = measure("import json, xml.etree.ElementTree, argparse", trials=3) + assert heavy.module_count > light.module_count + + +def test_attributed_is_sorted_by_self_time(): + result = measure("import xml.etree.ElementTree", trials=3) + times = [n.self_us for n in result.attributed()] + assert times == sorted(times, reverse=True) + + +def test_a_failing_target_reports_its_own_stderr(): + result = measure("import definitely_not_a_module_9a8b7c", trials=1) + assert result.returncode != 0 + assert "ModuleNotFoundError" in result.stderr + assert "import time:" not in result.stderr + + +def test_respects_an_explicit_interpreter(): + assert measure("import json", trials=1, python=sys.executable).returncode == 0 + + +def test_strip_importtime_keeps_everything_else(): + noisy = "import time: 177 | 177 | _io\nreal message\n" + assert strip_importtime(noisy) == "real message" diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..4435a18 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,162 @@ +"""End-to-end checks that need a real PEP 810 interpreter. + +Everything here is skipped unless a Python 3.15 is available, because there is +no honest way to fake ``sys.set_lazy_imports_filter``. +""" + +from __future__ import annotations + +import subprocess +import textwrap +from pathlib import Path + +import pytest + +from lazybudget import audit as audit_mod +from lazybudget import runtime +from lazybudget.audit import SAFE, UNSAFE, module_name, proposal +from lazybudget.static import analyze_file + +LAZY_PYTHON = runtime.find_lazy_python() +needs_315 = pytest.mark.skipif(LAZY_PYTHON is None, reason="needs a Python 3.15 interpreter") + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A package whose plugin registration only happens as an import side effect.""" + package = tmp_path / "svc" + package.mkdir() + (package / "registry.py").write_text("HANDLERS = {}\n") + (package / "plugins.py").write_text( + "from svc.registry import HANDLERS\n\nHANDLERS['greet'] = lambda: 'hello'\n" + ) + (package / "__init__.py").write_text( + textwrap.dedent( + """\ + import json + import xml.etree.ElementTree as ET + + import svc.plugins # noqa: F401 registers handlers on import + from svc.registry import HANDLERS + + + def dispatch(name): + return HANDLERS[name]() + + + def dump(obj): + return json.dumps(obj) + + + def parse(text): + return ET.fromstring(text) + """ + ) + ) + tests = tmp_path / "t" + tests.mkdir() + (tests / "test_svc.py").write_text( + "import svc\n\n\ndef test_dispatch():\n assert svc.dispatch('greet') == 'hello'\n" + ) + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + return tmp_path + + +def test_module_name_of_a_package_init(tmp_path): + package = tmp_path / "pkg" + package.mkdir() + (package / "__init__.py").write_text("") + (package / "thing.py").write_text("") + assert module_name(package / "__init__.py") == "pkg" + assert module_name(package / "thing.py") == "pkg.thing" + + +def test_module_name_of_a_loose_file(tmp_path): + (tmp_path / "script.py").write_text("") + assert module_name(tmp_path / "script.py") == "script" + + +def test_proposal_pairs_importer_with_imported(tmp_path): + path = tmp_path / "mod.py" + path.write_text("import json\n\n\ndef f():\n return json\n") + assert proposal([analyze_file(path)]) == ["mod|json"] + + +def test_supports_lazy_is_false_for_an_old_interpreter(): + assert not runtime.supports_lazy("/definitely/not/a/python") + + +@needs_315 +def test_audit_separates_safe_from_unsafe(project): + pytest_available = ( + subprocess.run( + [LAZY_PYTHON, "-c", "import pytest"], capture_output=True, check=False + ).returncode + == 0 + ) + if not pytest_available: + pytest.skip("the 3.15 interpreter has no pytest to run the safety check with") + + report = audit_mod.run( + [project / "svc"], + "import svc", + python=LAZY_PYTHON, + trials=3, + test_command=[LAZY_PYTHON, "-m", "pytest", "-q", str(project / "t")], + ) + by_module = {v.module: v for v in report.verdicts} + + assert by_module["json"].status == SAFE + assert by_module["json"].saving_ms > 0 + assert by_module["svc.plugins"].status == UNSAFE + assert "svc.plugins" not in report.accepted() + assert "json" in report.accepted() + + +@needs_315 +def test_reification_notices_a_module_that_loads_anyway(project): + # `svc.registry` is deferred in __init__, but __init__ also imports + # svc.plugins, which imports svc.registry. Deferring it buys nothing. + result = runtime.check_reification("import svc", ["svc|svc.registry"], python=LAZY_PYTHON) + assert result.returncode == 0 + assert result.reified == ["svc.registry"] + + +@needs_315 +def test_reification_confirms_a_real_deferral(project): + result = runtime.check_reification("import svc", ["svc|json"], python=LAZY_PYTHON) + assert result.deferred == ["json"] + assert result.reified == [] + + +@needs_315 +def test_safety_says_so_when_the_command_was_already_broken(project): + result = runtime.check_safety( + [LAZY_PYTHON, "-c", "raise SystemExit(3)"], ["svc|json"], python=LAZY_PYTHON + ) + assert not result.passed + assert "already fails" in result.output + + +@needs_315 +def test_applying_the_audit_keeps_the_program_working(project): + report = audit_mod.run([project / "svc"], "import svc", python=LAZY_PYTHON, trials=3) + accepted = report.accepted(min_saving_ms=0.5) + assert accepted + + from lazybudget.codemod import apply + + for analysis in report.analyses: + source = analysis.path.read_text() + edit = apply(analysis, source, only=accepted) + if edit.changed: + analysis.path.write_text(edit.after) + + check = subprocess.run( + [LAZY_PYTHON, "-c", "import svc; print(svc.dispatch('greet'))"], + capture_output=True, + text=True, + check=False, + ) + assert check.returncode == 0, check.stderr + assert check.stdout.strip() == "hello" diff --git a/tests/test_static.py b/tests/test_static.py new file mode 100644 index 0000000..2dadd91 --- /dev/null +++ b/tests/test_static.py @@ -0,0 +1,144 @@ +from pathlib import Path + +from lazybudget.static import ( + SKIP_EXPORTED, + SKIP_FUTURE, + SKIP_NESTED, + SKIP_STAR, + SKIP_TYPE_CHECKING, + analyze_source, +) + +PATH = Path("example.py") + + +def analyze(source: str, **kwargs): + return analyze_source(source, PATH, **kwargs) + + +def modules(analysis): + return [c.module for c in analysis.candidates] + + +def test_import_used_only_in_a_function_is_a_candidate(): + analysis = analyze("import json\n\n\ndef f():\n return json.dumps({})\n") + assert modules(analysis) == ["json"] + + +def test_import_used_at_module_scope_is_not(): + analysis = analyze("import json\n\nVALUE = json.dumps({})\n") + assert modules(analysis) == [] + assert "used at import time: json" in analysis.eager[0].reason + + +def test_decorator_counts_as_module_scope(): + analysis = analyze("import functools\n\n\n@functools.cache\ndef f():\n return 1\n") + assert modules(analysis) == [] + + +def test_default_argument_counts_as_module_scope(): + analysis = analyze("import os\n\n\ndef f(root=os.sep):\n return root\n") + assert modules(analysis) == [] + + +def test_class_body_counts_as_module_scope(): + analysis = analyze("import enum\n\n\nclass C(enum.Enum):\n A = 1\n") + assert modules(analysis) == [] + + +def test_annotations_count_without_the_future_import(): + analysis = analyze("import decimal\n\n\ndef f(x: decimal.Decimal) -> None:\n pass\n") + assert modules(analysis) == [] + + +def test_annotations_are_free_with_the_future_import(): + source = ( + "from __future__ import annotations\n" + "import decimal\n" + "\n" + "\n" + "def f(x: decimal.Decimal) -> None:\n" + " pass\n" + ) + assert modules(analyze(source)) == ["decimal"] + + +def test_annotations_are_free_when_told_to_assume_pep_649(): + source = "import decimal\n\n\ndef f(x: decimal.Decimal) -> None:\n pass\n" + assert modules(analyze(source, assume_lazy_annotations=True)) == ["decimal"] + + +def test_star_import_is_rejected(): + analysis = analyze("from os.path import *\n\n\ndef f():\n return join('a')\n") + assert [r.reason for r in analysis.rejected] == [SKIP_STAR] + + +def test_future_import_is_rejected(): + analysis = analyze("from __future__ import annotations\n") + assert SKIP_FUTURE in [r.reason for r in analysis.rejected] + + +def test_import_inside_a_try_block_is_rejected(): + source = "try:\n import tomllib\nexcept ImportError:\n tomllib = None\n" + assert SKIP_NESTED in [r.reason for r in analyze(source).rejected] + + +def test_type_checking_imports_are_left_alone(): + source = ( + "from typing import TYPE_CHECKING\n" + "\n" + "if TYPE_CHECKING:\n" + " import decimal\n" + "\n" + "\n" + "def f():\n" + " return 1\n" + ) + analysis = analyze(source) + assert SKIP_TYPE_CHECKING in [r.reason for r in analysis.rejected] + assert "decimal" not in modules(analysis) + + +def test_names_in_dunder_all_are_left_alone(): + source = '__all__ = ["dumps"]\nfrom json import dumps\n' + assert [r.reason for r in analyze(source).rejected] == [SKIP_EXPORTED] + + +def test_aliased_import_tracks_the_alias(): + source = "import numpy as np\n\n\ndef f():\n return np.array([])\n" + assert modules(analyze(source)) == ["numpy"] + + +def test_aliased_import_used_eagerly_is_not_a_candidate(): + assert modules(analyze("import numpy as np\n\nA = np.array([])\n")) == [] + + +def test_dotted_import_binds_the_root_name(): + source = "import xml.etree.ElementTree\n\nX = xml\n" + assert modules(analyze(source)) == [] + + +def test_relative_imports_are_flagged_as_relative(): + source = "from . import sibling\n\n\ndef f():\n return sibling\n" + analysis = analyze(source) + assert analysis.candidates[0].is_relative + assert analysis.lazy_modules == [] + + +def test_lazy_modules_are_sorted_and_unique(): + source = "import zlib\nimport json\n\n\ndef f():\n return json, zlib\n" + assert analyze(source).lazy_modules == ["json", "zlib"] + + +def test_source_that_is_already_lazy_still_parses_on_older_pythons(): + source = "lazy import json\nlazy from zlib import crc32\n\n\ndef f():\n return json, crc32\n" + analysis = analyze(source) + assert modules(analysis) == ["json", "zlib"] + assert [c.lineno for c in analysis.candidates] == [1, 2] + + +def test_a_real_syntax_error_is_still_raised(): + import pytest + + with pytest.raises(SyntaxError): + analyze("def (:\n") diff --git a/tests/test_targets.py b/tests/test_targets.py new file mode 100644 index 0000000..55881bc --- /dev/null +++ b/tests/test_targets.py @@ -0,0 +1,42 @@ +import pytest + +from lazybudget.targets import TargetError, resolve + + +def test_import_statement(): + target = resolve("import pandas") + assert target.kind == "code" + assert target.argv == ["-c", "import pandas"] + + +def test_from_import_statement(): + assert resolve("from a import b").argv == ["-c", "from a import b"] + + +def test_module(): + target = resolve("-m http.server --bind 127.0.0.1") + assert target.kind == "module" + assert target.argv == ["-m", "http.server", "--bind", "127.0.0.1"] + + +def test_module_without_a_name_is_rejected(): + with pytest.raises(TargetError, match="needs a module name"): + resolve("-m") + + +def test_script(tmp_path): + script = tmp_path / "run.py" + script.write_text("print(1)\n") + target = resolve(f"{script} --flag") + assert target.kind == "script" + assert target.argv == [str(script), "--flag"] + + +def test_unknown_target(): + with pytest.raises(TargetError, match="cannot resolve target"): + resolve("definitely-not-a-real-command-9a8b7c") + + +def test_empty_target(): + with pytest.raises(TargetError, match="empty target"): + resolve(" ") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f3914f3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,623 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", size = 222245, upload-time = "2026-08-06T13:46:55.253Z" }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", size = 222762, upload-time = "2026-08-06T13:46:57.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", size = 249498, upload-time = "2026-08-06T13:46:59.012Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", size = 251328, upload-time = "2026-08-06T13:47:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", size = 253194, upload-time = "2026-08-06T13:47:01.976Z" }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", size = 255106, upload-time = "2026-08-06T13:47:03.281Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", size = 250177, upload-time = "2026-08-06T13:47:04.713Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", size = 251234, upload-time = "2026-08-06T13:47:06.104Z" }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", size = 249237, upload-time = "2026-08-06T13:47:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", size = 253050, upload-time = "2026-08-06T13:47:08.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", size = 249508, upload-time = "2026-08-06T13:47:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", size = 250110, upload-time = "2026-08-06T13:47:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", size = 224294, upload-time = "2026-08-06T13:47:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", size = 224919, upload-time = "2026-08-06T13:47:14.17Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lazybudget" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.11" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-cov", specifier = ">=5" }, + { name = "ruff", specifier = ">=0.7" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From 4713accfe7c20f5dafa6ac451c65d8629c1ade79 Mon Sep 17 00:00:00 2001 From: Avi Seth Date: Mon, 24 Aug 2026 02:54:23 +0200 Subject: [PATCH 2/5] Address the review Correctness: - A verification run that crashes no longer reads as a clean report. The injected atexit handler never writes its file when the target dies, so every proposed module came back as "never imported, nothing to win". It now fails loudly with the captured stderr, the same way a failed eager measurement does. - `import a, b` is rejected instead of made a candidate. The statement can only be deferred as a unit, but only the first alias was ever verified, so accepting one module would have deferred the other unchecked. - The codemod sorts candidates by line and column. `import json; import zlib` puts two on one line, and inserting into the earlier one shifted the later one's column by five, dropping `lazy ` inside the following statement. - `audit` and `apply` exit non-zero when the target could not be measured. A typo in --target used to pass in CI. - `profile -m module` works. The epilog documented it; argparse rejected it. - Non-finite and negative budgets are rejected at load. `max_import_ms = nan` compares false against everything, so the check always passed. - `Edit.modules` no longer lists relative imports that the lazy-modules style never wrote. Robustness: - Timeouts on every child process. A target that starts a server or loops used to hang profile and audit forever, and `_bisect` runs the test command several times over. Both convert a hang into an ordinary failed run. - `trials=0` raises instead of an IndexError from the median. - The baseline runs with the same interpreter flags as the target, and the flags are part of its cache key. Passing -S otherwise subtracted site's startup cost from a target that never paid it. - `shlex` failures on an unbalanced quote become TargetError, as documented. - The lock file's parent directory is created, so `lock = "ci/imports.lock"` works on the first run. - An audit where every candidate comes back not-imported, while the target demonstrably imports some of them, now says so. That is what a namespace package looks like from here: the file's module name is computed by walking up through __init__.py files, the walk stops early, and the runtime filter never matches. Workflows: actions pinned to commit SHAs, checkout credentials not persisted, token permissions dropped to contents: read, and a release refuses to publish when the tag does not match the version in pyproject.toml. Two suggestions not taken. Padding `lazy ` out with spaces in the fallback parse would preserve column offsets and turn every top-level import into an IndentationError; the docstring was wrong, not the code, and now says so. Resolving namespace package names against sys.path is more machinery than the case warrants, so it warns instead. --- .github/workflows/ci.yml | 30 ++++++++++---- .github/workflows/release.yml | 24 ++++++++--- README.md | 12 +++--- src/lazybudget/audit.py | 44 +++++++++++++++++++- src/lazybudget/cli.py | 44 +++++++++++++++++--- src/lazybudget/codemod.py | 10 ++++- src/lazybudget/config.py | 20 ++++++--- src/lazybudget/lock.py | 1 + src/lazybudget/measure.py | 61 ++++++++++++++++++++++----- src/lazybudget/runtime.py | 78 +++++++++++++++++++++++++---------- src/lazybudget/static.py | 19 +++++++-- src/lazybudget/targets.py | 7 +++- tests/test_cli.py | 27 ++++++++++++ tests/test_codemod.py | 18 ++++++++ tests/test_config.py | 13 ++++++ tests/test_lock.py | 6 +++ tests/test_measure.py | 23 +++++++++++ tests/test_runtime.py | 26 +++++++++--- tests/test_static.py | 13 ++++++ tests/test_targets.py | 5 +++ 20 files changed, 407 insertions(+), 74 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4573307..e2e1e3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: +# Nothing here needs write access to anything. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -13,8 +17,12 @@ jobs: matrix: python: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + # 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 @@ -27,8 +35,10 @@ jobs: # of its own where a failure is unambiguous. runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - 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 @@ -36,8 +46,10 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - 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 . @@ -47,7 +59,9 @@ jobs: # lazybudget has to stay inside its own budget. runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - run: uv sync - run: uv run lazybudget check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32a64a7..6d89149 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,14 +4,28 @@ on: push: tags: ["v*"] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - 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@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: dist path: dist/ @@ -23,8 +37,8 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/README.md b/README.md index b1e041f..161598a 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ part. The hard part is knowing which of your imports are worth deferring, which a millisecond later anyway, and which ones quietly break something because they had a side effect you forgot about. lazybudget answers all three by running your code, not by reading it. -``` +```shell pip install lazybudget ``` ## Where is my startup time going -``` +```text $ lazybudget profile "import mypkg" import mypkg 418.3 ms of imports across 261 modules (wall 471.2 ms, median of 5) @@ -34,7 +34,7 @@ median-ed, because a single run of anything on a laptop is noise. ## Which imports should be lazy -``` +```text $ lazybudget audit src --target "import mypkg" --test "pytest -q" saves ms verdict module why @@ -71,7 +71,7 @@ Without one you get static analysis only, and it says so. ## Make the change -``` +```text $ lazybudget apply src --target "import mypkg" --min-saving-ms 5 --write updated src/mypkg/io.py: pandas updated src/mypkg/http.py: requests @@ -103,7 +103,7 @@ max_import_ms = 150 max_modules = 200 ``` -``` +```text $ lazybudget check ok import mypkg 118.4 ms, 173 modules ``` @@ -112,7 +112,7 @@ ok import mypkg 118.4 ms, 173 modules exactly which modules get imported. Commit it. After that, a dependency that starts pulling in something new fails the check with a diff: -``` +```text $ lazybudget check fail import mypkg 204.7 ms, 189 modules import time 204.7 ms is over the 150 ms budget by 54.7 ms diff --git a/src/lazybudget/audit.py b/src/lazybudget/audit.py index 64e3341..560bc7a 100644 --- a/src/lazybudget/audit.py +++ b/src/lazybudget/audit.py @@ -193,12 +193,30 @@ def run( ) reification = runtime.check_reification(resolved, entries, python=lazy_python) + if reification.returncode != 0: + # Without this the atexit hook never wrote its report, every module comes + # back as "never imported", and a run that crashed reads as a clean + # report with nothing to gain. + return AuditReport( + target=resolved, + eager=eager, + lazy=None, + verdicts=[], + analyses=analyses, + skipped_runtime=( + f"the target exited with status {reification.returncode} when the proposed " + "imports were deferred, so nothing could be verified:\n" + + reification.stderr.strip()[-1500:] + ), + ) + lazy_measurement = _measure_lazy(resolved, entries, python=lazy_python, trials=trials) culprits: set[str] = set() tests_run = False test_output = "" - deferred_entries = [e for e in entries if e.split("|", 1)[1] in set(reification.deferred)] + deferred = set(reification.deferred) + deferred_entries = [e for e in entries if e.split("|", 1)[1] in deferred] if test_command and deferred_entries: safety = runtime.check_safety(test_command, deferred_entries, python=lazy_python) tests_run = True @@ -220,6 +238,30 @@ def run( analyses=analyses, tests_run=tests_run, test_output=test_output, + skipped_runtime=_mismatch_warning(verdicts, eager), + ) + + +def _mismatch_warning(verdicts: list[Verdict], eager: Measurement) -> str: + """Warn when nothing matched but the modules clearly were imported. + + A proposal is matched on the name of the file's own module, worked out by + walking up through ``__init__.py`` files. Under a namespace package that walk + stops early, the name comes out short, and the runtime filter never fires -- + which looks exactly like "none of this runs on your startup path". Say so + rather than reporting a confident zero. + """ + if not verdicts or any(v.status != UNUSED for v in verdicts): + return "" + imported = set(eager.modules) + if not any(v.module in imported for v in verdicts): + return "" + return ( + "Every candidate came back as not-imported, yet the target does import some of them. " + "That usually means the files were analyzed under a different module name than the one " + "they are imported as, which happens with namespace packages (no __init__.py). Run the " + "audit from the directory those packages are imported relative to, or point --target at " + "the same package path." ) diff --git a/src/lazybudget/cli.py b/src/lazybudget/cli.py index 9fb9d8f..6b433f1 100644 --- a/src/lazybudget/cli.py +++ b/src/lazybudget/cli.py @@ -35,7 +35,16 @@ def build_parser() -> argparse.ArgumentParser: sub = parser.add_subparsers(dest="command", required=True) profile = sub.add_parser("profile", help="show where import time goes") - profile.add_argument("target", nargs="+", help='what to run, e.g. "import pandas" or -m pkg') + profile.add_argument( + "target", + nargs="*", + help='what to run, e.g. "import pandas" or a script path', + ) + profile.add_argument( + "-m", + "--module", + help="run a module, the way 'python -m' would", + ) profile.add_argument("-n", "--trials", type=int, default=5) profile.add_argument("--python", help="interpreter to measure with") profile.add_argument("--limit", type=int, default=25, help="rows to show (0 for all)") @@ -106,7 +115,14 @@ def _profile(args: argparse.Namespace) -> int: from lazybudget.measure import measure from lazybudget.report import style, table, tree - spec = " ".join(args.target) + spec = _target_spec(args) + if spec is None: + print( + "lazybudget: nothing to profile. Give it an import statement, a script, " + 'a console script, or -m module.\n lazybudget profile "import pandas"', + file=sys.stderr, + ) + return 2 result = measure(spec, trials=args.trials, python=args.python) if result.returncode != 0: print(f"lazybudget: target exited with status {result.returncode}", file=sys.stderr) @@ -160,6 +176,14 @@ def _profile(args: argparse.Namespace) -> int: return 0 +def _target_spec(args: argparse.Namespace) -> str | None: + """Combine the positional target with ``-m``, so both documented forms work.""" + rest = " ".join(args.target) + if args.module: + return f"-m {args.module} {rest}".strip() + return rest or None + + def _audit(args: argparse.Namespace) -> int: report = _run_audit(args) if args.json: @@ -184,8 +208,17 @@ def _audit(args: argparse.Namespace) -> int: indent=2, ) ) - return 0 + return _audit_status(report) _print_audit(report, args.min_saving_ms, show_all=args.show_all) + return _audit_status(report) + + +def _audit_status(report: AuditReport) -> int: + """Non-zero when nothing could be measured, so CI does not accept a bad target.""" + if report.eager.returncode != 0: + return 1 + if not report.verdicts and report.skipped_runtime: + return 1 return 0 @@ -260,10 +293,11 @@ def _apply(args: argparse.Namespace) -> int: from lazybudget.report import style report = _run_audit(args) + status = _audit_status(report) accepted = report.accepted(args.min_saving_ms) - if not accepted: + if status or not accepted: _print_audit(report, args.min_saving_ms) - return 0 + return status changed = 0 for analysis in report.analyses: diff --git a/src/lazybudget/codemod.py b/src/lazybudget/codemod.py index 161f4e9..23a23ca 100644 --- a/src/lazybudget/codemod.py +++ b/src/lazybudget/codemod.py @@ -63,18 +63,24 @@ def apply( after = _apply_dunder(source, chosen, line_length=line_length) else: raise ValueError(f"unknown style {style!r}; expected 'lazy' or 'lazy-modules'") + # The lazy-modules style cannot express a relative import, so those are not + # written. Reporting them as changed would name a module nothing touched. + written = [c for c in chosen if style == "lazy" or not c.is_relative] return Edit( path=analysis.path, before=source, after=after, style=style, - modules=sorted({c.module for c in chosen}), + modules=sorted({c.module for c in written}), ) def _apply_keyword(source: str, candidates: list[Candidate]) -> str: lines = source.splitlines(keepends=True) - for candidate in sorted(candidates, key=lambda c: c.lineno, reverse=True): + # Right to left as well as bottom to top: `import json; import zlib` puts two + # candidates on one line, and inserting into the earlier one first would shift + # the later one's column offset by five. + for candidate in sorted(candidates, key=lambda c: (c.lineno, c.col_offset), reverse=True): index = candidate.lineno - 1 line = lines[index] if line.lstrip().startswith("lazy "): diff --git a/src/lazybudget/config.py b/src/lazybudget/config.py index d71e33a..bb02cc5 100644 --- a/src/lazybudget/config.py +++ b/src/lazybudget/config.py @@ -24,6 +24,7 @@ from __future__ import annotations +import math import sys from dataclasses import dataclass, field from pathlib import Path @@ -113,9 +114,18 @@ def _budget(entry: dict[str, Any], pyproject: Path) -> Budget: if not isinstance(target, str) or not target.strip(): raise ConfigError(f"{pyproject}: every lazybudget budget needs a 'target' string") max_ms = entry.get("max_import_ms") + if max_ms is not None: + max_ms = float(max_ms) + # nan compares false against everything, which would turn the budget + # into a check that always passes. + if not math.isfinite(max_ms) or max_ms < 0: + raise ConfigError( + f"{pyproject}: max_import_ms for {target!r} must be a finite, " + f"non-negative number, not {max_ms!r}" + ) max_modules = entry.get("max_modules") - return Budget( - target=target, - max_import_ms=float(max_ms) if max_ms is not None else None, - max_modules=int(max_modules) if max_modules is not None else None, - ) + if max_modules is not None: + max_modules = int(max_modules) + if max_modules < 0: + raise ConfigError(f"{pyproject}: max_modules for {target!r} cannot be negative") + return Budget(target=target, max_import_ms=max_ms, max_modules=max_modules) diff --git a/src/lazybudget/lock.py b/src/lazybudget/lock.py index 95266ce..18bede2 100644 --- a/src/lazybudget/lock.py +++ b/src/lazybudget/lock.py @@ -79,6 +79,7 @@ def read(path: Path) -> Lock: def write(path: Path, lock: Lock) -> None: """Write the lock file with a trailing newline, so diffs stay clean.""" + path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(lock.to_json(), indent=2) + "\n", encoding="utf-8") diff --git a/src/lazybudget/measure.py b/src/lazybudget/measure.py index 022c82b..ae4c645 100644 --- a/src/lazybudget/measure.py +++ b/src/lazybudget/measure.py @@ -32,6 +32,11 @@ DEFAULT_TRIALS = 5 +#: Seconds before a target is assumed not to be coming back. A target that +#: starts a server or loops would otherwise hang every trial, and `audit` runs +#: several of them. +DEFAULT_TIMEOUT = 300.0 + @dataclass class Trial: @@ -74,14 +79,21 @@ def measure( python: str | None = None, env: dict[str, str] | None = None, extra_flags: list[str] | None = None, + timeout: float | None = DEFAULT_TIMEOUT, ) -> Measurement: """Profile ``target``'s import cost.""" + if trials < 1: + raise ValueError(f"trials must be at least 1, got {trials}") resolved = target if isinstance(target, Target) else resolve(target) interpreter = python or sys.executable - baseline_modules, baseline_wall = _baseline(interpreter, _env_key(env)) + flags = tuple(extra_flags or ()) + # The baseline has to run under the same flags as the target, or its + # startup cost is subtracted from a different interpreter configuration. + baseline_modules, baseline_wall = _baseline(interpreter, _env_key(env), flags) runs = [ - _run(interpreter, resolved.argv, env=env, extra_flags=extra_flags) for _ in range(trials) + _run(interpreter, resolved.argv, env=env, extra_flags=list(flags), timeout=timeout) + for _ in range(trials) ] failed = next((r for r in runs if r.returncode != 0), None) if failed is not None: @@ -134,11 +146,16 @@ def _env_key(env: dict[str, str] | None) -> frozenset[tuple[str, str]]: @lru_cache(maxsize=32) def _baseline( - interpreter: str, env_key: frozenset[tuple[str, str]] = frozenset() + interpreter: str, + env_key: frozenset[tuple[str, str]] = frozenset(), + flags: tuple[str, ...] = (), ) -> tuple[frozenset[str], float]: """Modules and wall time of a do-nothing interpreter, so we can subtract them.""" env = dict(env_key) or None - runs = [_run(interpreter, ["-c", ""], env=env) for _ in range(DEFAULT_TRIALS)] + runs = [ + _run(interpreter, ["-c", ""], env=env, extra_flags=list(flags)) + for _ in range(DEFAULT_TRIALS) + ] modules: set[str] = set() for run in runs: modules |= set(flatten(run.roots)) @@ -151,6 +168,7 @@ def _run( *, env: dict[str, str] | None = None, extra_flags: list[str] | None = None, + timeout: float | None = DEFAULT_TIMEOUT, ) -> Trial: command = [interpreter, "-X", "importtime", *(extra_flags or []), *argv] full_env = {**os.environ, **(env or {})} @@ -159,13 +177,28 @@ def _run( # random cwd's __pycache__ mid-measurement. full_env.setdefault("PYTHONDONTWRITEBYTECODE", "") start = time.perf_counter() - completed = subprocess.run( - command, - capture_output=True, - text=True, - env=full_env, - check=False, - ) + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + env=full_env, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired as expired: + return Trial( + wall_ms=(time.perf_counter() - start) * 1000, + roots=[], + returncode=124, + stdout="", + stderr=( + f"timed out after {timeout:g}s. lazybudget measures how long a program takes " + "to start, so a target that keeps running (a server, a REPL, a loop) never " + "finishes. Point it at the import instead, or raise the timeout.\n" + + strip_importtime(_text(expired.stderr)) + ), + ) wall_ms = (time.perf_counter() - start) * 1000 return Trial( wall_ms=wall_ms, @@ -178,6 +211,12 @@ def _run( ) +def _text(value: bytes | str | None) -> str: + if value is None: + return "" + return value.decode("utf-8", "replace") if isinstance(value, bytes) else value + + def strip_importtime(stderr: str) -> str: """Drop the ``-X importtime`` records, keeping whatever the program itself printed.""" return "\n".join( diff --git a/src/lazybudget/runtime.py b/src/lazybudget/runtime.py index 0a0620c..a7d188b 100644 --- a/src/lazybudget/runtime.py +++ b/src/lazybudget/runtime.py @@ -27,10 +27,13 @@ from lazybudget.targets import Target, resolve +#: Seconds before a verification run is abandoned. `_bisect` runs the test +#: command several times, so a hang here is multiplied. +DEFAULT_TIMEOUT = 600.0 + RESULT_ENV = "LAZYBUDGET_RESULT" LAZY_SET_ENV = "LAZYBUDGET_LAZY_MODULES" -#: Injected ahead of the target so the filter is installed before user code runs. #: Injected ahead of the target so the filter is installed before user code runs. #: Kept to builtin modules only -- anything this imports would show up in the very #: measurement it exists to take. @@ -156,6 +159,7 @@ def check_reification( *, python: str, env: dict[str, str] | None = None, + timeout: float | None = DEFAULT_TIMEOUT, ) -> Reification: """Run ``target`` with ``proposal`` made lazy and report what actually deferred. @@ -165,7 +169,7 @@ def check_reification( if not supports_lazy(python): raise LazyUnsupported(f"{python} does not support PEP 810 lazy imports") resolved = target if isinstance(target, Target) else resolve(target) - completed, payload = _run_with_lazy(python, resolved.argv, proposal, env=env) + completed, payload = _run_with_lazy(python, resolved.argv, proposal, env=env, timeout=timeout) if payload is None: names = sorted({entry.split("|", 1)[-1] for entry in proposal}) return Reification([], [], names, completed.returncode, completed.stderr) @@ -185,6 +189,7 @@ def check_safety( python: str, env: dict[str, str] | None = None, bisect: bool = True, + timeout: float | None = DEFAULT_TIMEOUT, ) -> SafetyResult: """Run ``command`` (usually a test suite) with ``proposal`` lazy. @@ -195,7 +200,7 @@ def check_safety( if not supports_lazy(python): raise LazyUnsupported(f"{python} does not support PEP 810 lazy imports") - baseline = _run_command(command, [], python=python, env=env) + baseline = _run_command(command, [], python=python, env=env, timeout=timeout) if baseline.returncode != 0: return SafetyResult( passed=False, @@ -207,7 +212,7 @@ def check_safety( ), ) - attempt = _run_command(command, list(proposal), python=python, env=env) + attempt = _run_command(command, list(proposal), python=python, env=env, timeout=timeout) if attempt.returncode == 0: return SafetyResult(passed=True, culprits=[], output="") if not bisect: @@ -215,7 +220,7 @@ def check_safety( passed=False, culprits=[], output=_tail(attempt.stdout + attempt.stderr) ) - culprits = _bisect(command, list(proposal), python=python, env=env) + culprits = _bisect(command, list(proposal), python=python, env=env, timeout=timeout) return SafetyResult( passed=False, culprits=culprits, @@ -229,6 +234,7 @@ def _bisect( *, python: str, env: dict[str, str] | None, + timeout: float | None = DEFAULT_TIMEOUT, ) -> list[str]: """Smallest set of proposal entries that still reproduces the failure.""" culprits: list[str] = [] @@ -239,9 +245,9 @@ def _bisect( break mid = len(remaining) // 2 left, right = remaining[:mid], remaining[mid:] - if _run_command(command, left, python=python, env=env).returncode != 0: + if _run_command(command, left, python=python, env=env, timeout=timeout).returncode != 0: remaining = left - elif _run_command(command, right, python=python, env=env).returncode != 0: + elif _run_command(command, right, python=python, env=env, timeout=timeout).returncode != 0: remaining = right else: # Neither half fails alone: the interaction needs both. Report the @@ -257,17 +263,22 @@ def _run_command( *, python: str, env: dict[str, str] | None, + timeout: float | None = DEFAULT_TIMEOUT, ) -> subprocess.CompletedProcess[str]: with tempfile.TemporaryDirectory(prefix="lazybudget-") as tmp: write_sitecustomize(Path(tmp)) full_env = child_env(tmp, modules, result_path=None, env=env) - return subprocess.run( - list(command), - capture_output=True, - text=True, - env=full_env, - check=False, - ) + try: + return subprocess.run( + list(command), + capture_output=True, + text=True, + env=full_env, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired as expired: + return _timed_out(list(command), expired, timeout) def _run_with_lazy( @@ -276,19 +287,24 @@ def _run_with_lazy( modules: Sequence[str], *, env: dict[str, str] | None, + timeout: float | None = DEFAULT_TIMEOUT, ) -> tuple[subprocess.CompletedProcess[str], dict[str, list[str]] | None]: with tempfile.TemporaryDirectory(prefix="lazybudget-") as tmp: tmpdir = Path(tmp) write_sitecustomize(tmpdir) result_path = tmpdir / "result.json" full_env = child_env(tmp, modules, result_path=result_path, env=env) - completed = subprocess.run( - [python, *argv], - capture_output=True, - text=True, - env=full_env, - check=False, - ) + try: + completed = subprocess.run( + [python, *argv], + capture_output=True, + text=True, + env=full_env, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired as expired: + return _timed_out([python, *argv], expired, timeout), None if not result_path.is_file(): return completed, None return completed, _parse_result(result_path.read_text(encoding="utf-8")) @@ -318,6 +334,26 @@ def child_env( return full +def _timed_out( + command: list[str], + expired: subprocess.TimeoutExpired, + timeout: float | None, +) -> subprocess.CompletedProcess[str]: + """Turn a hang into an ordinary failed run, so the caller can report it.""" + return subprocess.CompletedProcess( + args=command, + returncode=124, + stdout=_decode(expired.stdout), + stderr=f"timed out after {timeout:g}s\n" + _decode(expired.stderr), + ) + + +def _decode(value: bytes | str | None) -> str: + if value is None: + return "" + return value.decode("utf-8", "replace") if isinstance(value, bytes) else value + + def _tail(text: str, lines: int = 40) -> str: parts = text.strip().splitlines() return "\n".join(parts[-lines:]) diff --git a/src/lazybudget/static.py b/src/lazybudget/static.py index c42e986..38d5c22 100644 --- a/src/lazybudget/static.py +++ b/src/lazybudget/static.py @@ -37,6 +37,7 @@ SKIP_NESTED = "not at module top level" SKIP_TYPE_CHECKING = "already free: only imported under TYPE_CHECKING" SKIP_EXPORTED = "re-exported via __all__" +SKIP_MULTI = "several modules in one statement; split it first" @dataclass @@ -81,10 +82,14 @@ def parse_source(source: str, path: Path) -> ast.Module: Running ``apply`` twice, or auditing a codebase that has already adopted PEP 810, should not blow up just because the interpreter doing the analysis is - 3.13. When the parse fails on a ``lazy`` statement, the keyword is blanked - out with the same number of spaces and the file is parsed again -- so every - line number and column offset the analysis reports still points at the real - file. + 3.13. When the parse fails on a ``lazy`` statement, the keyword is removed + and the file is parsed again. + + Line numbers survive, which is what the rest of the analysis reads. Column + offsets on those particular lines shift left by five, and nothing reads + them: an import that is already lazy is skipped by the codemod anyway. + Padding the keyword out with spaces instead would preserve the columns and + turn every top-level import into an ``IndentationError``. """ try: return ast.parse(source, filename=str(path)) @@ -120,6 +125,12 @@ def analyze_source( reason = SKIP_TYPE_CHECKING if _under_type_checking(tree, node) else SKIP_NESTED analysis.rejected.append(Rejected(module, node.lineno, reason)) continue + if isinstance(node, ast.Import) and len(node.names) > 1: + # `import a, b` binds two modules but the statement can only be + # deferred as a unit. Verifying one and deferring both would be + # exactly the kind of unchecked change this tool exists to avoid. + analysis.rejected.append(Rejected(module, node.lineno, SKIP_MULTI)) + continue if isinstance(node, ast.ImportFrom): if node.module == "__future__": analysis.rejected.append(Rejected(module, node.lineno, SKIP_FUTURE)) diff --git a/src/lazybudget/targets.py b/src/lazybudget/targets.py index 4eb392a..be767eb 100644 --- a/src/lazybudget/targets.py +++ b/src/lazybudget/targets.py @@ -46,7 +46,12 @@ def resolve(spec: str) -> Target: if stripped.startswith(("import ", "from ")): return Target(spec=stripped, argv=["-c", stripped], kind="code") - parts = shlex.split(stripped) + try: + parts = shlex.split(stripped) + except ValueError as error: + raise TargetError(f"cannot parse target {spec!r}: {error}") from error + if not parts: + raise TargetError(f"cannot resolve target {spec!r}: nothing to run") head, rest = parts[0], parts[1:] if head == "-m": diff --git a/tests/test_cli.py b/tests/test_cli.py index 3769de6..ed6af80 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -70,3 +70,30 @@ def test_check_json(tmp_path, capsys, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["check", "-n", "2", "--json"]) == 0 assert json.loads(capsys.readouterr().out)["ok"] is True + + +def test_profile_module_target_is_measured(capsys): + assert main(["profile", "-m", "timeit", "-n", "2", "--min-ms", "0", "--", "-n1", "pass"]) == 0 + assert "self ms" in capsys.readouterr().out + + +def test_profile_with_no_target_explains_itself(capsys): + assert main(["profile"]) == 2 + assert "nothing to profile" in capsys.readouterr().err + + +def test_audit_fails_when_the_target_does_not_run(capsys, tmp_path): + (tmp_path / "m.py").write_text("import json\n\n\ndef f():\n return json\n") + code = main( + ["audit", str(tmp_path), "--target", "import definitely_not_a_module_9a8b7c", "-n", "1"] + ) + assert code == 1 + assert "exited with status" in capsys.readouterr().out + + +def test_apply_fails_when_the_target_does_not_run(tmp_path): + (tmp_path / "m.py").write_text("import json\n\n\ndef f():\n return json\n") + code = main( + ["apply", str(tmp_path), "--target", "import definitely_not_a_module_9a8b7c", "-n", "1"] + ) + assert code == 1 diff --git a/tests/test_codemod.py b/tests/test_codemod.py index c548b28..6c79208 100644 --- a/tests/test_codemod.py +++ b/tests/test_codemod.py @@ -96,3 +96,21 @@ def test_lazy_modules_declaration_stays_syntactically_valid_everywhere(): result = edit(SOURCE, style="lazy-modules") ast.parse(result.after) + + +def test_two_imports_on_one_line_are_not_corrupted(): + """Inserting into the first would shift the second's column offset.""" + source = "import json; import zlib\n\n\ndef f():\n return json, zlib\n" + analysis = analyze_source(source, PATH) + # They are rejected as candidates today; if that ever changes, the codemod + # must still produce something parseable. + result = apply(analysis, source, only={c.module for c in analysis.candidates}) + import ast + + ast.parse(result.after.replace("lazy ", "")) + + +def test_lazy_modules_does_not_claim_it_wrote_a_relative_import(): + source = "from . import sibling\nimport json\n\n\ndef f():\n return sibling, json\n" + result = apply(analyze_source(source, PATH), source, style="lazy-modules") + assert result.modules == ["json"] diff --git a/tests/test_config.py b/tests/test_config.py index dae5765..2f113b2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -59,3 +59,16 @@ def test_budget_must_be_a_list_of_tables(tmp_path): path = write(tmp_path, "[tool.lazybudget]\nbudget = 3\n") with pytest.raises(ConfigError, match="list of tables"): load(path) + + +@pytest.mark.parametrize("value", ["nan", "inf", "-1"]) +def test_a_budget_that_can_never_fail_is_rejected(tmp_path, value): + path = write(tmp_path, f'[tool.lazybudget]\ntarget = "import x"\nmax_import_ms = {value}\n') + with pytest.raises(ConfigError, match="finite, non-negative"): + load(path) + + +def test_a_negative_module_budget_is_rejected(tmp_path): + path = write(tmp_path, '[tool.lazybudget]\ntarget = "import x"\nmax_modules = -1\n') + with pytest.raises(ConfigError, match="cannot be negative"): + load(path) diff --git a/tests/test_lock.py b/tests/test_lock.py index 7bd55f2..eeb97df 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -36,3 +36,9 @@ def test_compare_reports_both_directions(): def test_compare_against_nothing_is_not_drift(): assert not lockfile.compare("t", None, ["a"]).changed + + +def test_writing_creates_the_configured_directory(tmp_path): + path = tmp_path / "ci" / "imports.lock" + lockfile.write(path, lockfile.Lock(targets={"t": lockfile.LockEntry([], 0.0, "3.13")})) + assert path.is_file() diff --git a/tests/test_measure.py b/tests/test_measure.py index 0142d9a..6e3942a 100644 --- a/tests/test_measure.py +++ b/tests/test_measure.py @@ -43,3 +43,26 @@ def test_respects_an_explicit_interpreter(): def test_strip_importtime_keeps_everything_else(): noisy = "import time: 177 | 177 | _io\nreal message\n" assert strip_importtime(noisy) == "real message" + + +def test_a_nonsense_trial_count_is_rejected(): + import pytest + + with pytest.raises(ValueError, match="trials must be at least 1"): + measure("import json", trials=0) + + +def test_a_target_that_never_finishes_times_out_instead_of_hanging(): + result = measure("import time; time.sleep(30)", trials=1, timeout=1.0) + assert result.returncode == 124 + assert "timed out" in result.stderr + + +def test_extra_flags_apply_to_the_baseline_too(): + # -S skips site. If the baseline ran without it, its startup cost would be + # subtracted from a target that never paid it. + plain = measure("import json", trials=3) + no_site = measure("import json", trials=3, extra_flags=["-S"]) + assert no_site.returncode == 0 + assert "site" not in no_site.modules + assert "site" not in plain.modules diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 4435a18..387ef4b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -86,15 +86,18 @@ def test_supports_lazy_is_false_for_an_old_interpreter(): assert not runtime.supports_lazy("/definitely/not/a/python") -@needs_315 -def test_audit_separates_safe_from_unsafe(project): - pytest_available = ( +def _has_pytest() -> bool: + return ( subprocess.run( [LAZY_PYTHON, "-c", "import pytest"], capture_output=True, check=False ).returncode == 0 ) - if not pytest_available: + + +@needs_315 +def test_audit_separates_safe_from_unsafe(project): + if not _has_pytest(): pytest.skip("the 3.15 interpreter has no pytest to run the safety check with") report = audit_mod.run( @@ -140,9 +143,22 @@ def test_safety_says_so_when_the_command_was_already_broken(project): @needs_315 def test_applying_the_audit_keeps_the_program_working(project): - report = audit_mod.run([project / "svc"], "import svc", python=LAZY_PYTHON, trials=3) + if not _has_pytest(): + pytest.skip("the 3.15 interpreter has no pytest to run the safety check with") + + # The test command is what makes this deterministic: without it, svc.plugins + # could be classified safe on a fast machine, applied, and take the plugin + # registration with it. + report = audit_mod.run( + [project / "svc"], + "import svc", + python=LAZY_PYTHON, + trials=3, + test_command=[LAZY_PYTHON, "-m", "pytest", "-q", str(project / "t")], + ) accepted = report.accepted(min_saving_ms=0.5) assert accepted + assert "svc.plugins" not in accepted from lazybudget.codemod import apply diff --git a/tests/test_static.py b/tests/test_static.py index 2dadd91..4334956 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -3,6 +3,7 @@ from lazybudget.static import ( SKIP_EXPORTED, SKIP_FUTURE, + SKIP_MULTI, SKIP_NESTED, SKIP_STAR, SKIP_TYPE_CHECKING, @@ -142,3 +143,15 @@ def test_a_real_syntax_error_is_still_raised(): with pytest.raises(SyntaxError): analyze("def (:\n") + + +def test_several_modules_in_one_import_are_rejected(): + source = "import json, zlib\n\n\ndef f():\n return json, zlib\n" + analysis = analyze(source) + assert modules(analysis) == [] + assert [r.reason for r in analysis.rejected] == [SKIP_MULTI] + + +def test_several_names_from_one_module_are_fine(): + source = "from json import dumps, loads\n\n\ndef f():\n return dumps, loads\n" + assert modules(analyze(source)) == ["json"] diff --git a/tests/test_targets.py b/tests/test_targets.py index 55881bc..006485c 100644 --- a/tests/test_targets.py +++ b/tests/test_targets.py @@ -40,3 +40,8 @@ def test_unknown_target(): def test_empty_target(): with pytest.raises(TargetError, match="empty target"): resolve(" ") + + +def test_an_unbalanced_quote_is_a_target_error_not_a_value_error(): + with pytest.raises(TargetError, match="cannot parse target"): + resolve('mypy "unterminated') From 16b19d2d30ec1e718f31714a62a57c8c9224ea08 Mon Sep 17 00:00:00 2001 From: Avi Seth Date: Mon, 24 Aug 2026 08:42:59 +0200 Subject: [PATCH 3/5] Two more from the review child_env read PYTHONPATH from os.environ and then merged the caller's env over it, so `env={"PYTHONPATH": "/project"}` was silently dropped and the child could not import the code being measured. It reads from the merged mapping now. The pop of RESULT_ENV next to an unconditional set was dead, and its comment described a branch that did not exist; both are gone. The column-offset invariant now actually holds rather than being documented away. `_parse` reports which lines it stripped a `lazy` keyword from, along with the indentation it started at, and only statements that followed the keyword on that line are shifted back. The lazy statement itself still starts where `lazy` starts, which is also what 3.15 reports natively, so the analysis gives the same answer on every version. This matters for `lazy import json; import zlib`: the codemod inserts at that column, and five characters out writes into the middle of the following statement. --- src/lazybudget/runtime.py | 19 ++++++++++------ src/lazybudget/static.py | 46 ++++++++++++++++++++++++++++++--------- tests/test_runtime.py | 16 ++++++++++++++ tests/test_static.py | 14 ++++++++++++ 4 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/lazybudget/runtime.py b/src/lazybudget/runtime.py index a7d188b..aa66353 100644 --- a/src/lazybudget/runtime.py +++ b/src/lazybudget/runtime.py @@ -321,16 +321,21 @@ def child_env( result_path: Path | None, env: dict[str, str] | None, ) -> dict[str, str]: - existing = os.environ.get("PYTHONPATH", "") + """Environment for a child process, with the sitecustomize directory prepended. + + The existing PYTHONPATH is read from the *merged* mapping, not from + ``os.environ``: a caller that passes ``env={"PYTHONPATH": "/project"}`` is + telling us where the code under measurement lives, and dropping it means the + child cannot import it. + """ full = {**os.environ, **(env or {})} + existing = full.get("PYTHONPATH", "") full["PYTHONPATH"] = os.pathsep.join([tmp, existing]) if existing else tmp full[LAZY_SET_ENV] = ",".join(modules) - if result_path is not None: - full[RESULT_ENV] = str(result_path) - else: - full.pop(RESULT_ENV, None) - # Without a result path the filter is inert, so switch it on explicitly. - full[RESULT_ENV] = str(Path(tmp) / "ignored.json") + # The injected sitecustomize only installs the filter when it has somewhere + # to write its report, so there is always a path here. Without a real one it + # goes to the scratch directory and is thrown away with it. + full[RESULT_ENV] = str(result_path if result_path is not None else Path(tmp) / "ignored.json") return full diff --git a/src/lazybudget/static.py b/src/lazybudget/static.py index 38d5c22..f1061bb 100644 --- a/src/lazybudget/static.py +++ b/src/lazybudget/static.py @@ -31,6 +31,9 @@ #: A ``lazy`` import as PEP 810 spells it. Only 3.15 can parse one. _LAZY_STATEMENT = re.compile(r"^(\s*)lazy (import|from)\b", re.MULTILINE) +#: Width of the keyword plus its space, for putting column offsets back. +LAZY_PREFIX_WIDTH = len("lazy ") + #: Reasons an import is disqualified, in the words we show the user. SKIP_STAR = "wildcard imports cannot be lazy" SKIP_FUTURE = "__future__ imports cannot be lazy" @@ -78,33 +81,56 @@ def lazy_modules(self) -> list[str]: def parse_source(source: str, path: Path) -> ast.Module: - """Parse ``source``, even if it already contains ``lazy import`` on an older Python. + """Parse ``source``, even if it already contains ``lazy import`` on an older Python.""" + return _parse(source, path)[0] + + +def _parse(source: str, path: Path) -> tuple[ast.Module, dict[int, int]]: + """Parse, and report which lines had a ``lazy`` keyword removed to get there. Running ``apply`` twice, or auditing a codebase that has already adopted PEP 810, should not blow up just because the interpreter doing the analysis is 3.13. When the parse fails on a ``lazy`` statement, the keyword is removed and the file is parsed again. - Line numbers survive, which is what the rest of the analysis reads. Column - offsets on those particular lines shift left by five, and nothing reads - them: an import that is already lazy is skipped by the codemod anyway. - Padding the keyword out with spaces instead would preserve the columns and - turn every top-level import into an ``IndentationError``. + Removing it rather than blanking it out with spaces is deliberate: five + spaces in front of a top-level import is an ``IndentationError``. + + The second return value maps each stripped line to the indentation the + keyword started at, which is what :func:`_real_column` needs to put the + column offsets on those lines back where the real file has them. """ try: - return ast.parse(source, filename=str(path)) + return ast.parse(source, filename=str(path)), {} except SyntaxError: if not _LAZY_STATEMENT.search(source): raise + stripped = { + source[: match.start()].count("\n") + 1: len(match.group(1)) + for match in _LAZY_STATEMENT.finditer(source) + } without_keyword = _LAZY_STATEMENT.sub(r"\1\2", source) - return ast.parse(without_keyword, filename=str(path)) + return ast.parse(without_keyword, filename=str(path)), stripped + + +def _real_column(node: ast.stmt, delazied: dict[int, int]) -> int: + """Where ``node`` starts in the file on disk, not in the de-lazied copy. + + Only statements that follow the keyword on the same line moved. The lazy + statement itself still begins where ``lazy`` begins, so it is left alone -- + which also keeps the answer identical on 3.15, where nothing is stripped. + """ + indent = delazied.get(node.lineno) + if indent is None or node.col_offset <= indent: + return node.col_offset + return node.col_offset + LAZY_PREFIX_WIDTH def analyze_source( source: str, path: Path, *, assume_lazy_annotations: bool = False ) -> FileAnalysis: """Analyze one module's source text.""" - tree = parse_source(source, path) + tree, delazied = _parse(source, path) lines = source.splitlines() analysis = FileAnalysis(path=path) @@ -156,7 +182,7 @@ def analyze_source( names=sorted(bound), lineno=node.lineno, end_lineno=node.end_lineno or node.lineno, - col_offset=node.col_offset, + col_offset=_real_column(node, delazied), is_relative=isinstance(node, ast.ImportFrom) and bool(node.level), source="\n".join(lines[node.lineno - 1 : (node.end_lineno or node.lineno)]), ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 387ef4b..2439d5b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -6,6 +6,7 @@ from __future__ import annotations +import os import subprocess import textwrap from pathlib import Path @@ -176,3 +177,18 @@ def test_applying_the_audit_keeps_the_program_working(project): ) assert check.returncode == 0, check.stderr assert check.stdout.strip() == "hello" + + +def test_child_env_keeps_a_caller_supplied_pythonpath(tmp_path): + """Without this the child cannot import the project being measured.""" + env = runtime.child_env( + str(tmp_path), ["a|b"], result_path=None, env={"PYTHONPATH": "/project"} + ) + parts = env["PYTHONPATH"].split(os.pathsep) + assert parts[0] == str(tmp_path) + assert "/project" in parts + + +def test_child_env_always_gives_the_filter_somewhere_to_report(tmp_path): + env = runtime.child_env(str(tmp_path), [], result_path=None, env=None) + assert env[runtime.RESULT_ENV].startswith(str(tmp_path)) diff --git a/tests/test_static.py b/tests/test_static.py index 4334956..2d05b63 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -155,3 +155,17 @@ def test_several_modules_in_one_import_are_rejected(): def test_several_names_from_one_module_are_fine(): source = "from json import dumps, loads\n\n\ndef f():\n return dumps, loads\n" assert modules(analyze(source)) == ["json"] + + +def test_column_offsets_point_at_the_real_file_after_a_lazy_statement(): + """The keyword is stripped in order to parse; the offsets must survive that. + + The lazy statement itself still begins where ``lazy`` begins, so it stays at + column 0. A statement that follows it on the same line moved by five, and + the codemod inserts at that column, so getting it wrong writes into the + middle of the following statement. + """ + source = "lazy import json; import zlib\n\n\ndef f():\n return json, zlib\n" + by_module = {c.module: c for c in analyze(source).candidates} + assert by_module["json"].col_offset == 0 + assert by_module["zlib"].col_offset == source.index("import zlib") From d2d264939dda5be2203d522989c98c4ee9ef9eaf Mon Sep 17 00:00:00 2001 From: Avi Seth Date: Mon, 24 Aug 2026 09:26:33 +0200 Subject: [PATCH 4/5] Second review round - Bisection recurses into both failing halves. A codebase with two unrelated import side effects has an unsafe entry in each half; following only the first left the other classified safe, and `apply` would then have deferred it. Two unit tests cover it, including the case where neither half fails alone. - A failed lazy profiling run no longer becomes savings. An empty module list from a crashed run is indistinguishable from a run that skipped everything, so `_attribute` was handing out positive numbers and `_verdict` was marking them safe. - The baseline honours the caller's timeout. `measure(timeout=1)` could still sit through five 300-second baseline runs before launching the target. - Config validation happens before conversion. `max_import_ms = "abc"` raised a bare ValueError instead of ConfigError, and `max_modules = -0.5` passed the negative check because int(-0.5) is 0. - The extra-flags baseline test actually distinguishes the regression now. It measured `import json` under -S and asserted `site` was absent, which was true either way. It measures `import site` and asserts the cost is attributed. - The budget job is pinned to one interpreter, and a drift failure says so when the lock was written on a different Python, since the standard library's own module set moves between releases. --- .github/workflows/ci.yml | 8 +++-- src/lazybudget/audit.py | 15 +++++++++ src/lazybudget/check.py | 20 +++++++++-- src/lazybudget/config.py | 32 +++++++++++++----- src/lazybudget/measure.py | 12 +++++-- src/lazybudget/runtime.py | 51 +++++++++++++++++----------- tests/test_check.py | 15 +++++++++ tests/test_config.py | 14 ++++++++ tests/test_measure.py | 19 +++++++---- tests/test_runtime.py | 71 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 213 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e1e3a..529317c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,12 +56,14 @@ jobs: - run: uv run mypy budget: - # lazybudget has to stay inside its own budget. + # lazybudget 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 - - run: uv run lazybudget check + - run: uv sync --python 3.12 + - run: uv run --python 3.12 lazybudget check diff --git a/src/lazybudget/audit.py b/src/lazybudget/audit.py index 560bc7a..26d7441 100644 --- a/src/lazybudget/audit.py +++ b/src/lazybudget/audit.py @@ -211,6 +211,21 @@ def run( ) lazy_measurement = _measure_lazy(resolved, entries, python=lazy_python, trials=trials) + if lazy_measurement.returncode != 0: + # An empty module list from a failed run looks exactly like a run that + # skipped everything, and _attribute would hand out savings for it. + return AuditReport( + target=resolved, + eager=eager, + lazy=None, + verdicts=[], + analyses=analyses, + skipped_runtime=( + f"profiling with the proposal in force exited with status " + f"{lazy_measurement.returncode}, so no saving could be measured:\n" + + lazy_measurement.stderr.strip()[-1500:] + ), + ) culprits: set[str] = set() tests_run = False diff --git a/src/lazybudget/check.py b/src/lazybudget/check.py index b7dded6..a2fe5fb 100644 --- a/src/lazybudget/check.py +++ b/src/lazybudget/check.py @@ -62,7 +62,13 @@ def run( else: result.failures.extend(_violations(budget, measurement)) if fail_on_drift and not update and drift.changed and budget.target in lock.targets: - result.failures.append(_drift_message(drift)) + result.failures.append( + _drift_message( + drift, + locked=lock.targets[budget.target].python, + measured=_python_tag(measurement.python), + ) + ) results.append(result) if update and measurement.returncode == 0: @@ -96,17 +102,25 @@ def _violations(budget: Budget, measurement: Measurement) -> list[str]: return failures -def _drift_message(drift: lockfile.Drift) -> str: +def _drift_message(drift: lockfile.Drift, *, locked: str = "", measured: str = "") -> str: bits = [] if drift.added: bits.append(f"{len(drift.added)} new: {_sample(drift.added)}") if drift.removed: bits.append(f"{len(drift.removed)} gone: {_sample(drift.removed)}") - return ( + message = ( "imported modules no longer match import.lock (" + "; ".join(bits) + "). Run 'lazybudget check --update' if this is intended." ) + if locked and measured and locked != measured: + # The standard library's own module set moves between releases, so this + # is usually the whole explanation. + message += ( + f" Note that the lock was recorded on Python {locked} and this ran on " + f"{measured}; pin one interpreter for the check if you have not." + ) + return message def _sample(names: list[str], limit: int = 5) -> str: diff --git a/src/lazybudget/config.py b/src/lazybudget/config.py index bb02cc5..36acbf5 100644 --- a/src/lazybudget/config.py +++ b/src/lazybudget/config.py @@ -113,19 +113,35 @@ def _budget(entry: dict[str, Any], pyproject: Path) -> Budget: target = entry.get("target") if not isinstance(target, str) or not target.strip(): raise ConfigError(f"{pyproject}: every lazybudget budget needs a 'target' string") - max_ms = entry.get("max_import_ms") - if max_ms is not None: - max_ms = float(max_ms) + raw_ms = entry.get("max_import_ms") + max_ms = None + if raw_ms is not None: + try: + max_ms = float(raw_ms) + except (TypeError, ValueError) as error: + raise ConfigError( + f"{pyproject}: max_import_ms for {target!r} must be a number, not {raw_ms!r}" + ) from error # nan compares false against everything, which would turn the budget # into a check that always passes. if not math.isfinite(max_ms) or max_ms < 0: raise ConfigError( f"{pyproject}: max_import_ms for {target!r} must be a finite, " - f"non-negative number, not {max_ms!r}" + f"non-negative number, not {raw_ms!r}" ) - max_modules = entry.get("max_modules") - if max_modules is not None: - max_modules = int(max_modules) - if max_modules < 0: + + raw_modules = entry.get("max_modules") + max_modules = None + if raw_modules is not None: + if not isinstance(raw_modules, int) or isinstance(raw_modules, bool): + # int(-0.5) is 0, so converting first would quietly accept a + # negative fractional budget as zero. + raise ConfigError( + f"{pyproject}: max_modules for {target!r} must be a whole number, " + f"not {raw_modules!r}" + ) + if raw_modules < 0: raise ConfigError(f"{pyproject}: max_modules for {target!r} cannot be negative") + max_modules = raw_modules + return Budget(target=target, max_import_ms=max_ms, max_modules=max_modules) diff --git a/src/lazybudget/measure.py b/src/lazybudget/measure.py index ae4c645..3422978 100644 --- a/src/lazybudget/measure.py +++ b/src/lazybudget/measure.py @@ -89,7 +89,7 @@ def measure( flags = tuple(extra_flags or ()) # The baseline has to run under the same flags as the target, or its # startup cost is subtracted from a different interpreter configuration. - baseline_modules, baseline_wall = _baseline(interpreter, _env_key(env), flags) + baseline_modules, baseline_wall = _baseline(interpreter, _env_key(env), flags, timeout) runs = [ _run(interpreter, resolved.argv, env=env, extra_flags=list(flags), timeout=timeout) @@ -149,11 +149,17 @@ def _baseline( interpreter: str, env_key: frozenset[tuple[str, str]] = frozenset(), flags: tuple[str, ...] = (), + timeout: float | None = DEFAULT_TIMEOUT, ) -> tuple[frozenset[str], float]: - """Modules and wall time of a do-nothing interpreter, so we can subtract them.""" + """Modules and wall time of a do-nothing interpreter, so we can subtract them. + + The caller's timeout applies here too. An environment where interpreter + startup itself blocks would otherwise sit through five default-length + timeouts before the target was even launched. + """ env = dict(env_key) or None runs = [ - _run(interpreter, ["-c", ""], env=env, extra_flags=list(flags)) + _run(interpreter, ["-c", ""], env=env, extra_flags=list(flags), timeout=timeout) for _ in range(DEFAULT_TRIALS) ] modules: set[str] = set() diff --git a/src/lazybudget/runtime.py b/src/lazybudget/runtime.py index aa66353..a4cc791 100644 --- a/src/lazybudget/runtime.py +++ b/src/lazybudget/runtime.py @@ -223,7 +223,7 @@ def check_safety( culprits = _bisect(command, list(proposal), python=python, env=env, timeout=timeout) return SafetyResult( passed=False, - culprits=culprits, + culprits=sorted({entry.split("|", 1)[-1] for entry in culprits}), output=_tail(attempt.stdout + attempt.stderr), ) @@ -236,25 +236,36 @@ def _bisect( env: dict[str, str] | None, timeout: float | None = DEFAULT_TIMEOUT, ) -> list[str]: - """Smallest set of proposal entries that still reproduces the failure.""" - culprits: list[str] = [] - remaining = list(entries) - while remaining: - if len(remaining) == 1: - culprits.append(remaining[0]) - break - mid = len(remaining) // 2 - left, right = remaining[:mid], remaining[mid:] - if _run_command(command, left, python=python, env=env, timeout=timeout).returncode != 0: - remaining = left - elif _run_command(command, right, python=python, env=env, timeout=timeout).returncode != 0: - remaining = right - else: - # Neither half fails alone: the interaction needs both. Report the - # whole remaining set rather than pretending we narrowed it. - culprits.extend(remaining) - break - return sorted({entry.split("|", 1)[-1] for entry in culprits}) + """Every proposal entry that reproduces the failure on its own. + + Both halves are recursed into, not just the first one that fails. A codebase + with two unrelated import side effects has an unsafe entry in each half, and + following only the left one would leave the right one classified safe and + then apply it. + """ + if len(entries) <= 1: + return list(entries) + + mid = len(entries) // 2 + left, right = entries[:mid], entries[mid:] + left_fails = ( + _run_command(command, left, python=python, env=env, timeout=timeout).returncode != 0 + ) + right_fails = ( + _run_command(command, right, python=python, env=env, timeout=timeout).returncode != 0 + ) + + if not left_fails and not right_fails: + # Neither half fails alone: the interaction needs both. Report the whole + # set rather than pretending we narrowed it. + return list(entries) + + found: list[str] = [] + if left_fails: + found += _bisect(command, left, python=python, env=env, timeout=timeout) + if right_fails: + found += _bisect(command, right, python=python, env=env, timeout=timeout) + return found def _run_command( diff --git a/tests/test_check.py b/tests/test_check.py index dcd88f5..8dadd33 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -74,3 +74,18 @@ def test_a_target_that_will_not_run_fails_loudly(tmp_path): report = check.run(conf) assert not report.ok assert "nothing was measured" in report.results[0].failures[0] + + +def test_drift_across_python_versions_says_so(tmp_path): + conf = config(tmp_path, max_import_ms=10_000) + check.run(conf, update=True) + + from lazybudget import lock as lockfile + + lock = lockfile.read(conf.lock_path) + lock.targets["import json"].python = "3.8" + lock.targets["import json"].modules.append("a_module_that_went_away") + lockfile.write(conf.lock_path, lock) + + failure = check.run(conf).results[0].failures[0] + assert "recorded on Python 3.8" in failure diff --git a/tests/test_config.py b/tests/test_config.py index 2f113b2..08f9237 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -72,3 +72,17 @@ def test_a_negative_module_budget_is_rejected(tmp_path): path = write(tmp_path, '[tool.lazybudget]\ntarget = "import x"\nmax_modules = -1\n') with pytest.raises(ConfigError, match="cannot be negative"): load(path) + + +def test_a_non_numeric_time_budget_is_a_config_error(tmp_path): + path = write(tmp_path, '[tool.lazybudget]\ntarget = "import x"\nmax_import_ms = "abc"\n') + with pytest.raises(ConfigError, match="must be a number"): + load(path) + + +@pytest.mark.parametrize("value", ["-0.5", "1.5", '"12"']) +def test_a_module_budget_that_is_not_a_whole_number_is_rejected(tmp_path, value): + """int(-0.5) is 0, so converting before validating would accept it.""" + path = write(tmp_path, f'[tool.lazybudget]\ntarget = "import x"\nmax_modules = {value}\n') + with pytest.raises(ConfigError, match="whole number"): + load(path) diff --git a/tests/test_measure.py b/tests/test_measure.py index 6e3942a..5af57a1 100644 --- a/tests/test_measure.py +++ b/tests/test_measure.py @@ -59,10 +59,15 @@ def test_a_target_that_never_finishes_times_out_instead_of_hanging(): def test_extra_flags_apply_to_the_baseline_too(): - # -S skips site. If the baseline ran without it, its startup cost would be - # subtracted from a target that never paid it. - plain = measure("import json", trials=3) - no_site = measure("import json", trials=3, extra_flags=["-S"]) - assert no_site.returncode == 0 - assert "site" not in no_site.modules - assert "site" not in plain.modules + """-S skips site, so a target that imports it explicitly is paying for it. + + If the baseline ran without -S it would contain `site` already, and the cost + the target really pays would be subtracted away to nothing. + """ + result = measure("import site", trials=3, extra_flags=["-S"]) + assert result.returncode == 0 + assert "site" in result.modules + + # Without -S the interpreter imports site during startup, so it belongs to + # the baseline and is correctly not attributed to the target. + assert "site" not in measure("import site", trials=3).modules diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 2439d5b..b82d4ee 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -192,3 +192,74 @@ def test_child_env_keeps_a_caller_supplied_pythonpath(tmp_path): def test_child_env_always_gives_the_filter_somewhere_to_report(tmp_path): env = runtime.child_env(str(tmp_path), [], result_path=None, env=None) assert env[runtime.RESULT_ENV].startswith(str(tmp_path)) + + +def test_bisect_finds_an_unsafe_entry_in_each_half(monkeypatch): + """Following only the first failing half leaves the other one classified safe.""" + entries = [f"mod|dep{i}" for i in range(8)] + bad = {"mod|dep1", "mod|dep6"} + + def fake_run(command, modules, *, python, env, timeout=None): + failed = bool(bad & set(modules)) + return subprocess.CompletedProcess(list(command), 1 if failed else 0, "", "") + + monkeypatch.setattr(runtime, "_run_command", fake_run) + found = runtime._bisect([], entries, python="python", env=None) + assert set(found) == bad + + +def test_bisect_keeps_the_whole_group_when_only_the_combination_fails(monkeypatch): + entries = ["mod|a", "mod|b", "mod|c", "mod|d"] + + def fake_run(command, modules, *, python, env, timeout=None): + both = {"mod|a", "mod|d"} <= set(modules) + return subprocess.CompletedProcess(list(command), 1 if both else 0, "", "") + + monkeypatch.setattr(runtime, "_run_command", fake_run) + assert runtime._bisect([], entries, python="python", env=None) == entries + + +def test_safety_reports_module_names_for_every_culprit(monkeypatch): + monkeypatch.setattr(runtime, "supports_lazy", lambda _p: True) + calls = {"n": 0} + + def fake_run(command, modules, *, python, env, timeout=None): + calls["n"] += 1 + if not modules: + return subprocess.CompletedProcess(list(command), 0, "", "") + failed = bool({"pkg|first", "pkg|second"} & set(modules)) + return subprocess.CompletedProcess(list(command), 1 if failed else 0, "", "boom") + + monkeypatch.setattr(runtime, "_run_command", fake_run) + result = runtime.check_safety( + ["pytest"], ["pkg|first", "pkg|quiet", "pkg|second", "pkg|other"], python="python" + ) + assert not result.passed + assert result.culprits == ["first", "second"] + + +@needs_315 +def test_a_failed_lazy_profile_is_not_turned_into_savings(project, monkeypatch): + """An empty module list from a crashed run looks just like one that skipped everything.""" + from lazybudget.measure import Measurement + from lazybudget.targets import resolve + + def broken(target, entries, *, python, trials): + return Measurement( + target=resolve("import svc"), + python=python, + trials=trials, + import_ms=0.0, + wall_ms=0.0, + modules=[], + roots=[], + baseline_modules=frozenset(), + returncode=7, + stderr="the profiling run fell over", + ) + + monkeypatch.setattr(audit_mod, "_measure_lazy", broken) + report = audit_mod.run([project / "svc"], "import svc", python=LAZY_PYTHON, trials=2) + assert report.verdicts == [] + assert report.accepted() == set() + assert "exited with status 7" in report.skipped_runtime From e517bf56895f2560f1865bef0ab8dcd575a3f08e Mon Sep 17 00:00:00 2001 From: Avi Seth Date: Mon, 24 Aug 2026 09:37:13 +0200 Subject: [PATCH 5/5] Rename lazybudget to importcost PyPI rejects `lazybudget`: `lazy-budget` already exists, and project creation normalizes away hyphens and underscores, so the two are the same name as far as PyPI is concerned. The per-name JSON API is exact-match and returns 404 for `lazybudget`, which is what misled the original check. `importcost` was verified against the full 877k-project index under the same normalization PyPI applies. It also reads better: the tool's pitch is finding out what imports cost, and the lazy-import machinery is how it fixes what it finds. Package, CLI, config section (`[tool.importcost]`), pytest plugin, and the IMPORTCOST_* environment variables all move together. --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 2 +- README.md | 40 +++++++-------- import.lock | 4 +- pyproject.toml | 22 ++++---- src/{lazybudget => importcost}/__init__.py | 8 +-- src/{lazybudget => importcost}/__main__.py | 2 +- src/{lazybudget => importcost}/audit.py | 12 ++--- src/{lazybudget => importcost}/check.py | 8 +-- src/{lazybudget => importcost}/cli.py | 50 +++++++++---------- src/{lazybudget => importcost}/codemod.py | 2 +- src/{lazybudget => importcost}/config.py | 20 ++++---- src/{lazybudget => importcost}/importtime.py | 0 src/{lazybudget => importcost}/lock.py | 2 +- src/{lazybudget => importcost}/measure.py | 6 +-- src/{lazybudget => importcost}/py.typed | 0 .../pytest_plugin.py | 2 +- src/{lazybudget => importcost}/report.py | 2 +- src/{lazybudget => importcost}/runtime.py | 18 +++---- src/{lazybudget => importcost}/static.py | 2 +- src/{lazybudget => importcost}/targets.py | 8 +-- tests/test_check.py | 10 ++-- tests/test_cli.py | 10 ++-- tests/test_codemod.py | 4 +- tests/test_config.py | 26 +++++----- tests/test_importtime.py | 2 +- tests/test_lock.py | 2 +- tests/test_measure.py | 2 +- tests/test_runtime.py | 14 +++--- tests/test_static.py | 2 +- tests/test_targets.py | 2 +- uv.lock | 2 +- 32 files changed, 145 insertions(+), 145 deletions(-) rename src/{lazybudget => importcost}/__init__.py (73%) rename src/{lazybudget => importcost}/__main__.py (74%) rename src/{lazybudget => importcost}/audit.py (97%) rename src/{lazybudget => importcost}/check.py (95%) rename src/{lazybudget => importcost}/cli.py (91%) rename src/{lazybudget => importcost}/codemod.py (98%) rename src/{lazybudget => importcost}/config.py (90%) rename src/{lazybudget => importcost}/importtime.py (100%) rename src/{lazybudget => importcost}/lock.py (97%) rename src/{lazybudget => importcost}/measure.py (97%) rename src/{lazybudget => importcost}/py.typed (100%) rename src/{lazybudget => importcost}/pytest_plugin.py (97%) rename src/{lazybudget => importcost}/report.py (98%) rename src/{lazybudget => importcost}/runtime.py (96%) rename src/{lazybudget => importcost}/static.py (99%) rename src/{lazybudget => importcost}/targets.py (91%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 529317c..0e682ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - run: uv run mypy budget: - # lazybudget has to stay inside its own budget. Pinned to one interpreter, + # 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 @@ -66,4 +66,4 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - run: uv sync --python 3.12 - - run: uv run --python 3.12 lazybudget check + - run: uv run --python 3.12 importcost check diff --git a/CHANGELOG.md b/CHANGELOG.md index a718904..05e6e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,6 @@ First release. 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.lazybudget]` budgets and diffs the imported module set against a +- `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. diff --git a/README.md b/README.md index 161598a..7f4d0f6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# lazybudget +# 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. @@ -6,16 +6,16 @@ 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. lazybudget answers all three by running your code, not by reading it. +effect you forgot about. importcost answers all three by running your code, not by reading it. ```shell -pip install lazybudget +pip install importcost ``` ## Where is my startup time going ```text -$ lazybudget profile "import mypkg" +$ 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 @@ -35,7 +35,7 @@ median-ed, because a single run of anything on a laptop is noise. ## Which imports should be lazy ```text -$ lazybudget audit src --target "import mypkg" --test "pytest -q" +$ importcost audit src --target "import mypkg" --test "pytest -q" saves ms verdict module why 181.9 safe pandas @@ -63,7 +63,7 @@ Here's how each verdict is reached: `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, lazybudget bisects to find which modules are responsible rather than making + 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`. @@ -72,7 +72,7 @@ Without one you get static analysis only, and it says so. ## Make the change ```text -$ lazybudget apply src --target "import mypkg" --min-saving-ms 5 --write +$ importcost apply src --target "import mypkg" --min-saving-ms 5 --write updated src/mypkg/io.py: pandas updated src/mypkg/http.py: requests ``` @@ -97,27 +97,27 @@ an at-import metadata fetch, or a new logging integration that costs 50 ms on lo that shows up in code review. ```toml -[tool.lazybudget] +[tool.importcost] target = "import mypkg" max_import_ms = 150 max_modules = 200 ``` ```text -$ lazybudget check +$ importcost check ok import mypkg 118.4 ms, 173 modules ``` -`lazybudget check --update` also writes an `import.lock` next to your pyproject.toml recording +`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 -$ lazybudget check +$ 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 'lazybudget check --update' if this is intended. + cryptography.hazmat, ... +13 more). Run 'importcost check --update' if this is intended. + cryptography, cryptography.fernet, cryptography.hazmat ``` @@ -127,14 +127,14 @@ which is why that's the part that gets pinned. Several entry points with different budgets: ```toml -[tool.lazybudget] +[tool.importcost] trials = 7 -[[tool.lazybudget.budget]] +[[tool.importcost.budget]] target = "import mypkg" max_import_ms = 150 -[[tool.lazybudget.budget]] +[[tool.importcost.budget]] target = "-m mypkg.cli" max_import_ms = 400 ``` @@ -142,8 +142,8 @@ max_import_ms = 400 In GitHub Actions: ```yaml -- run: pip install lazybudget -- run: lazybudget check +- run: pip install importcost +- run: importcost check ``` Or as a normal test, if you'd rather keep it with everything else: @@ -157,7 +157,7 @@ def test_import_stays_cheap(import_budget): [`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. lazybudget +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. @@ -170,7 +170,7 @@ leave the rest to you. 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 lazybudget. +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 @@ -191,7 +191,7 @@ Annotations count as import-time uses unless the file has `from __future__ impor On 3.14+ with PEP 649 that's stricter than it needs to be; pass the flag if it's costing you candidates. -lazybudget has no runtime dependencies on 3.11+ and enforces its own import budget in CI. A +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. diff --git a/import.lock b/import.lock index fd631bf..c7b4937 100644 --- a/import.lock +++ b/import.lock @@ -1,10 +1,10 @@ { "version": 1, "targets": { - "import lazybudget": { + "import importcost": { "modules": [ "__future__", - "lazybudget" + "importcost" ], "import_ms": 0.2, "python": "3.12" diff --git a/pyproject.toml b/pyproject.toml index fe02f53..86043df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "lazybudget" +name = "importcost" version = "0.1.0" description = "Measure what your imports actually cost, make the safe ones lazy (PEP 810), and stop regressions in CI." readme = "README.md" @@ -40,22 +40,22 @@ classifiers = [ dependencies = ["tomli>=2.0; python_version<'3.11'"] [project.urls] -Homepage = "https://github.com/aviseth/lazybudget" -Repository = "https://github.com/aviseth/lazybudget" -Changelog = "https://github.com/aviseth/lazybudget/blob/main/CHANGELOG.md" -Issues = "https://github.com/aviseth/lazybudget/issues" +Homepage = "https://github.com/aviseth/importcost" +Repository = "https://github.com/aviseth/importcost" +Changelog = "https://github.com/aviseth/importcost/blob/main/CHANGELOG.md" +Issues = "https://github.com/aviseth/importcost/issues" [project.scripts] -lazybudget = "lazybudget.cli:main" +importcost = "importcost.cli:main" [project.entry-points.pytest11] -lazybudget = "lazybudget.pytest_plugin" +importcost = "importcost.pytest_plugin" [dependency-groups] dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.7", "mypy>=1.11"] [tool.hatch.build.targets.wheel] -packages = ["src/lazybudget"] +packages = ["src/importcost"] [tool.ruff] line-length = 100 @@ -73,8 +73,8 @@ files = ["src"] testpaths = ["tests"] addopts = "-q" -[tool.lazybudget] -# lazybudget guards itself: importing the package must stay cheap. -target = "import lazybudget" +[tool.importcost] +# importcost guards itself: importing the package must stay cheap. +target = "import importcost" max_import_ms = 10 max_modules = 6 diff --git a/src/lazybudget/__init__.py b/src/importcost/__init__.py similarity index 73% rename from src/lazybudget/__init__.py rename to src/importcost/__init__.py index 01e38cf..6257866 100644 --- a/src/lazybudget/__init__.py +++ b/src/importcost/__init__.py @@ -1,6 +1,6 @@ """Measure what your imports cost, defer the safe ones, and keep them from creeping back. -The public API is intentionally tiny; everything else lives behind the ``lazybudget`` +The public API is intentionally tiny; everything else lives behind the ``importcost`` command-line interface. Importing this package must stay cheap -- it is the tool's own first test case -- so nothing here imports a submodule at module scope. """ @@ -13,10 +13,10 @@ def measure(target: str, *, trials: int = 3, python: str | None = None): # type: ignore[no-untyped-def] - """Measure the import cost of ``target``. See :mod:`lazybudget.measure`. + """Measure the import cost of ``target``. See :mod:`importcost.measure`. - Imported lazily so that ``import lazybudget`` stays under its own budget. + Imported lazily so that ``import importcost`` stays under its own budget. """ - from lazybudget.measure import measure as _measure + from importcost.measure import measure as _measure return _measure(target, trials=trials, python=python) diff --git a/src/lazybudget/__main__.py b/src/importcost/__main__.py similarity index 74% rename from src/lazybudget/__main__.py rename to src/importcost/__main__.py index 0a4267b..b996695 100644 --- a/src/lazybudget/__main__.py +++ b/src/importcost/__main__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from lazybudget.cli import main +from importcost.cli import main if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/lazybudget/audit.py b/src/importcost/audit.py similarity index 97% rename from src/lazybudget/audit.py rename to src/importcost/audit.py index 26d7441..ee82e55 100644 --- a/src/lazybudget/audit.py +++ b/src/importcost/audit.py @@ -13,11 +13,11 @@ from dataclasses import dataclass, field from pathlib import Path -from lazybudget import runtime -from lazybudget.importtime import ImportNode, flatten -from lazybudget.measure import DEFAULT_TRIALS, Measurement, measure -from lazybudget.static import FileAnalysis, analyze_file -from lazybudget.targets import Target, resolve +from importcost import runtime +from importcost.importtime import ImportNode, flatten +from importcost.measure import DEFAULT_TRIALS, Measurement, measure +from importcost.static import FileAnalysis, analyze_file +from importcost.targets import Target, resolve SAFE = "safe" NO_WIN = "no-win" @@ -322,7 +322,7 @@ def _measure_lazy( trials: int, ) -> Measurement: """Measure the target again, this time with the proposal in force.""" - with tempfile.TemporaryDirectory(prefix="lazybudget-") as tmp: + with tempfile.TemporaryDirectory(prefix="importcost-") as tmp: runtime.write_sitecustomize(Path(tmp)) env = runtime.child_env(tmp, entries, result_path=Path(tmp) / "result.json", env=None) return measure(target, trials=trials, python=python, env=env) diff --git a/src/lazybudget/check.py b/src/importcost/check.py similarity index 95% rename from src/lazybudget/check.py rename to src/importcost/check.py index a2fe5fb..81c0bb7 100644 --- a/src/lazybudget/check.py +++ b/src/importcost/check.py @@ -5,9 +5,9 @@ from dataclasses import dataclass, field from pathlib import Path -from lazybudget import lock as lockfile -from lazybudget.config import Budget, Config -from lazybudget.measure import Measurement, measure +from importcost import lock as lockfile +from importcost.config import Budget, Config +from importcost.measure import Measurement, measure @dataclass @@ -111,7 +111,7 @@ def _drift_message(drift: lockfile.Drift, *, locked: str = "", measured: str = " message = ( "imported modules no longer match import.lock (" + "; ".join(bits) - + "). Run 'lazybudget check --update' if this is intended." + + "). Run 'importcost check --update' if this is intended." ) if locked and measured and locked != measured: # The standard library's own module set moves between releases, so this diff --git a/src/lazybudget/cli.py b/src/importcost/cli.py similarity index 91% rename from src/lazybudget/cli.py rename to src/importcost/cli.py index 6b433f1..e2c5639 100644 --- a/src/lazybudget/cli.py +++ b/src/importcost/cli.py @@ -9,29 +9,29 @@ from pathlib import Path from typing import TYPE_CHECKING -from lazybudget import __version__ +from importcost import __version__ if TYPE_CHECKING: - from lazybudget.audit import AuditReport + from importcost.audit import AuditReport EPILOG = """\ examples: - lazybudget profile "import pandas" - lazybudget profile -m mypkg.cli --tree - lazybudget audit src --target "import mypkg" --test "pytest -x -q" - lazybudget apply src --target "import mypkg" --min-saving-ms 5 --write - lazybudget check --update + importcost profile "import pandas" + importcost profile -m mypkg.cli --tree + importcost audit src --target "import mypkg" --test "pytest -x -q" + importcost apply src --target "import mypkg" --min-saving-ms 5 --write + importcost check --update """ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="lazybudget", + prog="importcost", description="Measure what your imports cost and keep them honest.", epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument("--version", action="version", version=f"lazybudget {__version__}") + parser.add_argument("--version", action="version", version=f"importcost {__version__}") sub = parser.add_subparsers(dest="command", required=True) profile = sub.add_parser("profile", help="show where import time goes") @@ -106,26 +106,26 @@ def main(argv: list[str] | None = None) -> int: except KeyboardInterrupt: # pragma: no cover return 130 except (ValueError, OSError) as error: - print(f"lazybudget: {error}", file=sys.stderr) + print(f"importcost: {error}", file=sys.stderr) return 2 return 2 def _profile(args: argparse.Namespace) -> int: - from lazybudget.measure import measure - from lazybudget.report import style, table, tree + from importcost.measure import measure + from importcost.report import style, table, tree spec = _target_spec(args) if spec is None: print( - "lazybudget: nothing to profile. Give it an import statement, a script, " - 'a console script, or -m module.\n lazybudget profile "import pandas"', + "importcost: nothing to profile. Give it an import statement, a script, " + 'a console script, or -m module.\n importcost profile "import pandas"', file=sys.stderr, ) return 2 result = measure(spec, trials=args.trials, python=args.python) if result.returncode != 0: - print(f"lazybudget: target exited with status {result.returncode}", file=sys.stderr) + print(f"importcost: target exited with status {result.returncode}", file=sys.stderr) print(result.stderr.strip()[-2000:], file=sys.stderr) return 1 @@ -223,7 +223,7 @@ def _audit_status(report: AuditReport) -> int: def _run_audit(args: argparse.Namespace) -> AuditReport: - from lazybudget import audit as audit_mod + from importcost import audit as audit_mod paths = [Path(p) for p in args.paths] or [Path()] return audit_mod.run( @@ -236,8 +236,8 @@ def _run_audit(args: argparse.Namespace) -> AuditReport: def _print_audit(report: AuditReport, min_saving_ms: float, *, show_all: bool = False) -> None: - from lazybudget.audit import NO_WIN, SAFE, UNSAFE, UNUSED - from lazybudget.report import style, table + from importcost.audit import NO_WIN, SAFE, UNSAFE, UNUSED + from importcost.report import style, table if report.skipped_runtime: print(style(report.skipped_runtime, "yellow")) @@ -289,8 +289,8 @@ def _print_audit(report: AuditReport, min_saving_ms: float, *, show_all: bool = def _apply(args: argparse.Namespace) -> int: - from lazybudget.codemod import apply as apply_edit - from lazybudget.report import style + from importcost.codemod import apply as apply_edit + from importcost.report import style report = _run_audit(args) status = _audit_status(report) @@ -348,16 +348,16 @@ def _diff(before: str, after: str, path: Path) -> str: def _check(args: argparse.Namespace) -> int: - from lazybudget import check as check_mod - from lazybudget.config import load - from lazybudget.report import style + from importcost import check as check_mod + from importcost.config import load + from importcost.report import style config = load(args.config) if not config.budgets: where = config.source or "pyproject.toml" print( - f"lazybudget: no budgets configured. Add a [tool.lazybudget] section to {where}:\n\n" - " [tool.lazybudget]\n" + f"importcost: no budgets configured. Add a [tool.importcost] section to {where}:\n\n" + " [tool.importcost]\n" ' target = "import yourpackage"\n' " max_import_ms = 150\n", file=sys.stderr, diff --git a/src/lazybudget/codemod.py b/src/importcost/codemod.py similarity index 98% rename from src/lazybudget/codemod.py rename to src/importcost/codemod.py index 23a23ca..d3a2642 100644 --- a/src/lazybudget/codemod.py +++ b/src/importcost/codemod.py @@ -24,7 +24,7 @@ from dataclasses import dataclass from pathlib import Path -from lazybudget.static import Candidate, FileAnalysis, parse_source +from importcost.static import Candidate, FileAnalysis, parse_source DUNDER = "__lazy_modules__" diff --git a/src/lazybudget/config.py b/src/importcost/config.py similarity index 90% rename from src/lazybudget/config.py rename to src/importcost/config.py index 36acbf5..92f6eb7 100644 --- a/src/lazybudget/config.py +++ b/src/importcost/config.py @@ -1,23 +1,23 @@ -"""Read ``[tool.lazybudget]`` out of pyproject.toml. +"""Read ``[tool.importcost]`` out of pyproject.toml. Two shapes are accepted. The short one, for the common case of a single thing you care about:: - [tool.lazybudget] + [tool.importcost] target = "import mypkg" max_import_ms = 120 max_modules = 200 And the long one, when a project has several entry points with different budgets:: - [tool.lazybudget] + [tool.importcost] trials = 7 - [[tool.lazybudget.budget]] + [[tool.importcost.budget]] target = "import mypkg" max_import_ms = 120 - [[tool.lazybudget.budget]] + [[tool.importcost.budget]] target = "-m mypkg.cli" max_import_ms = 300 """ @@ -35,11 +35,11 @@ else: # pragma: no cover - exercised only on 3.10 import tomli as tomllib # type: ignore[import-not-found] -from lazybudget.measure import DEFAULT_TRIALS +from importcost.measure import DEFAULT_TRIALS class ConfigError(ValueError): - """pyproject.toml has a ``[tool.lazybudget]`` section we cannot use.""" + """pyproject.toml has a ``[tool.importcost]`` section we cannot use.""" @dataclass @@ -83,7 +83,7 @@ def load(path: Path | None = None) -> Config: with pyproject.open("rb") as handle: data = tomllib.load(handle) - section = data.get("tool", {}).get("lazybudget") + section = data.get("tool", {}).get("importcost") if not isinstance(section, dict): return Config(source=pyproject) @@ -102,7 +102,7 @@ def _budgets(section: dict[str, Any], pyproject: Path) -> list[Budget]: entries = section.get("budget") if entries is not None: if not isinstance(entries, list): - raise ConfigError(f"{pyproject}: [[tool.lazybudget.budget]] must be a list of tables") + raise ConfigError(f"{pyproject}: [[tool.importcost.budget]] must be a list of tables") return [_budget(entry, pyproject) for entry in entries] if "target" in section: return [_budget(section, pyproject)] @@ -112,7 +112,7 @@ def _budgets(section: dict[str, Any], pyproject: Path) -> list[Budget]: def _budget(entry: dict[str, Any], pyproject: Path) -> Budget: target = entry.get("target") if not isinstance(target, str) or not target.strip(): - raise ConfigError(f"{pyproject}: every lazybudget budget needs a 'target' string") + raise ConfigError(f"{pyproject}: every importcost budget needs a 'target' string") raw_ms = entry.get("max_import_ms") max_ms = None if raw_ms is not None: diff --git a/src/lazybudget/importtime.py b/src/importcost/importtime.py similarity index 100% rename from src/lazybudget/importtime.py rename to src/importcost/importtime.py diff --git a/src/lazybudget/lock.py b/src/importcost/lock.py similarity index 97% rename from src/lazybudget/lock.py rename to src/importcost/lock.py index 18bede2..7ba22c1 100644 --- a/src/lazybudget/lock.py +++ b/src/importcost/lock.py @@ -64,7 +64,7 @@ def read(path: Path) -> Lock: if version != LOCK_VERSION: raise ValueError( f"{path}: unsupported lock version {version!r}; " - f"delete it and re-run 'lazybudget check --update' to regenerate" + f"delete it and re-run 'importcost check --update' to regenerate" ) targets = { name: LockEntry( diff --git a/src/lazybudget/measure.py b/src/importcost/measure.py similarity index 97% rename from src/lazybudget/measure.py rename to src/importcost/measure.py index 3422978..b105e9b 100644 --- a/src/lazybudget/measure.py +++ b/src/importcost/measure.py @@ -27,8 +27,8 @@ from dataclasses import dataclass from functools import lru_cache -from lazybudget.importtime import ImportNode, flatten, parse -from lazybudget.targets import Target, resolve +from importcost.importtime import ImportNode, flatten, parse +from importcost.targets import Target, resolve DEFAULT_TRIALS = 5 @@ -199,7 +199,7 @@ def _run( returncode=124, stdout="", stderr=( - f"timed out after {timeout:g}s. lazybudget measures how long a program takes " + f"timed out after {timeout:g}s. importcost measures how long a program takes " "to start, so a target that keeps running (a server, a REPL, a loop) never " "finishes. Point it at the import instead, or raise the timeout.\n" + strip_importtime(_text(expired.stderr)) diff --git a/src/lazybudget/py.typed b/src/importcost/py.typed similarity index 100% rename from src/lazybudget/py.typed rename to src/importcost/py.typed diff --git a/src/lazybudget/pytest_plugin.py b/src/importcost/pytest_plugin.py similarity index 97% rename from src/lazybudget/pytest_plugin.py rename to src/importcost/pytest_plugin.py index 78259d6..e3b9b6f 100644 --- a/src/lazybudget/pytest_plugin.py +++ b/src/importcost/pytest_plugin.py @@ -26,7 +26,7 @@ def assert_within( trials: int = 5, python: str | None = None, ) -> object: - from lazybudget.measure import measure + from importcost.measure import measure result = measure(target, trials=trials, python=python) if result.returncode != 0: diff --git a/src/lazybudget/report.py b/src/importcost/report.py similarity index 98% rename from src/lazybudget/report.py rename to src/importcost/report.py index f5dd84a..bd5e347 100644 --- a/src/lazybudget/report.py +++ b/src/importcost/report.py @@ -11,7 +11,7 @@ import sys from collections.abc import Iterable, Sequence -from lazybudget.importtime import ImportNode +from importcost.importtime import ImportNode _RESET = "\033[0m" _STYLES = { diff --git a/src/lazybudget/runtime.py b/src/importcost/runtime.py similarity index 96% rename from src/lazybudget/runtime.py rename to src/importcost/runtime.py index a4cc791..96964fe 100644 --- a/src/lazybudget/runtime.py +++ b/src/importcost/runtime.py @@ -25,20 +25,20 @@ from dataclasses import dataclass from pathlib import Path -from lazybudget.targets import Target, resolve +from importcost.targets import Target, resolve #: Seconds before a verification run is abandoned. `_bisect` runs the test #: command several times, so a hang here is multiplied. DEFAULT_TIMEOUT = 600.0 -RESULT_ENV = "LAZYBUDGET_RESULT" -LAZY_SET_ENV = "LAZYBUDGET_LAZY_MODULES" +RESULT_ENV = "IMPORTCOST_RESULT" +LAZY_SET_ENV = "IMPORTCOST_LAZY_MODULES" #: Injected ahead of the target so the filter is installed before user code runs. #: Kept to builtin modules only -- anything this imports would show up in the very #: measurement it exists to take. _SITECUSTOMIZE = '''\ -"""Installed by lazybudget for one measurement run. Not written to your project.""" +"""Installed by importcost for one measurement run. Not written to your project.""" import atexit import os import sys @@ -46,9 +46,9 @@ # Entries are "importing.module|imported.module". Matching on the pair is what # makes this faithful to the codemod: writing `lazy import x` in one file does # not defer x for the rest of the program, and neither does this. -_wanted = set(filter(None, os.environ.get("LAZYBUDGET_LAZY_MODULES", "").split(","))) +_wanted = set(filter(None, os.environ.get("IMPORTCOST_LAZY_MODULES", "").split(","))) _names = {pair.split("|", 1)[1] for pair in _wanted} -_result = os.environ.get("LAZYBUDGET_RESULT") +_result = os.environ.get("IMPORTCOST_RESULT") _registered = set() @@ -164,7 +164,7 @@ def check_reification( """Run ``target`` with ``proposal`` made lazy and report what actually deferred. ``proposal`` holds ``"importing.module|imported.module"`` entries, as built by - :func:`lazybudget.audit.proposal`. + :func:`importcost.audit.proposal`. """ if not supports_lazy(python): raise LazyUnsupported(f"{python} does not support PEP 810 lazy imports") @@ -276,7 +276,7 @@ def _run_command( env: dict[str, str] | None, timeout: float | None = DEFAULT_TIMEOUT, ) -> subprocess.CompletedProcess[str]: - with tempfile.TemporaryDirectory(prefix="lazybudget-") as tmp: + with tempfile.TemporaryDirectory(prefix="importcost-") as tmp: write_sitecustomize(Path(tmp)) full_env = child_env(tmp, modules, result_path=None, env=env) try: @@ -300,7 +300,7 @@ def _run_with_lazy( env: dict[str, str] | None, timeout: float | None = DEFAULT_TIMEOUT, ) -> tuple[subprocess.CompletedProcess[str], dict[str, list[str]] | None]: - with tempfile.TemporaryDirectory(prefix="lazybudget-") as tmp: + with tempfile.TemporaryDirectory(prefix="importcost-") as tmp: tmpdir = Path(tmp) write_sitecustomize(tmpdir) result_path = tmpdir / "result.json" diff --git a/src/lazybudget/static.py b/src/importcost/static.py similarity index 99% rename from src/lazybudget/static.py rename to src/importcost/static.py index f1061bb..bf55ffc 100644 --- a/src/lazybudget/static.py +++ b/src/importcost/static.py @@ -18,7 +18,7 @@ This module only decides what is *permissible* and *plausible*. Whether deferring actually pays, and whether it actually works, is settled by -:mod:`lazybudget.runtime` against a running interpreter. +:mod:`importcost.runtime` against a running interpreter. """ from __future__ import annotations diff --git a/src/lazybudget/targets.py b/src/importcost/targets.py similarity index 91% rename from src/lazybudget/targets.py rename to src/importcost/targets.py index be767eb..dd0db74 100644 --- a/src/lazybudget/targets.py +++ b/src/importcost/targets.py @@ -3,10 +3,10 @@ A target is whatever you actually pay startup cost for. In practice that is one of four things, and we accept all of them:: - lazybudget profile "import pandas" # a bare import - lazybudget profile "-m http.server" # a module - lazybudget profile ./scripts/run.py # a script - lazybudget profile mypy # a console script on PATH + importcost profile "import pandas" # a bare import + importcost profile "-m http.server" # a module + importcost profile ./scripts/run.py # a script + importcost profile mypy # a console script on PATH """ from __future__ import annotations diff --git a/tests/test_check.py b/tests/test_check.py index 8dadd33..5041e6d 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -1,5 +1,5 @@ -from lazybudget import check -from lazybudget.config import Budget, Config +from importcost import check +from importcost.config import Budget, Config def config(tmp_path, **kwargs): @@ -40,7 +40,7 @@ def test_drift_fails_the_next_run(tmp_path): conf = config(tmp_path, max_import_ms=10_000) check.run(conf, update=True) - from lazybudget import lock as lockfile + from importcost import lock as lockfile lock = lockfile.read(conf.lock_path) lock.targets["import json"].modules.append("a_module_that_went_away") @@ -56,7 +56,7 @@ def test_drift_can_be_ignored(tmp_path): conf = config(tmp_path, max_import_ms=10_000) check.run(conf, update=True) - from lazybudget import lock as lockfile + from importcost import lock as lockfile lock = lockfile.read(conf.lock_path) lock.targets["import json"].modules.append("a_module_that_went_away") @@ -80,7 +80,7 @@ def test_drift_across_python_versions_says_so(tmp_path): conf = config(tmp_path, max_import_ms=10_000) check.run(conf, update=True) - from lazybudget import lock as lockfile + from importcost import lock as lockfile lock = lockfile.read(conf.lock_path) lock.targets["import json"].python = "3.8" diff --git a/tests/test_cli.py b/tests/test_cli.py index ed6af80..e77a73a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,14 +2,14 @@ import pytest -from lazybudget.cli import main +from importcost.cli import main def test_version(capsys): with pytest.raises(SystemExit) as exit_info: main(["--version"]) assert exit_info.value.code == 0 - assert "lazybudget" in capsys.readouterr().out + assert "importcost" in capsys.readouterr().out def test_profile_prints_a_table(capsys): @@ -53,19 +53,19 @@ def test_check_without_configuration_explains_itself(tmp_path, capsys, monkeypat def test_check_passes_and_fails(tmp_path, capsys, monkeypatch): pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[tool.lazybudget]\ntarget = "import json"\nmax_import_ms = 10000\n') + pyproject.write_text('[tool.importcost]\ntarget = "import json"\nmax_import_ms = 10000\n') monkeypatch.chdir(tmp_path) assert main(["check", "-n", "2"]) == 0 assert "ok" in capsys.readouterr().out - pyproject.write_text('[tool.lazybudget]\ntarget = "import json"\nmax_import_ms = 0.0001\n') + pyproject.write_text('[tool.importcost]\ntarget = "import json"\nmax_import_ms = 0.0001\n') assert main(["check", "-n", "2"]) == 1 assert "fail" in capsys.readouterr().out def test_check_json(tmp_path, capsys, monkeypatch): (tmp_path / "pyproject.toml").write_text( - '[tool.lazybudget]\ntarget = "import json"\nmax_import_ms = 10000\n' + '[tool.importcost]\ntarget = "import json"\nmax_import_ms = 10000\n' ) monkeypatch.chdir(tmp_path) assert main(["check", "-n", "2", "--json"]) == 0 diff --git a/tests/test_codemod.py b/tests/test_codemod.py index 6c79208..b7eded0 100644 --- a/tests/test_codemod.py +++ b/tests/test_codemod.py @@ -2,8 +2,8 @@ import pytest -from lazybudget.codemod import apply -from lazybudget.static import analyze_source +from importcost.codemod import apply +from importcost.static import analyze_source PATH = Path("example.py") diff --git a/tests/test_config.py b/tests/test_config.py index 08f9237..74a7e53 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,6 @@ import pytest -from lazybudget.config import ConfigError, load +from importcost.config import ConfigError, load def write(tmp_path, body): @@ -17,7 +17,7 @@ def test_no_section_gives_no_budgets(tmp_path): def test_short_form(tmp_path): path = write( tmp_path, - '[tool.lazybudget]\ntarget = "import x"\nmax_import_ms = 120\nmax_modules = 40\n', + '[tool.importcost]\ntarget = "import x"\nmax_import_ms = 120\nmax_modules = 40\n', ) config = load(path) assert len(config.budgets) == 1 @@ -29,9 +29,9 @@ def test_short_form(tmp_path): def test_long_form(tmp_path): path = write( tmp_path, - "[tool.lazybudget]\ntrials = 9\n\n" - '[[tool.lazybudget.budget]]\ntarget = "import x"\nmax_import_ms = 1\n\n' - '[[tool.lazybudget.budget]]\ntarget = "-m x.cli"\n', + "[tool.importcost]\ntrials = 9\n\n" + '[[tool.importcost.budget]]\ntarget = "import x"\nmax_import_ms = 1\n\n' + '[[tool.importcost.budget]]\ntarget = "-m x.cli"\n', ) config = load(path) assert config.trials == 9 @@ -40,42 +40,42 @@ def test_long_form(tmp_path): def test_lock_path_defaults_next_to_pyproject(tmp_path): - config = load(write(tmp_path, '[tool.lazybudget]\ntarget = "import x"\n')) + config = load(write(tmp_path, '[tool.importcost]\ntarget = "import x"\n')) assert config.lock_path == tmp_path / "import.lock" def test_lock_path_can_be_overridden(tmp_path): - path = write(tmp_path, '[tool.lazybudget]\ntarget = "import x"\nlock = "ci/imports.lock"\n') + path = write(tmp_path, '[tool.importcost]\ntarget = "import x"\nlock = "ci/imports.lock"\n') assert load(path).lock_path == tmp_path / "ci" / "imports.lock" def test_budget_without_a_target_is_an_error(tmp_path): - path = write(tmp_path, "[tool.lazybudget]\n[[tool.lazybudget.budget]]\nmax_import_ms = 1\n") + path = write(tmp_path, "[tool.importcost]\n[[tool.importcost.budget]]\nmax_import_ms = 1\n") with pytest.raises(ConfigError, match="needs a 'target' string"): load(path) def test_budget_must_be_a_list_of_tables(tmp_path): - path = write(tmp_path, "[tool.lazybudget]\nbudget = 3\n") + path = write(tmp_path, "[tool.importcost]\nbudget = 3\n") with pytest.raises(ConfigError, match="list of tables"): load(path) @pytest.mark.parametrize("value", ["nan", "inf", "-1"]) def test_a_budget_that_can_never_fail_is_rejected(tmp_path, value): - path = write(tmp_path, f'[tool.lazybudget]\ntarget = "import x"\nmax_import_ms = {value}\n') + path = write(tmp_path, f'[tool.importcost]\ntarget = "import x"\nmax_import_ms = {value}\n') with pytest.raises(ConfigError, match="finite, non-negative"): load(path) def test_a_negative_module_budget_is_rejected(tmp_path): - path = write(tmp_path, '[tool.lazybudget]\ntarget = "import x"\nmax_modules = -1\n') + path = write(tmp_path, '[tool.importcost]\ntarget = "import x"\nmax_modules = -1\n') with pytest.raises(ConfigError, match="cannot be negative"): load(path) def test_a_non_numeric_time_budget_is_a_config_error(tmp_path): - path = write(tmp_path, '[tool.lazybudget]\ntarget = "import x"\nmax_import_ms = "abc"\n') + path = write(tmp_path, '[tool.importcost]\ntarget = "import x"\nmax_import_ms = "abc"\n') with pytest.raises(ConfigError, match="must be a number"): load(path) @@ -83,6 +83,6 @@ def test_a_non_numeric_time_budget_is_a_config_error(tmp_path): @pytest.mark.parametrize("value", ["-0.5", "1.5", '"12"']) def test_a_module_budget_that_is_not_a_whole_number_is_rejected(tmp_path, value): """int(-0.5) is 0, so converting before validating would accept it.""" - path = write(tmp_path, f'[tool.lazybudget]\ntarget = "import x"\nmax_modules = {value}\n') + path = write(tmp_path, f'[tool.importcost]\ntarget = "import x"\nmax_modules = {value}\n') with pytest.raises(ConfigError, match="whole number"): load(path) diff --git a/tests/test_importtime.py b/tests/test_importtime.py index 812eabd..f0bf901 100644 --- a/tests/test_importtime.py +++ b/tests/test_importtime.py @@ -1,4 +1,4 @@ -from lazybudget.importtime import flatten, parse +from importcost.importtime import flatten, parse SAMPLE = """\ import time: self [us] | cumulative | imported package diff --git a/tests/test_lock.py b/tests/test_lock.py index eeb97df..a0a8299 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -2,7 +2,7 @@ import pytest -from lazybudget import lock as lockfile +from importcost import lock as lockfile def test_missing_file_reads_as_empty(tmp_path): diff --git a/tests/test_measure.py b/tests/test_measure.py index 5af57a1..b08e830 100644 --- a/tests/test_measure.py +++ b/tests/test_measure.py @@ -1,6 +1,6 @@ import sys -from lazybudget.measure import measure, strip_importtime +from importcost.measure import measure, strip_importtime def test_measures_a_trivial_import(): diff --git a/tests/test_runtime.py b/tests/test_runtime.py index b82d4ee..8b7d488 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -13,10 +13,10 @@ import pytest -from lazybudget import audit as audit_mod -from lazybudget import runtime -from lazybudget.audit import SAFE, UNSAFE, module_name, proposal -from lazybudget.static import analyze_file +from importcost import audit as audit_mod +from importcost import runtime +from importcost.audit import SAFE, UNSAFE, module_name, proposal +from importcost.static import analyze_file LAZY_PYTHON = runtime.find_lazy_python() needs_315 = pytest.mark.skipif(LAZY_PYTHON is None, reason="needs a Python 3.15 interpreter") @@ -161,7 +161,7 @@ def test_applying_the_audit_keeps_the_program_working(project): assert accepted assert "svc.plugins" not in accepted - from lazybudget.codemod import apply + from importcost.codemod import apply for analysis in report.analyses: source = analysis.path.read_text() @@ -241,8 +241,8 @@ def fake_run(command, modules, *, python, env, timeout=None): @needs_315 def test_a_failed_lazy_profile_is_not_turned_into_savings(project, monkeypatch): """An empty module list from a crashed run looks just like one that skipped everything.""" - from lazybudget.measure import Measurement - from lazybudget.targets import resolve + from importcost.measure import Measurement + from importcost.targets import resolve def broken(target, entries, *, python, trials): return Measurement( diff --git a/tests/test_static.py b/tests/test_static.py index 2d05b63..03cd783 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -1,6 +1,6 @@ from pathlib import Path -from lazybudget.static import ( +from importcost.static import ( SKIP_EXPORTED, SKIP_FUTURE, SKIP_MULTI, diff --git a/tests/test_targets.py b/tests/test_targets.py index 006485c..a0e0974 100644 --- a/tests/test_targets.py +++ b/tests/test_targets.py @@ -1,6 +1,6 @@ import pytest -from lazybudget.targets import TargetError, resolve +from importcost.targets import TargetError, resolve def test_import_statement(): diff --git a/uv.lock b/uv.lock index f3914f3..c6cfaf3 100644 --- a/uv.lock +++ b/uv.lock @@ -234,7 +234,7 @@ wheels = [ ] [[package]] -name = "lazybudget" +name = "importcost" version = "0.1.0" source = { editable = "." } dependencies = [