pymaxlines
A Python linter that fails when a file or function has too many code lines. Run it standalone or as a pre-commit hook.
Requires Python 3.12+.
- Why
- Quick example
- Installation
- Usage
- Configuration
- Suppressing a finding
- What counts as a code line
- Contributing
- License
Why
A file that runs into the thousands of lines is hard to navigate, test, and review, yet few Python
linters enforce a limit. Ruff has no max-lines rule and does not plan to add one.
McCabe complexity catches convoluted control flow but ignores sheer size — a 600-line function with
simple branches passes just fine.
The problem compounds with large language model (LLM) coding agents. Long files exhaust the context window and push agents toward destructive rewrites — splitting a file on a token boundary instead of a logical one. Enforcing a line budget per file and per function keeps the codebase in a shape that both humans and agents can work with.
pymaxlines counts only code lines, the way oxlint's max-lines rule does
with skipBlankLines and skipComments, so docstrings, comments, and blank lines stay free. It
applies one limit per file and one per function, with separate thresholds for test files.
Quick example
A failing run prints one line per finding and exits 1:
$ pymaxlines src/app/service.py
src/app/service.py: 412 code lines (max 400)
src/app/service.py:88: function 'handle_request' has 73 code lines (max 60)
Installation
Run directly with uvx (no install needed):
uvx pymaxlines
Or install into a project:
uv add --dev pymaxlines
When installed, python -m pymaxlines is equivalent to the pymaxlines command.
Pre-commit hook
Add the hook to .pre-commit-config.yaml and run pre-commit install or
prek install:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/jeffzi/pymaxlines
rev: v0.5.0
hooks:
- id: check-max-lines
The hook receives explicit file paths from the pre-commit framework and checks only those files. The
shipped hook passes --force-exclude, so exclude patterns from [tool.pymaxlines] are honored
automatically. To check every file pre-commit passes and ignore exclude patterns, add
args: [--no-force-exclude]. Pre-commit's own exclude: key remains available for coarser
filtering. Limits and skip-* settings from [tool.pymaxlines] in the repo root still apply.
Usage
File discovery
With no arguments, pymaxlines checks every *.py file under the current directory recursively:
$ pymaxlines
pkg/big.py: 412 code lines (max 400)
Pass one or more directories to scope the check:
pymaxlines src/ tests/
Discovery skips these directories at any depth: .git, .venv*, node_modules, __pycache__,
.tox, .nox, .eggs. The .venv* entry is a glob, so .venv, .venv-3.12, and .venv312 are
all pruned. Symlinked directories are also skipped. A path reached more than once (for example a
file also covered by a directory argument) is reported once, in first-seen order. Within each
directory, files are visited in sorted order for deterministic output.
Files named explicitly on the command line are checked as given, even if they match a skip directory,
an exclude glob, or lack a .py suffix. Pass --force-exclude to apply exclude globs to
explicit paths too. Files and directories can be mixed in one invocation.
Exclude globs
Use --exclude to skip files or directories by glob pattern:
pymaxlines --exclude "migrations" --exclude "generated/*.py"
A glob matches the discovered path (src/generated/parser.py) or the final path component
(migrations). A matched directory is not descended into. --exclude on the command line replaces
the exclude list from the config file.
Source and test files
A file is a test file when, relative to the current directory, the path has a tests component,
or the filename starts with test_ or ends with _test.py. A file outside the current directory is
classified by its filename only. Everything else is a source file.
Flags
| Flag | Applies to | Default | Meaning |
|---|---|---|---|
--max-lines |
source | 400 | code lines per file |
--max-lines-test |
test | 800 | code lines per file |
--max-lines-per-function |
source | 60 | code lines per function; 0 disables |
--max-lines-per-function-test |
test | 0 | code lines per function; 0 disables |
--skip-blank-lines |
all | True | exclude blank lines from counts |
--skip-comments |
all | True | exclude comment-only lines |
--skip-docstrings |
all | True | exclude standalone docstrings |
--report-unused-disable-directives |
all | False | fail on directives that suppress nothing |
--force-exclude |
explicit | False | apply exclude globs to explicitly passed paths |
--exclude GLOB |
discovery | — | skip matching files/directories (repeatable) |
--config PATH |
— | — | read config from PATH instead of pyproject.toml |
-v, --version |
— | — | print version and exit |
Each --skip-* flag has a --no-skip-* counterpart, and --report-unused-disable-directives has
--no-report-unused-disable-directives. --max-lines and --max-lines-test have no disable
value — 0 fails any file containing code. All four limit flags reject negative values.
Exit codes
| Code | Meaning |
|---|---|
| 0 | No findings. When discovery matches no .py files, pymaxlines prints a warning to stderr |
| and still exits 0. | |
| 1 | One or more findings, or a file that could not be read or parsed. |
| 2 | Invalid CLI usage, a negative limit, or an invalid/unknown config key. |
Configuration
pymaxlines reads defaults from [tool.pymaxlines] in the working directory's pyproject.toml:
[tool.pymaxlines]
max-lines = 300
max-lines-per-function = 40
exclude = ["migrations", "generated/*.py"]
Supported keys
| Key | Type | Default |
|---|---|---|
max-lines |
integer | 400 |
max-lines-test |
integer | 800 |
max-lines-per-function |
integer | 60 |
max-lines-per-function-test |
integer | 0 |
skip-blank-lines |
boolean | true |
skip-comments |
boolean | true |
skip-docstrings |
boolean | true |
report-unused-disable-directives |
boolean | false |
force-exclude |
boolean | false |
exclude |
list of strings | [] |
Precedence
A flag on the command line overrides the config file. A key absent from the config file keeps its built-in default. Order: built-in default < config file < CLI flag.
Error handling
An unknown key, a wrong-typed value, or a negative limit in the config file exits 2 with an error
naming the key and the file. --config PATH reads from a specific file; a missing or invalid TOML
path exits 2 with an error naming the path.
Suppressing a finding
Add a # pymaxlines: disable comment to exempt a file or function from the checks instead of
raising the global limit.
File-level — place the directive on a comment-only line before the first statement (after the module docstring is fine):
"""This generated module is intentionally large."""
# pymaxlines: disable=max-lines
import re
# ...
Function-level — trail the directive on any line of the def header, from def through the
colon. On a decorated function, place it on the def line, not a decorator line:
def big_handler(
request: Request,
db: Session,
): # pymaxlines: disable=max-lines-per-function
...
Bare disable — # pymaxlines: disable without =rule disables every rule at its scope.
At file scope it suppresses both the file check and every function check; on a def line it exempts
that function.
Separate several rules with commas: # pymaxlines: disable=max-lines,max-lines-per-function.
Only one directive is allowed per line — a second pymaxlines: segment is an error.
A directive can share its # line with other comments:
def big_handler(...): # noqa: C901 # pymaxlines: disable.
Any # segment whose first word is pymaxlines (any case, not followed by a word character) is a
directive attempt and must use the canonical pymaxlines: <action> form. A segment that matches but
does not parse fails the run — a wrong-case prefix (PYMAXLINES:), a missing colon
(pymaxlines disable), and a non-colon separator (pymaxlines-disable) are common examples, but
any non-canonical form triggers the same error. A word character immediately after pymaxlines
suppresses the check, so pymaxlines_disable is not treated as an attempt.
Each rule is only valid at certain scopes:
| Rule | Valid scopes |
|---|---|
max-lines |
file |
max-lines-per-function |
file, def |
An unknown rule name, a malformed directive, or a misplaced directive fails the run with a
diagnostic message, even when the file is within its limits. A directive is misplaced when it
appears on a comment-only line after the module's first statement, inside a function body, or
trailing a non-def statement (including decorator lines). Naming a file-scope-only rule on a def
line is a separate error —
# pymaxlines: disable=max-lines on a def line fails with
rule 'max-lines' does not apply to a function; use max-lines-per-function.
Pass --report-unused-disable-directives to fail the run on directives that suppress no findings.
pymaxlines reports a directive for a disabled check (limit 0) as unused. It reports a bare
disable as unused only when neither check would have fired, and never reports a directive that
already failed validation.
What counts as a code line
By default (all --skip-* flags on):
- Blank lines, comment-only lines, and standalone docstrings (module, class, or function) are free.
- Whitespace-only lines inside a multi-line string are free.
- Every other line counts once, including non-blank lines inside multi-line strings.
- A function's count includes its
defline and every code line up to the end of its body, nested functions included. - Methods, async functions, and nested functions are each checked against the per-function limit; lambdas are not.
- A function-level directive exempts only the function whose
defline it sits on — a nested function still needs its own directive. - Decorator lines count toward the file total but not toward the decorated function's count.
Turning a --skip-* flag off (e.g. --no-skip-blank-lines) makes that category count toward both
file and function totals.
pymaxlines reports unreadable files and directories it cannot list (missing, permission denied,
undecodable, or syntax errors) and fails the run.
Contributing
Install Task, uv, and
dprint, then run task install to sync dependencies and install
the git hooks. task --list shows the full development workflow. task check runs every hook;
task test:matrix runs the suite on each supported Python version.
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pymaxlines-0.5.0.tar.gz.
File metadata
- Download URL: pymaxlines-0.5.0.tar.gz
- Upload date:
- Size: 20.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8036f95a71c226d801e4999a2acf7a69e2b93645beea805157f59db162e18c38
|
|
| MD5 |
aaa4f0064495804c2123b875a1176fd9
|
|
| BLAKE2b-256 |
7caab1d21b40eef99c5b01cc7553bb36b970d15729fee08d6b63d600e485e3df
|
Provenance
The following attestation bundles were made for pymaxlines-0.5.0.tar.gz:
Publisher:
publish.yml on jeffzi/pymaxlines
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pymaxlines-0.5.0.tar.gz -
Subject digest:
8036f95a71c226d801e4999a2acf7a69e2b93645beea805157f59db162e18c38 - Sigstore transparency entry: 2725902950
- Sigstore integration time:
-
Permalink:
jeffzi/pymaxlines@c336e0d8ea520c31f628b1b51ca56b80411c5ecf -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/jeffzi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c336e0d8ea520c31f628b1b51ca56b80411c5ecf -
Trigger Event:
release
-
Statement type:
File details
Details for the file pymaxlines-0.5.0-py3-none-any.whl.
File metadata
- Download URL: pymaxlines-0.5.0-py3-none-any.whl
- Upload date:
- Size: 22.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
840695de7d8cff07d8c7029d4b7ec61c56573fa8d7fd70103dfeec1381c92e5a
|
|
| MD5 |
3ce2679d73bfd889763c88190ee76e60
|
|
| BLAKE2b-256 |
f79affe5e7e6a8faf5f0179b66e1dcecf989e3381ff88dacd67e79605d11d26d
|
Provenance
The following attestation bundles were made for pymaxlines-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on jeffzi/pymaxlines
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pymaxlines-0.5.0-py3-none-any.whl -
Subject digest:
840695de7d8cff07d8c7029d4b7ec61c56573fa8d7fd70103dfeec1381c92e5a - Sigstore transparency entry: 2725903081
- Sigstore integration time:
-
Permalink:
jeffzi/pymaxlines@c336e0d8ea520c31f628b1b51ca56b80411c5ecf -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/jeffzi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c336e0d8ea520c31f628b1b51ca56b80411c5ecf -
Trigger Event:
release
-
Statement type: