Ruff's defaults — pycodestyle errors and pyflakes — catch undefined names and unused imports, which is useful but misses most of the bugs that actually hurt command-line tools. The rules that matter for a CLI are about shell usage, text encodings, portable paths, and making sure only the command layer writes to the terminal. This guide builds a Ruff configuration for a CLI project rule set by rule set, explains why each matters for command-line code specifically, adds per-path exceptions that encode the project's architecture, sets up formatting, and shows how to adopt it on an existing codebase without a thousand-line cleanup commit. It is part of the linting and type-checking topic.
Prerequisites
- A CLI project with a
pyproject.tomland asrc/layout. - Ruff installed as a pinned development dependency:
uv add --dev ruff. Pinning matters — new Ruff releases add rules and occasionally change formatting, and you want those changes to arrive as a reviewed lockfile update.
The recipe: a CLI-oriented configuration
# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py310" # the oldest Python in requires-python
src = ["src", "tests"]
extend-exclude = ["scripts/vendor"]
[tool.ruff.lint]
select = [
"E", "W", "F", # pycodestyle, pyflakes: the baseline
"I", # import sorting
"B", # bugbear: likely bugs
"UP", # pyupgrade: modern syntax for target-version
"S", # bandit: security, especially subprocess
"PTH", # prefer pathlib to os.path
"T20", # print statements
"SIM", # simplifications, including unclosed files
"RUF", # Ruff's own rules, including unused noqa
"PLW1514", # open() in text mode without encoding
]
ignore = [
"E501", # line length is the formatter's job
"S404", # importing subprocess is fine; how you call it is checked
]
preview = true # PLW1514 is a preview rule in current Ruff releases
explicit-preview-rules = true # ...but only preview rules named explicitly
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "S603", "S607", "T20"]
"src/mytool/cli.py" = ["T201"]
"src/mytool/commands/**" = ["T201"]
"scripts/**" = ["T20", "S603", "S607"]
[tool.ruff.lint.isort]
known-first-party = ["mytool"]
[tool.ruff.format]
docstring-code-format = true
The preview pair deserves a note: preview = true alone would enable every preview rule in the selected families, which change between releases; explicit-preview-rules = true limits it to preview rules you name exactly, such as PLW1514.
Why these rule sets, for a CLI
S (security). The flake8-bandit rules find subprocess calls with shell=True (S602, S604), os.system (S605), unsafe yaml.load (S506), hard-coded passwords and temporary paths like /tmp/foo (S108). For a CLI that shells out, S602 alone justifies the family — it flags exactly the pattern described in avoiding shell injection in Python CLIs. S404 (importing subprocess at all) is noise for a tool whose job involves running programs, which is why it is ignored.
PTH (pathlib). Flags os.path.join, os.listdir, open() and friends in favour of pathlib equivalents. Path-handling code written with pathlib has far fewer platform bugs, as discussed in cross-platform paths with pathlib.
PLW1514 (unspecified encoding). Flags text-mode open(), read_text() and friends without encoding=. Until UTF-8 mode is the default everywhere, these use the locale encoding — often cp1252 on Windows — and are the single most common source of Windows-only CLI bugs.
T20 (print). In a CLI, printing is a deliberate act of the command layer: results to stdout, diagnostics to stderr. A print() in a core module writes to stdout behind the command's back, which breaks --json output and anything piping your tool. Ignoring T201 only in command modules encodes that rule.
B (bugbear). Catches mutable default arguments (common in hand-written option defaults), except clauses that hide KeyboardInterrupt, and loop-variable closure bugs in callbacks.
UP (pyupgrade), with the right target. Rewrites old syntax — Optional[X] to X | None, typing.List to list — but only as far as target-version allows. Set it to your oldest supported Python, or Ruff will suggest syntax your users cannot run.
What it finds in practice
Run uv run ruff check and you will usually see a handful of findings in each category on an existing CLI. Most have safe automatic fixes (ruff check --fix); security findings and encoding findings deserve a human look, because the right fix depends on what the code means. For example, a subprocess call flagged with S603 ("check for untrusted input") may be perfectly fine with a fixed argument list — in which case a specific, explained suppression is the right response:
subprocess.run(["git", "rev-parse", "HEAD"], check=True) # noqa: S603 (fixed argv)
RUF100 then flags that noqa if a later refactor makes it unnecessary, so suppressions do not accumulate silently.
Per-path ignores encode the architecture
The per-file-ignores table is more than a list of exceptions: it states where each kind of code is allowed to live. Tests may use assert and run subprocesses. The command layer may print. Everything else must go through logging or return values. When someone adds a print to src/mytool/core/plan.py, the linter explains the project's rule for you. Prefer ignoring by path over sprinkling noqa comments; a path rule documents a decision once, whereas scattered comments hide it.
Formatting
ruff format is a Black-compatible formatter, so if the project used Black, switching changes almost nothing. With docstring-code-format = true it also formats code examples inside docstrings — useful for CLIs, whose command docstrings often double as help text with usage examples. Run ruff format before ruff check --fix in hooks, or let ruff check --fix handle import sorting (I) and the formatter handle layout; they are designed not to fight.
UX considerations
The "users" here are contributors, and the goal is a configuration they barely notice:
- Fast feedback. Enable the Ruff language server (or editor extension) so findings appear on save. Ruff is fast enough that there is no reason to wait for CI.
- Autofix on commit. A pre-commit hook running
ruff check --fixandruff formaton staged files turns most findings into non-events. See setting up pre-commit for Python CLI repos. - Messages that teach.
ruff rule S602prints the rationale and examples for any rule, which is a better answer to "why is this flagged?" than a link to a wiki page. - Few surprises. Pin the version, review upgrades like any dependency, and add new rule families one pull request at a time.
Adopting it on an existing codebase
Turning this configuration on in a mature CLI may produce hundreds of findings. Rather than a single enormous fix-up commit that nobody can review, stage it:
- Run
ruff formatonce, commit it alone, and add the commit hash to.git-blame-ignore-revs. - Apply safe automatic fixes:
ruff check --fix, review, commit. - Suppress the remaining findings in place with
ruff check --add-noqa, commit. The configuration is now enforced for all new code. - Burn down the inserted
noqacomments file by file in ordinary pull requests, starting withSandPLW1514, which are the most likely to be real bugs.
This keeps CI green from the first commit and makes every later change small and reviewable.
Testing the behaviour
A linter configuration is itself something you can test. Two quick checks catch most mistakes:
# 1. Does the configuration parse, and which rules are actually active?
uv run ruff check --show-settings src/mytool/cli.py | grep -A3 "linter.rules.enabled" | head
# 2. Does it catch what it should? Lint a deliberately bad snippet.
printf 'import subprocess\nsubprocess.run(f"ls {x}", shell=True)\nprint(open("f").read())\n' \
| uv run ruff check --stdin-filename src/mytool/core/probe.py -
The second command should report the shell call, the print outside the command layer, the unencoded open() and the missing context manager. Repeating it with --stdin-filename src/mytool/cli.py should drop the print finding, proving the per-path ignore works. Wiring a snippet like this into a test is overkill for most projects, but running it once after changing the configuration is a good habit.
Conclusion
A CLI's most damaging bugs — shell injection, missing encodings, non-portable paths, stray output — are exactly what Ruff's S, PLW1514, PTH and T20 rules catch. Add them to the baseline, set target-version to your oldest Python, encode your architecture in per-path ignores, pin Ruff, and adopt the configuration in stages so every commit stays green. The result is a codebase where a whole category of bug reports simply stops arriving.
Frequently asked questions
Should I select ALL and ignore what I do not want?
Some teams do, and it is a legitimate way to discover rules. The cost is that every Ruff upgrade can enable new rules that fail CI. Selecting families explicitly gives more predictable upgrades; periodically running with --select ALL in a scratch branch is a good way to find families worth adding.
What line length should a CLI project use?
Anything between 88 and 120 works; consistency matters more than the number. Help text strings are the usual source of long lines in CLI code — the formatter will not split strings, so rely on implicit string concatenation or textwrap.dedent for long help.
Does Ruff replace mypy?
No. Ruff checks syntax-level patterns and a few type-adjacent issues, but it does not do type inference across modules. Use both; they are complementary. See type-checking Click and Typer code with mypy.
How do I handle generated code?
Exclude it with extend-exclude, or apply per-file ignores to the generated directory. Linting code you do not edit by hand only produces noise.