66import platform
77import shutil
88import sys
9+ import re
910
1011from PySide6 .QtCore import QProcess , QTimer
1112from 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
@@ -120,29 +124,283 @@ def __init__(self, parent_widget):
120124 },
121125 }
122126
123- # ---------- Public helpers for engines ----------
127+ # Centralized active workspace/venv state
128+ self .workspace_dir : str | None = None
129+ self .active_venv : dict | None = None # {'path': str, 'python': str, 'score': float, 'distance': int}
130+ self ._project_requirements_path : str | None = None
131+
132+ # ---------- Public helpers for engines and analyzers ----------
133+ def set_selected_workspace (self , workspace_dir : str ):
134+ """Set the active workspace, ensure requirements, discover/select venv, and cache interpreter/pip."""
135+ try :
136+ self .workspace_dir = os .path .abspath (workspace_dir ) if workspace_dir else None
137+ if not self .workspace_dir or not os .path .isdir (self .workspace_dir ):
138+ self ._safe_log ("❌ Workspace invalide pour VenvManager." )
139+ return
140+ # Ensure requirements file exists
141+ self ._project_requirements_path = self .ensure_project_requirements (self .workspace_dir )
142+ # Discover venvs
143+ venvs = self .discover_venvs (self .workspace_dir )
144+ # Select best venv (may be None)
145+ reqs = self .get_project_requirements (self .workspace_dir )
146+ best = self .select_best_venv (self .workspace_dir , venvs , reqs )
147+ self .active_venv = best
148+ if best and best .get ('python' ):
149+ self ._safe_log (f"ℹ️ Venv sélectionné: { best .get ('path' )} (score={ best .get ('score' , 0 ):.2f} , distance={ best .get ('distance' , 0 )} )" )
150+ else :
151+ self ._safe_log ("ℹ️ Aucun venv approprié trouvé. Les opérations pip utiliseront l'interpréteur courant." )
152+ except Exception as e :
153+ self ._safe_log (f"⚠️ set_selected_workspace: { e } " )
154+
124155 def resolve_project_venv (self ) -> str | None :
125- """Resolve the venv root to use based on manual selection or workspace.
126- Prefers an existing .venv over venv; if none exists, returns the default path (.venv).
127- """
156+ """Resolve the venv root to use based on manual selection or discovered best venv."""
128157 try :
129158 manual = getattr (self .parent , "venv_path_manuel" , None )
130159 if manual :
131- base = os .path .abspath (manual )
132- return base
133- if getattr ( self . parent , "workspace_dir" , None ):
134- base = os . path . abspath ( self .parent . workspace_dir )
135- existing , default_path = self ._detect_venv_in (base )
160+ return os .path .abspath (manual )
161+ if self . active_venv and self . active_venv . get ( 'path' ):
162+ return self . active_venv [ 'path' ]
163+ if self .workspace_dir :
164+ existing , default_path = self ._detect_venv_in (self . workspace_dir )
136165 return existing or default_path
137166 except Exception :
138167 return None
139168 return None
140169
170+ def get_active_interpreter (self ) -> str | None :
171+ """Return the python interpreter path of the selected venv, if any."""
172+ try :
173+ root = self .resolve_project_venv ()
174+ if root :
175+ return self .python_path (root )
176+ except Exception :
177+ pass
178+ return None
179+
180+ def get_pip_command (self ) -> tuple [str , list [str ]]:
181+ """Return (program, prefix_args) for invoking pip as python -m pip in the active venv (or current python)."""
182+ prog = self .get_active_interpreter () or sys .executable
183+ return prog , ["-m" , "pip" ]
184+
141185 def pip_path (self , venv_root : str ) -> str :
142186 return os .path .join (
143187 venv_root , "Scripts" if platform .system () == "Windows" else "bin" , "pip"
144188 )
145189
190+ # ---------- Discovery/select best venv ----------
191+ def discover_venvs (self , workspace_dir : str , max_depth : int = 3 ) -> list [dict ]:
192+ """Scan workspace for venv-like folders and return list of dicts with path, python, distance."""
193+ results : list [dict ] = []
194+ try :
195+ ws = os .path .abspath (workspace_dir )
196+ candidates = {"venv" , ".venv" , "env" , ".env" , "ev" , ".ev" }
197+ for root , dirs , files in os .walk (ws ):
198+ # compute depth relative to ws
199+ rel = os .path .relpath (root , ws )
200+ depth = 0 if rel == '.' else len (rel .split (os .sep ))
201+ if depth > max_depth :
202+ # prune deeper levels
203+ dirs [:] = []
204+ continue
205+ # if this folder itself is a venv root candidate name
206+ base = os .path .basename (root )
207+ if base in candidates :
208+ py = self .python_path (root )
209+ if os .path .isfile (py ) and os .path .isfile (os .path .join (root , 'pyvenv.cfg' )):
210+ results .append ({
211+ 'path' : root ,
212+ 'python' : py ,
213+ 'distance' : depth ,
214+ })
215+ # do not dive into venv internals further
216+ dirs [:] = []
217+ continue
218+ # Also prune typical venv internals for performance
219+ if base in (".git" , "__pycache__" , "node_modules" ):
220+ dirs [:] = []
221+ continue
222+ except Exception as e :
223+ self ._safe_log (f"⚠️ discover_venvs: { e } " )
224+ # Manual venv takes precedence if set
225+ manual = getattr (self .parent , "venv_path_manuel" , None )
226+ if manual :
227+ mpath = os .path .abspath (manual )
228+ py = self .python_path (mpath )
229+ if os .path .isfile (py ):
230+ return [{'path' : mpath , 'python' : py , 'distance' : 0 }] + [v for v in results if v ['path' ] != mpath ]
231+ return results
232+
233+ def get_project_requirements (self , workspace_dir : str ) -> set [str ]:
234+ """Return a normalized set of requirement package names for the project.
235+ Looks for requirements.txt/constraints.txt/pyproject.toml; if none and a cached path exists, use it."""
236+ reqs : set [str ] = set ()
237+ try :
238+ ws = os .path .abspath (workspace_dir )
239+ # requirements.txt
240+ req_path = os .path .join (ws , "requirements.txt" )
241+ if os .path .isfile (req_path ):
242+ with open (req_path , encoding = "utf-8" , errors = "ignore" ) as f :
243+ for line in f :
244+ line = line .strip ()
245+ if not line or line .startswith ('#' ):
246+ continue
247+ # strip version specifiers
248+ pkg = re .split (r"[<>=!~]" , line , 1 )[0 ].strip ()
249+ if pkg :
250+ reqs .add (pkg )
251+ return reqs
252+ # constraints.txt
253+ c_path = os .path .join (ws , "constraints.txt" )
254+ if os .path .isfile (c_path ):
255+ with open (c_path , encoding = "utf-8" , errors = "ignore" ) as f :
256+ for line in f :
257+ line = line .strip ()
258+ if not line or line .startswith ('#' ):
259+ continue
260+ pkg = re .split (r"[<>=!~]" , line , 1 )[0 ].strip ()
261+ if pkg :
262+ reqs .add (pkg )
263+ return reqs
264+ # pyproject.toml (basic read without tomli dependency)
265+ pp = os .path .join (ws , "pyproject.toml" )
266+ if os .path .isfile (pp ):
267+ try :
268+ import tomllib # py311+
269+ except Exception :
270+ tomllib = None
271+ if tomllib :
272+ try :
273+ with open (pp , 'rb' ) as f :
274+ data = tomllib .load (f )
275+ deps = data .get ('project' , {}).get ('dependencies' , []) or []
276+ for d in deps :
277+ pkg = re .split (r"[<>=!~]" , d , 1 )[0 ].strip ()
278+ if pkg :
279+ reqs .add (pkg )
280+ return reqs
281+ except Exception :
282+ pass
283+ except Exception :
284+ pass
285+ return reqs
286+
287+ def ensure_project_requirements (self , workspace_dir : str ) -> str | None :
288+ """Ensure the project has a requirements.txt; if missing, generate it via dependency analysis."""
289+ try :
290+ ws = os .path .abspath (workspace_dir )
291+ req_path = os .path .join (ws , "requirements.txt" )
292+ if os .path .isfile (req_path ):
293+ return req_path
294+ # Try to generate: basic import scan from Python files in workspace
295+ pkgs = self ._analyze_workspace_imports (ws )
296+ if pkgs :
297+ with open (req_path , 'w' , encoding = 'utf-8' ) as f :
298+ for p in sorted (pkgs ):
299+ f .write (p + "\n " )
300+ self ._safe_log (f"📝 requirements.txt généré ({ len (pkgs )} packages)." )
301+ return req_path
302+ return None
303+ except Exception as e :
304+ self ._safe_log (f"⚠️ ensure_project_requirements: { e } " )
305+ return None
306+
307+ def select_best_venv (self , workspace_dir : str , venvs : list [dict ], requirements : set [str ]) -> dict | None :
308+ """Select the best venv based on distance to root and how many deps are already satisfied."""
309+ if not venvs :
310+ return None
311+ try :
312+ # Precompute installed pkgs once per venv using 'pip list --format=json'
313+ enriched = []
314+ for v in venvs :
315+ installed = self ._pip_list_names (v ['python' ])
316+ if installed is None :
317+ score = 0.0
318+ else :
319+ have = sum (1 for r in requirements if r .lower () in installed )
320+ total = len (requirements ) or 1
321+ score = have / total
322+ v2 = dict (v )
323+ v2 ['score' ] = score
324+ enriched .append (v2 )
325+ # Choose: minimal distance, then maximal score
326+ enriched .sort (key = lambda x : (x .get ('distance' , 0 ), - x .get ('score' , 0.0 )))
327+ return enriched [0 ]
328+ except Exception as e :
329+ self ._safe_log (f"⚠️ select_best_venv: { e } " )
330+ return venvs [0 ]
331+
332+ # ---------- Internal helpers ----------
333+ def _pip_list_names (self , python_exe : str ) -> set [str ] | None :
334+ try :
335+ import subprocess , json
336+ cp = subprocess .run ([
337+ python_exe , '-m' , 'pip' , 'list' , '--format' , 'json'
338+ ], capture_output = True , text = True )
339+ if cp .returncode != 0 :
340+ return None
341+ data = json .loads (cp .stdout .strip () or '[]' )
342+ names = { (item .get ('name' ) or '' ).lower () for item in data if item .get ('name' ) }
343+ return names
344+ except Exception :
345+ return None
346+
347+ def _analyze_workspace_imports (self , workspace_dir : str ) -> set [str ]:
348+ """Very lightweight import analyzer to extract top-level packages from .py files, excluding stdlib and venv dirs."""
349+ pkgs : set [str ] = set ()
350+ try :
351+ import ast , sysconfig , pkgutil , re as _re
352+ ws = os .path .abspath (workspace_dir )
353+ # Exclude common venv dirs
354+ exclude_dirs = {".venv" , "venv" , "env" , ".env" , "ev" , ".ev" , "__pycache__" , ".git" }
355+ for root , dirs , files in os .walk (ws ):
356+ base = os .path .basename (root )
357+ if base in exclude_dirs :
358+ dirs [:] = []
359+ continue
360+ # prune venv internals
361+ for d in list (dirs ):
362+ if d in exclude_dirs :
363+ dirs .remove (d )
364+ for fn in files :
365+ if not fn .endswith ('.py' ):
366+ continue
367+ fpath = os .path .join (root , fn )
368+ try :
369+ with open (fpath , encoding = 'utf-8' ) as f :
370+ src = f .read ()
371+ tree = ast .parse (src , filename = fpath )
372+ for node in ast .walk (tree ):
373+ if isinstance (node , ast .Import ):
374+ for alias in node .names :
375+ pkgs .add (alias .name .split ('.' ) [0 ])
376+ elif isinstance (node , ast .ImportFrom ):
377+ if node .module :
378+ pkgs .add (node .module .split ('.' ) [0 ])
379+ # dynamic imports
380+ dyn = _re .findall (r"__import__\(['\"]([\w\.]+)['\"]\)" , src )
381+ pkgs .update ([m .split ('.' )[0 ] for m in dyn ])
382+ mod = _re .findall (r"importlib\.import_module\(['\"]([\w\.]+)['\"]\)" , src )
383+ pkgs .update ([m .split ('.' )[0 ] for m in mod ])
384+ except Exception :
385+ continue
386+ # remove stdlib and common internals
387+ std = set (sys .builtin_module_names )
388+ try :
389+ import sysconfig as _sc
390+ stdlib_path = _sc .get_path ('stdlib' ) or ''
391+ stdlib_path = os .path .realpath (stdlib_path )
392+ # best effort: also add stdlib discovered modules
393+ for _ , name , _ in pkgutil .iter_modules ([stdlib_path ]):
394+ std .add (name )
395+ except Exception :
396+ pass
397+ # Heuristic removal of common internals
398+ core_names = {"Core" , "ENGINES" , "Plugins" , "engine_sdk" , "Plugins_SDK" }
399+ out = {m for m in pkgs if m not in std and m not in core_names }
400+ return out
401+ except Exception :
402+ return set ()
403+
146404 def python_path (self , venv_root : str ) -> str :
147405 base = os .path .join (
148406 venv_root , "Scripts" if platform .system () == "Windows" else "bin"
0 commit comments