Instruction file imported from lbliii/bengal (
.cursor/rules/python.mdc). Copyright stays with the author.
Python Compliance (ruff + ty, Python 3.14t)
Ruff Rules I Must Follow
line-length: 100
rules: E, W, F, UP, B, SIM, I, PIE, PERF, C4, RUF
Import Order (I - isort)
# 1. Standard library
import threading
from collections.abc import Callable, Mapping
from contextvars import ContextVar
from dataclasses import dataclass
# 2. Third-party
import click
# 3. Local
from bengal.core import Page
Modern Syntax (UP - pyupgrade)
# Use these (3.14 syntax)
list[str] # not List[str]
dict[str, int] # not Dict[str, int]
str | None # not Optional[str]
type[MyClass] # not Type[MyClass]
X | Y # not Union[X, Y]
# Generics
class MyClass[T]: # not class MyClass(Generic[T])
pass
Exception Syntax (PEP 758)
Ruff format with py314 converts except (A, B): → except A, B:. Both are valid in 3.14.
If pre-commit fails with "Files were modified", run poe format then git add -A && git commit again.
Bugbear (B) - Avoid
# B006: No mutable default args
def bad(items: list[str] = []): # ❌
def good(items: list[str] | None = None): # ✅
# B008: No function calls in defaults
def bad(now: datetime = datetime.now()): # ❌
def good(now: datetime | None = None): # ✅
# B905: zip() needs strict=True
for a, b in zip(x, y): # ❌
for a, b in zip(x, y, strict=True): # ✅
Simplify (SIM)
# SIM102: Collapse nested ifs
if a:
if b: # ❌
if a and b: # ✅
# SIM108: Use ternary
if x:
y = a
else:
y = b # ❌
y = a if x else b # ✅
# SIM118: Use `in` for dict keys
if key in dict.keys(): # ❌
if key in dict: # ✅
Type Annotations (ty)
# Always annotate public functions
def process(items: list[str]) -> dict[str, int]: ...
# Use frozen dataclasses for immutable data
@dataclass(frozen=True, slots=True)
class Config:
name: str
value: int
# Protocols over ABCs when possible
from typing import Protocol
class Renderable(Protocol):
def render(self) -> str: ...
Free-Threading (3.14t) Patterns
Immutable by Default
@dataclass(frozen=True, slots=True) # Always use both
class ASTNode:
children: tuple[Node, ...] # tuple not list
Thread-Local via ContextVar
from contextvars import ContextVar
_config: ContextVar[Config] = ContextVar('config')
def get_config() -> Config:
return _config.get()
Locks for Shared Mutable State
import threading
class Cache:
def __init__(self) -> None:
self._data: dict[str, str] = {}
self._lock = threading.Lock()
def get_or_set(self, key: str, compute: Callable[[], str]) -> str:
if key in self._data: # Fast path
return self._data[key]
with self._lock:
if key in self._data: # Double-check
return self._data[key]
self._data[key] = compute()
return self._data[key]
@cached_property Is NOT Thread-Safe
functools.cached_property writes to instance.__dict__ without synchronization.
Two threads can race to compute and store the value simultaneously.
- Immutable return (str, int, bool, tuple, frozenset, frozen dataclass): race is benign — both threads compute the same value, one wins, no corruption
- Mutable return (list, set, dict): race is a real bug — one thread's result is silently dropped; any consumer that mutates the container triggers a data race
Rule: @cached_property accessed during parallel render MUST return immutable types.
Alternative: pre-warm the property during the sequential build/snapshot phase.
See modules/free-threading for the full decision tree and audit reference.
# ❌ Returns mutable list — race under parallel render
@cached_property
def sorted_pages(self) -> list[Page]:
return sorted(self.pages, key=lambda p: p.weight)
# ✅ Returns immutable tuple — race is benign
@cached_property
def sorted_pages(self) -> tuple[Page, ...]:
return tuple(sorted(self.pages, key=lambda p: p.weight))
# ✅ Fixed-schema dict → frozen dataclass
@dataclass(frozen=True, slots=True)
class VisibilitySettings:
menu: bool = True
listings: bool = True
@cached_property
def visibility(self) -> VisibilitySettings:
return VisibilitySettings(menu=..., listings=...)
Avoid
# ❌ Global mutable state
_cache: dict[str, str] = {}
# ❌ Module-level initialization
_instance = HeavyObject()
# ❌ Assuming atomicity
counter += 1
# ❌ @cached_property returning mutable container
@cached_property
def items(self) -> list[str]: # list is mutable — race under 3.14t
return [...]
# ❌ Fast-path read outside lock on module-level dict
cached = _cache.get(key) # races with concurrent .clear()
if cached is not None:
return cached # stale or corrupted under free-threading
Common Mistakes to Avoid
- Don't use
Optional→ useX | None - Don't use
List,Dict,Type→ use lowercaselist,dict,type - Don't use
from __future__ import annotations→ not needed in 3.14 - Don't put mutable defaults → use
Noneand create in function body - Don't forget
slots=Trueon dataclasses for performance - Don't use bare
zip()→ usezip(..., strict=True)
Imports
- Standard library first, then third-party, then local (isort handles this)
- Use
from __future__ import annotationsonly if needed for forward refs - Prefer explicit imports over
from x import *
Line Length
- Max 100 characters
- Break long function signatures after
(, align params
Type Annotations
- Always annotate public functions
- Use
| NoneoverOptional[X](Python 3.10+) - Use
list[X]overList[X](Python 3.9+) - Use
type[X]overType[X]
Free-Threading (3.14t) Patterns
- Avoid module-level mutable state
- Use
threading.Lockfor shared state, not global singletons - Prefer
contextvarsfor thread-local-like behavior - No
@cache/@lru_cacheon methods without care (not thread-safe by default) @cached_propertyMUST return immutable types if accessed during parallel phases- Module-level mutable dicts need lock protection on ALL reads, not just writes
- Never use
.clear()for cache eviction — causes stampede; use LRU eviction instead
Common Ruff Fixes I Should Apply Automatically
- UP: Use modern syntax (
match,|unions, f-strings, etc.) - SIM: Simplify conditionals (
if x is not Nonevsif x != None) - B: Avoid mutable default args, don't use
assertfor validation
Data Structure Selection
O(1) vs O(n) Lookups
# ❌ O(n) - scanning list for membership
if item in my_list: # Scans entire list
...
# ✅ O(1) - hash lookup
if item in my_set: # Constant time
...
# ✅ Convert once if checking multiple times
allowed = set(allowed_list) # O(n) once
for x in items:
if x in allowed: # O(1) each time
...
dict over repeated list searches
# ❌ O(n²) - nested loop searching
for user in users:
for order in orders:
if order.user_id == user.id: # O(n) each time
...
# ✅ O(n) - index once, lookup O(1)
orders_by_user = {}
for order in orders:
orders_by_user.setdefault(order.user_id, []).append(order)
for user in users:
user_orders = orders_by_user.get(user.id, []) # O(1)
Avoid Hidden O(n) Operations
# ❌ O(n) - list.index() scans
idx = my_list.index(item)
# ❌ O(n) - list.remove() scans then shifts
my_list.remove(item)
# ❌ O(n) - list.insert(0, x) shifts all elements
my_list.insert(0, new_item)
# ✅ Use deque for O(1) front operations
from collections import deque
d = deque()
d.appendleft(item) # O(1)
Comprehensions Over Loops
# ❌ Slower, more verbose
result = []
for x in items:
if x.valid:
result.append(x.value)
# ✅ Faster (C-level loop), clearer intent
result = [x.value for x in items if x.valid]
Generator Expressions for Large Data
# ❌ Creates entire list in memory
sum([x * x for x in range(1_000_000)])
# ✅ Generator - O(1) memory
sum(x * x for x in range(1_000_000))
# ✅ any()/all() short-circuit with generators
if any(is_valid(x) for x in huge_list): # Stops at first True
...
String Building
# ❌ O(n²) - string concatenation in loop
result = ""
for s in strings:
result += s # Creates new string each time
# ✅ O(n) - join is optimized
result = "".join(strings)
dict.get() vs try/except vs in check
# ❌ Two lookups
if key in d:
value = d[key]
# ❌ Exception overhead (fine if key usually exists)
try:
value = d[key]
except KeyError:
value = default
# ✅ Single lookup
value = d.get(key, default)
Caching Expensive Computations
# For pure functions (same input → same output)
from functools import cache
@cache # ⚠️ Not thread-safe in 3.14t without care
def expensive(n: int) -> int:
...
# ✅ Thread-safe caching pattern (from python-compliance.mdc)
class Cache:
def __init__(self) -> None:
self._data: dict[str, Any] = {}
self._lock = threading.Lock()
def get_or_compute(self, key: str, compute: Callable[[], Any]) -> Any:
if key in self._data:
return self._data[key]
with self._lock:
if key in self._data:
return self._data[key]
self._data[key] = compute()
return self._data[key]
Dead Code Indicators
Watch for:
- Functions never called (grep for usages)
- Imports not used (ruff F401 catches this)
- Variables assigned but never read (ruff F841)
- Unreachable code after return/raise (ruff catches this)
- Parameters that are always the same value
- Feature flags that are always True/False
Bloat Indicators
Watch for:
- Classes with only
__init__and one method → use a function - Inheritance depth > 2 → prefer composition
- Functions > 50 lines → break up
- Files > 500 lines → split module
- More than 5 parameters → use a dataclass/config object
- Wrapper classes that just delegate → remove the wrapper