Skip to content

Repository files navigation

python-structure-linter

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.

Installation

pip install python-structure-linter

Or with uv:

uv add python-structure-linter

Quick Start

Create .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 check

Rule Types

Each 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.

directory-exists

Every directory matching within must contain the require directories.

- name: context-layers
  type: directory-exists
  within: src/contexts/{ctx}
  require: [domain, application, adapters]

allowed-children

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]

mirror

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]

symbol-count

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: 1

Path Patterns

within, 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.

Inline Ignore

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 / Exclude

include restricts the scanned roots; exclude drops matching paths. Both take project-root-relative glob patterns.

Configuration File

psl reads .python-structure-linter.yaml, or [tool.python-structure-linter] in pyproject.toml, searching upward from the current directory.

Pre-commit

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/heumsi/python-structure-linter
    rev: v0.1.0
    hooks:
      - id: python-structure-linter

License

MIT

About

A filesystem structure and symbol-placement linter for Python projects

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages