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¶
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:
- Parse source code into AST
- Find class definitions in the module
- Check for constructors:
__init__or__new__methods - Check for state: Instance or class-level attributes
- Check for exceptions: ABC, Protocol, decorators, base classes
- Count methods: Requires 2+ methods to flag
- 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¶
Ignoring Violations¶
The stateless-class linter supports the 5-level ignore system:
Level 1: Project-Level Ignore (via config)¶
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:
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
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:
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: ...
4. Group Related Functions in Modules¶
# 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
Related Documentation¶
- How to Ignore Violations - Complete ignore guide
- Configuration Reference - Config file format
- CLI Reference - Command-line usage
- API Reference - Library API documentation
Version History¶
- v0.8.1: Configuration and ignore support
- Full 5-level ignore system integration
StatelessClassConfigwith 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