Metadata-Version: 2.4
Name: pymaxlines
Version: 0.5.0
Summary: Linter that caps code lines per file and per function
Keywords: code-quality,code-size,linter,max-lines,pre-commit,static-analysis
Author: jeffzi
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Typing :: Typed
Requires-Python: >=3.12
Project-URL: Repository, https://github.com/jeffzi/pymaxlines
Project-URL: Changelog, https://github.com/jeffzi/pymaxlines/blob/main/CHANGELOG.md
Description-Content-Type: text/markdown

# pymaxlines

[![PyPI](https://img.shields.io/pypi/v/pymaxlines)](https://pypi.org/project/pymaxlines/)
[![Python: 3.12 | 3.13 | 3.14](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue.svg)](https://github.com/jeffzi/pymaxlines)
[![CI](https://github.com/jeffzi/pymaxlines/actions/workflows/pytest.yml/badge.svg)](https://github.com/jeffzi/pymaxlines/actions/workflows/pytest.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jeffzi/pymaxlines/blob/main/LICENSE)

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](#why)
- [Quick example](#quick-example)
- [Installation](#installation)
- [Usage](#usage)
  - [Source and test files](#source-and-test-files)
- [Configuration](#configuration)
- [Suppressing a finding](#suppressing-a-finding)
- [What counts as a code line](#what-counts-as-a-code-line)
- [Contributing](#contributing)
- [License](#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][ruff-c0302].
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`][oxlint-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.

[ruff-c0302]: https://github.com/astral-sh/ruff/issues/25001
[oxlint-max-lines]: https://oxc.rs/docs/guide/usage/linter/rules/eslint/max-lines.html

## Quick example

A failing run prints one line per finding and exits 1:

```console
$ 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):

```bash
uvx pymaxlines
```

Or install into a project:

```bash
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`][prek]:

```yaml
# .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.

[prek]: https://github.com/j178/prek

## Usage

### File discovery

With no arguments, `pymaxlines` checks every `*.py` file under the current directory recursively:

```console
$ pymaxlines
pkg/big.py: 412 code lines (max 400)
```

Pass one or more directories to scope the check:

```bash
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:

```bash
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`:

```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):

```python
"""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:

```python
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 `def` line 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 `def` line 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](https://taskfile.dev), [uv](https://docs.astral.sh/uv/), and
[dprint](https://dprint.dev/install/), 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](https://github.com/jeffzi/pymaxlines/blob/main/LICENSE).
