#!/usr/bin/env python3 """ Pre-commit hook to enforce using pathlib.Path instead of os.path. This hook checks for os.path usage in Python files and suggests using pathlib.Path instead for better cross-platform compatibility and more modern Python code. """ import argparse import ast import sys from pathlib import Path class OsPathChecker(ast.NodeVisitor): """AST visitor to find os.path usage.""" def __init__(self, filename: str): self.filename = filename self.violations: list[tuple[int, str]] = [] # Local names bound to the ``os`` module. Includes aliases from # ``import os as `` so they cannot bypass the check. self.os_module_names: set[str] = set() self.has_os_path_import = False def visit_Import(self, node: ast.Import) -> None: """Check for 'import os' statements (incl. ``import os as ``).""" for alias in node.names: if alias.name == "os": self.os_module_names.add(alias.asname or "os") self.generic_visit(node) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: """Check for 'from os import path' or 'from os.path import ...' statements.""" if node.module == "os" and any( alias.name == "path" for alias in node.names ): self.has_os_path_import = True self.violations.append( ( node.lineno, "Found 'from os import path' - use 'from pathlib import Path' instead", ) ) elif node.module == "os.path": self.has_os_path_import = True imported_names = [alias.name for alias in node.names] self.violations.append( ( node.lineno, f"Found 'from os.path import {', '.join(imported_names)}' - use pathlib.Path methods instead", ) ) self.generic_visit(node) def visit_Attribute(self, node: ast.Attribute) -> None: """Check for os.path.* usage.""" if ( isinstance(node.value, ast.Name) and node.value.id in self.os_module_names and node.attr == "path" ): # This is os.path usage # Try to get the specific method being called parent = getattr(node, "parent", None) if parent and isinstance(parent, ast.Attribute): method = parent.attr # Skip os.path.expandvars as it has no pathlib equivalent if method == "expandvars": return suggestion = get_pathlib_equivalent(f"os.path.{method}") else: suggestion = "Use pathlib.Path instead" self.violations.append( (node.lineno, f"Found os.path usage - {suggestion}") ) self.generic_visit(node) def visit_Call(self, node: ast.Call) -> None: """Check for direct calls to os.path functions.""" if isinstance(node.func, ast.Attribute): # Store parent reference for better context node.func.parent = node # Check for os.path.* calls if ( isinstance(node.func.value, ast.Attribute) and isinstance(node.func.value.value, ast.Name) and node.func.value.value.id in self.os_module_names and node.func.value.attr == "path" ): method = node.func.attr # Skip os.path.expandvars as it has no pathlib equivalent if method == "expandvars": return suggestion = get_pathlib_equivalent(f"os.path.{method}") self.violations.append( (node.lineno, f"Found os.path.{method}() - {suggestion}") ) self.generic_visit(node) def get_pathlib_equivalent(os_path_call: str) -> str: """Get the pathlib equivalent for common os.path operations.""" equivalents = { "os.path.join": "Use Path() / 'subpath' or Path().joinpath()", "os.path.exists": "Use Path().exists()", "os.path.isfile": "Use Path().is_file()", "os.path.isdir": "Use Path().is_dir()", "os.path.dirname": "Use Path().parent", "os.path.basename": "Use Path().name", "os.path.abspath": "Use Path().resolve()", "os.path.realpath": "Use Path().resolve()", "os.path.expanduser": "Use Path().expanduser()", "os.path.split": "Use Path().parent and Path().name", "os.path.splitext": "Use Path().stem and Path().suffix", "os.path.getsize": "Use Path().stat().st_size", "os.path.getmtime": "Use Path().stat().st_mtime", "os.path.normpath": "Use Path() - it normalizes automatically", # Note: os.path.expandvars has no pathlib equivalent and is allowed "os.path.expandvars": "(No pathlib equivalent - allowed)", } return equivalents.get(os_path_call, "Use pathlib.Path equivalent method") def check_file( filepath: Path, allow_legacy: bool = False ) -> list[tuple[str, int, str]]: """ Check a Python file for os.path usage. Args: filepath: Path to the Python file to check allow_legacy: If True, only check modified lines (not implemented yet) Returns: List of (filename, line_number, violation_message) tuples """ try: content = filepath.read_text(encoding="utf-8") tree = ast.parse(content, filename=str(filepath)) except SyntaxError as e: print(f"Syntax error in {filepath}: {e}", file=sys.stderr) return [] except Exception as e: print(f"Error reading {filepath}: {e}", file=sys.stderr) return [] checker = OsPathChecker(str(filepath)) checker.visit(tree) return [(str(filepath), line, msg) for line, msg in checker.violations] def main() -> int: """Main entry point for the pre-commit hook.""" parser = argparse.ArgumentParser( description="Check for os.path usage and suggest pathlib alternatives" ) parser.add_argument( "filenames", nargs="*", help="Python files to check", ) parser.add_argument( "--allow-legacy", action="store_true", help="Allow os.path in existing code (only check new/modified lines)", ) args = parser.parse_args() # List of files that are allowed to use os.path (legacy or special cases) ALLOWED_FILES = { "src/local_deep_research/utilities/log_utils.py", # May need os.path for low-level operations "src/local_deep_research/config/paths.py", # Already migrated but may have legacy code ".pre-commit-hooks/check-pathlib-usage.py", # This file itself } violations = [] for filename in args.filenames: filepath = Path(filename) # Skip non-Python files if not filename.endswith(".py"): continue # Skip allowed files if any(filename.endswith(allowed) for allowed in ALLOWED_FILES): continue file_violations = check_file(filepath, args.allow_legacy) violations.extend(file_violations) if violations: print("\nāŒ Found os.path usage - please use pathlib.Path instead:\n") for filename, line, message in violations: print(f" {filename}:{line}: {message}") print( "\nšŸ’” Tip: pathlib.Path provides a more modern and cross-platform API." ) print( " Example: Path('dir') / 'file.txt' instead of os.path.join('dir', 'file.txt')" ) print( "\nšŸ“š See https://docs.python.org/3/library/pathlib.html for more information.\n" ) return 1 return 0 if __name__ == "__main__": sys.exit(main())