Skip to content

Stateless Class Linter

AI Agent Context (click to expand)

Purpose: Complete guide to using the stateless-class linter for detecting classes that should be module-level functions

Scope: Configuration, usage, refactoring patterns, and best practices for avoiding unnecessary class usage

Overview: Comprehensive documentation for the stateless-class linter that detects Python classes without constructors or instance state that should be refactored to module-level functions. Covers detection patterns, exclusion rules, configuration options, CLI usage, and refactoring guidance. Follows functional programming principles that prefer functions over stateless classes.

Dependencies: ast module (Python parser)

Exports: Usage documentation, configuration examples, refactoring patterns

Related: cli-reference.md for CLI commands, configuration.md for config format, how-to-ignore-violations.md for ignore patterns

Implementation: AST-based detection with comprehensive exclusion rules to minimize false positives

This follows the AI-Optimized Documentation Standard.


Try It Now

pip install thailint
thailint stateless-class src/

Example output:

src/utils.py:15 - Class 'StringUtils' has no state and should be module-level functions
  Suggestion: Convert methods to standalone functions: capitalize_words(), reverse_words()

Fix it: Convert stateless class methods to module-level functions.


Overview

The stateless-class linter detects Python classes that have no state (no constructor, no instance attributes) and should be refactored to module-level functions. This pattern is common in code generated by AI assistants that default to class-based designs when simpler function-based approaches would be cleaner and more Pythonic.

What Are Stateless Classes?

Stateless classes are classes that: - Have no __init__ or __new__ method - Have no instance attributes (self.attr assignments) - Have no class-level attributes - Have 2+ methods (indicating grouped functionality)

# Bad - Stateless class (no state, just grouped functions)
class TokenHasher:
    def hash_token(self, token: str) -> str:
        return hashlib.sha256(token.encode()).hexdigest()

    def verify_token(self, token: str, hash_value: str) -> bool:
        return self.hash_token(token) == hash_value

# Good - Module-level functions
def hash_token(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()

def verify_token(token: str, hash_value: str) -> bool:
    return hash_token(token) == hash_value

Why Avoid Stateless Classes?

Stateless classes add unnecessary complexity: - Extra Boilerplate: Requires self parameter that's never used - Misleading API: Suggests state management when there is none - Memory Overhead: Creates object instances for no benefit - Harder Testing: Requires instantiation for simple function calls - Less Pythonic: Python prefers explicit over implicit

When Classes ARE Appropriate

Classes are appropriate when they: - Manage State: Store data that persists across method calls - Define Interfaces: ABC, Protocol, or abstract base classes - Use Decorators: Framework integration (@dataclass, @pytest.fixture) - Have Constructors: Initialize with __init__ or __new__ - Use Class Attributes: Shared state across instances - Use Inheritance: Subclass other classes (polymorphism, Template Method pattern)

How It Works

AST-Based Detection

The linter uses Python's Abstract Syntax Tree (AST) to analyze classes:

  1. Parse source code into AST
  2. Find class definitions in the module
  3. Check for constructors: __init__ or __new__ methods
  4. Check for state: Instance or class-level attributes
  5. Check for exceptions: ABC, Protocol, decorators, base classes
  6. Count methods: Requires 2+ methods to flag
  7. Report violations with class name and location

Detection Patterns

The linter flags classes that match ALL of these criteria:

Criteria Requirement
No constructor No __init__ or __new__ method
No instance state No self.attr = value assignments
No class attributes No class-level variable definitions
No base classes Not inheriting from other classes (except object)
Not ABC/Protocol Not inheriting from ABC or Protocol
No decorators No @decorator on the class
Multiple methods 2+ methods in the class

Exclusion Rules

The linter does not flag classes that:

Exclusion Example Why Excluded
Have __init__ def __init__(self): pass Has constructor (could be placeholder)
Have __new__ def __new__(cls): ... Custom object creation
Have instance attributes self._cache = {} Has state
Have class attributes DEFAULT_VALUE = 10 Has shared state
Inherit from ABC class Base(ABC) Interface definition
Inherit from Protocol class Handler(Protocol) Type interface
Inherit from base classes class Foo(BaseClass) Polymorphism/inheritance pattern
Have decorators @dataclass, @register Framework integration
Have 0-1 methods Single method class Too simple to flag

Configuration

Quick Start: Generate Configuration File

The easiest way to get started is to use the init-config command:

# Interactive mode (for humans - asks questions)
thailint init-config

# Non-interactive mode (for AI agents)
thailint init-config --non-interactive

Basic Configuration

Create .thailint.yaml:

stateless-class:
  enabled: true
  min_methods: 2  # Minimum methods to flag (default: 2)
  ignore:         # File patterns to ignore
    - "tests/"
    - "*_test.py"

Configuration Options

Option Type Default Description
enabled boolean true Enable/disable linter
min_methods integer 2 Minimum methods to flag as violation
ignore array [] File patterns to exclude

JSON Configuration

{
  "stateless-class": {
    "enabled": true,
    "min_methods": 2,
    "ignore": ["tests/", "*_test.py"]
  }
}

Ignoring Violations

The stateless-class linter supports the 5-level ignore system:

Level 1: Project-Level Ignore (via config)

# .thailint.yaml
stateless-class:
  ignore:
    - "tests/"
    - "migrations/"
    - "**/legacy_*.py"

Level 2: File-Level Ignore Directive

# thailint: ignore-file[stateless-class]
# This file contains legacy classes that can't be refactored

class LegacyHelper:
    def method1(self): ...
    def method2(self): ...

Level 3: Block-Level Ignore

# thailint: ignore-start[stateless-class]
class FrameworkClass:
    def setup(self): ...
    def teardown(self): ...

class AnotherFrameworkClass:
    def initialize(self): ...
    def finalize(self): ...
# thailint: ignore-end[stateless-class]

Level 4: Line-Level Ignore

class LegacyAPI:  # thailint: ignore[stateless-class] - Required by framework
    def method1(self): ...
    def method2(self): ...

Level 5: Ignore-Next-Line

# thailint: ignore-next-line[stateless-class]
class PluginInterface:
    def activate(self): ...
    def deactivate(self): ...

See How to Ignore Violations for complete guide.

Usage

CLI Mode

Basic Usage

# Check current directory
thailint stateless-class .

# Check specific directory
thailint stateless-class src/

# Check specific file
thailint stateless-class src/utils.py

With Configuration

# Use config file
thailint stateless-class --config .thailint.yaml src/

# Auto-discover config (.thailint.yaml or .thailint.json)
thailint stateless-class src/

Output Formats

# Human-readable text (default)
thailint stateless-class src/

# JSON output for CI/CD
thailint stateless-class --format json src/

# SARIF output for GitHub Actions
thailint stateless-class --format sarif src/ > report.sarif

Library Mode

from src.linters.stateless_class import StatelessClassRule
from src.core.base import BaseLintContext

# Create rule
rule = StatelessClassRule()

# Create context with config
context = BaseLintContext(
    file_path=Path('src/utils.py'),
    file_content=open('src/utils.py').read(),
    language='python'
)
# Optional: Add config
context.config = {
    'stateless-class': {
        'enabled': True,
        'min_methods': 2,
        'ignore': ['tests/']
    }
}

# Check for violations
violations = rule.check(context)

# Process results
for violation in violations:
    print(f"Line {violation.line}: {violation.message}")

Docker Mode

# Run with default config
docker run --rm -v $(pwd):/workspace \
  washad/thailint:latest stateless-class /workspace/src/

# With custom config file
docker run --rm \
  -v $(pwd):/workspace \
  -v $(pwd)/.thailint.yaml:/config/.thailint.yaml:ro \
  washad/thailint:latest stateless-class \
  --config /config/.thailint.yaml /workspace/src/

Violation Examples

Example 1: Utility Class Pattern

Code with violation:

class StringUtils:
    def capitalize_words(self, text: str) -> str:
        return ' '.join(w.capitalize() for w in text.split())

    def reverse_words(self, text: str) -> str:
        return ' '.join(reversed(text.split()))

    def count_words(self, text: str) -> int:
        return len(text.split())

Violation message:

src/utils.py:1 - Class 'StringUtils' has no state and should be refactored to module-level functions

Refactored code:

def capitalize_words(text: str) -> str:
    return ' '.join(w.capitalize() for w in text.split())

def reverse_words(text: str) -> str:
    return ' '.join(reversed(text.split()))

def count_words(text: str) -> int:
    return len(text.split())

Example 2: Service Class Pattern

Code with violation:

class EmailValidator:
    def is_valid(self, email: str) -> bool:
        return '@' in email and '.' in email

    def get_domain(self, email: str) -> str:
        return email.split('@')[1]

Refactored code:

def is_valid_email(email: str) -> bool:
    return '@' in email and '.' in email

def get_email_domain(email: str) -> str:
    return email.split('@')[1]

Example 3: Acceptable Classes (No Violations)

# Has constructor - OK
class ConfigLoader:
    def __init__(self, path: str):
        self._path = path

    def load(self) -> dict:
        return json.load(open(self._path))

# Has state - OK
class Counter:
    def increment(self):
        self._count += 1

    def get_count(self) -> int:
        return self._count

# Is ABC - OK
class BaseHandler(ABC):
    @abstractmethod
    def handle(self, data): ...

    @abstractmethod
    def validate(self, data): ...

# Has decorator - OK
@dataclass
class User:
    def full_name(self) -> str:
        return f"{self.first} {self.last}"

    def email_domain(self) -> str:
        return self.email.split('@')[1]

# Has class attributes - OK
class Constants:
    DEFAULT_TIMEOUT = 30
    MAX_RETRIES = 3

    def get_timeout(self) -> int:
        return self.DEFAULT_TIMEOUT

    def get_retries(self) -> int:
        return self.MAX_RETRIES

# Single method - OK (too simple to flag)
class Calculator:
    def add(self, a, b):
        return a + b

# Has base class - OK (inheritance/polymorphism)
class StagingDeployer(BaseDeployer):
    def get_steps(self):
        return STAGING_STEPS

    def get_state_file_path(self):
        return Path(".deploy_state_staging")

Refactoring Patterns

Pattern 1: Simple Utility Class

Before:

class MathUtils:
    def square(self, n: int) -> int:
        return n * n

    def cube(self, n: int) -> int:
        return n * n * n

After:

def square(n: int) -> int:
    return n * n

def cube(n: int) -> int:
    return n * n * n

Pattern 2: Validator Class

Before:

class InputValidator:
    def validate_email(self, email: str) -> bool:
        return '@' in email

    def validate_phone(self, phone: str) -> bool:
        return phone.isdigit() and len(phone) == 10

After:

def validate_email(email: str) -> bool:
    return '@' in email

def validate_phone(phone: str) -> bool:
    return phone.isdigit() and len(phone) == 10

Pattern 3: Transformer Class

Before:

class DataTransformer:
    def to_json(self, data: dict) -> str:
        return json.dumps(data)

    def from_json(self, text: str) -> dict:
        return json.loads(text)

After:

def to_json(data: dict) -> str:
    return json.dumps(data)

def from_json(text: str) -> dict:
    return json.loads(text)

Pattern 4: Helper Class

Before:

class PathHelper:
    def get_extension(self, path: str) -> str:
        return os.path.splitext(path)[1]

    def get_filename(self, path: str) -> str:
        return os.path.basename(path)

    def join_paths(self, *paths: str) -> str:
        return os.path.join(*paths)

After:

def get_extension(path: str) -> str:
    return os.path.splitext(path)[1]

def get_filename(path: str) -> str:
    return os.path.basename(path)

def join_paths(*paths: str) -> str:
    return os.path.join(*paths)

Pattern 5: Keep State When Needed

Sometimes refactoring reveals that state IS needed:

Before (stateless):

class TokenHasher:
    def hash(self, token: str) -> str:
        return hashlib.sha256(token.encode()).hexdigest()

    def verify(self, token: str, expected: str) -> bool:
        return self.hash(token) == expected

After Option A (functions):

def hash_token(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()

def verify_token(token: str, expected: str) -> bool:
    return hash_token(token) == expected

After Option B (if you need configurable algorithm):

class TokenHasher:
    def __init__(self, algorithm: str = 'sha256'):
        self._algorithm = algorithm
        self._hasher = getattr(hashlib, algorithm)

    def hash(self, token: str) -> str:
        return self._hasher(token.encode()).hexdigest()

    def verify(self, token: str, expected: str) -> bool:
        return self.hash(token) == expected

Language Support

Python Support

Fully Supported

The linter analyzes Python files using the built-in ast module.

Detection patterns: - Class definitions without __init__/__new__ - Instance attribute assignments (self.attr = value) - Class-level attribute definitions - Inheritance from ABC/Protocol - Inheritance from other base classes

Exclusions: - Classes with constructors - Classes with any decorator - Classes inheriting from ABC or Protocol - Classes inheriting from other base classes (polymorphism) - Classes with class attributes - Classes with instance attribute assignments - Classes with 0-1 methods

TypeScript Support

Not Supported

TypeScript uses different patterns (static classes, namespaces) and is not analyzed by this linter.

CI/CD Integration

GitHub Actions

name: Lint

on: [push, pull_request]

jobs:
  stateless-class-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Install thailint
        run: pip install thailint

      - name: Check stateless classes
        run: |
          thailint stateless-class src/

Pre-commit Hook

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: stateless-class-check
        name: Check stateless classes
        entry: thailint stateless-class
        language: python
        types: [python]
        pass_filenames: true

Makefile / Justfile Integration

# Makefile
lint-stateless-class:
    @echo "=== Checking stateless classes ==="
    @poetry run thailint stateless-class src/ || exit 1

lint-all: lint-stateless-class
    @echo "All checks passed"
# justfile
lint-stateless-class:
    @echo "=== Checking stateless classes ==="
    poetry run thailint stateless-class src/

lint-all: lint-stateless-class
    @echo "All checks passed"

Troubleshooting

Common Issues

Issue: Class with property methods flagged

Properties that don't use state are still flagged:

# Problem - no state but has properties
class Config:
    @property
    def timeout(self):
        return 30

    @property
    def retries(self):
        return 3

Solution: Add class attributes or use module constants:

# Solution 1: Class attributes
class Config:
    _timeout = 30
    _retries = 3

    @property
    def timeout(self):
        return self._timeout

# Solution 2: Module constants (preferred)
TIMEOUT = 30
RETRIES = 3

Issue: Interface class flagged

# Problem - interface without ABC
class Handler:
    def handle(self, data): ...
    def validate(self, data): ...

Solution: Use ABC or Protocol:

from abc import ABC, abstractmethod

class Handler(ABC):
    @abstractmethod
    def handle(self, data): ...

    @abstractmethod
    def validate(self, data): ...

Issue: Framework class flagged

# Problem - framework expects a class
class MyPlugin:
    def setup(self): ...
    def teardown(self): ...

Solution: Add decorator or use ignore:

# Solution 1: Add decorator
@register_plugin
class MyPlugin:
    def setup(self): ...

# Solution 2: Ignore directive
class MyPlugin:  # thailint: ignore[stateless-class] - Framework requirement
    def setup(self): ...

Issue: Subclass flagged incorrectly

# Problem - subclass should not be flagged
class StagingDeployer(BaseDeployer):
    def get_steps(self): ...
    def get_state_file(self): ...

Solution: This is now fixed in v0.8.1. Subclasses are automatically excluded because they use inheritance for polymorphism. If you're on an older version, upgrade:

pip install --upgrade thailint

Best Practices

1. Prefer Functions for Utilities

# Good - utility functions
def format_date(dt: datetime) -> str:
    return dt.strftime('%Y-%m-%d')

def parse_date(text: str) -> datetime:
    return datetime.strptime(text, '%Y-%m-%d')

2. Use Classes for State

# Good - class with state
class Cache:
    def __init__(self, max_size: int = 100):
        self._data: dict = {}
        self._max_size = max_size

    def get(self, key: str):
        return self._data.get(key)

    def set(self, key: str, value):
        if len(self._data) >= self._max_size:
            self._data.pop(next(iter(self._data)))
        self._data[key] = value

3. Use Protocols for Interfaces

from typing import Protocol

class Serializer(Protocol):
    def serialize(self, data: dict) -> str: ...
    def deserialize(self, text: str) -> dict: ...
# validators.py
def validate_email(email: str) -> bool: ...
def validate_phone(phone: str) -> bool: ...
def validate_url(url: str) -> bool: ...

# usage.py
from . import validators

if validators.validate_email(user.email):
    ...

5. Use Inheritance for Polymorphism

# Good - inheritance pattern (not flagged)
class BaseDeployer(ABC):
    @abstractmethod
    def get_steps(self): ...

class StagingDeployer(BaseDeployer):
    def get_steps(self):
        return STAGING_STEPS

class ProductionDeployer(BaseDeployer):
    def get_steps(self):
        return PRODUCTION_STEPS

Version History

  • v0.8.1: Configuration and ignore support
  • Full 5-level ignore system integration
  • StatelessClassConfig with enabled, min_methods, ignore patterns
  • Configuration via .thailint.yaml
  • Fix: Classes with base classes (inheritance) no longer flagged
  • 46 tests passing

  • v0.8.0: Stateless-class linter release

  • Python support with AST-based detection
  • Comprehensive exclusion rules
  • ABC, Protocol, and decorator detection
  • 28 tests passing (15 detector + 13 CLI)
  • Self-dogfooded on thai-lint codebase