Files
thrillwiki_django_no_react/scripts/lint_choices.py
pacnpal d631f3183c Based on the git diff provided, here's a concise and descriptive commit message:
feat: add passkey authentication and enhance user preferences

- Add passkey login security event type with fingerprint icon
- Include request and site context in email confirmation for backend
- Add user_id exact match filter to prevent incorrect user lookups
- Enable PATCH method for updating user preferences via API
- Add moderation_preferences support to user settings
- Optimize ticket queries with select_related and prefetch_related

This commit introduces passkey authentication tracking, improves user
profile filtering accuracy, and extends the preferences API to support
updates. Query optimizations reduce database hits for ticket listings.
2026-01-12 19:13:05 -05:00

175 lines
5.5 KiB
Python
Executable File

#!/usr/bin/env python
"""
RichChoiceField Enforcement Lint Script
This script checks for prohibited choice patterns in the codebase.
Exit code 0 = no violations found, exit code 1 = violations found.
Usage:
python scripts/lint_choices.py
python scripts/lint_choices.py --fix # Show fix suggestions
Add to CI:
python scripts/lint_choices.py || exit 1
"""
import argparse
import re
import sys
from pathlib import Path
# Patterns to detect prohibited choice usage
PROHIBITED_PATTERNS = [
# TextChoices / IntegerChoices class definitions
(r'class\s+\w+\s*\(\s*models\.(TextChoices|IntegerChoices)\s*\)',
'models.TextChoices/IntegerChoices class definition'),
# Inline tuple choices in CharField
(r'choices\s*=\s*\[\s*\(\s*["\']',
'Inline tuple choices'),
# Direct reference to .choices attribute
(r'choices\s*=\s*\w+\.choices',
'Reference to inner TextChoices.choices'),
]
# Directories/files to exclude
EXCLUDE_PATTERNS = [
'*/migrations/*',
'*/.venv/*',
'*/node_modules/*',
'*/__pycache__/*',
'*.pyc',
'lint_choices.py', # Exclude this script
]
# Files allowed to define TextChoices (infrastructure only)
ALLOWED_EXCEPTION_FILES = [
# Core choice infrastructure files can reference these patterns
'apps/core/choices/',
]
def should_exclude(path: Path) -> bool:
"""Check if path should be excluded from linting."""
path_str = str(path)
for pattern in EXCLUDE_PATTERNS:
if pattern.startswith('*/'):
if pattern[2:].rstrip('/*') in path_str:
return True
elif pattern.endswith('/*'):
if path_str.startswith(pattern[:-2]):
return True
elif pattern in path_str:
return True
return False
def is_exception_file(path: Path) -> bool:
"""Check if file is an allowed exception."""
path_str = str(path)
for exception in ALLOWED_EXCEPTION_FILES:
if exception in path_str:
return True
return False
def scan_file(filepath: Path) -> list[tuple[int, str, str]]:
"""Scan a file for prohibited patterns. Returns list of (line_num, line, pattern_name)."""
violations = []
if should_exclude(filepath) or is_exception_file(filepath):
return violations
try:
content = filepath.read_text(encoding='utf-8')
lines = content.split('\n')
for i, line in enumerate(lines, 1):
# Skip comments
stripped = line.strip()
if stripped.startswith('#'):
continue
for pattern, description in PROHIBITED_PATTERNS:
if re.search(pattern, line):
violations.append((i, line.strip(), description))
break # Only report one violation per line
except Exception as e:
print(f"Warning: Could not read {filepath}: {e}", file=sys.stderr)
return violations
def scan_directory(root_dir: Path) -> dict[Path, list]:
"""Scan all Python files in directory for violations."""
all_violations = {}
for filepath in root_dir.rglob('*.py'):
violations = scan_file(filepath)
if violations:
all_violations[filepath] = violations
return all_violations
def print_violations(violations: dict, show_fix: bool = False):
"""Print violations in a readable format."""
total = 0
for filepath, file_violations in sorted(violations.items()):
print(f"\n\033[1;31m{filepath}\033[0m")
for line_num, line_content, description in file_violations:
print(f" Line {line_num}: [{description}]")
print(f" {line_content[:80]}{'...' if len(line_content) > 80 else ''}")
total += 1
if show_fix:
print(f" \033[1;33mFix:\033[0m Use RichChoiceField(choice_group='...', domain='...')")
return total
def main():
parser = argparse.ArgumentParser(description='Lint for prohibited choice patterns')
parser.add_argument('--fix', action='store_true', help='Show fix suggestions')
parser.add_argument('path', nargs='?', default='apps', help='Path to scan (default: apps)')
args = parser.parse_args()
# Find backend directory
script_dir = Path(__file__).parent
backend_dir = script_dir.parent
if (backend_dir / 'apps').exists():
scan_path = backend_dir / args.path
elif (backend_dir / 'backend' / 'apps').exists():
scan_path = backend_dir / 'backend' / args.path
else:
print("Error: Could not find apps directory", file=sys.stderr)
sys.exit(1)
print(f"Scanning {scan_path} for prohibited choice patterns...")
print("=" * 60)
violations = scan_directory(scan_path)
if violations:
total = print_violations(violations, show_fix=args.fix)
print("\n" + "=" * 60)
print(f"\033[1;31mFound {total} violation(s) in {len(violations)} file(s)\033[0m")
print("\nProhibited patterns:")
print(" - models.TextChoices / models.IntegerChoices classes")
print(" - Inline choices=[(value, label), ...]")
print(" - References to InnerClass.choices")
print("\nRequired pattern:")
print(" RichChoiceField(choice_group='group_name', domain='domain_name')")
print("\nSee: .agent/workflows/enforce-richchoice.md for migration guide")
sys.exit(1)
else:
print("\n\033[1;32m✓ No prohibited choice patterns found!\033[0m")
sys.exit(0)
if __name__ == '__main__':
main()