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 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 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
$ pymaxlines --max-lines 20 --max-lines-per-function 5 src/app/service.py
src/app/service.py:1: Too many lines in module (21 > 20) [max-lines]
src/app/service.py:14: Too many lines in function 'handle_request' (8 > 5, lines 14-22) [max-lines-per-function]
Found 2 errors.
Findings from the two size rules end with [max-lines] or [max-lines-per-function], which are
the names you pass to # pymaxlines: disable=<rule>. The [unused-disable-directive] id appears
only with --report-unused-disable-directives; it counts as an error like any other finding and
cannot itself be suppressed.
Installation
Run directly with uvx (no install needed):
uvx pymaxlines
Or install into a project:
uv add --dev pymaxlines
The package is a standard PyPI wheel with no dependencies — pip install pymaxlines and
pipx run pymaxlines work the same way. 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.7.0
hooks:
- id: check-max-lines
The shipped hook passes --force-exclude, so exclude patterns from [tool.pymaxlines] apply
automatically. --force-exclude matches explicit paths by directory component and ancestor prefix,
so bare directory names work while path globs may not. Add args: [--no-force-exclude] to skip
them.
Usage
With no arguments, pymaxlines checks every *.py file under the current directory recursively.
Pass directories or files to scope the check:
pymaxlines src/ tests/
Discovery skips .git, .venv*, node_modules, __pycache__, .tox, .nox, .eggs, and
symlinked directories. Files named explicitly on the command line are checked as given, even if they
match a skip directory or lack a .py suffix.
Use --exclude to skip files or directories by glob pattern (repeatable):
pymaxlines --exclude "migrations" --exclude "generated"
pymaxlines matches each pattern against the bare filename, the path relative to the directory
being walked, and that path prefixed with the walked directory. A bare name like generated matches
at any depth; running pymaxlines src/ accepts either generated/*.py or src/generated/*.py.
--exclude on the command line replaces the exclude list from the config file.
A file is a test file when its path, relative to the working directory, contains a tests
component, or when its name starts with test_ or ends with _test.py. A tests directory
above the working directory does not count; only the filename conventions apply there. Everything
else is a source file. Source and test files have separate limits.
Flags
| Flag | Scope | Default | Meaning |
|---|---|---|---|
--max-lines |
source files | 400 | code lines per file |
--max-lines-test |
test files | 800 | code lines per file |
--max-lines-per-function |
source files | 60 | code lines per function; 0 disables |
--max-lines-per-function-test |
test files | 0 | code lines per function; 0 disables |
--skip-blank-lines |
all files | True | exclude blank lines from counts |
--skip-comments |
all files | True | exclude comment-only lines |
--skip-docstrings |
all files | True | exclude standalone docstrings |
--report-unused-disable-directives |
all files | False | fail on directives that suppress nothing |
--force-exclude |
— | False | apply exclude globs to explicit paths too |
--exclude GLOB |
— | — | skip matching files/directories (repeatable) |
--show-sizes |
all files | False | print a code-line breakdown instead of checking limits |
--config PATH |
— | — | read config from PATH instead of pyproject |
-v, --version |
— | — | print version and exit |
The --skip-*, --report-unused-disable-directives, and --force-exclude flags each have a
--no- counterpart. All four limit flags reject negative values.
Exit codes
| Code | Meaning |
|---|---|
| 0 | No findings (empty discovery prints a warning and still exits 0) |
| 1 | One or more findings, an invalid # pymaxlines: directive, a file that could not be read or parsed, or a broken stdout pipe |
| 2 | Invalid usage, negative limit, or config-file error (unknown key, wrong type, unparsable file, or a --config path that does not exist) |
Size breakdown
--show-sizes prints a code-line breakdown of every file instead of checking limits, largest first.
Counts respect --skip-* settings, so they match what the check enforces. The run exits 0 even when
files exceed their limits; only unreadable files, parse failures, directive errors, and a broken
stdout pipe produce exit 1.
Function rows show count/limit when the applicable per-function limit is non-zero; other rows show
a plain count. A function row's span begins at its first decorator while its count starts at def.
$ pymaxlines --show-sizes --max-lines 20 --max-lines-per-function 5 src/app/service.py
src/app/service.py: 21/20 code lines!
1-3 imports 3
6-22 class RequestHandler 14
├ 9-12 def __init__ 3/5
└ 14-22 def handle_request 8/5!
25-28 def validate 3/5
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"]
Supported keys: max-lines, max-lines-test, max-lines-per-function,
max-lines-per-function-test, skip-blank-lines, skip-comments, skip-docstrings,
report-unused-disable-directives, force-exclude, and exclude (list of strings). --config,
--version, and --show-sizes are command-line only. CLI flags override the
config file; absent keys keep their built-in defaults.
Suppressing a finding
Add a # pymaxlines: disable comment to exempt a file or function 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
The directive must share a line with part of the def signature, from def through the closing
colon. A comment-only line inside the parentheses is misplaced, and a decorator line sits above the
header — a directive on either is reported as misplaced:
def big_handler(
request: Request,
db: Session,
): # pymaxlines: disable=max-lines-per-function
...
Directive syntax
# pymaxlines: disable without =rule disables every rule at its scope. Separate multiple rules
with commas: # pymaxlines: disable=max-lines,max-lines-per-function.
A directive can share its # line with other comments:
def big_handler(...): # noqa: C901 # pymaxlines: disable.
max-lines is valid only at file scope. max-lines-per-function is valid at file scope and on
def lines. The directive is case-sensitive and the colon is required; # PyMaxLines: disable or
# pymaxlines disable exits 1. Use at most one pymaxlines: directive per line — combine rules
with commas instead. An unknown rule, a malformed directive, or a misplaced directive exits 1.
What counts as a code line
By default (all --skip-* flags on):
- Blank lines, comment-only lines, and standalone docstrings are free.
- Every other line counts, including non-blank lines inside multi-line strings.
- A
defheader counts toward the file total but not toward that function's own count. - Nested functions count toward the enclosing function.
- Decorator lines count toward the file total but not toward the decorated function.
pymaxlineschecks methods, async functions, and nested functions. It does not check lambdas.- A function-level directive exempts only its own
def— nested functions still need their own.
Turning a --skip-* flag off makes that category count toward both file and function totals.
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 the
pre-commit-stage hooks; task test runs the pytest suite; task test:matrix runs it 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.7.0.tar.gz.
File metadata
- Download URL: pymaxlines-0.7.0.tar.gz
- Upload date:
- Size: 24.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9d0393d204125e98ff7c6051be3a6a698aea27d979265e60b74097560f9c633e
|
|
| MD5 |
c2d99fa708b98cb52d96fb9a9a6c5299
|
|
| BLAKE2b-256 |
131c90145d3219c5bdfcb20273705fba7db13c7e2978ac6dcaa84c87ab9c89a3
|
Provenance
The following attestation bundles were made for pymaxlines-0.7.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.7.0.tar.gz -
Subject digest:
9d0393d204125e98ff7c6051be3a6a698aea27d979265e60b74097560f9c633e - Sigstore transparency entry: 2751934327
- Sigstore integration time:
-
Permalink:
jeffzi/pymaxlines@1ba36c2d1e9a91bb0c41114f30ff85a704d03bac -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/jeffzi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1ba36c2d1e9a91bb0c41114f30ff85a704d03bac -
Trigger Event:
release
-
Statement type:
File details
Details for the file pymaxlines-0.7.0-py3-none-any.whl.
File metadata
- Download URL: pymaxlines-0.7.0-py3-none-any.whl
- Upload date:
- Size: 27.9 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 |
d092a045064bfd41cfca2e953b3096e1148db49088c2cc484304177247440b5c
|
|
| MD5 |
447eb92d4bb887d9d9932e82f49b380a
|
|
| BLAKE2b-256 |
d014d84aec8c13ccdbf76424821454e9cb475254fa2e260f48044a89f83a4a6e
|
Provenance
The following attestation bundles were made for pymaxlines-0.7.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.7.0-py3-none-any.whl -
Subject digest:
d092a045064bfd41cfca2e953b3096e1148db49088c2cc484304177247440b5c - Sigstore transparency entry: 2751934398
- Sigstore integration time:
-
Permalink:
jeffzi/pymaxlines@1ba36c2d1e9a91bb0c41114f30ff85a704d03bac -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/jeffzi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1ba36c2d1e9a91bb0c41114f30ff85a704d03bac -
Trigger Event:
release
-
Statement type: