7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
218 lines
7.7 KiB
Python
Executable File
218 lines
7.7 KiB
Python
Executable File
#!/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 <alias>`` 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 <alias>``)."""
|
|
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())
|