Skip to main content

pathlint

PyPI version pre-commit

Find os.path usage and migrate supported calls to pathlib. Pathlint has a standard-library-only runtime and supports Python 3.9 and newer.

  • Detect imports, aliases, function references, calls, and usage in annotations.
  • Exclude files and directories with repeatable name or path patterns.
  • Honor Git ignore rules automatically when Git is available.
  • Get concise text diagnostics, quiet output, or JSON reports.
  • Preview experimental fixes as unified diffs before applying them.

Installation

pip install pathlint

# Verify installation
pathlint --version

# Check Python files in the current directory
pathlint check .

Git is optional. Without Git, directory scans use built-in and explicit exclusions; .gitignore rules are not interpreted.

Usage

# Check the current directory (also the behavior of bare pathlint)
pathlint check

# Check one or more files or directories
pathlint check myfile.py
pathlint check src/ tests/ scripts/

# See exactly which files will be selected, without parsing them
pathlint files .

# Preview fixes, then apply them
pathlint fix src/ --dry-run
pathlint fix src/

# Module invocation works too
python -m pathlint check .

Use pathlint --help or pathlint check --help for command help.

Excluding files and directories

All three commands use the same selection rules:

pathlint check . \
  --exclude vendor \
  --exclude 'training/wandb/*' \
  --exclude backups

pathlint files . -e '*.generated.py' -e 'src/legacy/'
pathlint fix . --exclude vendor --dry-run

# Disable Git ignores or built-in exclusions independently
pathlint check . --no-gitignore
pathlint check . --no-default-excludes

Each -e / --exclude accepts one pattern; repeat the argument for multiple patterns. Explicit exclusions add to the built-in exclusions.

  • Names without /, such as backups or test_*.py, match at any depth.
  • Patterns containing / match paths relative to the working directory. ./vendor matches the top-level vendor, while vendor matches that name anywhere. Absolute patterns can exclude paths outside the working directory.
  • Directory matches exclude their descendants. A trailing / restricts a pattern to directories.
  • Patterns use case-sensitive fnmatch syntax: *, ?, and bracket expressions. * can span /; ** has no additional meaning. Quote wildcards to prevent shell expansion. These patterns do not support Git-style ! negation.

Built-in exclusions are __pycache__, .venv, venv, .git, node_modules, .tox, build, and dist.

In Git worktrees, directory scans include tracked files and untracked files that Git does not ignore. Git handles nested .gitignore rules, negation, repository-local excludes, and global ignores. Tracked files remain eligible even when they match an ignore rule. Nonexcluded nested repositories use their own Git rules.

Explicitly named files bypass Git and built-in exclusions. Explicit --exclude patterns still apply, including to filenames supplied by pre-commit. File symlinks select their resolved Python targets once; exclusions match both the link and target paths.

Output

src/utils.py:12: PL001 os.path usage: os.path.exists(value)
Found 1 violation in 1 of 42 files checked.

Paths are relative to the working directory when possible, and absolute for external files. Text output retains the stream's encoding and escapes characters it cannot represent, including when redirected through legacy Windows encodings. Each offending source line is reported once, even if it contains several usages. PL001 means “Use pathlib instead of os.path.”

# Findings only; no summary
pathlint check . --quiet

# Show the five files with the most violations
pathlint check . --stats

# Machine-readable report
pathlint check . --output-format json

# Include offender statistics in the JSON report
pathlint check . --output-format json --stats

# Optional aggressive message
pathlint check . --aggressive

JSON reports contain diagnostics, errors, and summary. Each diagnostic has path, line, code, message, and source. Summary fields are files_checked, files_with_issues, violations, and errors; files that fail reading or parsing are not counted as checked. --stats adds a statistics list containing up to five {path, violations} entries.

JSON is available only for check and cannot be combined with --quiet or --aggressive. Runtime errors are included in the JSON report without mixing text into stdout. Text-mode errors go to stderr.

Exit codes and compatibility

Command 0 1 2
check Clean or no files selected Violations found Input, discovery, read, or parse error
fix Completed — Input, discovery, read, parse, or write error
fix --dry-run No edits proposed Edits proposed Error
files Listing completed, including empty selection — Input or discovery error

Errors take precedence over violations or proposed edits. Other valid files are still processed. Ctrl-C exits with status 130.

Existing invocations continue to work without warnings:

pathlint .
pathlint --fix src/
pathlint --dry-run src/

The original python -m pathlint.linter and python -m pathlint.autofix entry points also remain available. A valid empty selection now succeeds; dry runs now return 1 when they propose edits.

What it detects

import os
import os as system
import os.path as osp
from os import path as paths
from os.path import exists as present

os.path.exists("file")
system.path.join("directory", "file")
osp.isdir("directory")
paths.dirname(__file__)
present("file")
callback = os.path.join

Pathlint resolves imported names in function, class, and comprehension scopes so ordinary local shadowing is not mistaken for os.path. Private aliases in class scopes are left unresolved because Python mangles their names. Deleted class locals and cleared exception aliases fall back to module globals. Function and generator bodies use the enclosing scope's final bindings for detection; a generator's first iterable is inspected immediately. Dynamic rebinding can prevent resolution. Wildcard imports invalidate tracked bindings; later explicit imports can restore them. Detection inspects if branches independently and uses literal conditions when merging bindings in the current scope. Loop bindings are merged conservatively across iterations and else suites. Fixes in deferred bodies require enclosing imports to exist when the function, lambda, or generator is created, and conservatively skip aliases rebound anywhere in the file, including imports with conflicting origins. Stable imports inside the body can still be migrated. This is a static check, not full Python execution analysis.

Expressions in annotations are checked too. Valid os.PathLike annotations are not os.path usage and are not flagged. The fixer leaves annotation expressions unchanged: postponed annotations and Python 3.14's lazy annotations can resolve names after their bindings change.

Auto-fix (experimental)

# Review a unified diff without changing files
pathlint fix src/ --dry-run

# Apply supported migrations
pathlint fix src/

# Print only the diff
pathlint fix src/ --dry-run --quiet

# Disable terminal diff colors
pathlint fix src/ --dry-run --no-color

Fixes use AST locations to change expressions while preserving surrounding source text, including comments and strings. Imported aliases, named function imports, calls inside nested expressions, and expressions inside f-strings are supported. Pathlib imports follow module docstrings and future imports. Existing Pathlib aliases are reused where possible; new constructor imports use a private alias to preserve wildcard exports.

Supported calls include isabs, join, basename, dirname, and expanduser with string-literal arguments. Calls with nonliteral arguments, including variables and nested calls, remain unchanged; eligible literal calls inside them can still be fixed. Values and types are not inferred. Unsupported references retain their imports. Keyword arguments, starred arguments, call targets containing comments, literal file descriptors, byte paths, and known inexact literal forms are left unchanged. Known inexact forms include empty paths, trailing separators, named-user expansion, and rooted literals passed to isabs whose interpretation can differ under Windows path rules. All tilde-prefixed expanduser arguments remain unchanged because home values can contain spellings that Pathlib normalizes or fail to resolve. On Windows, string-returning fixes also skip literals whose separators or dot components Pathlib would normalize. Calls with uncertain branch, exception, context manager, match, or loop bindings and methods whose os.path attributes or callable internals are assigned or deleted are also left unchanged. Calls outside annotation expressions remain eligible for fixes. Mutation checks retain possible copies of imported modules and callables through assignments, loop targets, and default parameters. They inspect all expression children, conservatively including container selections and comprehensions. Calls retain possible origins from their arguments; return types are not inferred. Copies stored in object attributes, class fields, or container entries conservatively prevent fixes for the copied module or callable. The same applies when these objects are passed to calls, returned, yielded, or used by context managers or match patterns. These copies do not establish bindings for detection or migration of copied calls. Mutation origins are conservative across scopes and statement order; copies of callable internals can also exclude fixes for their exported parent. Assigning an imported module's __class__ invalidates all of its lookups because module subclasses can override attributes with descriptors. Explicit global writes prevent affected aliases, constructors, and str conversions from being used by fixes, including in module-level code. Sources using dynamic code, namespace dictionaries, or dynamic import/attribute builtins are left unchanged. This includes exec, eval, globals, locals, vars, __import__, getattr, setattr, and delattr, their bound __setattr__/__delattr__ equivalents, exposed builtins, function/frame namespaces, and module registries (sys.modules or importlib.import_module); these capabilities can overwrite aliases or generated constructors. Exposed function defaults, keyword defaults, and annotation dictionaries also prevent fixes. Bare dir() use retains imports while supported calls remain eligible. Fixes retain module-level imports for re-exports, including conditional imports and bindings declared global. Imports referenced by annotations or __all__ also remain. Retained imports can still appear in check results. Unused local imports can still be removed. Fixes avoid constructors affected by Pathlib mutations. Sources containing wildcard imports are left unchanged because their exports can overwrite names used by a fix. Sources using type parameters or type aliases are also left unchanged because their annotation scopes and lazy values are not analyzed by the fixer. abspath, splitext, sep, and pathsep are not rewritten because their former replacements changed behavior or returned incorrect values. exists, isfile, and isdir are also left unchanged: on supported Python versions, their Pathlib replacements can raise filesystem errors where os.path returns False.

Review every diff and test the result. Pathlib normalizes path strings. The fixer does not promise to remove every violation. Diff colors are enabled only on interactive terminals.

Pre-commit hook integration

Add to .pre-commit-config.yaml:

repos:
  - repo: https://github.com/pszemraj/pathlint
    rev: v0.3.0  # Use the release you want to pin
    hooks:
      - id: pathlint
        args: [--exclude, vendor, --exclude, backups]

Install and verify the hook:

pre-commit install
pre-commit run pathlint --all-files

The pathlint hook runs pathlint check. To apply experimental fixes instead, use the pathlint-fix hook, which runs pathlint fix. Review its changes before committing.

Development

pip install -e '.[dev]'
pytest
ruff check .
ruff format --check .

License

MIT License — see LICENSE.txt.

Metadata

Release files for pathlint 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pathlint 0.3.0
File Size Uploaded
pathlint-0.3.0.tar.gz 74.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pathlint 0.3.0
File Interpreter ABI Platform
pathlint-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 107.0 kB

Release files / pathlint-0.3.0.tar.gz

Download URL pathlint-0.3.0.tar.gz
Size 74.2 kB
Tags Source
SHA-256 checksum
How to use checksums
ed920ebb9a366bc7f10e273d2d64599c1380bd48584201085aea6063b8ba1272
BLAKE2b-256 checksum
How to use checksums
96ce694797d8480f7e9e7c88c882103251a101c4395c19236746d3efc4c376fc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / pathlint-0.3.0-py3-none-any.whl

Download URL pathlint-0.3.0-py3-none-any.whl
Size 32.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
25938d15626de0a9b62dd5bbb5965dc452e6dcab80f116f16b894ea607221328
BLAKE2b-256 checksum
How to use checksums
da272e2e0095677379a0352cad60d00f2f5be4c42ae2254715d4aa231c2f8f0d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

0.3.1

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page