-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall_dependencies.py
More file actions
180 lines (148 loc) · 5.6 KB
/
install_dependencies.py
File metadata and controls
180 lines (148 loc) · 5.6 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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
#!/usr/bin/env python3
from __future__ import annotations
"""Helper script to ensure ReconScript runtime dependencies are installed."""
import argparse
import hashlib
import importlib
import subprocess
import sys
from collections.abc import Iterable
from pathlib import Path
from typing import Dict
ROOT = Path(__file__).resolve().parent
REQUIREMENTS_FILE = ROOT / "requirements.txt"
MARKER_IN_VENV = ROOT / ".venv" / ".requirements-hash"
MARKER_FALLBACK = ROOT / ".requirements-hash"
# Mapping of requirement name to import name so we can detect missing modules quickly.
REQUIREMENT_IMPORTS: Dict[str, str] = {
"requests": "requests",
"urllib3": "urllib3",
"jinja2": "jinja2",
"flask": "flask",
"rich": "rich",
"tabulate": "tabulate",
"colorama": "colorama",
"weasyprint": "weasyprint",
"fonttools": "fontTools",
"tinycss2": "tinycss2",
"cssselect2": "cssselect2",
"pyphen": "pyphen",
"pydyf": "pydyf",
"markupsafe": "markupsafe",
"itsdangerous": "itsdangerous",
"werkzeug": "werkzeug",
"python-dotenv": "dotenv",
}
def create_console(): # type: ignore[return-value]
"""Return a ``rich`` console if available, otherwise a simple fallback printer."""
try: # pragma: no cover - gracefully degrade if Rich is absent
from rich.console import Console
return Console(highlight=False)
except Exception:
class _PlainConsole:
def print(self, *values: object, sep: str = " ", end: str = "\n") -> None:
text = sep.join(str(v) for v in values)
sys.stdout.write(text + end)
sys.stdout.flush()
def rule(self, text: str) -> None:
self.print(f"--- {text} ---")
def log(self, *values: object, **kwargs: object) -> None:
self.print(*values)
return _PlainConsole()
def _hash_requirements(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _marker_path() -> Path:
preferred_parent = MARKER_IN_VENV.parent
if preferred_parent.exists():
return MARKER_IN_VENV
return MARKER_FALLBACK
def _missing_modules(modules: Dict[str, str]) -> Iterable[str]:
for requirement, module_name in modules.items():
try:
importlib.import_module(module_name)
except Exception:
yield requirement
def install_dependencies(
python_executable: str | Path | None = None,
requirements_path: str | Path | None = None,
*,
force: bool = False,
console=None,
) -> None:
"""Ensure project requirements are installed for the supplied interpreter."""
py_exec = Path(python_executable or sys.executable)
requirements = Path(requirements_path or REQUIREMENTS_FILE)
output = console or create_console()
if not requirements.exists():
raise FileNotFoundError(f"Requirements file not found at {requirements}")
marker = _marker_path()
expected_hash = _hash_requirements(requirements)
recorded_hash = marker.read_text().strip() if marker.exists() else None
missing = list(_missing_modules(REQUIREMENT_IMPORTS))
if missing:
output.print(
f"Installing missing dependencies: {', '.join(sorted(set(missing)))} …"
)
if force:
output.print("Force flag supplied — reinstalling requirements…")
if missing or force or recorded_hash != expected_hash:
command = [
str(py_exec),
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-warn-script-location",
"--quiet",
"-r",
str(requirements),
]
output.print("Resolving Python requirements (this may take a moment)…")
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
combined_output = "\n".join(
part for part in (result.stdout, result.stderr) if part
)
raise RuntimeError(
"Dependency installation failed with exit code"
f" {result.returncode}:\n{combined_output or '(no output)'}"
)
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(expected_hash)
output.print("Dependencies installed successfully.")
else:
output.print("Dependencies already satisfied.")
def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Install ReconScript runtime dependencies."
)
parser.add_argument(
"--python",
dest="python",
default=sys.executable,
help="Python interpreter to use when invoking pip (defaults to current interpreter).",
)
parser.add_argument(
"--requirements",
dest="requirements",
default=str(REQUIREMENTS_FILE),
help="Path to the requirements.txt file (defaults to the project root).",
)
parser.add_argument(
"--force",
action="store_true",
help="Force reinstall dependencies even if hashes match and modules are present.",
)
return parser.parse_args(argv)
def main(argv: Iterable[str] | None = None) -> None:
args = _parse_args(argv)
console = create_console()
try:
install_dependencies(
args.python, args.requirements, force=args.force, console=console
)
except Exception as exc: # pragma: no cover - invoked from CLI
console.print(f"[red]Failed to install dependencies: {exc}[/red]")
raise SystemExit(1)
if __name__ == "__main__": # pragma: no cover - CLI entrypoint
main()