main
py 195 lines 6.12 KB
Raw
1 from __future__ import annotations
2
3 import importlib
4 import importlib.metadata
5 import importlib.util
6 import shutil
7 import subprocess
8 import sys
9 import threading
10 from pathlib import Path
11
12 from helpers import files, plugins, yaml as yaml_helper
13 from plugins._browser.helpers.config import (
14 PLUGIN_NAME,
15 browser_runtime_config,
16 normalize_browser_config,
17 )
18 from plugins._browser.helpers.playwright import (
19 ensure_playwright_binary,
20 find_playwright_binary,
21 get_playwright_cache_dir,
22 get_retired_playwright_cache_dirs,
23 )
24 from plugins._browser.helpers.runtime import close_all_runtimes_sync
25
26
27 _SETUP_LOCK = threading.Lock()
28 _PLUGIN_DIR = Path(__file__).resolve().parent
29 _ROOT_REQUIREMENTS_FILE = _PLUGIN_DIR.parents[1] / "requirements.txt"
30
31
32 def _load_saved_browser_config(project_name: str = "", agent_profile: str = "") -> dict:
33 entries = plugins.find_plugin_assets(
34 plugins.CONFIG_FILE_NAME,
35 plugin_name=PLUGIN_NAME,
36 project_name=project_name,
37 agent_profile=agent_profile,
38 only_first=True,
39 )
40 path = entries[0].get("path", "") if entries else ""
41 if path and files.exists(path):
42 return files.read_file_json(path) or {}
43
44 plugin_dir = plugins.find_plugin_dir(PLUGIN_NAME)
45 default_path = (
46 files.get_abs_path(plugin_dir, plugins.CONFIG_DEFAULT_FILE_NAME)
47 if plugin_dir
48 else ""
49 )
50 if default_path and files.exists(default_path):
51 return yaml_helper.loads(files.read_file(default_path)) or {}
52
53 return {}
54
55
56 def get_plugin_config(default=None, **kwargs):
57 return normalize_browser_config(default)
58
59
60 def save_plugin_config(settings=None, project_name="", agent_profile="", **kwargs):
61 normalized = normalize_browser_config(settings)
62 current = normalize_browser_config(
63 _load_saved_browser_config(project_name=project_name, agent_profile=agent_profile)
64 )
65 if browser_runtime_config(normalized) != browser_runtime_config(current):
66 close_all_runtimes_sync()
67 return normalized
68
69
70 def cleanup_playwright_cache() -> dict:
71 primary = Path(get_playwright_cache_dir())
72 retired_dirs = [
73 path for path in get_retired_playwright_cache_dirs() if path.resolve() != primary.resolve()
74 ]
75 result = {"primary": str(primary), "migrated": "", "removed": [], "errors": []}
76
77 if find_playwright_binary(primary):
78 _remove_cache_dirs(retired_dirs, result)
79 return result
80
81 source = _best_playwright_cache(retired_dirs)
82 if not source:
83 return result
84
85 backup = _next_backup_path(primary) if primary.exists() else None
86 try:
87 if backup:
88 primary.rename(backup)
89 primary.parent.mkdir(parents=True, exist_ok=True)
90 shutil.move(str(source), str(primary))
91 result["migrated"] = str(source)
92 except Exception as exc:
93 if backup and backup.exists() and not primary.exists():
94 backup.rename(primary)
95 result["errors"].append(f"Failed to migrate {source} to {primary}: {exc}")
96 return result
97
98 if not find_playwright_binary(primary):
99 result["errors"].append(f"Migrated Playwright cache is not valid: {primary}")
100 if backup:
101 result["errors"].append(f"Previous primary Playwright cache retained at {backup}")
102 return result
103
104 if backup:
105 _remove_cache_dirs([backup], result)
106 _remove_cache_dirs(retired_dirs, result)
107 return result
108
109
110 def prepare_playwright_cache() -> dict:
111 with _SETUP_LOCK:
112 _ensure_patchright_dependency()
113 result = cleanup_playwright_cache()
114 if result["errors"]:
115 return result
116 result["binary"] = str(ensure_playwright_binary())
117 return result
118
119
120 def install() -> dict:
121 return prepare_playwright_cache()
122
123
124 def _ensure_patchright_dependency() -> None:
125 requirement = _patchright_requirement()
126 if _patchright_is_current(requirement):
127 return
128
129 uv = shutil.which("uv")
130 if not uv:
131 raise RuntimeError("Browser plugin requires 'uv' to install Patchright automatically")
132
133 subprocess.check_call(
134 [uv, "pip", "install", "--python", sys.executable, requirement],
135 cwd=str(_PLUGIN_DIR),
136 )
137 importlib.invalidate_caches()
138 if not _patchright_is_current(requirement):
139 raise RuntimeError(
140 f"Browser dependency {requirement!r} is unavailable after installation"
141 )
142
143
144 def _patchright_requirement() -> str:
145 if _ROOT_REQUIREMENTS_FILE.is_file():
146 for line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines():
147 requirement = line.strip()
148 if requirement.startswith("patchright=="):
149 return requirement
150 raise RuntimeError(f"Browser Patchright requirement not found in {_ROOT_REQUIREMENTS_FILE}")
151
152
153 def _patchright_is_current(requirement: str) -> bool:
154 expected_version = requirement.partition("==")[2]
155 if not expected_version or importlib.util.find_spec("patchright") is None:
156 return False
157 try:
158 return importlib.metadata.version("patchright") == expected_version
159 except importlib.metadata.PackageNotFoundError:
160 return False
161
162
163 def _best_playwright_cache(candidates: list[Path]) -> Path | None:
164 valid = [path for path in candidates if path.is_dir() and find_playwright_binary(path)]
165 if not valid:
166 return None
167
168 def modified_at(path: Path) -> float:
169 binary = find_playwright_binary(path)
170 try:
171 return binary.stat().st_mtime if binary else path.stat().st_mtime
172 except OSError:
173 return 0
174
175 return max(valid, key=modified_at)
176
177
178 def _next_backup_path(path: Path) -> Path:
179 backup = path.with_name(f"{path.name}.migration-backup")
180 counter = 2
181 while backup.exists():
182 backup = path.with_name(f"{path.name}.migration-backup-{counter}")
183 counter += 1
184 return backup
185
186
187 def _remove_cache_dirs(paths: list[Path], result: dict) -> None:
188 for path in paths:
189 if not path.exists():
190 continue
191 try:
192 shutil.rmtree(path)
193 result["removed"].append(str(path))
194 except Exception as exc:
195 result["errors"].append(f"Failed to remove Playwright cache {path}: {exc}")