Skip to main content

AST-based linter for Python DTO discipline and facade-ban enforcement — framework-agnostic.

Project description

dto-strict

AST-based linter for Python DTO discipline and facade-ban enforcement — pluggable, framework-agnostic.

Why dto-strict?

Data Transfer Objects (DTOs) provide a critical boundary between services and prevent the fragmentation of business-logic definitions across codebases. However, when function signatures leak Dict[str, Any] or when services build dict literals inline instead of using structured DTOs, code becomes:

  • Loosely typed: Shape mismatches only surface at runtime.
  • Duplicated: The same business object gets redefined wherever it's used.
  • Hard to evolve: Changing a field requires updating dicts in 10+ places.

Facade functions (module-level helpers that wrap framework machinery) similarly tend to proliferate and obscure intent when unmarked. The "facade—celery schedule" pattern makes intent explicit.

dto-strict enforces DTO and facade discipline via static AST analysis, with 5 focused rules:

  1. R001 (HIGH): Detect Dict[str, Any] in service-layer function signatures.
  2. R002 (MEDIUM): Flag inline dict literals with 3+ string keys.
  3. R003 (MEDIUM): Require @dataclass(frozen=True, slots=True, repr=False) trio in DTOs.
  4. R004 (HIGH): Demand exception tags on module-level functions (e.g., # facade — celery schedule).
  5. R005 (LOW): Encourage validators to use DTO.from_dict() pattern.

All rules are configurable; violations can be disabled, severity overridden, or paths scoped.

Install

pip install dto-strict

Quick Start

Basic CLI Usage

# Lint a single file
dto-strict apps/compliance/services.py

# Lint a directory
dto-strict apps/

# Output as GitHub Actions annotations
dto-strict apps/ --format github

# Output as JSON
dto-strict apps/ --format json

Configuration (pyproject.toml)

[tool.dto-strict]
service_paths = [
    "apps/*/services/*.py",
    "**/services/*.py",
]
dto_paths = [
    "**/dtos.py",
    "**/dtos/*.py",
]
exception_tags = [
    "facade — celery schedule",
    "FRAMEWORK",
]
disabled_rules = ["R005"]  # Disable low-priority rules if desired
severity_overrides = { "R002" = "low" }  # Downgrade specific rules

GitHub Actions

Create .github/workflows/dto-strict.yml:

name: dto-strict
on:
  pull_request:
    paths: ['apps/**.py']

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install dto-strict
      - run: dto-strict apps/ --format github

Pre-commit Hook

Add to .pre-commit-config.yaml:

- repo: local
  hooks:
    - id: dto-strict
      name: dto-strict
      entry: dto-strict
      language: python
      types: [python]
      additional_dependencies: ['dto-strict']
      stages: [commit]

Rules

R001: Dict[str, Any] in Service Signatures (HIGH)

Service-layer functions should not accept or return Dict[str, Any]. Use a DTO instead.

Fail:

def process_user(config: Dict[str, Any]) -> Dict[str, Any]:
    return {"status": "ok"}

Pass:

@dataclass(frozen=True, slots=True, repr=False)
class UserConfigDTO:
    timeout: int
    retries: int

def process_user(config: UserConfigDTO) -> dict:
    return {"status": "ok"}

Rationale: Typed parameters enable IDE completion and catch shape mismatches early.


R002: Inline Dict Literals (MEDIUM)

Service files with inline dict literals containing 3+ string keys should define a DTO instead.

Fail:

def build_response(user_id: int) -> dict:
    return {
        "user_id": user_id,
        "status": "active",
        "timestamp": "2025-01-01",
    }

Pass:

@dataclass(frozen=True, slots=True, repr=False)
class ResponseDTO:
    user_id: int
    status: str
    timestamp: str

def build_response(user_id: int) -> ResponseDTO:
    return ResponseDTO(user_id, "active", "2025-01-01")

Rationale: Shared shapes should live in DTOs. Inline dicts make duplication invisible.


R003: Dataclass Decorator Trio (MEDIUM)

All @dataclass definitions in DTO files must include frozen=True, slots=True, and repr=False.

Fail:

@dataclass
class UserDTO:
    user_id: int

@dataclass(frozen=True)
class ConfigDTO:
    timeout: int

Pass:

@dataclass(frozen=True, slots=True, repr=False)
class UserDTO:
    user_id: int

@dataclass(frozen=True, slots=True, repr=False)
class ConfigDTO:
    timeout: int

Rationale:

  • frozen=True: Immutability enforces single-source-of-truth.
  • slots=True: Memory efficiency and prevents attribute typos.
  • repr=False: Prevents accidental logging of sensitive fields in tracebacks.

R004: Module-Level Functions (HIGH)

Bare module-level functions (facades, framework hooks) must carry an exception tag in a comment or docstring.

Fail:

def process_user(user_id: int):
    pass

def send_notification(message: str):
    pass

Pass:

def process_user(user_id: int):  # facade — celery schedule
    pass

def send_notification(message: str):  # FRAMEWORK
    """Send via SNS."""
    pass

class UserService:
    def process(self, user_id: int):
        # Class methods don't need tags
        pass

Exception Tags: Configurable via pyproject.toml exception_tags list.

Rationale: Facades blur intent. Tags make intent explicit and signal "this is framework-specific, not business logic."


R005: Validator Pattern (LOW)

validate_*() functions should use DTO.from_dict() or raise ValidationError to enforce payload shape.

Fail:

def validate_user_payload(payload: dict) -> bool:
    return "user_id" in payload and "email" in payload

Pass:

def validate_user_payload(payload: dict) -> UserDTO:
    try:
        user = UserDTO(
            user_id=payload["user_id"],
            email=payload["email"],
        )
        return user
    except (KeyError, TypeError) as e:
        raise ValidationError(f"Invalid shape: {e}")

Rationale: Validators should enforce structure, not just presence.


Output Formats

Text (default)

app.py:10: R001 Dict[str, Any] in signature: process_user
service.py:20: R002 Inline dict literal with 4 keys

GitHub Actions

::error file=app.py,line=10,col=5::R001 Dict[str, Any] in signature: process_user
::warning file=service.py,line=20,col=0::R002 Inline dict literal with 4 keys

JSON

[
  {
    "rule_id": "R001",
    "severity": "HIGH",
    "file": "app.py",
    "line": 10,
    "col": 5,
    "message": "Dict[str, Any] in signature: process_user"
  }
]

Exit Codes

Code Meaning
0 No violations
1 HIGH severity violations present
2 MEDIUM severity violations only
3 LOW severity violations only

Configuration Reference

[tool.dto-strict]

# Paths to check for service-layer violations (R001, R002, R004)
# Default: ["apps/*/services/*.py", "**/services/*.py"]
service_paths = [
    "apps/*/services/*.py",
    "**/services/*.py",
]

# Paths to check for DTO definitions (R003)
# Default: ["**/dtos.py", "**/dtos/*.py"]
dto_paths = [
    "**/dtos.py",
    "**/dtos/*.py",
]

# Allowed exception tags for R004 (module-level facades)
# Default: ["facade — celery schedule", "FRAMEWORK"]
exception_tags = [
    "facade — celery schedule",
    "FRAMEWORK",
    "CUSTOM_TAG",
]

# Disable specific rules entirely
# Default: []
disabled_rules = ["R005"]

# Override severity for specific rules
# Valid values: "HIGH", "MEDIUM", "LOW"
# Default: {}
severity_overrides = {
    "R002" = "low",
}

Design Philosophy

Pluggable, not opinionated. Every rule is:

  • Configurable: Path patterns, exception tags, severity levels.
  • Disable-able: Set disabled_rules = ["R001"] to skip it entirely.
  • Framework-agnostic: No Django/FastAPI/Flask assumptions; adapters for each framework are opt-in extras.

Defaults bundled, not imposed. Out-of-the-box rules target Django + DRF + Celery patterns, but you can customize for your stack.

Development

git clone https://github.com/jekhator/dto-strict.git
cd dto-strict
python3 -m venv .venv && source .venv/bin/activate
pip install -e .[dev]

# Run tests
pytest tests/ -v

# Run linter on itself
dto-strict src/ --format github

License

Apache License 2.0. See LICENSE.

Contributing

Issues and PRs welcome. Please include fixtures (good + bad examples) for new rules.

See Also

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

dto_strict-0.1.0.tar.gz (17.6 kB view details)

Uploaded Source

Built Distribution

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

dto_strict-0.1.0-py3-none-any.whl (15.0 kB view details)

Uploaded Python 3

File details

Details for the file dto_strict-0.1.0.tar.gz.

File metadata

  • Download URL: dto_strict-0.1.0.tar.gz
  • Upload date:
  • Size: 17.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.25

File hashes

Hashes for dto_strict-0.1.0.tar.gz
Algorithm Hash digest
SHA256 94dbefa120bfbd4c014d84ba91d3bbb073b9bf05b7304b517ee1a0b58081b3f8
MD5 ce5d35e0e8bdd4bbe69d8df1c85bd772
BLAKE2b-256 766308d0c2186a2518f7353e92c03433262b18a173a59aa8abb90df450c666af

See more details on using hashes here.

File details

Details for the file dto_strict-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: dto_strict-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 15.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.25

File hashes

Hashes for dto_strict-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2dc55a8c8fa52f183ad48f21355417be4b442d493066aa62d0aa48a5976e7e26
MD5 0fb5e350fd257dc4dc5d9d17dfd251a6
BLAKE2b-256 e737dc77f421f2a75d3c9e4564a5691a1e2a3295475f926f65a5da87c5b9ce55

See more details on using hashes here.

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