├── tests ├── __init__.py ├── example.py └── test_imports.py ├── .gitignore ├── benchmark_imports ├── __main__.py ├── _colors.py ├── __init__.py ├── _stack.py ├── _cli.py ├── _imports.py └── _tracker.py ├── screenshot.png ├── pyproject.toml ├── LICENSE └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .*_cache/ 3 | /dist/ 4 | -------------------------------------------------------------------------------- /tests/example.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | print(math.sin(1)) 4 | -------------------------------------------------------------------------------- /benchmark_imports/__main__.py: -------------------------------------------------------------------------------- 1 | from ._cli import entrypoint 2 | 3 | entrypoint() 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orsinium-labs/benchmark-imports/HEAD/screenshot.png -------------------------------------------------------------------------------- /benchmark_imports/_colors.py: -------------------------------------------------------------------------------- 1 | RED = "\033[91m" 2 | YELLOW = "\033[93m" 3 | GREEN = "\033[92m" 4 | BLUE = "\033[94m" 5 | MAGENTA = "\033[95m" 6 | END = "\033[0m" 7 | -------------------------------------------------------------------------------- /benchmark_imports/__init__.py: -------------------------------------------------------------------------------- 1 | """A CLI tool to record how much time it takes to import each dependency. 2 | """ 3 | 4 | from ._imports import activate, deactivate 5 | 6 | __version__ = '1.0.0' 7 | __all__ = ['activate', 'deactivate'] 8 | -------------------------------------------------------------------------------- /benchmark_imports/_stack.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Iterator, List, Optional 3 | 4 | 5 | class Stack: 6 | _stack: List[str] 7 | 8 | def __init__(self) -> None: 9 | self._stack = [] 10 | 11 | @contextmanager 12 | def context(self, module_name: str) -> Iterator[Optional[str]]: 13 | parent = self._stack[-1] if self._stack else None 14 | self._stack.append(module_name) 15 | try: 16 | yield parent 17 | finally: 18 | self._stack.pop() 19 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from benchmark_imports._imports import activate, deactivate 3 | from benchmark_imports._tracker import ModuleType 4 | 5 | 6 | def test_smoke(): 7 | name = 'tests.example' 8 | tr = activate(name) 9 | try: 10 | import_module(name) 11 | finally: 12 | deactivate() 13 | assert len(tr.records) == 1 14 | rec = tr.records[0] 15 | assert rec.module == name 16 | assert rec.parent is None 17 | assert rec.type == ModuleType.ROOT 18 | assert 0 < rec.time < 5 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "benchmark-imports" 7 | authors = [ 8 | {name = "Gram", email = "gram@orsinium.dev"}, 9 | ] 10 | license = {file = "LICENSE"} 11 | readme = "README.md" 12 | requires-python = ">=3.7" 13 | dynamic = ["version", "description"] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python", 19 | "Topic :: Software Development", 20 | "Topic :: Software Development :: Quality Assurance", 21 | ] 22 | keywords = [ 23 | "import", 24 | "benchmark", 25 | "profile", 26 | "profiler", 27 | "time", 28 | "performance", 29 | "cli", 30 | ] 31 | dependencies = [] 32 | 33 | [project.optional-dependencies] 34 | test = ["pytest"] 35 | 36 | [project.urls] 37 | Source = "https://github.com/orsinium-labs/benchmark-imports" 38 | 39 | [tool.flit.module] 40 | name = "benchmark_imports" 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | 2022 Gram 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included 13 | in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /benchmark_imports/_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from argparse import ArgumentParser 5 | from importlib import import_module 6 | from typing import NamedTuple, NoReturn, TextIO 7 | 8 | from ._colors import END, MAGENTA, RED 9 | from ._imports import activate 10 | from ._tracker import ModuleType 11 | 12 | 13 | class Command(NamedTuple): 14 | argv: list[str] 15 | stream: TextIO 16 | 17 | def run(self) -> int: 18 | parser = ArgumentParser() 19 | parser.add_argument("--top", type=int, default=25) 20 | parser.add_argument("--precision", type=int, default=4) 21 | parser.add_argument("module_name") 22 | args = parser.parse_args(self.argv) 23 | 24 | tracker = activate(args.module_name) 25 | import_module(args.module_name) 26 | tracker.sort() 27 | for rec in tracker.records[:args.top]: 28 | time = format(rec.time, f'0.0{args.precision}f') 29 | line = f'{time} {rec.type.colored} {MAGENTA}{rec.module:40}{END}' 30 | if rec.parent and rec.type == ModuleType.TRANSITIVE: 31 | line += f' from {MAGENTA}{rec.parent}{END}' 32 | self._print(line) 33 | 34 | if tracker.errors: 35 | self._print('\nImport-time errors that were handled by modules:') 36 | for module, error in tracker.errors: 37 | etype = type(error).__name__ 38 | self._print(f'{MAGENTA}{module}{END}: {RED}{etype}: {error}{END}') 39 | return 0 40 | 41 | def _print(self, *args, end: str = "\n") -> None: 42 | print(*args, end=end, file=self.stream) 43 | 44 | 45 | def entrypoint() -> NoReturn: 46 | cmd = Command(argv=sys.argv[1:], stream=sys.stdout) 47 | sys.exit(cmd.run()) 48 | -------------------------------------------------------------------------------- /benchmark_imports/_imports.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from importlib.abc import Loader, MetaPathFinder 3 | from importlib.machinery import ModuleSpec 4 | from types import ModuleType 5 | from typing import Optional 6 | 7 | from _frozen_importlib_external import \ 8 | SourceFileLoader # pyright: reportMissingImports=false 9 | 10 | from ._stack import Stack 11 | from ._tracker import Tracker 12 | 13 | 14 | class BenchFinder(MetaPathFinder): 15 | __slots__ = ('_tracker', '_stack') 16 | _tracker: Tracker 17 | _stack: Stack 18 | 19 | def __init__(self, tracker: Tracker) -> None: 20 | self._tracker = tracker 21 | self._stack = Stack() 22 | 23 | def find_spec(self, *args, **kwargs) -> Optional[ModuleSpec]: 24 | for finder in sys.meta_path: 25 | if finder is self: 26 | continue 27 | try: 28 | spec = finder.find_spec(*args, **kwargs) 29 | except AttributeError: 30 | continue 31 | if spec is None: 32 | continue 33 | if isinstance(spec.loader, SourceFileLoader): 34 | spec.loader = BenchLoader(spec.loader, self._tracker, self._stack) 35 | return spec 36 | return None 37 | 38 | 39 | class BenchLoader(Loader): 40 | def __init__(self, loader: Loader, tracker: Tracker, stack: Stack) -> None: 41 | self._loader = loader 42 | self._tracker = tracker 43 | self._stack = stack 44 | 45 | def __getattr__(self, name: str): 46 | return getattr(self._loader, name) 47 | 48 | def exec_module(self, module: ModuleType) -> None: 49 | with self._stack.context(module.__name__) as parent: 50 | with self._tracker.track(module=module.__name__, parent=parent): 51 | try: 52 | self._loader.exec_module(module) 53 | except Exception as exc: 54 | self._tracker.record_error(module.__name__, exc) 55 | raise 56 | 57 | 58 | def activate(root_module: str) -> Tracker: 59 | assert BenchFinder not in sys.meta_path 60 | tracker = Tracker(root_module) 61 | sys.meta_path.insert(0, BenchFinder(tracker)) 62 | return tracker 63 | 64 | 65 | def deactivate() -> None: 66 | for finder in sys.meta_path.copy(): 67 | if isinstance(finder, BenchFinder): 68 | sys.meta_path.remove(finder) 69 | -------------------------------------------------------------------------------- /benchmark_imports/_tracker.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from contextlib import contextmanager 4 | from enum import Enum 5 | from time import perf_counter 6 | from typing import Iterator, List, NamedTuple, Optional, Tuple 7 | 8 | from . import _colors 9 | 10 | 11 | class ModuleType(Enum): 12 | ROOT = "root" 13 | PROJECT = "project" 14 | DIRECT = "dependency" 15 | TRANSITIVE = "transitive" 16 | 17 | @property 18 | def colored(self) -> str: 19 | return f'{self.color}{self.value:10}{_colors.END}' 20 | 21 | @property 22 | def color(self) -> str: 23 | if self == ModuleType.ROOT: 24 | return _colors.BLUE 25 | if self == ModuleType.PROJECT: 26 | return _colors.RED 27 | if self == ModuleType.DIRECT: 28 | return _colors.YELLOW 29 | if self == ModuleType.TRANSITIVE: 30 | return _colors.GREEN 31 | raise RuntimeError("unreachable") 32 | 33 | 34 | class Record(NamedTuple): 35 | module: str # the absolute module name 36 | type: ModuleType # how the module is related to the one we track 37 | time: float # how long it took to import the module 38 | parent: Optional[str] # the name of the parent module 39 | 40 | 41 | class Tracker: 42 | __slots__ = ('records', '_root', 'errors') 43 | records: List[Record] 44 | errors: List[Tuple[str, Exception]] 45 | _root: str 46 | 47 | def __init__(self, root: str) -> None: 48 | self.records = [] 49 | self.errors = [] 50 | self._root = root 51 | 52 | @contextmanager 53 | def track(self, *, module: str, parent: Optional[str]) -> Iterator[None]: 54 | start = perf_counter() 55 | try: 56 | yield 57 | finally: 58 | total = perf_counter() - start 59 | self.records.append(Record( 60 | module=module, 61 | type=self._get_type(module=module, parent=parent), 62 | time=total, 63 | parent=parent, 64 | )) 65 | 66 | def record_error(self, module: str, error: Exception) -> None: 67 | self.errors.append((module, error)) 68 | 69 | def sort(self) -> None: 70 | self.records.sort(key=lambda r: r.time, reverse=True) 71 | 72 | def _get_type(self, *, module: Optional[str], parent: Optional[str]) -> ModuleType: 73 | if module is None: 74 | return ModuleType.ROOT 75 | if module == self._root or self._root.startswith(f'{module}.'): 76 | return ModuleType.ROOT 77 | if module.startswith(f'{self._root}.'): 78 | return ModuleType.PROJECT 79 | direct_parent = (ModuleType.ROOT, ModuleType.PROJECT) 80 | if self._get_type(module=parent, parent=None) in direct_parent: 81 | return ModuleType.DIRECT 82 | return ModuleType.TRANSITIVE 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # benchmark-imports 2 | 3 | A CLI tool to record how much time it takes to import each dependency in a Python project. Use it when the import time for your code matters. 4 | 5 | ![example of the program output](./screenshot.png) 6 | 7 | ## Usage 8 | 9 | Install: 10 | 11 | ```bash 12 | python3 -m pip install benchmark-imports 13 | ``` 14 | 15 | Use: 16 | 17 | ```bash 18 | python3 -m benchmark_imports my_module_name 19 | ``` 20 | 21 | For example, measure import time for numpy: 22 | 23 | ```bash 24 | python3 -m pip install numpy 25 | python3 -m benchmark_imports numpy 26 | ``` 27 | 28 | ## Troubleshooting 29 | 30 | + To be able to import your module and all its dependencies, the tool has to be installed in the same environment as the tested module and all its dependencies. 31 | + Keep in mind that the tool actually imports and executes the given module and all its dependencies. So, don't run it on untrusted code or code that can break something at import time. 32 | + The tool will report you at the end the list of errors that occured in some modules when importing them but were suppressed by other modules. It doesn't mean something is broken. For example, it can indicate that the library has an optional dependency that it tries to import but just ignores if unavailable. However, that means that on some environment the module will be successfully imported, and so the import time may be different. 33 | + To avoid starting your service when it's imported by the tool, put the startup logic inside `if __name__ == "__main__"` block. 34 | + A module is executed by Python only when it's imported in the very first time. So, if you change the order of imports or remove some, you may see different parents for dependencies because they will be loaded from whatever module imports them first. 35 | 36 | ## Improving import time 37 | 38 | When you identified the slow modules, this is what you can do: 39 | 40 | + **Decrease coupling**. "A little copying is better than a little dependency". For example, if you import [numpy](https://numpy.org/) only to use a single small function (like [numpy.sign](https://numpy.org/doc/stable/reference/generated/numpy.sign.html)), just implement the function by yourself. 41 | + **Use local imports**. The best practice is to have imports on the top-level of the file. However, if this is a slow module that is used only in one function which isn't called too often, just move the import into the function body. It won't make the function much slower (well, except when you call it in the first time) because Python caches all imports in [sys.modules](https://docs.python.org/3/library/sys.html#sys.modules). 42 | + **Use lazy imports**. The idea is about the same as with function-local imports: the module will be actually imported and executed only when you try to use it in the first time. It can be achieved either with [deferred-import](https://github.com/orsinium-labs/deferred-import) library or [Implementing lazy imports](https://docs.python.org/3/library/importlib.html#implementing-lazy-imports) snippet from the `importlib` documentation. 43 | + **Make type annotations lazy** by adding `from __future__ import annotations` at the beginning of each file (see [PEP 563](https://peps.python.org/pep-0563/)). 44 | + If something is imported only to be used in type annotations, move the import inside [if TYPE_CHECKING](https://docs.python.org/3/library/typing.html#typing.TYPE_CHECKING) block. 45 | 46 | And as a general rule, don't optimize something until you prove it is slow. This is why this tool exists. 47 | 48 | ## Module type 49 | 50 | For each reported module, the tool will show you one of the following types: 51 | 52 | + **root** is the original module you benchmark (or one of its parent modules). 53 | + **project** is a child module of the root module. 54 | + **dependency** is a direct dependency of one of the modules in the project. 55 | + **transitive** is a dependency of one of the dependencies. 56 | --------------------------------------------------------------------------------