Skip to main content

A dependency linter for Python projects

Project description

python-dependency-linter

A dependency linter for Python projects. Define rules for which modules can depend on what, and catch violations.

What It Does

  • Define dependency rules between modules using a simple YAML or TOML config
  • Detect imports that violate your rules with a single CLI command
  • Integrate into CI or pre-commit to keep your architecture consistent

For Python developers who care about module boundaries and dependency direction — whether you're applying Layered, Hexagonal, Clean Architecture, or your own conventions.

Installation

pip install python-dependency-linter

Or with uv:

uv add python-dependency-linter

Quick Start

Create .python-dependency-linter.yaml in your project root:

rules:
  - name: domain-isolation
    modules: contexts.*.domain
    allow:
      standard_library: [dataclasses, typing]
      third_party: [pydantic]
      local: [contexts.*.domain]

  - name: application-dependency
    modules: contexts.*.application
    allow:
      standard_library: ["*"]
      third_party: [pydantic]
      local:
        - contexts.*.application
        - contexts.*.domain

Run:

pdl check

Output:

contexts/boards/domain/models.py:6
    [domain-isolation] contexts.boards.domain.models → contexts.boards.application.service (local)

contexts/boards/domain/models.py:9
    [domain-isolation] contexts.boards.domain.models → sqlalchemy (third_party)

Found 2 violation(s).

Examples

Layered Architecture

Enforce dependency direction: presentation → application → domain, where domain has no outward dependencies.

rules:
  - name: domain-isolation
    modules: my_app.domain
    allow:
      standard_library: ["*"]
      third_party: []
      local: [my_app.domain]

  - name: application-layer
    modules: my_app.application
    allow:
      standard_library: ["*"]
      third_party: [pydantic]
      local:
        - my_app.application
        - my_app.domain

  - name: presentation-layer
    modules: my_app.presentation
    allow:
      standard_library: ["*"]
      third_party: [fastapi, pydantic]
      local:
        - my_app.presentation
        - my_app.application
        - my_app.domain

Hexagonal Architecture

Isolate domain from infrastructure. Ports (interfaces) live in domain, adapters depend on domain but not vice versa.

Using named captures ({context}), you can enforce that each bounded context only depends on its own domain — not other contexts' domains:

rules:
  - name: domain-no-infra
    modules: contexts.{context}.domain
    allow:
      standard_library: [dataclasses, typing, abc]
      third_party: []
      local: [contexts.{context}.domain, shared.domain]

  - name: adapters-depend-on-domain
    modules: contexts.{context}.adapters
    allow:
      standard_library: ["*"]
      third_party: ["*"]
      local:
        - contexts.{context}.adapters
        - contexts.{context}.domain
        - shared

With {context}, contexts.boards.domain can only import from contexts.boards.domain and shared.domain — not from contexts.auth.domain. See Named Capture for details.

Configuration

Include / Exclude

Control which files are scanned using include and exclude:

include:
  - src
exclude:
  - src/generated/**

rules:
  - name: ...
  • No include or exclude — All .py files under the project root are scanned
  • include only — Only files matching the given paths are scanned
  • exclude only — All files except those matching the given paths are scanned
  • Bothinclude is applied first, then exclude filters within that result

Bare directory names (e.g., src) and trailing-slash forms (e.g., src/) are treated the same as src/**.

In pyproject.toml:

[tool.python-dependency-linter]
include = ["src"]
exclude = ["src/generated/**"]

Rule Structure

Each rule has:

  • name — Rule identifier, shown in violation output
  • modules — Module pattern to apply the rule to (supports * wildcard)
  • allow — Whitelist: only listed dependencies are allowed
  • deny — Blacklist: listed dependencies are denied
rules:
  - name: rule-name
    modules: my_package.*.domain
    allow:
      standard_library: [dataclasses]
      third_party: [pydantic]
      local: [my_package.*.domain]
    deny:
      third_party: [boto3]

Import Categories

Dependencies are classified into three categories (per PEP 8):

  • standard_library — Python built-in modules (os, sys, typing, ...)
  • third_party — Installed packages (pydantic, sqlalchemy, ...)
  • local — Modules in your project

Both absolute imports (from contexts.boards.domain import models) and relative imports (from ..domain import models) are analyzed. Relative imports are resolved to absolute module names based on the file's location.

Behavior

  • No rule — Everything is allowed
  • allow only — Whitelist mode. Only listed dependencies are allowed
  • deny only — Blacklist mode. Listed dependencies are denied, rest allowed
  • allow + deny — Allow first, then deny removes exceptions
  • If allow exists but a category is omitted, that category allows all. For example:
rules:
  - name: domain-isolation
    modules: contexts.*.domain
    allow:
      third_party: [pydantic]
      local: [contexts.*.domain]
      # standard_library is omitted → all standard library imports are allowed

Use "*" to allow all within a category:

allow:
  standard_library: ["*"]  # allow all standard library imports

Wildcard

* matches a single level in dotted module paths:

modules: contexts.*.domain  # matches contexts.boards.domain, contexts.auth.domain, ...

** matches one or more levels in dotted module paths:

modules: contexts.**.domain  # matches contexts.boards.domain, contexts.boards.sub.domain, ...

Named Capture

{name} captures a single level (like *) and allows back-referencing the captured value in allow and deny:

rules:
  - name: domain-isolation
    modules: contexts.{context}.domain
    allow:
      local: [contexts.{context}.domain, shared.domain]

When this rule matches contexts.boards.domain, {context} captures "boards". The allow pattern contexts.{context}.domain resolves to contexts.boards.domain, so only the same context's domain is allowed.

You can use multiple captures in a single rule:

rules:
  - name: bounded-context-layers
    modules: contexts.{context}.{layer}
    allow:
      local:
        - contexts.{context}.{layer}
        - contexts.{context}.domain
        - shared

Named captures coexist with * and ** wildcards. {name} always matches exactly one level.

Submodule Matching

When a pattern is used in modules, allow, or deny, it also matches submodules of the matched module.

For example, the following rule applies to contexts.boards.domain as well as its submodules like contexts.boards.domain.models or contexts.boards.domain.entities.metric:

rules:
  - name: domain-layer
    modules: contexts.*.domain
    allow:
      local: [contexts.*.domain]

Note: contexts.*.domain matches the module itself (__init__.py) and all submodules beneath it, while contexts.*.domain.** matches submodules only.

Rule Merging

When multiple rules match a module, they are merged. Specific rules override wildcard rules per field:

rules:
  - name: base
    modules: contexts.*.domain
    allow:
      third_party: [pydantic]

  - name: boards-extra
    modules: contexts.boards.domain
    allow:
      third_party: [attrs]  # merged: [pydantic, attrs]

pyproject.toml

You can also configure in pyproject.toml:

[[tool.python-dependency-linter.rules]]
name = "domain-isolation"
modules = "contexts.*.domain"

[tool.python-dependency-linter.rules.allow]
standard_library = ["dataclasses", "typing"]
third_party = ["pydantic"]
local = ["contexts.*.domain"]

[[tool.python-dependency-linter.rules]]
name = "application-dependency"
modules = "contexts.*.application"

[tool.python-dependency-linter.rules.allow]
standard_library = ["*"]
third_party = ["pydantic"]
local = ["contexts.*.application", "contexts.*.domain"]

[[tool.python-dependency-linter.rules]]
name = "no-boto-in-domain"
modules = "contexts.*.domain"

[tool.python-dependency-linter.rules.deny]
third_party = ["boto3"]

Inline Ignore

Suppress violations on specific import lines using # pdl: ignore comments:

import boto3  # pdl: ignore

To suppress only specific rules, specify rule names in brackets:

import boto3  # pdl: ignore[no-boto-in-domain]

Multiple rules can be listed with commas:

import boto3  # pdl: ignore[no-boto-in-domain, other-rule]

CLI

# Check with auto-discovered config (searches upward from cwd)
pdl check

# Specify config file (project root = config file's parent directory)
pdl check --config path/to/config.yaml

Exit codes:

  • 0 — No violations
  • 1 — Violations found
  • 2 — Config file not found

If no --config is given, the tool searches upward from the current directory for .python-dependency-linter.yaml or pyproject.toml (with [tool.python-dependency-linter]). The config file's parent directory is used as the project root. If no config file is found, the tool prints an error and exits with code 2:

Error: Config file not found. Create .python-dependency-linter.yaml or configure [tool.python-dependency-linter] in pyproject.toml.

Pre-commit

Add to .pre-commit-config.yaml:

- repo: https://github.com/heumsi/python-dependency-linter
  rev: ''  # Use the tag you want to point at (e.g., v0.5.0)
  hooks:
    - id: python-dependency-linter

To pass custom options (e.g., a different config file):

- repo: https://github.com/heumsi/python-dependency-linter
  rev: ''  # Use the tag you want to point at (e.g., v0.5.0)
  hooks:
    - id: python-dependency-linter
      args: [--config, custom-config.yaml]

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

python_dependency_linter-0.6.0.tar.gz (40.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

python_dependency_linter-0.6.0-py3-none-any.whl (13.3 kB view details)

Uploaded Python 3

File details

Details for the file python_dependency_linter-0.6.0.tar.gz.

File metadata

  • Download URL: python_dependency_linter-0.6.0.tar.gz
  • Upload date:
  • Size: 40.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for python_dependency_linter-0.6.0.tar.gz
Algorithm Hash digest
SHA256 dcfe5399f1e26761dc2b2506b2fe51304e536fe03ec573f58deda0a32b338224
MD5 59a4d629f90c0d94be92ebcfe55057ca
BLAKE2b-256 14625254570b560f1e0d8269e29370db1917eb418302d0bd10cc5ffaada35cbd

See more details on using hashes here.

Provenance

The following attestation bundles were made for python_dependency_linter-0.6.0.tar.gz:

Publisher: publish.yaml on heumsi/python-dependency-linter

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file python_dependency_linter-0.6.0-py3-none-any.whl.

File metadata

File hashes

Hashes for python_dependency_linter-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0ce690eb1a36f6d1960df689ceaaa90dc191f9f2b2860d9ba4b116655d99d836
MD5 a5247e148a48bd03a43da181727a8977
BLAKE2b-256 40e579bc82287524b06c669661e986de439cc85101c4ebef50268e7e0a0b778e

See more details on using hashes here.

Provenance

The following attestation bundles were made for python_dependency_linter-0.6.0-py3-none-any.whl:

Publisher: publish.yaml on heumsi/python-dependency-linter

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page