Coverage for lintro / utils / path_utils.py: 91%
76 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Path utilities for Lintro.
3Small helpers to normalize paths for display consistency and path safety validation.
4"""
6from pathlib import Path
8from loguru import logger
11def validate_safe_path(path: str | Path, base_dir: Path | None = None) -> bool:
12 """Validate that a path doesn't escape the project boundaries.
14 This function prevents path traversal attacks by ensuring the resolved path
15 stays within the specified base directory (or current working directory).
17 Args:
18 path: The path to validate (can be absolute or relative).
19 base_dir: The base directory that paths must stay within.
20 Defaults to current working directory if not specified.
22 Returns:
23 True if the path is safe (within boundaries), False otherwise.
25 Examples:
26 >>> validate_safe_path("./src/file.py") # Safe relative path
27 True
28 >>> validate_safe_path("../../../etc/passwd") # Escapes project
29 False
30 >>> validate_safe_path("/absolute/path/outside") # Outside project
31 False
32 """
33 try:
34 base = (base_dir or Path.cwd()).resolve()
35 resolved = Path(path).resolve()
37 # Check if resolved path is within base directory
38 resolved.relative_to(base)
39 return True
40 except ValueError:
41 # Path escapes the base directory
42 return False
43 except OSError:
44 # Invalid path (e.g., too long, invalid characters on some systems)
45 return False
48def find_lintro_ignore() -> Path | None:
49 """Find .lintro-ignore file by searching upward from current directory.
51 Searches upward from the current working directory to find the project root
52 by looking for .lintro-ignore or pyproject.toml files.
54 Returns:
55 Path | None: Path to .lintro-ignore file if found, None otherwise.
56 """
57 current_dir = Path.cwd()
58 # Limit search to prevent infinite loops (e.g., if we're in /)
59 max_depth = 20
60 depth = 0
62 while depth < max_depth:
63 lintro_ignore_path = current_dir / ".lintro-ignore"
64 if lintro_ignore_path.exists():
65 return lintro_ignore_path
67 # Also check for pyproject.toml as project root indicator
68 pyproject_path = current_dir / "pyproject.toml"
69 if pyproject_path.exists():
70 # If pyproject.toml exists, check for .lintro-ignore in same directory
71 lintro_ignore_path = current_dir / ".lintro-ignore"
72 if lintro_ignore_path.exists():
73 return lintro_ignore_path
74 # Even if .lintro-ignore doesn't exist, we found project root
75 # Return None to indicate no .lintro-ignore found
76 return None
78 # Move up one directory
79 parent_dir = current_dir.parent
80 if parent_dir == current_dir:
81 # Reached filesystem root
82 break
83 current_dir = parent_dir
84 depth += 1
86 return None
89def load_lintro_ignore() -> list[str]:
90 """Load ignore patterns from .lintro-ignore file.
92 Returns:
93 list[str]: List of ignore patterns.
94 """
95 ignore_patterns: list[str] = []
96 lintro_ignore_path = find_lintro_ignore()
98 if lintro_ignore_path and lintro_ignore_path.exists():
99 try:
100 with open(lintro_ignore_path, encoding="utf-8") as f:
101 for line in f:
102 line_stripped = line.strip()
103 if not line_stripped or line_stripped.startswith("#"):
104 continue
105 ignore_patterns.append(line_stripped)
106 except (OSError, UnicodeDecodeError) as e:
107 logger.warning(f"Failed to load .lintro-ignore: {e}")
109 return ignore_patterns
112def normalize_file_path_for_display(file_path: str) -> str:
113 """Normalize file path to be relative to project root for consistent display.
115 This ensures all tools show file paths in the same format:
116 - Relative to project root (like ./src/file.py)
117 - Consistent across all tools regardless of how they output paths
119 Args:
120 file_path: File path (can be absolute or relative). If empty, returns as is.
122 Returns:
123 Normalized relative path from project root (e.g., "./src/file.py")
124 """
125 # Fast-path: empty or whitespace-only input
126 if not file_path or not str(file_path).strip():
127 return file_path
129 try:
130 project_root = Path.cwd().resolve()
131 abs_path = Path(file_path).resolve()
133 # Attempt to make path relative to project root
134 try:
135 rel_path = abs_path.relative_to(project_root)
136 rel_path_str = str(rel_path)
138 # Ensure it starts with "./" for consistency
139 if not rel_path_str.startswith("./"):
140 rel_path_str = "./" + rel_path_str
142 return rel_path_str
144 except ValueError:
145 # Path is outside project root - log warning and return with ../
146 logger.debug(f"Path '{file_path}' is outside project root")
147 # Use the original behavior for paths outside project
148 # Calculate relative path that may include ../
149 try:
150 # Find common ancestor and build relative path
151 rel_parts: list[str] = []
152 # Walk up from project_root to find common ancestor
153 project_parts = project_root.parts
154 path_parts = abs_path.parts
156 # Find common prefix length
157 common_len = 0
158 for p1, p2 in zip(project_parts, path_parts, strict=False):
159 if p1 == p2:
160 common_len += 1
161 else:
162 break
164 # Build relative path
165 ups = len(project_parts) - common_len
166 rel_parts = [".."] * ups + list(path_parts[common_len:])
167 return "/".join(rel_parts) if rel_parts else "."
169 except (ValueError, IndexError):
170 return file_path
172 except (OSError, ValueError):
173 # If path normalization fails, return the original path
174 return file_path