Command-line tools accumulate a particular kind of bug. A subprocess.run(f"...", shell=True) that works until a filename contains a space. An open(path) without an encoding that works on every Linux machine and breaks on the first Windows laptop. An option declared as int and later used as a string. A print() in a library module that corrupts the JSON output of a command three layers up. A core module that quietly imported rich and now adds 80 ms to every invocation. None of these needs a test to find — static analysis catches all of them in seconds, before anything runs.
This topic covers the static checks that pay off most for a Python CLI: Ruff for linting and formatting, a type checker that understands Click and Typer, and import contracts that keep the layers of the codebase separate. It sits alongside pre-commit hooks for CLI projects, which run these checks on every commit, and CI/CD pipelines for Python CLIs, which run them on every push.
TL;DR
- Use Ruff for both linting and formatting. One fast tool, configured in
pyproject.toml, replaces flake8, isort, pyupgrade, black and several plugins. - Enable rule sets that matter for CLIs: security (
S), pathlib (PTH), bugbear (B), print statements (T20) — not just the defaults. - Type-check with mypy or pyright, annotate command parameters, and turn
ctx.objinto a typed object instead ofAny. - Enforce layers with import-linter so core logic never imports the CLI framework, terminal libraries or HTTP clients.
- Run the same configuration everywhere: editor, pre-commit and CI.
Why static checks suit CLIs so well
A CLI's surface area is broad and shallow: many commands, many options, many interactions with the operating system, each exercised by a handful of tests at most. Tests catch the behaviour you thought to check; static analysis checks every line, including the error path for a Windows user with a non-ASCII username that no test will ever take.
The rules in that table are not style preferences. S602 flags the shell-injection pattern covered in avoiding shell injection in Python CLIs. PLW1514 flags text-mode open() without an encoding, the root of many Windows-only failures. T201 flags print() calls, which in a CLI should appear only in the command layer where output is deliberately produced — anywhere else they risk writing to a stdout that a script is parsing. The type checker catches the None that reaches a function expecting a Path only when a user omits an optional flag.
Ruff: one tool for lint and format
Ruff implements hundreds of rules from flake8 and its plugins, plus isort, pyupgrade and a Black-compatible formatter, in a single Rust binary that checks a typical CLI codebase in well under a second. Configuration lives in pyproject.toml:
[tool.ruff]
line-length = 100
target-version = "py310" # match requires-python
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "S", "PTH", "T20", "SIM", "RUF"]
ignore = ["E501"] # the formatter owns line length
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "S603", "S607"] # assert and subprocess are normal in tests
"src/mytool/cli.py" = ["T201"] # the command layer may print
[tool.ruff.format]
docstring-code-format = true
target-version matters more than it looks: pyupgrade rules (UP) will suggest syntax only available in that version and newer, so it must match your oldest supported Python or Ruff will "modernise" code into something your users cannot run. The per-file ignores encode architecture — tests may use assert and subprocesses freely, and only the CLI layer may print. Configuring Ruff for a CLI project goes through the rule sets in detail, including how to adopt them on an existing codebase without a thousand-line diff.
Types across the CLI boundary
Typer builds its whole interface from type annotations, which makes a Typer CLI unusually friendly to type checkers: the same annotation that tells Typer to convert --retries to an int tells mypy that the function receives an int. Click is less direct — decorators add parameters at runtime that the type checker cannot see — but annotating the function parameters yourself gives the same result.
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer()
def render(source: Path, dest: Path) -> int:
...
return 0
@app.command()
def build(
source: Annotated[Path, typer.Argument(exists=True, file_okay=False)],
out: Annotated[Path | None, typer.Option("--out", "-o")] = None,
) -> None:
written = render(source, out or source.with_name("site"))
typer.echo(f"wrote {written} files", err=True)
The Annotated form keeps the default value where Python and type checkers expect it, instead of hiding it inside typer.Option(...), so mypy knows out is Path | None and forces you to handle the None case — here with a fallback — rather than letting it reach render and crash. The gaps that remain are predictable: ctx.obj is typed Any, Click's Choice produces a plain str, and some decorators erase signatures. Type-checking Click and Typer code with mypy closes each one, and using Annotated options in Typer covers the Annotated style itself.
A minimal mypy configuration to start from:
[tool.mypy]
python_version = "3.10"
files = ["src"]
strict = true
warn_unreachable = true
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_defs = false
strict = true on new code is realistic for a CLI, because most of the code is your own and the main dependencies — Click, Typer, Rich, httpx — ship type information.
Keeping the layers apart
Well-structured CLIs keep a thin command layer on top of core logic that knows nothing about the command line, as described in how to structure a large Python CLI project. That separation is what makes core logic reusable from other programs, testable without CliRunner, and fast to import. It is also easy to erode one convenient import at a time: a helper in core/ that uses rich.print for a debug message, a model that imports typer.BadParameter to raise a nicer error.
import-linter turns the architecture into a checked contract:
[tool.importlinter]
root_package = "mytool"
[[tool.importlinter.contracts]]
name = "CLI layers"
type = "layers"
layers = ["mytool.cli", "mytool.services", "mytool.core"]
[[tool.importlinter.contracts]]
name = "Core has no framework dependencies"
type = "forbidden"
source_modules = ["mytool.core"]
forbidden_modules = ["typer", "click", "rich", "httpx"]
lint-imports then fails whenever a lower layer imports a higher one, or core imports a framework, and reports the full chain of imports that caused it — including indirect ones. The forbidden contract doubles as a startup-time guard: heavy libraries cannot creep into modules every command imports. Enforcing import boundaries in a CLI codebase covers contracts for plugins and independent subcommands as well.
Where the checks run
The same configuration should run in three places, so the answer never depends on where you ask:
- In the editor, through the Ruff language server and your editor's mypy or Pylance/pyright integration. Problems appear as you type, which is when they are cheapest to fix.
- On commit, through pre-commit:
ruff check --fixandruff formaton changed files, fast enough that nobody disables them. See setting up pre-commit for Python CLI repos. - In CI, on the whole repository, including slower checks such as mypy over everything and
lint-imports. This is the gate; the other two are conveniences.
Run all three from the versions pinned in your dev dependency group (uv add --dev ruff mypy import-linter), so a new Ruff release with new default behaviour arrives as a reviewed lockfile change rather than a surprise failure on Monday morning.
Adopting checks on an existing codebase
Turning on strict checks across a codebase that grew without them produces hundreds of findings, and the temptation is to either fix everything in one heroic pull request or give up. A staged approach works better:
- Format everything in one mechanical commit and add its hash to
.git-blame-ignore-revssogit blameskips it. - Enable Ruff's defaults, fix what is quick, and suppress the rest with per-file ignores that you then burn down.
- Add mypy with lenient settings and enable strictness module by module through
[[tool.mypy.overrides]], starting with the core logic that benefits most. - Add rule families one at a time —
S, thenPTH, thenB— each in its own pull request so the discussion stays focused. - Add import contracts last, once the structure they describe exists;
lint-importssupports ignoring specific known violations while you untangle them.
Every step lands with CI green, so the checks start protecting new code immediately while old code catches up.
A worked example: three findings, three real bugs
It is easier to see why these rules earn their place with a concrete case. Here is a plausible module from a deploy tool, written quickly and working fine on its author's Mac:
# src/mytool/core/deploy.py (before)
import os
import subprocess
def deploy(site_dir, host, retries=None):
for name in os.listdir(site_dir):
print("uploading", name)
config = open(os.path.join(site_dir, "deploy.toml")).read()
subprocess.run(f"rsync -a {site_dir}/ {host}:/srv/site", shell=True)
return retries + 1
Ruff reports T201 (a print in core logic, which will corrupt --json output when a command calls this), PTH findings for os.listdir, open and os.path.join, PLW1514 for the unencoded open() (a preview rule at the time of writing), SIM115 for opening a file without a context manager, F841 because config is never used, and S602 for the shell string — a directory with a space breaks it and one with a semicolon exploits it. mypy, once the function is annotated, reports that retries may be None when + 1 is applied. Nearly every finding is a bug some user would eventually hit. The fixed version:
# src/mytool/core/deploy.py (after)
import logging
import subprocess
from pathlib import Path
log = logging.getLogger(__name__)
def deploy(site_dir: Path, host: str, retries: int = 0) -> int:
for path in sorted(site_dir.iterdir()):
log.info("uploading %s", path.name)
config = (site_dir / "deploy.toml").read_text(encoding="utf-8")
log.debug("deploy config: %d bytes", len(config))
subprocess.run(["rsync", "-a", "--", f"{site_dir}/", f"{host}:/srv/site"], check=True)
return retries + 1
Narration moved to logging, which the command layer routes to stderr; paths became Path objects; the file is read with an explicit encoding; the shell is gone and -- protects against option injection; and the parameter's type makes the None case impossible. None of this required a test, a debugger or a bug report.
Checks that are not about code
Two small checks outside Python source files catch bugs specific to CLI projects and are worth adding to the same lint job:
- Workflow and config files.
actionlintfor GitHub Actions workflows,check-yamlandcheck-tomlfrom pre-commit's standard hooks, andvalidate-pyprojectforpyproject.toml, which catches invalid metadata before a build does. A typo in[project.scripts]is a CLI with no command. - Help text and documentation. If your README or docs include
--helpoutput, regenerate and diff it in CI so documentation cannot drift from the real interface. The approach is covered in generating man pages and docs from a CLI.
Portability checks: other platforms, other Pythons
A CLI supports a range of Python versions and several operating systems, and static analysis can check some of that range without running anything there.
Oldest supported Python. Set Ruff's target-version and mypy's python_version to the oldest version in requires-python. mypy then flags standard-library APIs added later — Path.walk() (3.12), tomllib (3.11), itertools.batched (3.12) — and Ruff avoids suggesting syntax your users cannot run. Pair it with the oldest-version CI job described in testing a CLI across Python versions with GitHub Actions; static and runtime checks catch different subsets.
Windows. mypy can check platform-specific branches with --platform win32, verifying that code under if sys.platform == "win32": uses only APIs that exist there. Running mypy once per platform in CI (mypy --platform linux, --platform win32, --platform darwin) costs seconds and catches calls to os.getuid, fcntl or signal.SIGKILL on paths Windows will take. Ruff's PTH rules push code towards pathlib, which removes the most common source of separator bugs, and PLW1514 removes the most common source of encoding bugs.
Optional dependencies. If parts of the CLI use extras — mytool[s3] — type-check with and without them installed, or guard the imports so mypy sees the fallback path. A missing optional import that only fails for users who did not install the extra is a classic late-discovered bug.
Keeping the checks trusted
Static checks only help while people trust them. Three habits keep that trust:
- Treat a noisy rule as a bug in the configuration. If a rule produces mostly false positives in your codebase, disable it with a comment explaining why, rather than littering the code with
# noqa. - Make suppressions specific and explained.
# noqa: S603 # argv is a fixed listtells the next reader what was considered; a bare# noqahides everything on the line. Ruff'sRUF100flags suppressions that no longer suppress anything. - Upgrade tools on purpose. A Dependabot or Renovate pull request that bumps Ruff and shows the new findings in CI is a small, reviewable change. An unpinned tool that changes under you is how teams end up disabling checks in frustration.
Key takeaways
- Static checks find CLI-specific bugs — shell injection, missing encodings, stray prints,
Nonefrom optional flags — without writing tests. - Ruff covers linting and formatting; enable
S,PTH,BandT20beyond the defaults, and settarget-versionto your oldest Python. - Annotate command parameters with
Annotated, and givectx.obja real type. - Use import-linter to keep core logic free of CLI frameworks and heavy libraries.
- Run one configuration in the editor, in pre-commit and in CI; adopt strictness in stages.
Frequently asked questions
mypy or pyright?
Both work well on CLI code. mypy is the long-standing default with a plugin ecosystem and fine-grained per-module configuration; pyright is faster and powers VS Code's Pylance, so many developers already see its errors. Pick one for CI and let people use either in their editor; they agree on the vast majority of real bugs.
Do I still need Black and isort?
No. ruff format is designed to produce Black-compatible output, and Ruff's I rules sort imports compatibly with isort's defaults. Replacing them removes two dependencies and two configuration sections.
How do I stop a new rule from failing hundreds of existing lines?
Enable it with --add-noqa: ruff check --select PTH --add-noqa inserts a targeted suppression comment on every current violation, so the rule applies to all new code immediately while the existing suppressions become a visible to-do list you can burn down file by file. For mypy, the equivalent is a per-module override that relaxes strictness for legacy modules only.
Should tests be type-checked?
Lightly. Type errors in tests are real bugs too, but requiring full annotations on every test function adds noise. Check tests with disallow_untyped_defs = false, so calls into your typed code are still verified.
What about docstring and complexity rules?
Ruff offers pydocstyle (D) and complexity (C90, PLR) rules. Docstring rules are worth enabling on public modules, because Typer and Click use docstrings as help text and a missing one becomes an empty help entry. Complexity limits are useful as a prompt rather than a gate: a command function with a McCabe complexity of twenty usually wants its logic moved into a core module.
Will these checks slow down development?
Ruff is fast enough to run on every save and every commit without anyone noticing. mypy on a mid-sized CLI takes a few seconds with its cache warm. lint-imports is similar. If CI lint takes more than a minute, something is misconfigured — usually the cache directory or checking a virtual environment by accident.