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, a summary, and exits 1:
$ pymaxlines src/app/service.py
src/app/service.py:1: Too many lines in module (412 > 400) [max-lines]
src/app/service.py:88: Too many lines in function 'handle_request' (73 > 60) [max-lines-per-function]
Found 2 errors.
Each limit finding ends with its rule id — [max-lines] or [max-lines-per-function] — which is the
name you pass to # pymaxlines: disable=<rule>. The [unused-disable-directive] id identifies a
finding only; it is not a rule you can disable.
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.
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 pymaxlines honors exclude patterns from
[tool.pymaxlines] 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:1: Too many lines in module (412 > 400) [max-lines]
Found 1 error.
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. pymaxlines also skips symlinked directories. When a path appears more than once (for
example a file also covered by a directory argument), pymaxlines reports it once, in first-seen
order. Within each directory, pymaxlines visits files in sorted order for deterministic output.
pymaxlines checks files named explicitly on the command line 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"
Each exclude glob is tested against three candidates: the filename alone (parser.py), the path
relative to the walked directory (generated/parser.py), and that relative path re-joined to the
walked directory (src/generated/parser.py when walking src/). A pattern must match the entire
candidate string, so generated/*.py works only when generated/ sits directly under the walked
root — use generated to match the directory component at any depth. pymaxlines skips a matched
directory and everything beneath it. --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. pymaxlines classifies a file outside
the current directory 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 command-line 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"]
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 malformed
configuration file 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. Common causes: a wrong-case prefix (PYMAXLINES:), a missing colon
(pymaxlines disable), and a non-colon separator (pymaxlines-disable). Any other non-canonical
form triggers the same error. A word character immediately after pymaxlines suppresses the check,
so pymaxlines does not treat pymaxlines_disable 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 finding,
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
defheader (thedefkeyword through the closing:) counts toward the file total but not toward that function's own count; a nested function's header counts toward the enclosing function. - A one-liner's body line (
def f(): return 1) still counts because it carries body code. pymaxlinesincludes nested functions in the enclosing function's count.pymaxlineschecks methods, async functions, and nested functions against the per-function limit; it does not check lambdas.- 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.6.0.tar.gz.
File metadata
- Download URL: pymaxlines-0.6.0.tar.gz
- Upload date:
- Size: 21.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 |
34c4ca03fe09d6af1a20553c01940fc2447a8eb68679419c166fc83d92b8d203
|
|
| MD5 |
6c7e013d642dfd09f0475c421d83c079
|
|
| BLAKE2b-256 |
a848de7d7f12f1bedf4238601d959dd9a10c8ebe8c5452370bd6bdfff45b6adf
|
Provenance
The following attestation bundles were made for pymaxlines-0.6.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.6.0.tar.gz -
Subject digest:
34c4ca03fe09d6af1a20553c01940fc2447a8eb68679419c166fc83d92b8d203 - Sigstore transparency entry: 2730739586
- Sigstore integration time:
-
Permalink:
jeffzi/pymaxlines@72f2dcaffddda1582b5e7b9f0d6f5ac32d31b0cd -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/jeffzi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@72f2dcaffddda1582b5e7b9f0d6f5ac32d31b0cd -
Trigger Event:
release
-
Statement type:
File details
Details for the file pymaxlines-0.6.0-py3-none-any.whl.
File metadata
- Download URL: pymaxlines-0.6.0-py3-none-any.whl
- Upload date:
- Size: 23.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 |
b6f37b11479e223505c8d2ce91ac77b7555127eca906cd052db84405d1a1e2d9
|
|
| MD5 |
e8e189f2134d02f83212524b456c1f7e
|
|
| BLAKE2b-256 |
e6c3c798a32ea5133b4ad71102b9eea641a8798836df45c85b50819b2b0d0321
|
Provenance
The following attestation bundles were made for pymaxlines-0.6.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.6.0-py3-none-any.whl -
Subject digest:
b6f37b11479e223505c8d2ce91ac77b7555127eca906cd052db84405d1a1e2d9 - Sigstore transparency entry: 2730740327
- Sigstore integration time:
-
Permalink:
jeffzi/pymaxlines@72f2dcaffddda1582b5e7b9f0d6f5ac32d31b0cd -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/jeffzi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@72f2dcaffddda1582b5e7b9f0d6f5ac32d31b0cd -
Trigger Event:
release
-
Statement type: