A filesystem structure and symbol-placement linter for Python projects. Declare rules about how your source tree is laid out — which directories must exist, which components may live where, whether tests mirror source, how many symbols a module may define — and catch violations at commit time.
psl is the structural sibling of python-dependency-linter (import direction) and python-naming-linter (identifier names). It never inspects imports or names — only the shape of the tree and the population of each module.
pip install python-structure-linterOr with uv:
uv add python-structure-linterCreate .python-structure-linter.yaml in your project root:
include: [src]
exclude: ["**/__pycache__/**"]
rules:
- name: context-layers
type: directory-exists
description: Each context has domain, application, and adapters layers
within: src/contexts/{ctx}
require: [domain, application, adapters]
- name: one-class-per-domain-module
type: symbol-count
description: Domain modules define a single class
within: src/contexts/{ctx}/domain/**
symbol: class
max: 1
ignore_files: [__init__.py]Run:
psl checkEach rule declares a type (the primitive) plus type-specific options. The engine
is neutral — your project's taxonomy (layer names, component directories) lives
entirely in the config values, never in the rule vocabulary.
Every directory matching within must contain the require directories.
- name: context-layers
type: directory-exists
within: src/contexts/{ctx}
require: [domain, application, adapters]A scope directory may only contain the allowed child names. With
group_level: optional, a child directory that is not itself an allowed name is
treated as a one-level grouping tier (e.g. a subdomain) and passes only if all of
its own children are allowed.
- name: domain-components
type: allowed-children
within: src/contexts/{ctx}/domain
kind: dir # dir (default) | file | any
group_level: optional # none (default) | optional
allow: [entities, value_objects, events, services, errors]
ignore: [__init__.py]The source and mirror trees must mirror each other at directory granularity.
direction selects which way the requirement runs:
mirror-to-source(default): every mirror directory must have a source counterpart, so the mirror tree invents no structure of its own (a test's location is determined by the source it covers).source-to-mirror: every source directory must have a mirror counterpart.both: both requirements hold.
mirror_extra_allowed exempts mirror-side directories (e.g. test scaffolding)
from the mirror-to-source check; source_extra_allowed exempts source-side
directories from the source-to-mirror check.
- name: tests-mirror-src
type: mirror
source: src
mirror: tests
direction: mirror-to-source
granularity: directory
ignore: [__pycache__]
mirror_extra_allowed: [doubles, fixtures, conftest]Bound the number of top-level symbols (classes or functions) a module may define.
Nested definitions are not counted. visibility: public counts only names that
do not start with an underscore, so you can constrain a module's public surface
(e.g. exactly one public function) while leaving private helpers unrestricted.
- name: one-error-per-file
type: symbol-count
within: src/contexts/{ctx}/domain/**/errors/**
symbol: class
max: 1
ignore_files: [__init__.py]
- name: one-public-function-per-checker
type: symbol-count
within: src/checks/**
symbol: function
visibility: public # any (default) | public
min: 1
max: 1within, and the tree roots in mirror, are project-root-relative paths whose
segments may be:
| Token | Matches |
|---|---|
| literal | a segment with that exact name |
* |
exactly one segment |
** |
zero or more segments |
{name} |
one segment, captured as name |
File-targeting rules (symbol-count) match .py files; the others match
directories.
File-based rules honor an inline ignore comment:
# psl: ignore[one-error-per-file]Use # psl: ignore (no brackets) to skip every rule for that file.
include restricts the scanned roots; exclude drops matching paths. Both take
project-root-relative glob patterns.
psl reads .python-structure-linter.yaml, or [tool.python-structure-linter]
in pyproject.toml, searching upward from the current directory.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/heumsi/python-structure-linter
rev: v0.1.0
hooks:
- id: python-structure-linterMIT