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
@@ -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