Skip to content

Commit 05c90b1

Browse files
author
PyCompiler ARK++
committed
Refactor dependency analysis and packaging plugin
- Added a method to resolve the Python interpreter for virtual environments in `analyser.py`. - Enhanced the logic to exclude virtual environment directories from analysis. - Updated the dependency checking mechanism to utilize the active interpreter from the Venv Manager. - Removed the entire Packaging plugin and its associated language files, as it is no longer needed. - Introduced a new modern theme for the UI with a comprehensive stylesheet for better aesthetics.
1 parent 24f6c14 commit 05c90b1

16 files changed

Lines changed: 946 additions & 316 deletions

File tree

Core/Venv_Manager/Manager.py

Lines changed: 267 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import platform
77
import shutil
88
import sys
9+
import re
910

1011
from PySide6.QtCore import QProcess, QTimer
1112
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox
@@ -20,6 +21,9 @@ class VenvManager:
2021
Responsibilities:
2122
- Manual venv selection (updates parent UI label and internal path)
2223
- Create venv if missing
24+
- Discover multiple venvs and select the best suited for the workspace
25+
- Ensure/generate project requirements automatically if missing
26+
- Provide interpreter and pip command to other components (central authority)
2327
- Check/install tools in an existing venv
2428
- Install project requirements.txt
2529
- Report/terminate active background tasks related to venv operations
@@ -62,29 +66,283 @@ def __init__(self, parent_widget):
6266
# Internal timers to enforce timeouts on background processes
6367
self._proc_timers: list[QTimer] = []
6468

65-
# ---------- Public helpers for engines ----------
69+
# Centralized active workspace/venv state
70+
self.workspace_dir: str | None = None
71+
self.active_venv: dict | None = None # {'path': str, 'python': str, 'score': float, 'distance': int}
72+
self._project_requirements_path: str | None = None
73+
74+
# ---------- Public helpers for engines and analyzers ----------
75+
def set_selected_workspace(self, workspace_dir: str):
76+
"""Set the active workspace, ensure requirements, discover/select venv, and cache interpreter/pip."""
77+
try:
78+
self.workspace_dir = os.path.abspath(workspace_dir) if workspace_dir else None
79+
if not self.workspace_dir or not os.path.isdir(self.workspace_dir):
80+
self._safe_log("❌ Workspace invalide pour VenvManager.")
81+
return
82+
# Ensure requirements file exists
83+
self._project_requirements_path = self.ensure_project_requirements(self.workspace_dir)
84+
# Discover venvs
85+
venvs = self.discover_venvs(self.workspace_dir)
86+
# Select best venv (may be None)
87+
reqs = self.get_project_requirements(self.workspace_dir)
88+
best = self.select_best_venv(self.workspace_dir, venvs, reqs)
89+
self.active_venv = best
90+
if best and best.get('python'):
91+
self._safe_log(f"ℹ️ Venv sélectionné: {best.get('path')} (score={best.get('score', 0):.2f}, distance={best.get('distance', 0)})")
92+
else:
93+
self._safe_log("ℹ️ Aucun venv approprié trouvé. Les opérations pip utiliseront l'interpréteur courant.")
94+
except Exception as e:
95+
self._safe_log(f"⚠️ set_selected_workspace: {e}")
96+
6697
def resolve_project_venv(self) -> str | None:
67-
"""Resolve the venv root to use based on manual selection or workspace.
68-
Prefers an existing .venv over venv; if none exists, returns the default path (.venv).
69-
"""
98+
"""Resolve the venv root to use based on manual selection or discovered best venv."""
7099
try:
71100
manual = getattr(self.parent, "venv_path_manuel", None)
72101
if manual:
73-
base = os.path.abspath(manual)
74-
return base
75-
if getattr(self.parent, "workspace_dir", None):
76-
base = os.path.abspath(self.parent.workspace_dir)
77-
existing, default_path = self._detect_venv_in(base)
102+
return os.path.abspath(manual)
103+
if self.active_venv and self.active_venv.get('path'):
104+
return self.active_venv['path']
105+
if self.workspace_dir:
106+
existing, default_path = self._detect_venv_in(self.workspace_dir)
78107
return existing or default_path
79108
except Exception:
80109
return None
81110
return None
82111

112+
def get_active_interpreter(self) -> str | None:
113+
"""Return the python interpreter path of the selected venv, if any."""
114+
try:
115+
root = self.resolve_project_venv()
116+
if root:
117+
return self.python_path(root)
118+
except Exception:
119+
pass
120+
return None
121+
122+
def get_pip_command(self) -> tuple[str, list[str]]:
123+
"""Return (program, prefix_args) for invoking pip as python -m pip in the active venv (or current python)."""
124+
prog = self.get_active_interpreter() or sys.executable
125+
return prog, ["-m", "pip"]
126+
83127
def pip_path(self, venv_root: str) -> str:
84128
return os.path.join(
85129
venv_root, "Scripts" if platform.system() == "Windows" else "bin", "pip"
86130
)
87131

132+
# ---------- Discovery/select best venv ----------
133+
def discover_venvs(self, workspace_dir: str, max_depth: int = 3) -> list[dict]:
134+
"""Scan workspace for venv-like folders and return list of dicts with path, python, distance."""
135+
results: list[dict] = []
136+
try:
137+
ws = os.path.abspath(workspace_dir)
138+
candidates = {"venv", ".venv", "env", ".env", "ev", ".ev"}
139+
for root, dirs, files in os.walk(ws):
140+
# compute depth relative to ws
141+
rel = os.path.relpath(root, ws)
142+
depth = 0 if rel == '.' else len(rel.split(os.sep))
143+
if depth > max_depth:
144+
# prune deeper levels
145+
dirs[:] = []
146+
continue
147+
# if this folder itself is a venv root candidate name
148+
base = os.path.basename(root)
149+
if base in candidates:
150+
py = self.python_path(root)
151+
if os.path.isfile(py) and os.path.isfile(os.path.join(root, 'pyvenv.cfg')):
152+
results.append({
153+
'path': root,
154+
'python': py,
155+
'distance': depth,
156+
})
157+
# do not dive into venv internals further
158+
dirs[:] = []
159+
continue
160+
# Also prune typical venv internals for performance
161+
if base in (".git", "__pycache__", "node_modules"):
162+
dirs[:] = []
163+
continue
164+
except Exception as e:
165+
self._safe_log(f"⚠️ discover_venvs: {e}")
166+
# Manual venv takes precedence if set
167+
manual = getattr(self.parent, "venv_path_manuel", None)
168+
if manual:
169+
mpath = os.path.abspath(manual)
170+
py = self.python_path(mpath)
171+
if os.path.isfile(py):
172+
return [{'path': mpath, 'python': py, 'distance': 0}] + [v for v in results if v['path'] != mpath]
173+
return results
174+
175+
def get_project_requirements(self, workspace_dir: str) -> set[str]:
176+
"""Return a normalized set of requirement package names for the project.
177+
Looks for requirements.txt/constraints.txt/pyproject.toml; if none and a cached path exists, use it."""
178+
reqs: set[str] = set()
179+
try:
180+
ws = os.path.abspath(workspace_dir)
181+
# requirements.txt
182+
req_path = os.path.join(ws, "requirements.txt")
183+
if os.path.isfile(req_path):
184+
with open(req_path, encoding="utf-8", errors="ignore") as f:
185+
for line in f:
186+
line = line.strip()
187+
if not line or line.startswith('#'):
188+
continue
189+
# strip version specifiers
190+
pkg = re.split(r"[<>=!~]", line, 1)[0].strip()
191+
if pkg:
192+
reqs.add(pkg)
193+
return reqs
194+
# constraints.txt
195+
c_path = os.path.join(ws, "constraints.txt")
196+
if os.path.isfile(c_path):
197+
with open(c_path, encoding="utf-8", errors="ignore") as f:
198+
for line in f:
199+
line = line.strip()
200+
if not line or line.startswith('#'):
201+
continue
202+
pkg = re.split(r"[<>=!~]", line, 1)[0].strip()
203+
if pkg:
204+
reqs.add(pkg)
205+
return reqs
206+
# pyproject.toml (basic read without tomli dependency)
207+
pp = os.path.join(ws, "pyproject.toml")
208+
if os.path.isfile(pp):
209+
try:
210+
import tomllib # py311+
211+
except Exception:
212+
tomllib = None
213+
if tomllib:
214+
try:
215+
with open(pp, 'rb') as f:
216+
data = tomllib.load(f)
217+
deps = data.get('project', {}).get('dependencies', []) or []
218+
for d in deps:
219+
pkg = re.split(r"[<>=!~]", d, 1)[0].strip()
220+
if pkg:
221+
reqs.add(pkg)
222+
return reqs
223+
except Exception:
224+
pass
225+
except Exception:
226+
pass
227+
return reqs
228+
229+
def ensure_project_requirements(self, workspace_dir: str) -> str | None:
230+
"""Ensure the project has a requirements.txt; if missing, generate it via dependency analysis."""
231+
try:
232+
ws = os.path.abspath(workspace_dir)
233+
req_path = os.path.join(ws, "requirements.txt")
234+
if os.path.isfile(req_path):
235+
return req_path
236+
# Try to generate: basic import scan from Python files in workspace
237+
pkgs = self._analyze_workspace_imports(ws)
238+
if pkgs:
239+
with open(req_path, 'w', encoding='utf-8') as f:
240+
for p in sorted(pkgs):
241+
f.write(p + "\n")
242+
self._safe_log(f"📝 requirements.txt généré ({len(pkgs)} packages).")
243+
return req_path
244+
return None
245+
except Exception as e:
246+
self._safe_log(f"⚠️ ensure_project_requirements: {e}")
247+
return None
248+
249+
def select_best_venv(self, workspace_dir: str, venvs: list[dict], requirements: set[str]) -> dict | None:
250+
"""Select the best venv based on distance to root and how many deps are already satisfied."""
251+
if not venvs:
252+
return None
253+
try:
254+
# Precompute installed pkgs once per venv using 'pip list --format=json'
255+
enriched = []
256+
for v in venvs:
257+
installed = self._pip_list_names(v['python'])
258+
if installed is None:
259+
score = 0.0
260+
else:
261+
have = sum(1 for r in requirements if r.lower() in installed)
262+
total = len(requirements) or 1
263+
score = have / total
264+
v2 = dict(v)
265+
v2['score'] = score
266+
enriched.append(v2)
267+
# Choose: minimal distance, then maximal score
268+
enriched.sort(key=lambda x: (x.get('distance', 0), -x.get('score', 0.0)))
269+
return enriched[0]
270+
except Exception as e:
271+
self._safe_log(f"⚠️ select_best_venv: {e}")
272+
return venvs[0]
273+
274+
# ---------- Internal helpers ----------
275+
def _pip_list_names(self, python_exe: str) -> set[str] | None:
276+
try:
277+
import subprocess, json
278+
cp = subprocess.run([
279+
python_exe, '-m', 'pip', 'list', '--format', 'json'
280+
], capture_output=True, text=True)
281+
if cp.returncode != 0:
282+
return None
283+
data = json.loads(cp.stdout.strip() or '[]')
284+
names = { (item.get('name') or '').lower() for item in data if item.get('name') }
285+
return names
286+
except Exception:
287+
return None
288+
289+
def _analyze_workspace_imports(self, workspace_dir: str) -> set[str]:
290+
"""Very lightweight import analyzer to extract top-level packages from .py files, excluding stdlib and venv dirs."""
291+
pkgs: set[str] = set()
292+
try:
293+
import ast, sysconfig, pkgutil, re as _re
294+
ws = os.path.abspath(workspace_dir)
295+
# Exclude common venv dirs
296+
exclude_dirs = {".venv", "venv", "env", ".env", "ev", ".ev", "__pycache__", ".git"}
297+
for root, dirs, files in os.walk(ws):
298+
base = os.path.basename(root)
299+
if base in exclude_dirs:
300+
dirs[:] = []
301+
continue
302+
# prune venv internals
303+
for d in list(dirs):
304+
if d in exclude_dirs:
305+
dirs.remove(d)
306+
for fn in files:
307+
if not fn.endswith('.py'):
308+
continue
309+
fpath = os.path.join(root, fn)
310+
try:
311+
with open(fpath, encoding='utf-8') as f:
312+
src = f.read()
313+
tree = ast.parse(src, filename=fpath)
314+
for node in ast.walk(tree):
315+
if isinstance(node, ast.Import):
316+
for alias in node.names:
317+
pkgs.add(alias.name.split('.') [0])
318+
elif isinstance(node, ast.ImportFrom):
319+
if node.module:
320+
pkgs.add(node.module.split('.') [0])
321+
# dynamic imports
322+
dyn = _re.findall(r"__import__\(['\"]([\w\.]+)['\"]\)", src)
323+
pkgs.update([m.split('.')[0] for m in dyn])
324+
mod = _re.findall(r"importlib\.import_module\(['\"]([\w\.]+)['\"]\)", src)
325+
pkgs.update([m.split('.')[0] for m in mod])
326+
except Exception:
327+
continue
328+
# remove stdlib and common internals
329+
std = set(sys.builtin_module_names)
330+
try:
331+
import sysconfig as _sc
332+
stdlib_path = _sc.get_path('stdlib') or ''
333+
stdlib_path = os.path.realpath(stdlib_path)
334+
# best effort: also add stdlib discovered modules
335+
for _, name, _ in pkgutil.iter_modules([stdlib_path]):
336+
std.add(name)
337+
except Exception:
338+
pass
339+
# Heuristic removal of common internals
340+
core_names = {"Core", "ENGINES", "Plugins", "engine_sdk", "Plugins_SDK"}
341+
out = {m for m in pkgs if m not in std and m not in core_names}
342+
return out
343+
except Exception:
344+
return set()
345+
88346
def python_path(self, venv_root: str) -> str:
89347
base = os.path.join(
90348
venv_root, "Scripts" if platform.system() == "Windows" else "bin"

0 commit comments

Comments
 (0)