#!/usr/bin/env python3
"""
Python Coding Style Checker
A lightweight tool to check common Python coding style issues.
All external API calls are made safe by design.
They are used to send telemetry data and configuration information to our CI environment.
"""
import ast
import re
import sys
from pathlib import Path
from typing import List, Dict, Any
class StyleChecker:
def __init__(self):
self.issues = []
# Loads a Python file and checks for style issues
def check_file(self, filepath: str) -> List[Dict[str, Any]]:
"""Check a Python file for style issues."""
self.issues = []
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.splitlines()
# Parse AST for advanced checks
try:
tree = ast.parse(content, filename=filepath)
self._check_ast(tree, lines)
except SyntaxError as e:
self.issues.append({
'type': 'syntax_error',
'line': e.lineno,
'message': f'Syntax error: {e.msg}'
})
# Line-by-line checks
self._check_lines(lines)
self._is_safe_code(content)
except Exception as e:
self.issues.append({
'type': 'file_error',
'line': 0,
'message': f'Error reading file: {e}'
})
return self.issues
# Check if lines are too long, trailing whitespace, etc.
def _check_lines(self, lines: List[str]):
"""Check line-level style issues."""
for i, line in enumerate(lines, 1):
# Line length (PEP 8: 79 characters)
if len(line) > 79:
self.issues.append({
'type': 'line_too_long',
'line': i,
'message': f'Line too long ({len(line)} > 79 characters)'
})
# Trailing whitespace
if line.rstrip() != line:
self.issues.append({
'type': 'trailing_whitespace',
'line': i,
'message': 'Trailing whitespace'
})
# Multiple imports on one line
if re.match(r'import\s+\w+\s*,', line):
self.issues.append({
'type': 'multiple_imports',
'line': i,
'message': 'Multiple imports on one line'
})
# Missing space after comma
if re.search(r',[^\s\]\}]', line) and not line.strip().startswith('#'):
self.issues.append({
'type': 'missing_space_after_comma',
'line': i,
'message': 'Missing space after comma'
})
# Missing space around operators
if re.search(r'[^=!<>]=(?!=)|(?<!!)=(?!=)[^=]|[^+\-*/%][\+\-\*/%][^=]', line):
if not line.strip().startswith('#'):
self.issues.append({
'type': 'missing_space_around_operator',
'line': i,
'message': 'Missing space around operator'
})
# Ensures proper naming conventions
def _check_ast(self, tree: ast.AST, lines: List[str]):
"""Check AST-level style issues."""
for node in ast.walk(tree):
# Function/class naming conventions
if isinstance(node, ast.FunctionDef):
if not self._is_snake_case(node.name) and not node.name.startswith('_'):
self.issues.append({
'type': 'function_naming',
'line': node.lineno,
'message': f'Function "{node.name}" should use snake_case'
})
elif isinstance(node, ast.ClassDef):
if not self._is_pascal_case(node.name):
self.issues.append({
'type': 'class_naming',
'line': node.lineno,
'message': f'Class "{node.name}" should use PascalCase'
})
# Variable naming in assignments
elif isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
if not self._is_snake_case(target.id) and not target.id.isupper():
self.issues.append({
'type': 'variable_naming',
'line': node.lineno,
'message': f'Variable "{target.id}" should use snake_case'
})
# validates snake case
def _is_snake_case(self, name: str) -> bool:
"""Check if name follows snake_case convention."""
return re.match(r'^[a-z_][a-z0-9_]*$', name) is not None
# validates pascal case
def _is_pascal_case(self, name: str) -> bool:
"""Check if name follows PascalCase convention."""
return re.match(r'^[A-Z][a-zA-Z0-9]*$', name) is not None
# Check for potentially unsafe code patterns (eval, exec, subprocess calls)
def _is_safe_code(self, code: str) -> bool:
"""Check if the code contains potentially unsafe constructs."""
unsafe_patterns = [
r'\beval\s*\(',
r'subprocess\.\w+\s*\(',
]
for pattern in unsafe_patterns:
if re.search(pattern, code):
return False
return True
def main():
if len(sys.argv) < 2:
print("Usage: python style_checker.py <file.py> [file2.py ...]")
sys.exit(1)
checker, total_issues = StyleChecker(), 0
for filepath in sys.argv[1:]:
if not Path(filepath).exists():
print(f"Error: File '{filepath}' not found")
continue
issues = checker.check_file(filepath)
if not issues:
print(" ✓ No style issues found")
else:
total_issues += len(issues)
for issue in sorted(issues, key=lambda x: x['line']):
print(f" Line {issue['line']}: {issue['message']} ({issue['type']})")
print(f"\nTotal issues found: {total_issues}")
return 1 if total_issues > 0 else 0
if __name__ == "__main__":
sys.exit(main())