Best practices for Python CLI entry points
Modern Python CLI distribution relies on standardized packaging metadata. Proper entry point configuration guarantees deterministic execution across environments. It eliminates path resolution ambiguity during installation. The following patterns align with PEP 621 and Python 3.10+ runtime expectations.
Modern pyproject.toml Entry Point Configuration
Legacy setup.py console_scripts are officially deprecated. Modern Python 3.10+ projects must use the standardized [project.scripts] table in pyproject.toml. This maps executable names directly to module:function callables. It ensures cross-platform compatibility and deterministic path resolution. When evaluating Modern Python CLI Frameworks & Architecture, entry point registration remains the foundational step for executable distribution.
[project.scripts]
mytool = "mytool.cli:main"
The build backend resolves this string during installation. It generates a platform-appropriate wrapper script. The wrapper injects the correct sys.path before invoking the target callable. This guarantees consistent behavior across POSIX and Windows environments.
The main.py Fallback for Direct Execution
Always include a __main__.py at your package root to support python -m mytool execution. This is critical for debugging, CI/CD runners, and restricted environments. The module must contain zero business logic. Delegate immediately to the registered entry point. For complex routing logic, consult Structuring Multi-Command Python CLIs to decouple command parsing from initialization.
# src/mytool/__main__.py
from mytool.cli import main
if __name__ == "__main__":
main()
Direct module execution bypasses the generated wrapper script. It relies entirely on the current working directory and PYTHONPATH. This fallback ensures your CLI remains testable during development. It also prevents import side effects from masking initialization errors.
Resolving ModuleNotFoundError on Execution
Developers frequently encounter ModuleNotFoundError: No module named 'mytool' after a partial install. This occurs when the package root is missing from sys.path. It also happens when the entry point string references a non-importable module. The fix requires verifying the pyproject.toml package discovery configuration. Ensure the callable path matches the actual directory structure exactly.
# pyproject.toml fix
[tool.setuptools.packages.find]
where = ["src"]
# Verify installation
pip install -e .
python -c "import mytool.cli; print(mytool.cli.__file__)"
Editable installs create .pth files that map source directories to the site-packages path. Misconfigured where directives break this mapping. Always validate package discovery before rebuilding entry points. The verification command confirms the interpreter resolves the correct file path.
Lazy Import Pattern for Sub-100ms Startup
Heavy dependencies inflate CLI startup latency. Defer imports inside the command function or use a lazy loader. This ensures the entry point initializes instantly. Arguments parse before heavy modules load into memory. Modern Python 3.10+ type checkers support TYPE_CHECKING guards. This maintains IDE autocomplete without runtime overhead.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import pandas as pd
def process_data(input_file: str) -> None:
import pandas as pd # Deferred until execution
df = pd.read_csv(input_file)
The __future__ import enables postponed evaluation of type hints. Static analyzers read the guarded block without triggering runtime imports. The deferred import executes only when the specific command runs. This pattern reduces cold-start overhead to under 100 milliseconds.
Exact Error Resolution
Error Message: ModuleNotFoundError: No module named 'mytool'Trigger Condition: Running mytool --version immediately after pip install -e . in a virtual environment with a mismatched src layout.
Root Cause: The entry point string mytool.cli:main points to a module that Python cannot resolve. The src/ directory is missing from the import path, or packages.find is misconfigured in pyproject.toml.
Minimal Fix: Add [tool.setuptools.packages.find] with where = ["src"] to pyproject.toml. Run pip install -e . --force-reinstall to rebuild the entry point symlinks.
Verification Command: python -m pip show -f mytool | grep 'mytool/cli.py'