-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathchecker.py
More file actions
135 lines (114 loc) · 5.24 KB
/
Copy pathchecker.py
File metadata and controls
135 lines (114 loc) · 5.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
"""Core logic for detecting development tools and reading their versions.
DevCheck never installs, uninstalls, modifies, or updates anything. Every
function in this module is strictly read-only: it looks for executables on
the system PATH and, if found, runs a harmless "--version"-style command.
"""
import shutil
import subprocess
from typing import List, Optional, Tuple
from config import COMMAND_TIMEOUT_SECONDS, TOOL_DEFINITIONS
from tool import ToolResult, ToolStatus
from version_parser import clean_output, extract_version
class EnvironmentChecker:
"""Scans the local system for a configured list of development tools."""
def __init__(self, tool_definitions: Optional[List[dict]] = None) -> None:
"""Create a checker.
Args:
tool_definitions: Optional custom list of tool definitions, mainly
used by unit tests. Defaults to config.TOOL_DEFINITIONS.
"""
self.tool_definitions = (
tool_definitions if tool_definitions is not None else TOOL_DEFINITIONS
)
def _find_executable(self, commands: List[str]) -> Optional[str]:
"""Return the full path to the first matching command found on PATH."""
for command in commands:
path = shutil.which(command)
if path:
return path
return None
def _run_version_command(
self, executable_path: str, version_args: List[str]
) -> Tuple[Optional[str], Optional[str]]:
"""Run a tool's version flag(s) and return (raw_output, error_message).
Tries each argument in version_args in turn (some tools only respond
to one particular flag). Handles timeouts, permission errors, and
other OS-level failures without raising.
"""
last_error: Optional[str] = None
for arg in version_args:
try:
completed = subprocess.run(
[executable_path, arg],
capture_output=True,
text=True,
timeout=COMMAND_TIMEOUT_SECONDS,
check=False,
)
output = clean_output(completed.stdout, completed.stderr)
if output:
return output, None
last_error = "Command produced no output"
except subprocess.TimeoutExpired:
last_error = "Command timed out"
except PermissionError:
last_error = "Permission denied when executing command"
except FileNotFoundError:
last_error = "Executable disappeared before it could be run"
except OSError as exc:
last_error = f"OS error while executing command: {exc}"
return None, last_error or "No version output produced"
def check_tool(self, definition: dict) -> ToolResult:
"""Check a single tool given its definition dictionary."""
name = definition["name"]
commands = definition["commands"]
version_args = definition["version_args"]
executable_path = self._find_executable(commands)
if not executable_path:
return ToolResult(name=name, status=ToolStatus.NOT_INSTALLED)
raw_output, error = self._run_version_command(executable_path, version_args)
if not raw_output:
return ToolResult(
name=name,
status=ToolStatus.VERSION_UNAVAILABLE,
path=executable_path,
error=error,
)
version = extract_version(raw_output)
if not version:
return ToolResult(
name=name,
status=ToolStatus.VERSION_UNAVAILABLE,
path=executable_path,
error="Could not parse version from command output",
)
return ToolResult(
name=name, status=ToolStatus.INSTALLED, version=version, path=executable_path
)
def check_tool_by_name(self, tool_name: str) -> Optional[ToolResult]:
"""Check a single tool by (case-insensitive) name. Returns None if unknown."""
for definition in self.tool_definitions:
if str(definition["name"]).lower() == tool_name.lower():
return self.check_tool(definition)
return None
def scan_all(self) -> List[ToolResult]:
"""Check every configured tool and return a list of results.
Any unexpected exception for an individual tool is caught so that one
misbehaving tool cannot crash the whole scan.
"""
results: List[ToolResult] = []
for definition in self.tool_definitions:
try:
results.append(self.check_tool(definition))
except Exception as exc: # noqa: BLE001 - deliberately broad, must never crash
results.append(
ToolResult(
name=str(definition.get("name", "Unknown")),
status=ToolStatus.NOT_INSTALLED,
error=f"Unexpected error: {exc}",
)
)
return results
def list_tool_names(self) -> List[str]:
"""Return the names of all tools this checker is configured to check."""
return [str(definition["name"]) for definition in self.tool_definitions]