From 61299c31d2bc3f48bf50a0eaa76e78689b9cf1fa Mon Sep 17 00:00:00 2001 From: Samuel Amen Ague Date: Thu, 30 Jul 2026 15:05:45 +0000 Subject: [PATCH 1/9] remove state logging Signed-off-by: Samuel Amen Ague --- pycompiler_ark/Core/utils/internet.py | 15 +++++++++++++++ pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py | 11 ++++++----- pycompiler_ark/Ui/output.py | 6 ------ 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/pycompiler_ark/Core/utils/internet.py b/pycompiler_ark/Core/utils/internet.py index 7dc5159d..09170c83 100644 --- a/pycompiler_ark/Core/utils/internet.py +++ b/pycompiler_ark/Core/utils/internet.py @@ -1,3 +1,18 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Samuel Amen Ague +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """ Helpers related to internet diff --git a/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py b/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py index 02a89c1d..18913402 100644 --- a/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py +++ b/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py @@ -28,6 +28,7 @@ from ....Core.Venv_Manager.Manager import VenvManager from ..WidgetsCreator import ProgressDialog +from ... import output class VenvManagerUI(VenvManager): @@ -83,14 +84,14 @@ def _ui_tr(self, fr: str, en: str) -> str: def _ui_log(self, level: str, text: str) -> None: """Log a message via the UI logging system.""" try: - output.log(level, text, gui=self.parent) + output.log(level, text) except Exception: pass def _ui_log_message(self, level: str, text_fr: str, text_en: str) -> None: """Log an internationalized message via the UI logging system.""" try: - output.log(level, (text_fr, text_en), gui=self.parent) + output.log(level, (text_fr, text_en)) except Exception: pass @@ -279,7 +280,7 @@ def select_venv_manually(self) -> None: else: if missing: try: - self._safe_log( + output.log( f"ℹ️ Python système incomplet (dépendances manquantes: {', '.join(sorted(set(missing)))})" ) except Exception: @@ -302,14 +303,14 @@ def select_venv_manually(self) -> None: pass self.parent.venv_path_manuel = path self._update_venv_label(f"Venv sélectionné : {path}") - self._safe_log(f"✅ Venv valide sélectionné: {path}") + output.success(f"✅ Venv valide sélectionné: {path}") try: workspace_dir = getattr(self.parent, "workspace_dir", None) self.save_workspace_pref(workspace_dir) except Exception: pass else: - self._safe_log(f"❌ Venv refusé: {reason}") + output.warn(f"❌ Venv refusé: {reason}") self.parent.venv_path_manuel = None try: setattr(self.parent, "use_system_python", False) diff --git a/pycompiler_ark/Ui/output.py b/pycompiler_ark/Ui/output.py index d2e2a760..2cef8b0f 100644 --- a/pycompiler_ark/Ui/output.py +++ b/pycompiler_ark/Ui/output.py @@ -65,7 +65,6 @@ "warning": "#ffaa00", "error": "#ff4444", "success": "#00cc66", - "state": "#5500ff", } # Global widget cache @@ -233,7 +232,6 @@ def log( "WARNING": "warning", "ERROR": "error", "SUCCESS": "success", - "STATE": "state", } style = style_map.get(lvl, "info") @@ -294,7 +292,3 @@ def error(message: str | tuple | list | Any, gui: object | None = None): def success(message: str | tuple | list | Any, gui: object | None = None): log("SUCCESS", message, err=False, gui=gui) - - -def state(message: str | tuple | list | Any, gui: object | None = None): - log("STATE", message, err=False, gui=gui) From f8baefac9cad31f09b950d9ee6a39763a80294d9 Mon Sep 17 00:00:00 2001 From: Samuel Amen Ague Date: Fri, 31 Jul 2026 18:02:18 +0000 Subject: [PATCH 2/9] refactor(venv_manager): integrate executor system and fix config parsing - Connect VenvManager._prepare_manager_command to ExecutorFactory - Support both list and dict formats for manager commands in VenvManagerConfig - Add create_venv command definition to VenvManagers.yml - Add unit tests for ExecutorFactory and VenvManager command preparation Signed-off-by: Samuel Amen Ague --- pycompiler_ark/Core/Venv_Manager/Manager.py | 153 +++++++++++++------ pycompiler_ark/Core/Venv_Manager/config.py | 65 ++++++-- pycompiler_ark/Core/Venv_Manager/executor.py | 93 +++++++++++ pycompiler_ark/data/VenvManagers.yml | 25 ++- tests/test_venv_manager_config.py | 48 +++++- 5 files changed, 317 insertions(+), 67 deletions(-) create mode 100644 pycompiler_ark/Core/Venv_Manager/executor.py diff --git a/pycompiler_ark/Core/Venv_Manager/Manager.py b/pycompiler_ark/Core/Venv_Manager/Manager.py index 499249fa..53e65d03 100644 --- a/pycompiler_ark/Core/Venv_Manager/Manager.py +++ b/pycompiler_ark/Core/Venv_Manager/Manager.py @@ -14,6 +14,7 @@ from ...Ui import output as output from ..globals import WORKSPACE_CONFIG_DIRNAME from .config import VenvManagerConfig +from .executor import ExecutorFactory, PythonModuleExecutor, ExecutableExecutor class VenvManager: @@ -84,15 +85,68 @@ def __init__(self, parent_widget): self._cancel_requested = False # ---------- Manager mapping from YAML ---------- - def _get_manager_commands(self) -> dict[str, dict[str, list[str]]]: + def _get_manager_commands(self) -> dict[str, list[str]]: """Return commands from the YAML configuration for all available managers.""" - commands: dict[str, dict[str, list[str]]] = {} + commands: dict[str, list[str]] = {} for manager_name in self._config.get_available_managers(): commands[manager_name] = self._config.get_commands(manager_name) if not commands: - return {"pip": {"create_venv": ["python", "-m", "venv"]}} + return {"pip": {"create_venv": ["-m", "venv"]}} return commands + def _get_executor_config(self, manager: str) -> dict: + """get executor config (type / module / executable) from VenvManagerConfig. + Fallback to python_module + pip si la méthode n'existe pas encore dans Config. + """ + try: + cfg = self._config.get_executor(manager) + if isinstance(cfg, dict) and cfg: + return cfg + except Exception: + pass + return {"type": "python_module", "module": "pip"} + + def _prepare_manager_command( + self, + action: str, + extra_args: list[str] | None = None, + python_exe: str | None = None, + ) -> tuple[str, list[str]]: + """ + build (program, arguments) from executor + commands of YAML file using ExecutorFactory. + + - executor.type == "python_module" → -m + - executor.type == "executable" → + """ + extra_args = list(extra_args or []) + manager = ( + self._detected_manager + if isinstance(getattr(self, "_detected_manager", None), str) + else "pip" + ) + + # Commande pure depuis la config (liste d'args, sans interpréteur) + cmd_args = self._get_manager_command(manager, action) + if not cmd_args: + # Fallbacks minimaux + defaults = { + "install": ["install", "-r"], + "add": ["install"], + "check": ["check"], + "create_venv": ["-m", "venv"], + } + cmd_args = defaults.get(action, ["install"]) + + executor_cfg = self._get_executor_config(manager) + python_interpreter = ( + python_exe + or getattr(self, "_venv_python_exe", None) + or sys.executable + ) + + executor = ExecutorFactory.create(executor_cfg, python_interpreter) + return executor.build_command(list(cmd_args) + extra_args) + def _call_ui(self, method: str, *args, **kwargs): """Invoke a registered UI callback by name. Returns None if no delegate registered.""" fn = self._ui_callbacks.get(method) @@ -1121,13 +1175,26 @@ def _on_venv_pkg_checked(self, process, code, status, pkg): process2 = QProcess(self.parent) self._venv_check_install_process = process2 - process2.setProgram(self._venv_check_pip_exe) - - # Use stored pip args (e.g. ['-m', 'pip']) if available - args = list(getattr(self, "_venv_check_pip_args", [])) - process2.setArguments( - args + ["install"] + self._pip_break_system_args() + [pkg] - ) + # --- Executor system --- + try: + program, args = self._prepare_manager_command( + "add", # "add" == install d'un paquet + extra_args=self._pip_break_system_args() + [pkg], + python_exe=self._venv_check_pip_exe + if self._venv_check_use_python + else None, + ) + # if in venv, force venv's python using + if not self._venv_check_use_python and self._venv_check_path: + program = self.python_path(self._venv_check_path) + process2.setProgram(program) + process2.setArguments(args) + except Exception: + process2.setProgram(self._venv_check_pip_exe) + args = list(getattr(self, "_venv_check_pip_args", [])) + process2.setArguments( + args + ["install"] + self._pip_break_system_args() + [pkg] + ) process2.setWorkingDirectory(self._venv_check_path) process2.readyReadStandardOutput.connect( @@ -1660,17 +1727,19 @@ def create_venv_if_needed(self, path: str): process = QProcess(self.parent) self._venv_create_process = process - process.setProgram(python_candidate) - create_args = self._get_manager_command( + # --- Executor system --- + # create_venv reste spécial (module venv), on force python_module + create_cmd = self._get_manager_command( self._detected_manager, "create_venv" ) or ["-m", "venv"] - if create_args and create_args[0] == "python": - args = create_args[1:] + [venv_path] + # if command already start with -m, keep + if create_cmd and create_cmd[0] == "-m": + args = create_cmd + [venv_path] else: - args = create_args + [venv_path] - # If you use the Windows 'py' launcher, force Python 3 with -3 + args = ["-m", "venv", venv_path] if base in ("py", "py.exe"): args = ["-3"] + args + process.setProgram(python_candidate) process.setArguments(args) process.setWorkingDirectory(path) process.readyReadStandardOutput.connect( @@ -2049,29 +2118,19 @@ def _on_pip_finished(self, process, code, status): ) p2 = QProcess(self.parent) self._req_install_process = p2 - p2.setProgram(self._venv_python_exe) - upgrade_args = self._get_manager_command( - self._detected_manager, "add" - ) or ["-m", "pip", "install"] - p2.setArguments( - [ - *( - ["-m"] - if upgrade_args and upgrade_args[0] == "pip" - else [] - ), - *upgrade_args, - "--upgrade", - "pip", - "setuptools", - "wheel", - ] + # --- Executor system --- + program, args = self._prepare_manager_command( + "add", + extra_args=["--upgrade", "pip", "setuptools", "wheel"] + ( self._pip_break_system_args() if getattr(self, "_req_use_system_python", False) else [] - ) + ), + python_exe=self._venv_python_exe, ) + p2.setProgram(program) + p2.setArguments(args) p2.setWorkingDirectory(os.path.dirname(self._req_path)) p2.readyReadStandardOutput.connect(lambda: self._on_pip_output(p2)) p2.readyReadStandardError.connect( @@ -2097,26 +2156,19 @@ def _on_pip_finished(self, process, code, status): ) p2 = QProcess(self.parent) self._req_install_process = p2 - p2.setProgram(self._venv_python_exe) - install_args = self._get_manager_command( - self._detected_manager, "install" - ) or ["-m", "pip", "install", "-r"] - p2.setArguments( - [ - *( - ["-m"] - if install_args and install_args[0] == "pip" - else [] - ), - *install_args, - self._req_path, - ] + # --- Executor system --- + program, args = self._prepare_manager_command( + "install", + extra_args=[self._req_path] + ( self._pip_break_system_args() if getattr(self, "_req_use_system_python", False) else [] - ) + ), + python_exe=self._venv_python_exe, ) + p2.setProgram(program) + p2.setArguments(args) p2.setWorkingDirectory(os.path.dirname(self._req_path)) p2.readyReadStandardOutput.connect( lambda: self._on_pip_output(p2) @@ -2235,6 +2287,9 @@ def _get_manager_command( ) -> list[str] | None: """Get the command for a specific manager and action.""" try: + cmd = self._config.get_command(manager, action) + if cmd: + return cmd if manager in self._manager_commands: if action in self._manager_commands[manager]: return self._manager_commands[manager][action] diff --git a/pycompiler_ark/Core/Venv_Manager/config.py b/pycompiler_ark/Core/Venv_Manager/config.py index baba662b..d650f48c 100644 --- a/pycompiler_ark/Core/Venv_Manager/config.py +++ b/pycompiler_ark/Core/Venv_Manager/config.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from pathlib import Path from typing import Any @@ -28,31 +27,77 @@ def _load_config(self) -> dict[str, Any]: return loaded except Exception: pass + return {} def get_manager(self, manager_name: str) -> dict[str, Any] | None: + """Return complete manager configuration.""" managers = self._data.get("managers", {}) + if isinstance(managers, dict): - value = managers.get(manager_name) - if isinstance(value, dict): - return value + manager = managers.get(manager_name) + + if isinstance(manager, dict): + return manager + return None def get_commands(self, manager_name: str) -> dict[str, list[str]]: + """Return manager commands with their arguments.""" manager = self.get_manager(manager_name) + if not manager: return {} - commands: dict[str, list[str]] = {} - for key, value in manager.items(): - if isinstance(value, list) and all( - isinstance(item, str) for item in value + commands = manager.get("commands", {}) + + if not isinstance(commands, dict): + return {} + + result: dict[str, list[str]] = {} + + for name, command in commands.items(): + if isinstance(command, list) and all( + isinstance(item, str) for item in command ): - commands[key] = value - return commands + result[name] = command + elif isinstance(command, dict): + args = command.get("args", []) + if isinstance(args, list) and all( + isinstance(item, str) for item in args + ): + result[name] = args + + return result + + def get_executor(self, manager_name: str) -> dict[str, Any]: + """Return executor configuration of a manager.""" + manager = self.get_manager(manager_name) + + if not manager: + return {} + + executor = manager.get("executor", {}) + + if isinstance(executor, dict): + return executor + + return {} + + def get_command( + self, + manager_name: str, + command_name: str, + ) -> list[str]: + """Return only command arguments.""" + commands = self.get_commands(manager_name) + return commands.get(command_name, []) def get_available_managers(self) -> list[str]: + """Return available manager names.""" managers = self._data.get("managers", {}) + if isinstance(managers, dict): return [name for name in managers.keys() if isinstance(name, str)] + return [] diff --git a/pycompiler_ark/Core/Venv_Manager/executor.py b/pycompiler_ark/Core/Venv_Manager/executor.py new file mode 100644 index 00000000..9c930513 --- /dev/null +++ b/pycompiler_ark/Core/Venv_Manager/executor.py @@ -0,0 +1,93 @@ +"""Command execution resolvers for Venv Managers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class BaseExecutor(ABC): + """Base class for command executors.""" + + def __init__( + self, + config: dict[str, Any], + python_interpreter: str | None = None, + ) -> None: + self.config = config + self.python_interpreter = python_interpreter + + @abstractmethod + def build_command( + self, + args: list[str], + ) -> tuple[str, list[str]]: + """Build executable program and arguments.""" + + +class PythonModuleExecutor(BaseExecutor): + """Executor for Python modules (python -m module).""" + + def build_command( + self, + args: list[str], + ) -> tuple[str, list[str]]: + module = self.config.get("module") + + if not module: + raise ValueError("Missing Python module in executor config") + + if not self.python_interpreter: + raise ValueError("Missing Python interpreter") + + return ( + self.python_interpreter, + [ + "-m", + module, + *args, + ], + ) + + +class ExecutableExecutor(BaseExecutor): + """Executor for external executables.""" + + def build_command( + self, + args: list[str], + ) -> tuple[str, list[str]]: + executable = self.config.get("executable") + + if not executable: + raise ValueError("Missing executable in executor config") + + return ( + executable, + args, + ) + + +class ExecutorFactory: + """Create executor instances from configuration.""" + + @staticmethod + def create( + executor_config: dict[str, Any], + python_interpreter: str | None = None, + ) -> BaseExecutor: + executor_type = executor_config.get("type") + + if executor_type == "python_module": + return PythonModuleExecutor( + executor_config, + python_interpreter, + ) + + if executor_type == "executable": + return ExecutableExecutor( + executor_config, + python_interpreter, + ) + + raise ValueError(f"Unsupported executor type: {executor_type}") diff --git a/pycompiler_ark/data/VenvManagers.yml b/pycompiler_ark/data/VenvManagers.yml index fbd0982c..11ead64b 100644 --- a/pycompiler_ark/data/VenvManagers.yml +++ b/pycompiler_ark/data/VenvManagers.yml @@ -1,7 +1,22 @@ managers: + pip: - create_venv: ["python", "-m", "venv"] - install: ["pip", "install", "-r"] - add: ["pip", "install"] - show: ["pip", "show"] - check: ["pip", "check"] + executor: + type: python_module + module: pip + + commands: + + create_venv: + - -m + - venv + + install: + - install + - -r + + add: + - install + + check: + - check \ No newline at end of file diff --git a/tests/test_venv_manager_config.py b/tests/test_venv_manager_config.py index d4492b7f..437d1593 100644 --- a/tests/test_venv_manager_config.py +++ b/tests/test_venv_manager_config.py @@ -1,17 +1,59 @@ import unittest +from unittest.mock import MagicMock from pycompiler_ark.Core.Venv_Manager.config import VenvManagerConfig +from pycompiler_ark.Core.Venv_Manager.executor import ( + ExecutorFactory, + PythonModuleExecutor, + ExecutableExecutor, +) +from pycompiler_ark.Core.Venv_Manager.Manager import VenvManager class TestVenvManagerConfig(unittest.TestCase): def test_loads_default_commands_from_yaml(self): config = VenvManagerConfig() + executor = config.get_executor("pip") + self.assertEqual(executor, {"type": "python_module", "module": "pip"}) + commands = config.get_commands("pip") + self.assertEqual(commands["create_venv"], ["-m", "venv"]) + self.assertEqual(commands["install"], ["install", "-r"]) + self.assertEqual(commands["add"], ["install"]) + self.assertEqual(commands["check"], ["check"]) + + +class TestExecutorFactory(unittest.TestCase): + def test_python_module_executor(self): + cfg = {"type": "python_module", "module": "pip"} + executor = ExecutorFactory.create(cfg, "/usr/bin/python3") + self.assertIsInstance(executor, PythonModuleExecutor) + + program, args = executor.build_command(["install", "requests"]) + self.assertEqual(program, "/usr/bin/python3") + self.assertEqual(args, ["-m", "pip", "install", "requests"]) + + def test_executable_executor(self): + cfg = {"type": "executable", "executable": "uv"} + executor = ExecutorFactory.create(cfg) + self.assertIsInstance(executor, ExecutableExecutor) + + program, args = executor.build_command(["pip", "install", "requests"]) + self.assertEqual(program, "uv") + self.assertEqual(args, ["pip", "install", "requests"]) + - self.assertEqual(commands["create_venv"], ["python", "-m", "venv"]) - self.assertEqual(commands["install"], ["pip", "install", "-r"]) - self.assertEqual(commands["add"], ["pip", "install"]) +class TestVenvManagerCommandPreparation(unittest.TestCase): + def test_prepare_manager_command_pip_install(self): + manager = VenvManager(MagicMock()) + program, args = manager._prepare_manager_command( + "install", + extra_args=["reqs.txt"], + python_exe="/fake/python", + ) + self.assertEqual(program, "/fake/python") + self.assertEqual(args, ["-m", "pip", "install", "-r", "reqs.txt"]) if __name__ == "__main__": From a59915e5dd5272747824a624397ac936d61fec04 Mon Sep 17 00:00:00 2001 From: Samuel Amen Ague Date: Fri, 31 Jul 2026 18:15:22 +0000 Subject: [PATCH 3/9] feat(venv_manager): add support for poetry manager configuration Signed-off-by: Samuel Amen Ague --- pycompiler_ark/data/VenvManagers.yml | 20 ++++++++++++++++++++ tests/test_venv_manager_config.py | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/pycompiler_ark/data/VenvManagers.yml b/pycompiler_ark/data/VenvManagers.yml index 11ead64b..7eb7f312 100644 --- a/pycompiler_ark/data/VenvManagers.yml +++ b/pycompiler_ark/data/VenvManagers.yml @@ -18,5 +18,25 @@ managers: add: - install + check: + - check + + poetry: + executor: + type: executable + executable: poetry + + commands: + + create_venv: + - env + - use + + install: + - install + + add: + - add + check: - check \ No newline at end of file diff --git a/tests/test_venv_manager_config.py b/tests/test_venv_manager_config.py index 437d1593..d228c982 100644 --- a/tests/test_venv_manager_config.py +++ b/tests/test_venv_manager_config.py @@ -23,6 +23,20 @@ def test_loads_default_commands_from_yaml(self): self.assertEqual(commands["add"], ["install"]) self.assertEqual(commands["check"], ["check"]) + def test_loads_poetry_config_from_yaml(self): + config = VenvManagerConfig() + + executor = config.get_executor("poetry") + self.assertEqual( + executor, {"type": "executable", "executable": "poetry"} + ) + + commands = config.get_commands("poetry") + self.assertEqual(commands["create_venv"], ["env", "use"]) + self.assertEqual(commands["install"], ["install"]) + self.assertEqual(commands["add"], ["add"]) + self.assertEqual(commands["check"], ["check"]) + class TestExecutorFactory(unittest.TestCase): def test_python_module_executor(self): @@ -55,6 +69,16 @@ def test_prepare_manager_command_pip_install(self): self.assertEqual(program, "/fake/python") self.assertEqual(args, ["-m", "pip", "install", "-r", "reqs.txt"]) + def test_prepare_manager_command_poetry_add(self): + manager = VenvManager(MagicMock()) + manager._detected_manager = "poetry" + program, args = manager._prepare_manager_command( + "add", + extra_args=["requests"], + ) + self.assertEqual(program, "poetry") + self.assertEqual(args, ["add", "requests"]) + if __name__ == "__main__": unittest.main() From 3fef2a98777436969dcc61f8771a52dbcc105daa Mon Sep 17 00:00:00 2001 From: Samuel Amen Ague Date: Fri, 31 Jul 2026 23:54:19 +0000 Subject: [PATCH 4/9] feat(venv_manager): enhance manager detection and configuration - Implement action-specific executor retrieval in VenvManagerConfig. - Add methods for detecting the default manager and resolving the manager for a workspace. - Update VenvManagers.yml to define commands for both pip and poetry. - Modify VenvManager to utilize dynamic detection and user preferences for manager selection. - Introduce unit tests for workspace manager detection and preference handling. Signed-off-by: Samuel Amen Ague --- pycompiler_ark/Core/Venv_Manager/Manager.py | 160 ++++++++++++++++---- pycompiler_ark/Core/Venv_Manager/config.py | 97 +++++++++++- pycompiler_ark/data/VenvManagers.yml | 55 ++++--- tests/test_venv_manager_config.py | 37 ++++- tests/test_venv_manager_detection.py | 74 +++++++++ 5 files changed, 360 insertions(+), 63 deletions(-) create mode 100644 tests/test_venv_manager_detection.py diff --git a/pycompiler_ark/Core/Venv_Manager/Manager.py b/pycompiler_ark/Core/Venv_Manager/Manager.py index 53e65d03..4f450d1d 100644 --- a/pycompiler_ark/Core/Venv_Manager/Manager.py +++ b/pycompiler_ark/Core/Venv_Manager/Manager.py @@ -76,8 +76,8 @@ def __init__(self, parent_widget): self._fallback_encodings = ["utf-8", "latin-1", "cp1252", "ascii"] # Environment manager detection (Simplified to PIP only) - self._detected_manager = "pip" self._config = VenvManagerConfig() + self._detected_manager = self._config.get_default_manager() self._manager_commands = self._get_manager_commands() # Cache for auto-selected venv per workspace self._auto_venv_cache: dict[str, str] = {} @@ -91,15 +91,18 @@ def _get_manager_commands(self) -> dict[str, list[str]]: for manager_name in self._config.get_available_managers(): commands[manager_name] = self._config.get_commands(manager_name) if not commands: - return {"pip": {"create_venv": ["-m", "venv"]}} + default_mgr = self._config.get_default_manager() + return {default_mgr: {"create_venv": []}} return commands - def _get_executor_config(self, manager: str) -> dict: + def _get_executor_config( + self, manager: str, action: str | None = None + ) -> dict: """get executor config (type / module / executable) from VenvManagerConfig. - Fallback to python_module + pip si la méthode n'existe pas encore dans Config. + Fallback to python_module + pip if missing. """ try: - cfg = self._config.get_executor(manager) + cfg = self._config.get_executor(manager, action=action) if isinstance(cfg, dict) and cfg: return cfg except Exception: @@ -111,6 +114,7 @@ def _prepare_manager_command( action: str, extra_args: list[str] | None = None, python_exe: str | None = None, + kwargs: dict[str, str] | None = None, ) -> tuple[str, list[str]]: """ build (program, arguments) from executor + commands of YAML file using ExecutorFactory. @@ -122,22 +126,31 @@ def _prepare_manager_command( manager = ( self._detected_manager if isinstance(getattr(self, "_detected_manager", None), str) - else "pip" + else self._config.get_default_manager() ) - # Commande pure depuis la config (liste d'args, sans interpréteur) cmd_args = self._get_manager_command(manager, action) - if not cmd_args: + resolved_args = [] + if cmd_args: + fmt_vars = kwargs or {} + for arg in cmd_args: + formatted_arg = str(arg) + for k, v in fmt_vars.items(): + formatted_arg = formatted_arg.replace(f"{{{k}}}", str(v)) + resolved_args.append(formatted_arg) + elif action != "create_venv": # Fallbacks minimaux defaults = { "install": ["install", "-r"], "add": ["install"], "check": ["check"], - "create_venv": ["-m", "venv"], } - cmd_args = defaults.get(action, ["install"]) + resolved_args = defaults.get(action, ["install"]) - executor_cfg = self._get_executor_config(manager) + if extra_args: + resolved_args.extend(extra_args) + + executor_cfg = self._get_executor_config(manager, action=action) python_interpreter = ( python_exe or getattr(self, "_venv_python_exe", None) @@ -145,7 +158,7 @@ def _prepare_manager_command( ) executor = ExecutorFactory.create(executor_cfg, python_interpreter) - return executor.build_command(list(cmd_args) + extra_args) + return executor.build_command(resolved_args) def _call_ui(self, method: str, *args, **kwargs): """Invoke a registered UI callback by name. Returns None if no delegate registered.""" @@ -201,9 +214,32 @@ def _write_workspace_pref(self, workspace_dir: str, data: dict) -> None: except Exception: pass + def resolve_workspace_manager(self, workspace_dir: str) -> str: + """Resolve environment manager for workspace (User Pref -> Dynamic Detection -> Fallback pip).""" + if workspace_dir: + pref_data = self._read_workspace_pref(workspace_dir) + if pref_data and isinstance(pref_data, dict): + saved_mgr = pref_data.get("manager") + if ( + isinstance(saved_mgr, str) + and saved_mgr in self._config.get_available_managers() + ): + self._detected_manager = saved_mgr + return saved_mgr + + detected = self._config.detect_manager_for_workspace(workspace_dir) + if detected: + self._detected_manager = detected + return detected + + default_mgr = self._config.get_default_manager() + self._detected_manager = default_mgr + return default_mgr + def apply_workspace_pref(self, workspace_dir: str) -> bool: """Apply saved venv/system selection from .ark/pref.json if available.""" try: + self.resolve_workspace_manager(workspace_dir) data = self._read_workspace_pref(workspace_dir) if not data: return False @@ -249,24 +285,24 @@ def save_workspace_pref(self, workspace_dir: str | None) -> None: if not workspace_dir: return try: + pref_data = self._read_workspace_pref(workspace_dir) or {} + pref_data["manager"] = self._detected_manager if getattr(self.parent, "use_system_python", False): - self._write_workspace_pref( - workspace_dir, - {"venv_mode": "system", "venv_path": None}, - ) + pref_data.update({"venv_mode": "system", "venv_path": None}) + self._write_workspace_pref(workspace_dir, pref_data) return venv_path = getattr(self.parent, "venv_path_manuel", None) if not venv_path and hasattr(self.parent, "venv_path"): venv_path = getattr(self.parent, "venv_path", None) if venv_path: - self._write_workspace_pref( - workspace_dir, + pref_data.update( { "venv_mode": "venv", "venv_path": os.path.abspath(venv_path), - }, + } ) + self._write_workspace_pref(workspace_dir, pref_data) return except Exception: pass @@ -307,6 +343,9 @@ def resolve_existing_venv( if not base: return None + # Dynamically resolve manager for workspace + self.resolve_workspace_manager(base) + # Apply saved workspace preference first (.ark/pref.json) try: if self.apply_workspace_pref(base): @@ -1390,6 +1429,36 @@ def _clear_timer(*_args): except Exception: pass + def _query_manager_venv_path(self, base_dir: str) -> str | None: + """Query active manager dynamically for environment path via YAML get_venv_path command.""" + try: + manager = ( + self._detected_manager + if isinstance(getattr(self, "_detected_manager", None), str) + else "pip" + ) + cmd = self._get_manager_command(manager, "get_venv_path") + if not cmd: + return None + + program, args = self._prepare_manager_command("get_venv_path") + import subprocess + + res = subprocess.run( + [program, *args], + cwd=base_dir, + capture_output=True, + text=True, + timeout=5, + ) + if res.returncode == 0 and res.stdout.strip(): + detected_path = res.stdout.strip().splitlines()[-1].strip() + if os.path.isdir(detected_path): + return detected_path + except Exception: + pass + return None + def _detect_venv_in(self, base: str) -> tuple[str | None, str]: """Return (existing_venv_path_or_None, default_venv_path). Prefers .venv if present, otherwise venv. Default path is .venv.""" try: @@ -1403,6 +1472,12 @@ def _detect_venv_in(self, base: str) -> tuple[str | None, str]: if os.path.isdir(p_dot) else (p_std if os.path.isdir(p_std) else None) ) + + if not existing: + mgr_path = self._query_manager_venv_path(base) + if mgr_path: + existing = mgr_path + default = p_dot return existing, default @@ -1425,6 +1500,13 @@ def _find_all_venvs_in(self, base: str) -> list[str]: if ok: venvs.append(venv_path) + if not venvs: + mgr_path = self._query_manager_venv_path(base) + if mgr_path: + ok, _ = self.validate_venv_strict(mgr_path) + if ok: + venvs.append(mgr_path) + return venvs def _score_venv( @@ -1727,19 +1809,15 @@ def create_venv_if_needed(self, path: str): process = QProcess(self.parent) self._venv_create_process = process - # --- Executor system --- - # create_venv reste spécial (module venv), on force python_module - create_cmd = self._get_manager_command( - self._detected_manager, "create_venv" - ) or ["-m", "venv"] - # if command already start with -m, keep - if create_cmd and create_cmd[0] == "-m": - args = create_cmd + [venv_path] - else: - args = ["-m", "venv", venv_path] - if base in ("py", "py.exe"): + # --- Dynamic Executor System for venv creation --- + program, args = self._prepare_manager_command( + "create_venv", + kwargs={"venv_path": venv_path, "python": python_candidate}, + python_exe=python_candidate, + ) + if base in ("py", "py.exe") and program == python_candidate: args = ["-3"] + args - process.setProgram(python_candidate) + process.setProgram(program) process.setArguments(args) process.setWorkingDirectory(path) process.readyReadStandardOutput.connect( @@ -1828,9 +1906,19 @@ def _on_venv_created(self, process, code, status, venv_path): ) self._call_ui("close_progress", "venv_creation") + # Persist workspace preference automatically in .ark/pref.json + ws_dir = getattr( + self.parent, "workspace_dir", None + ) or os.path.dirname(venv_path) + try: + setattr(self.parent, "venv_path", venv_path) + except Exception: + pass + self.save_workspace_pref(ws_dir) + # Install project dependencies from requirements.txt if present try: - self.install_requirements_if_needed(os.path.dirname(venv_path)) + self.install_requirements_if_needed(ws_dir) except Exception: pass else: @@ -2304,6 +2392,9 @@ def setup_workspace( try: workspace_dir = os.path.abspath(workspace_dir) + # Dynamically resolve manager for workspace + self.resolve_workspace_manager(workspace_dir) + # Resolve an existing environment first existing_env = self.resolve_existing_venv(workspace_dir) @@ -2312,6 +2403,11 @@ def setup_workspace( self.create_venv_if_needed(workspace_dir) else: output.success(f"Venv existant detecte: {existing_env}") + try: + setattr(self.parent, "venv_path", existing_env) + except Exception: + pass + self.save_workspace_pref(workspace_dir) # Check and install tools if requested if check_tools: diff --git a/pycompiler_ark/Core/Venv_Manager/config.py b/pycompiler_ark/Core/Venv_Manager/config.py index d650f48c..d0a23974 100644 --- a/pycompiler_ark/Core/Venv_Manager/config.py +++ b/pycompiler_ark/Core/Venv_Manager/config.py @@ -70,13 +70,22 @@ def get_commands(self, manager_name: str) -> dict[str, list[str]]: return result - def get_executor(self, manager_name: str) -> dict[str, Any]: - """Return executor configuration of a manager.""" + def get_executor( + self, manager_name: str, action: str | None = None + ) -> dict[str, Any]: + """Return executor configuration of a manager (action-specific or general).""" manager = self.get_manager(manager_name) if not manager: return {} + if action: + executors = manager.get("executors", {}) + if isinstance(executors, dict) and action in executors: + act_exec = executors[action] + if isinstance(act_exec, dict): + return act_exec + executor = manager.get("executor", {}) if isinstance(executor, dict): @@ -101,3 +110,87 @@ def get_available_managers(self) -> list[str]: return [name for name in managers.keys() if isinstance(name, str)] return [] + + def get_default_manager(self) -> str: + """Return default fallback manager name from configuration.""" + available = self.get_available_managers() + if not available: + return "pip" + + rules = [ + (name, self.get_detection_rules(name).get("priority", 0)) + for name in available + ] + rules.sort(key=lambda x: x[1]) + return rules[0][0] + + def get_detection_rules(self, manager_name: str) -> dict[str, Any]: + """Return detection rules of a manager.""" + manager = self.get_manager(manager_name) + + if not manager or not isinstance(manager.get("detection"), dict): + return {"priority": 0, "files": [], "patterns": {}} + + detection = manager["detection"] + priority = detection.get("priority", 0) + if not isinstance(priority, int): + priority = 0 + + files = detection.get("files", []) + if not isinstance(files, list): + files = [] + files = [f for f in files if isinstance(f, str)] + + patterns = detection.get("patterns", {}) + if not isinstance(patterns, dict): + patterns = {} + patterns = { + k: str(v) for k, v in patterns.items() if isinstance(k, str) + } + + return { + "priority": priority, + "files": files, + "patterns": patterns, + } + + def detect_manager_for_workspace(self, workspace_dir: str) -> str | None: + """Detect environment manager for a workspace directory based on detection rules.""" + try: + workspace_path = Path(workspace_dir) + if not workspace_path.is_dir(): + return None + except Exception: + return None + + available = self.get_available_managers() + manager_rules = [] + + for name in available: + rules = self.get_detection_rules(name) + if rules["files"]: + manager_rules.append((name, rules)) + + manager_rules.sort(key=lambda item: item[1]["priority"], reverse=True) + + for name, rules in manager_rules: + files = rules["files"] + patterns = rules["patterns"] + + for filename in files: + target_file = workspace_path / filename + if target_file.is_file(): + required_pattern = patterns.get(filename) + if required_pattern: + try: + content = target_file.read_text( + encoding="utf-8", errors="ignore" + ) + if required_pattern in content: + return name + except Exception: + continue + else: + return name + + return None diff --git a/pycompiler_ark/data/VenvManagers.yml b/pycompiler_ark/data/VenvManagers.yml index 7eb7f312..6ac759d1 100644 --- a/pycompiler_ark/data/VenvManagers.yml +++ b/pycompiler_ark/data/VenvManagers.yml @@ -1,42 +1,51 @@ managers: - pip: + poetry: executor: - type: python_module - module: pip - + type: executable + executable: poetry + detection: + priority: 100 + files: + - pyproject.toml + patterns: + pyproject.toml: "[tool.poetry]" commands: - create_venv: - - -m - - venv - + - env + - use + - "{python}" + get_venv_path: + - env + - info + - -p install: - install - - -r - add: - - install - + - add check: - check - poetry: + pip: executor: - type: executable - executable: poetry - + type: python_module + module: pip + executors: + create_venv: + type: python_module + module: venv + detection: + priority: 10 + files: + - requirements.txt + - setup.py commands: - create_venv: - - env - - use - + - "{venv_path}" install: - install - + - -r add: - - add - + - install check: - check \ No newline at end of file diff --git a/tests/test_venv_manager_config.py b/tests/test_venv_manager_config.py index d228c982..3c64da75 100644 --- a/tests/test_venv_manager_config.py +++ b/tests/test_venv_manager_config.py @@ -18,7 +18,11 @@ def test_loads_default_commands_from_yaml(self): self.assertEqual(executor, {"type": "python_module", "module": "pip"}) commands = config.get_commands("pip") - self.assertEqual(commands["create_venv"], ["-m", "venv"]) + self.assertEqual(commands["create_venv"], ["{venv_path}"]) + self.assertEqual( + config.get_executor("pip", "create_venv"), + {"type": "python_module", "module": "venv"}, + ) self.assertEqual(commands["install"], ["install", "-r"]) self.assertEqual(commands["add"], ["install"]) self.assertEqual(commands["check"], ["check"]) @@ -32,7 +36,7 @@ def test_loads_poetry_config_from_yaml(self): ) commands = config.get_commands("poetry") - self.assertEqual(commands["create_venv"], ["env", "use"]) + self.assertEqual(commands["create_venv"], ["env", "use", "{python}"]) self.assertEqual(commands["install"], ["install"]) self.assertEqual(commands["add"], ["add"]) self.assertEqual(commands["check"], ["check"]) @@ -49,13 +53,13 @@ def test_python_module_executor(self): self.assertEqual(args, ["-m", "pip", "install", "requests"]) def test_executable_executor(self): - cfg = {"type": "executable", "executable": "uv"} + cfg = {"type": "executable", "executable": "poetry"} executor = ExecutorFactory.create(cfg) self.assertIsInstance(executor, ExecutableExecutor) - program, args = executor.build_command(["pip", "install", "requests"]) - self.assertEqual(program, "uv") - self.assertEqual(args, ["pip", "install", "requests"]) + program, args = executor.build_command(["install"]) + self.assertEqual(program, "poetry") + self.assertEqual(args, ["install"]) class TestVenvManagerCommandPreparation(unittest.TestCase): @@ -69,6 +73,27 @@ def test_prepare_manager_command_pip_install(self): self.assertEqual(program, "/fake/python") self.assertEqual(args, ["-m", "pip", "install", "-r", "reqs.txt"]) + def test_prepare_manager_command_pip_create_venv(self): + manager = VenvManager(MagicMock()) + manager._detected_manager = "pip" + program, args = manager._prepare_manager_command( + "create_venv", + kwargs={"venv_path": "/path/to/venv", "python": "/fake/python"}, + python_exe="/fake/python", + ) + self.assertEqual(program, "/fake/python") + self.assertEqual(args, ["-m", "venv", "/path/to/venv"]) + + def test_prepare_manager_command_poetry_create_venv(self): + manager = VenvManager(MagicMock()) + manager._detected_manager = "poetry" + program, args = manager._prepare_manager_command( + "create_venv", + kwargs={"venv_path": "/path/to/venv", "python": "/fake/python"}, + ) + self.assertEqual(program, "poetry") + self.assertEqual(args, ["env", "use", "/fake/python"]) + def test_prepare_manager_command_poetry_add(self): manager = VenvManager(MagicMock()) manager._detected_manager = "poetry" diff --git a/tests/test_venv_manager_detection.py b/tests/test_venv_manager_detection.py new file mode 100644 index 00000000..be7aa4e3 --- /dev/null +++ b/tests/test_venv_manager_detection.py @@ -0,0 +1,74 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +from pycompiler_ark.Core.Venv_Manager.config import VenvManagerConfig +from pycompiler_ark.Core.Venv_Manager.Manager import VenvManager + + +class TestVenvManagerDetection(unittest.TestCase): + def setUp(self): + self.config = VenvManagerConfig() + self.temp_dir = tempfile.TemporaryDirectory() + self.workspace_dir = self.temp_dir.name + + def tearDown(self): + self.temp_dir.cleanup() + + def test_detect_poetry_workspace(self): + pyproject = Path(self.workspace_dir) / "pyproject.toml" + pyproject.write_text( + "[tool.poetry]\nname = 'test'\n", encoding="utf-8" + ) + + manager = self.config.detect_manager_for_workspace(self.workspace_dir) + self.assertEqual(manager, "poetry") + + def test_detect_pip_workspace(self): + reqs = Path(self.workspace_dir) / "requirements.txt" + reqs.write_text("requests==2.28.1\n", encoding="utf-8") + + manager = self.config.detect_manager_for_workspace(self.workspace_dir) + self.assertEqual(manager, "pip") + + def test_detection_priority(self): + pyproject = Path(self.workspace_dir) / "pyproject.toml" + pyproject.write_text( + "[tool.poetry]\nname = 'test'\n", encoding="utf-8" + ) + + reqs = Path(self.workspace_dir) / "requirements.txt" + reqs.write_text("requests==2.28.1\n", encoding="utf-8") + + manager = self.config.detect_manager_for_workspace(self.workspace_dir) + self.assertEqual(manager, "poetry") + + def test_fallback_to_default(self): + manager = self.config.detect_manager_for_workspace(self.workspace_dir) + self.assertIsNone(manager) + + venv_manager = VenvManager(MagicMock()) + resolved = venv_manager.resolve_workspace_manager(self.workspace_dir) + self.assertEqual(resolved, "pip") + + def test_user_preference_override(self): + # Even with poetry detected, user pref in .ark/pref.json should override + pyproject = Path(self.workspace_dir) / "pyproject.toml" + pyproject.write_text( + "[tool.poetry]\nname = 'test'\n", encoding="utf-8" + ) + + ark_dir = Path(self.workspace_dir) / ".ark" + ark_dir.mkdir(parents=True, exist_ok=True) + pref_file = ark_dir / "pref.json" + pref_file.write_text(json.dumps({"manager": "pip"}), encoding="utf-8") + + venv_manager = VenvManager(MagicMock()) + resolved = venv_manager.resolve_workspace_manager(self.workspace_dir) + self.assertEqual(resolved, "pip") + + +if __name__ == "__main__": + unittest.main() From 75222a069296b760da36e0bdc07ff40d3dfc4a12 Mon Sep 17 00:00:00 2001 From: Samuel Amen Ague Date: Fri, 31 Jul 2026 23:56:10 +0000 Subject: [PATCH 5/9] docs: add VenvManager Architecture doc & link to README Signed-off-by: Samuel Amen Ague --- README.md | 1 + docs/VenvManager.md | 162 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 docs/VenvManager.md diff --git a/README.md b/README.md index c803c53e..6ae1db6a 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ graph TD ## Documentation - [Contributing guide](https://github.com/raidos23/PyCompiler_ARK/blob/main/CONTRIBUTING.md) +- [VenvManager Architecture](https://github.com/raidos23/PyCompiler_ARK/blob/main/docs/VenvManager.md) - [How to create an engine](https://github.com/raidos23/PyCompiler_ARK/blob/main/docs/how_to_create_an_engine.md) - [How to create a BC plugin](https://github.com/raidos23/PyCompiler_ARK/blob/main/docs/how_to_create_a_bc_plugin.md) diff --git a/docs/VenvManager.md b/docs/VenvManager.md new file mode 100644 index 00000000..ffab3a5b --- /dev/null +++ b/docs/VenvManager.md @@ -0,0 +1,162 @@ +# Technical Documentation: Virtual Environment Manager (`VenvManager`) + +--- + +## 1. Overview & Architectural Vision + +The `VenvManager` system in **PyCompiler_ARK** handles virtual environment detection, creation, and management for workspace projects. + +### Key Architectural Principles: +- **Strict Decoupling**: Python code (`Manager.py`, `config.py`, `executor.py`) contains **no hardcoded manager names** (`poetry`, `pip`, `uv`, etc.) or hardcoded detection rules (`if os.path.exists(...)`). +- **Declarative YAML Configuration**: All detection rules, executors, and command definitions are maintained exclusively in the central configuration file `pycompiler_ark/data/VenvManagers.yml`. +- **3-Tier Resolution Strategy**: + 1. **Tier 1 (User Preference)**: Reads `.ark/pref.json` (`"manager"` and `"venv_path"` keys). + 2. **Tier 2 (Automatic Detection)**: Dynamically evaluates workspace indicator files and priority rules defined in YAML. + 3. **Tier 3 (Default Fallback)**: Uses the default fallback manager configured in system (`self._config.get_default_manager()`). + +--- + +## 2. YAML Schema Specification (`VenvManagers.yml`) + +The file `pycompiler_ark/data/VenvManagers.yml` registers all available environment managers: + +```yaml +managers: + + poetry: + executor: + type: executable + executable: poetry + detection: + priority: 100 + files: + - pyproject.toml + patterns: + pyproject.toml: "[tool.poetry]" + commands: + create_venv: + - env + - use + - "{python}" + get_venv_path: + - env + - info + - -p + install: + - install + add: + - add + check: + - check + + pip: + executor: + type: python_module + module: pip + executors: + create_venv: + type: python_module + module: venv + detection: + priority: 10 + files: + - requirements.txt + - setup.py + commands: + create_venv: + - "{venv_path}" + install: + - install + - -r + add: + - install + check: + - check +``` + +### Field Definitions: + +- `executor`: Defines the primary command executor (`python_module` or `executable`). +- `executors`: Optional action-specific executor overrides (e.g., `create_venv` for `pip` using the `venv` module). +- `detection`: + - `priority` *(integer)*: Evaluation priority order (higher priority values are evaluated first). + - `files` *(list)*: Indicator files located at workspace root. + - `patterns` *(dictionary)*: Required substring/pattern in target indicator file (e.g., `[tool.poetry]` in `pyproject.toml`). +- `commands`: Argument lists for actions (`create_venv`, `get_venv_path`, `install`, `add`, `check`). + - Supports dynamic placeholders: `{python}` (target Python interpreter) and `{venv_path}` (target virtual environment path). + +--- + +## 3. Core Engine Components (`pycompiler_ark/Core/Venv_Manager/`) + +### A. Command Executors (`executor.py`) +- `PythonModuleExecutor`: Resolves commands in the form ` -m `. +- `ExecutableExecutor`: Resolves standalone executables ` `. +- `ExecutorFactory`: Dynamically instantiates the correct executor from YAML configuration. + +### B. Configuration Parser (`config.py`) +- `get_available_managers()`: Returns available manager names. +- `get_default_manager()`: Retrieves default manager (lowest priority or configured fallback). +- `get_detection_rules(manager_name)`: Returns normalized detection rules. +- `detect_manager_for_workspace(workspace_dir)`: Evaluates workspace indicator files and patterns in priority order and returns matching manager. + +### C. Manager Engine (`Manager.py`) +- `resolve_workspace_manager(workspace_dir)`: Dynamically resolves workspace manager and updates `self._detected_manager`. +- `_query_manager_venv_path(base_dir)`: Dynamically queries manager executable via YAML `get_venv_path` command to locate external virtualenvs (e.g., Poetry cache). +- `_prepare_manager_command(action, extra_args, python_exe, kwargs)`: Builds `(program, args)` tuple with placeholder substitution for `{python}` and `{venv_path}`. +- `save_workspace_pref(workspace_dir)`: Persists active manager and environment selection into `.ark/pref.json`. + +--- + +## 4. GUI and CLI Integration + +- **GUI (`VenvDialog.py` / `VenvManagerUI`)**: + - Registers UI delegates (`_ui_callbacks`) to display progress dialogs (`ProgressDialog`), stream standard output/error logs, and show confirmation prompts (`QMessageBox`). +- **CLI (`Ui/Cli/app.py`)**: + - Automatically triggers dynamic manager resolution during workspace build or initialization operations. +- **Automatic Persistence**: + - Whenever a virtual environment is created or detected, preferences are persisted to `.ark/pref.json`: + ```json + { + "manager": "poetry", + "venv_mode": "venv", + "venv_path": "/path/to/virtualenv" + } + ``` + +--- + +## 5. How to Add a New Environment Manager + +To add support for a new custom environment manager, **no Python code changes are required**. + +Simply add its definition entry to `VenvManagers.yml`: + +```yaml + custom_manager: + executor: + type: executable + executable: custom_cmd + detection: + priority: 90 + files: + - custom_lock.json + - pyproject.toml + patterns: + pyproject.toml: "[tool.custom]" + commands: + create_venv: + - env + - create + - "{venv_path}" + get_venv_path: + - env + - info + - --path + install: + - install + add: + - add + check: + - check +``` From b77a2d5fb5372fb220f3d211f37750f9ab58b620 Mon Sep 17 00:00:00 2001 From: Samuel Amen Ague Date: Sat, 1 Aug 2026 00:35:04 +0000 Subject: [PATCH 6/9] refactor(venv_manager): decouple VenvManager into pure core architecture - Remove PySide6/Qt imports and QProcess/QTimer dependencies from Core/Venv_Manager/Manager.py - Remove _ui_callbacks dictionary and _call_ui delegate invocation pattern - Implement protected virtual hooks (_tr, _show_progress, _close_progress, etc.) in VenvManager - Override virtual hooks in VenvManagerUI for PySide6 GUI progress and message handling - Ensure 100% pure core decoupled architecture with 62/62 passing tests Signed-off-by: Samuel Amen Ague --- pycompiler_ark/Core/Venv_Manager/Manager.py | 280 +++++++++----------- pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py | 30 +-- 2 files changed, 125 insertions(+), 185 deletions(-) diff --git a/pycompiler_ark/Core/Venv_Manager/Manager.py b/pycompiler_ark/Core/Venv_Manager/Manager.py index 4f450d1d..fe3735ed 100644 --- a/pycompiler_ark/Core/Venv_Manager/Manager.py +++ b/pycompiler_ark/Core/Venv_Manager/Manager.py @@ -5,8 +5,7 @@ import platform import shutil import sys - -from PySide6.QtCore import QProcess, QTimer +from typing import Any import pycompiler_ark.Core.deps_analyser.analyser as deps_analyser import pycompiler_ark.Core.SystemDepsManager as sys_deps @@ -61,11 +60,8 @@ def __init__(self, parent_widget): # Progress state management handled via UI callbacks # (venv_progress_dialog, venv_check_progress, progress_dialog removed) - # UI delegate callbacks — registered by VenvManagerUI (Ui layer) - self._ui_callbacks: dict = {} - # Internal timers to enforce timeouts on background processes - self._proc_timers: list[QTimer] = [] + self._proc_timers: list = [] # Retry counters for resilience self._venv_check_retries = {} @@ -160,22 +156,65 @@ def _prepare_manager_command( executor = ExecutorFactory.create(executor_cfg, python_interpreter) return executor.build_command(resolved_args) - def _call_ui(self, method: str, *args, **kwargs): - """Invoke a registered UI callback by name. Returns None if no delegate registered.""" - fn = self._ui_callbacks.get(method) - if callable(fn): - try: - return fn(*args, **kwargs) - except Exception: - pass - return None + # ---------- UI / Event Hooks (Overridden by UI Layer) ---------- + def _tr(self, fr: str, en: str) -> str: + """Translation helper (overridden by UI layer).""" + return en def tr(self, fr: str, en: str) -> str: - """Translation helper using UI callback or fallback to English.""" - res = self._call_ui("tr", fr, en) - if res is not None: - return str(res) - return en + """Translation helper using UI hook or fallback to English.""" + return self._tr(fr, en) + + def _on_pref_applied(self, mode: str, venv_path: str | None) -> None: + """Hook called when preference is applied (overridden by UI layer).""" + pass + + def _show_progress(self, id: str, title: str, cancel_label: str) -> None: + """Hook to display progress (overridden by UI layer).""" + pass + + def _update_progress_message(self, id: str, message: str) -> None: + """Hook to update progress message (overridden by UI layer).""" + pass + + def _update_progress_progress( + self, id: str, value: int, total: int + ) -> None: + """Hook to update progress bar value (overridden by UI layer).""" + pass + + def _close_progress(self, id: str) -> None: + """Hook to close progress indicator (overridden by UI layer).""" + pass + + def _is_progress_visible(self, id: str) -> bool: + """Hook to check if progress is visible (overridden by UI layer).""" + return False + + def _bind_cancel(self, id: str, callback) -> None: + """Hook to bind cancel action (overridden by UI layer).""" + pass + + def _process_events(self) -> None: + """Hook to process UI events (overridden by UI layer).""" + pass + + def _ask_recreate_invalid_venv(self, venv_root: str, reason: str) -> bool: + """Hook to prompt user for venv recreation (overridden by UI layer).""" + return False + + def _show_error_dialog(self, title: str, text: str) -> None: + """Hook to display error dialog (overridden by UI layer).""" + pass + + def _create_process(self): + """Create a process instance (overridden by UI layer or fallback).""" + try: + from PySide6.QtCore import QProcess + + return QProcess(self.parent) + except Exception: + return None # ---------- Workspace pref management ---------- def _workspace_pref_path(self, workspace_dir: str) -> str: @@ -274,7 +313,7 @@ def apply_workspace_pref(self, workspace_dir: str) -> bool: self._clear_workspace_pref(workspace_dir) if applied: - self._call_ui("on_pref_applied", mode, venv_path) + self._on_pref_applied(mode, venv_path) return True return False except Exception: @@ -541,7 +580,7 @@ def is_tool_installed_async( if not pip_exe or not os.path.isfile(pip_exe): callback(False) return - proc = QProcess(self.parent) + proc = self._create_process() def _done(code, _status): """Execute _done logic for this component.""" @@ -585,8 +624,7 @@ def ensure_tools_installed(self, venv_root: str, tools: list[str]) -> None: self._venv_check_path = venv_root self._venv_check_use_python = False - self._call_ui( - "show_progress", + self._show_progress( "tools_check", "Verification du venv", "Verification du venv", @@ -594,14 +632,11 @@ def ensure_tools_installed(self, venv_root: str, tools: list[str]) -> None: self._bind_cancel_for_progress( "tools_check", "verification des outils" ) - self._call_ui( - "update_progress_message", + self._update_progress_message( "tools_check", f"Verification de {tools[0]}...", ) - self._call_ui( - "update_progress_progress", "tools_check", 0, len(tools) - ) + self._update_progress_progress("tools_check", 0, len(tools)) # We need a local event loop if we are in a background thread or CLI mode # to process QProcess signals and QTimer events. @@ -662,8 +697,7 @@ def ensure_tools_installed_system(self, tools: list[str]) -> None: ) self._venv_check_use_python = True - self._call_ui( - "show_progress", + self._show_progress( "tools_check", "Verification du Python systeme", "Verification du Python systeme", @@ -671,14 +705,11 @@ def ensure_tools_installed_system(self, tools: list[str]) -> None: self._bind_cancel_for_progress( "tools_check", "verification des outils systeme" ) - self._call_ui( - "update_progress_message", + self._update_progress_message( "tools_check", f"Verification de {tools[0]}...", ) - self._call_ui( - "update_progress_progress", "tools_check", 0, len(tools) - ) + self._update_progress_progress("tools_check", 0, len(tools)) # We need a local event loop if we are in a background thread or CLI mode from PySide6.QtCore import QCoreApplication, QEventLoop, QThread @@ -766,8 +797,7 @@ def _bind_cancel_for_progress( self, progress_id: str, action_label: str ) -> None: """Bind cancellation logic for a named progress dialog via UI callback.""" - self._call_ui( - "bind_cancel", + self._bind_cancel( progress_id, lambda: self._request_cancel(action_label), ) @@ -823,9 +853,7 @@ def _prompt_recreate_invalid_venv( If the user confirms, performs deletion then triggers recreation (business logic). Returns True if recreation was initiated, False otherwise. """ - confirmed = self._call_ui( - "ask_recreate_invalid_venv", venv_root, reason - ) + confirmed = self._ask_recreate_invalid_venv(venv_root, reason) if not confirmed: return False # Business logic: delete the bad venv @@ -838,8 +866,7 @@ def _prompt_recreate_invalid_venv( except Exception: pass except Exception as e: - self._call_ui( - "show_error_dialog", + self._show_error_dialog( "Environnement virtuel invalide / Invalid virtual environment", f"Echec suppression venv / Failed to delete venv: {e}", ) @@ -850,8 +877,7 @@ def _prompt_recreate_invalid_venv( self.create_venv_if_needed(workspace_dir) return True except Exception as e: - self._call_ui( - "show_error_dialog", + self._show_error_dialog( "Environnement virtuel invalide / Invalid virtual environment", f"Echec de recreation du venv / Failed to recreate venv: {e}", ) @@ -1062,8 +1088,7 @@ def _after_binding(ok_bind: bool): self._venv_check_path = venv_path self._venv_check_use_python = False - self._call_ui( - "show_progress", + self._show_progress( "tools_check", "Verification du venv", "Verification du venv", @@ -1071,13 +1096,11 @@ def _after_binding(ok_bind: bool): self._bind_cancel_for_progress( "tools_check", "verification des outils du venv" ) - self._call_ui( - "update_progress_message", + self._update_progress_message( "tools_check", f"Verification de {self._venv_check_pkgs[0]}...", ) - self._call_ui( - "update_progress_progress", + self._update_progress_progress( "tools_check", 0, len(self._venv_check_pkgs), @@ -1098,15 +1121,14 @@ def _after_binding(ok_bind: bool): def _check_next_venv_pkg(self): """Execute _check_next_venv_pkg logic for this component.""" if self._is_cancel_requested(): - self._call_ui("close_progress", "tools_check") + self._close_progress("tools_check") # Exit local loop if any loop = getattr(self, "_venv_check_loop", None) if loop: loop.quit() return if self._venv_check_index >= len(self._venv_check_pkgs): - self._call_ui( - "update_progress_message", + self._update_progress_message( "tools_check", "Verification terminee.", ) @@ -1115,10 +1137,8 @@ def _check_next_venv_pkg(self): if hasattr(self, "_venv_check_pkgs") and self._venv_check_pkgs else 0 ) - self._call_ui( - "update_progress_progress", "tools_check", total, total - ) - self._call_ui("close_progress", "tools_check") + self._update_progress_progress("tools_check", total, total) + self._close_progress("tools_check") # Exit local loop if any loop = getattr(self, "_venv_check_loop", None) @@ -1135,7 +1155,7 @@ def _check_next_venv_pkg(self): pass return pkg = self._venv_check_pkgs[self._venv_check_index] - process = QProcess(self.parent) + process = self._create_process() self._venv_check_process = process process.setProgram(self._venv_check_pip_exe) # Use stored pip args (e.g. ['-m', 'pip']) if available @@ -1172,13 +1192,11 @@ def _on_venv_pkg_checked(self, process, code, status, pkg): if self._venv_check_index < len(self._venv_check_pkgs) else "" ) - self._call_ui( - "update_progress_message", + self._update_progress_message( "tools_check", f"Verification de {next_label}...", ) - self._call_ui( - "update_progress_progress", + self._update_progress_progress( "tools_check", self._venv_check_index, len(self._venv_check_pkgs), @@ -1192,7 +1210,7 @@ def _on_venv_pkg_checked(self, process, code, status, pkg): f"Pas de connexion internet. Impossible d'installer {pkg}.", f"No internet connection. Unable to install {pkg}.", ) - self._call_ui("close_progress", "tools_check") + self._close_progress("tools_check") # Exit local loop if any loop = getattr(self, "_venv_check_loop", None) @@ -1203,16 +1221,15 @@ def _on_venv_pkg_checked(self, process, code, status, pkg): output.info( f"{self._tools_stage_prefix()}Installation automatique de {pkg}..." ) - self._call_ui( - "update_progress_message", + self._update_progress_message( "tools_check", f"Installation de {pkg}...", ) - self._call_ui( - "update_progress_progress", "tools_check", 0, 0 + self._update_progress_progress( + "tools_check", 0, 0 ) # indeterminate - process2 = QProcess(self.parent) + process2 = self._create_process() self._venv_check_install_process = process2 # --- Executor system --- try: @@ -1264,9 +1281,7 @@ def _on_venv_check_output(self, process, error=False): ) lines = data.strip().splitlines() if lines: - self._call_ui( - "update_progress_message", "tools_check", lines[-1][:200] - ) + self._update_progress_message("tools_check", lines[-1][:200]) # Detailed logging for verbose mode or errors is_verbose = getattr(self.parent, "verbose", False) @@ -1326,7 +1341,7 @@ def _verify_venv_binding_async(self, venv_root: str, callback): callback(False) return # Step 1: Check sys.prefix - p1 = QProcess(self.parent) + p1 = self._create_process() def _p1_finished(code, _status): """Execute _p1_finished logic for this component.""" @@ -1344,7 +1359,7 @@ def _p1_finished(code, _status): if not os.path.isfile(vpip): callback(False) return - p2 = QProcess(self.parent) + p2 = self._create_process() def _p2_finished(code2, _status2): """Execute _p2_finished logic for this component.""" @@ -1387,45 +1402,11 @@ def _p2_finished(code2, _status2): except Exception: callback(False) - def _arm_process_timeout( - self, process: QProcess, timeout_ms: int, label: str - ): - """Arm a one-shot timer to kill a long-running process and keep UI responsive.""" + def _arm_process_timeout(self, process: Any, timeout_ms: int, label: str): + """Arm a one-shot timeout for a background process (overridden by UI layer).""" try: - if timeout_ms and timeout_ms > 0: - t = QTimer(self.parent) - t.setSingleShot(True) - - def _on_timeout(): - """Handle the related event callback.""" - try: - if process.state() != QProcess.NotRunning: - output.warn( - f"Timeout exceeded for {label} ({timeout_ms} ms). Killing process..." - ) - from ..process_killer import ( - kill_process_tree, - ) - - kill_process_tree(process.processId()) - except Exception: - pass - - t.timeout.connect(_on_timeout) - t.start(timeout_ms) - # keep reference to avoid GC - self._proc_timers.append(t) - - # also attach to process so timer can be cleared if process finishes earlier - def _clear_timer(*_args): - """Clear the related cached state or UI values.""" - try: - if t.isActive(): - t.stop() - except Exception: - pass - - process.finished.connect(_clear_timer) + if hasattr(self.parent, "_arm_process_timeout"): + self.parent._arm_process_timeout(process, timeout_ms, label) except Exception: pass @@ -1684,8 +1665,7 @@ def _on_venv_pkg_installed(self, process, code, status, pkg): except Exception: pass self._venv_check_index += 1 - self._call_ui( - "update_progress_progress", + self._update_progress_progress( "tools_check", self._venv_check_index, len(self._venv_check_pkgs), @@ -1792,8 +1772,7 @@ def create_venv_if_needed(self, path: str): except Exception: pass - self._call_ui( - "show_progress", + self._show_progress( "venv_creation", "Creation de l'environnement virtuel", "creation de l'environnement virtuel", @@ -1801,13 +1780,12 @@ def create_venv_if_needed(self, path: str): self._bind_cancel_for_progress( "venv_creation", "creation de l'environnement virtuel" ) - self._call_ui( - "update_progress_message", + self._update_progress_message( "venv_creation", "Creation du venv...", ) - process = QProcess(self.parent) + process = self._create_process() self._venv_create_process = process # --- Dynamic Executor System for venv creation --- program, args = self._prepare_manager_command( @@ -1858,12 +1836,9 @@ def _on_venv_output(self, process, error=False): ) lines = data.strip().splitlines() if lines: - self._call_ui( - "update_progress_message", "venv_creation", lines[-1][:200] - ) + self._update_progress_message("venv_creation", lines[-1][:200]) self._venv_progress_lines += len(lines) - self._call_ui( - "update_progress_progress", + self._update_progress_progress( "venv_creation", self._venv_progress_lines, 0, @@ -1892,7 +1867,7 @@ def _on_venv_created(self, process, code, status, venv_path): output.info("Creation du venv annulee.") except Exception: pass - self._call_ui("close_progress", "venv_creation") + self._close_progress("venv_creation") return if code == 0: try: @@ -1901,10 +1876,8 @@ def _on_venv_created(self, process, code, status, venv_path): output.success("Environnement virtuel cree avec succes.") except Exception: pass - self._call_ui( - "update_progress_message", "venv_creation", "Venv cree." - ) - self._call_ui("close_progress", "venv_creation") + self._update_progress_message("venv_creation", "Venv cree.") + self._close_progress("venv_creation") # Persist workspace preference automatically in .ark/pref.json ws_dir = getattr( @@ -1930,12 +1903,11 @@ def _on_venv_created(self, process, code, status, venv_path): ) except Exception: pass - self._call_ui( - "update_progress_message", + self._update_progress_message( "venv_creation", "Erreur lors de la creation du venv.", ) - self._call_ui("close_progress", "venv_creation") + self._close_progress("venv_creation") # ---------- Requirements detection and generation ---------- def _find_requirements_files( @@ -2117,8 +2089,7 @@ def _start_requirements_install( self._req_use_system_python = bool(use_system_python) self._pip_phase = "ensurepip" - self._call_ui( - "show_progress", + self._show_progress( "reqs_install", "Installation des dependances", "installation des dependances", @@ -2126,13 +2097,12 @@ def _start_requirements_install( self._bind_cancel_for_progress( "reqs_install", "installation des dependances" ) - self._call_ui( - "update_progress_message", + self._update_progress_message( "reqs_install", "Activation de pip (ensurepip)...", ) - process = QProcess(self.parent) + process = self._create_process() self._req_install_process = process process.setProgram(py_exe) process.setArguments(["-m", "ensurepip", "--upgrade"]) @@ -2169,13 +2139,10 @@ def _on_pip_output(self, process, error=False): # Shows the last line received lines = data.strip().splitlines() if lines: - self._call_ui( - "update_progress_message", "reqs_install", lines[-1][:200] - ) + self._update_progress_message("reqs_install", lines[-1][:200]) self._pip_progress_lines += len(lines) # Simulates progress (pip does not give %) - self._call_ui( - "update_progress_progress", + self._update_progress_progress( "reqs_install", self._pip_progress_lines, 0, @@ -2194,17 +2161,16 @@ def _on_pip_finished(self, process, code, status): return if self._is_cancel_requested(): output.info("Installation des dependances annulee.") - self._call_ui("close_progress", "reqs_install") + self._close_progress("reqs_install") return phase = self._pip_phase if phase == "ensurepip": # Proceed to upgrade pip/setuptools/wheel regardless of ensurepip result - self._call_ui( - "update_progress_message", + self._update_progress_message( "reqs_install", "Mise a niveau de pip/setuptools/wheel...", ) - p2 = QProcess(self.parent) + p2 = self._create_process() self._req_install_process = p2 # --- Executor system --- program, args = self._prepare_manager_command( @@ -2237,12 +2203,11 @@ def _on_pip_finished(self, process, code, status): elif phase == "upgrade": if code == 0: # now install requirements.txt - self._call_ui( - "update_progress_message", + self._update_progress_message( "reqs_install", "Installation des dependances (requirements.txt)...", ) - p2 = QProcess(self.parent) + p2 = self._create_process() self._req_install_process = p2 # --- Executor system --- program, args = self._prepare_manager_command( @@ -2280,8 +2245,7 @@ def _on_pip_finished(self, process, code, status): output.error( f"Echec mise a niveau pip/setuptools/wheel (code {code})" ) - self._call_ui( - "update_progress_message", + self._update_progress_message( "reqs_install", "Echec upgrade pip/setuptools/wheel.", ) @@ -2302,8 +2266,7 @@ def _on_pip_finished(self, process, code, status): finally: self._req_marker_path = None self._req_marker_hash = None - self._call_ui( - "update_progress_message", + self._update_progress_message( "reqs_install", "Installation terminee.", ) @@ -2311,19 +2274,18 @@ def _on_pip_finished(self, process, code, status): output.error( f"Echec installation requirements.txt (code {code})" ) - self._call_ui( - "update_progress_message", + self._update_progress_message( "reqs_install", "Erreur lors de l'installation.", ) - self._call_ui("close_progress", "reqs_install") - self._call_ui("process_events") + self._close_progress("reqs_install") + self._process_events() # ---------- Background tasks status/control ---------- def has_active_tasks(self) -> bool: """Return whether any venv-related tasks are active.""" for task_id in ["venv_creation", "reqs_install", "tools_check"]: - if self._call_ui("is_progress_visible", task_id): + if self._is_progress_visible(task_id): return True return False @@ -2356,7 +2318,7 @@ def terminate_tasks(self): # Close dialogs via UI callbacks for task_id in ["venv_creation", "reqs_install", "tools_check"]: - self._call_ui("close_progress", task_id) + self._close_progress(task_id) # ---------- Environment Manager Detection & Handling ---------- def _detect_environment_manager(self, workspace_dir: str) -> str: diff --git a/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py b/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py index 18913402..f39d096d 100644 --- a/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py +++ b/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py @@ -36,43 +36,21 @@ class VenvManagerUI(VenvManager): Extension GUI de VenvManager. Responsibilities: - - Manuel venv selection dialog (QFileDialog) + - Manual venv selection dialog (QFileDialog) - Invalid-venv confirmation dialog (QMessageBox) - Updating parent widget labels (venv_label, venv_path_edit) - - Managing ProgressDialog instances via callbacks - - Registering all UI callbacks into VenvManager's _ui_callbacks dict + - Managing ProgressDialog instances via virtual hook overrides """ def __init__(self, parent_widget): super().__init__(parent_widget) self._progress_dialogs: dict[str, ProgressDialog] = {} - # Register UI callbacks so Core methods can trigger GUI updates - self._ui_callbacks.update( - { - "tr": self._ui_tr, - "log": self._ui_log, - "log_message": self._ui_log_message, - "on_pref_applied": self._on_pref_applied, - "update_venv_label": self._update_venv_label, - "update_venv_path_edit": self._update_venv_path_edit, - "ask_recreate_invalid_venv": self._ask_recreate_invalid_venv, - "show_error_dialog": self._show_error_dialog, - "show_progress": self._show_progress, - "update_progress_message": self._update_progress_message, - "update_progress_progress": self._update_progress_progress, - "close_progress": self._close_progress, - "is_progress_visible": self._is_progress_visible, - "bind_cancel": self._bind_cancel, - "process_events": self._process_events, - } - ) - # ------------------------------------------------------------------ - # UI callback implementations + # UI Hook implementations (overriding VenvManager Core hooks) # ------------------------------------------------------------------ - def _ui_tr(self, fr: str, en: str) -> str: + def _tr(self, fr: str, en: str) -> str: """Translate text via the UI translator.""" try: if hasattr(self.parent, "tr"): From 42ce4a4edd049f861fa6fa50b7e16400669ee93a Mon Sep 17 00:00:00 2001 From: Samuel Amen Ague Date: Sat, 1 Aug 2026 08:37:02 +0000 Subject: [PATCH 7/9] fix: Change import statements for `get_interpreter_version_str` from `internet` to `os_helpers` Signed-off-by: Samuel Amen Ague --- pycompiler_ark/Ui/Cli/app.py | 2 +- pycompiler_ark/Ui/Gui/Compilation/compiler.py | 2 +- pycompiler_ark/Ui/Gui/Dialogs/CompilerDialog.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pycompiler_ark/Ui/Cli/app.py b/pycompiler_ark/Ui/Cli/app.py index 0eaa3049..87fe5e95 100644 --- a/pycompiler_ark/Ui/Cli/app.py +++ b/pycompiler_ark/Ui/Cli/app.py @@ -118,7 +118,7 @@ def _build_impl( # Shared Python version resolution for locking/comparison python_version = None try: - from ...Core.utils.internet import get_interpreter_version_str + from ...Core.utils.os_helpers import get_interpreter_version_str from ...Core.Venv_Manager.Manager import VenvManager # We create a dummy bridge for VenvManager diff --git a/pycompiler_ark/Ui/Gui/Compilation/compiler.py b/pycompiler_ark/Ui/Gui/Compilation/compiler.py index 478ca0dc..06c6ba61 100644 --- a/pycompiler_ark/Ui/Gui/Compilation/compiler.py +++ b/pycompiler_ark/Ui/Gui/Compilation/compiler.py @@ -173,7 +173,7 @@ def run(self) -> None: from ....Core.Compiler.engine_runner import ( resolve_engine_command, ) - from ....Core.utils.internet import ( + from ....Core.utils.os_helpers import ( get_interpreter_version_str, ) from ....Core.Venv_Manager.Manager import VenvManager diff --git a/pycompiler_ark/Ui/Gui/Dialogs/CompilerDialog.py b/pycompiler_ark/Ui/Gui/Dialogs/CompilerDialog.py index 4b5e28e4..8bb21744 100644 --- a/pycompiler_ark/Ui/Gui/Dialogs/CompilerDialog.py +++ b/pycompiler_ark/Ui/Gui/Dialogs/CompilerDialog.py @@ -483,7 +483,7 @@ def _confirm(msg: str) -> bool: # Shared Python version resolution for locking/comparison (Aligned with CLI) self._python_version = None try: - from ....Core.utils.internet import get_interpreter_version_str + from ....Core.utils.os_helpers import get_interpreter_version_str from ....Core.Venv_Manager.Manager import VenvManager vm = VenvManager(self) From 3904a9035318613196806999935023d57790fc3e Mon Sep 17 00:00:00 2001 From: Samuel Amen Ague Date: Sat, 1 Aug 2026 09:58:27 +0000 Subject: [PATCH 8/9] feat(venv_manager): enhance venv path resolution and executor integration - Introduce `resolve_venv_path` method in VenvManagerConfig to dynamically resolve virtual environment paths based on YAML configuration. - Implement `WorkspacePathExecutor` to handle workspace-relative path resolution. - Update VenvManager to utilize the new path resolution method and improve command preparation logic. - Modify VenvManagers.yml to include `get_venv_path` command for workspace path retrieval. - Enhance unit tests to cover new functionality and ensure robust behavior. Signed-off-by: Samuel Amen Ague --- pycompiler_ark/Core/Venv_Manager/Manager.py | 452 +++++-------------- pycompiler_ark/Core/Venv_Manager/config.py | 56 ++- pycompiler_ark/Core/Venv_Manager/executor.py | 95 ++++ pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py | 22 +- pycompiler_ark/data/VenvManagers.yml | 6 +- tests/test_venv_manager_config.py | 62 ++- tests/test_venv_manager_detection.py | 14 +- 7 files changed, 361 insertions(+), 346 deletions(-) diff --git a/pycompiler_ark/Core/Venv_Manager/Manager.py b/pycompiler_ark/Core/Venv_Manager/Manager.py index fe3735ed..ce6465db 100644 --- a/pycompiler_ark/Core/Venv_Manager/Manager.py +++ b/pycompiler_ark/Core/Venv_Manager/Manager.py @@ -71,7 +71,7 @@ def __init__(self, parent_widget): self._output_encoding = "utf-8" self._fallback_encodings = ["utf-8", "latin-1", "cp1252", "ascii"] - # Environment manager detection (Simplified to PIP only) + # Environment manager detection is driven by YAML configuration. self._config = VenvManagerConfig() self._detected_manager = self._config.get_default_manager() self._manager_commands = self._get_manager_commands() @@ -81,29 +81,44 @@ def __init__(self, parent_widget): self._cancel_requested = False # ---------- Manager mapping from YAML ---------- - def _get_manager_commands(self) -> dict[str, list[str]]: + def _get_manager_commands(self) -> dict[str, dict[str, list[str]]]: """Return commands from the YAML configuration for all available managers.""" - commands: dict[str, list[str]] = {} + commands: dict[str, dict[str, list[str]]] = {} for manager_name in self._config.get_available_managers(): commands[manager_name] = self._config.get_commands(manager_name) - if not commands: - default_mgr = self._config.get_default_manager() - return {default_mgr: {"create_venv": []}} return commands def _get_executor_config( self, manager: str, action: str | None = None ) -> dict: - """get executor config (type / module / executable) from VenvManagerConfig. - Fallback to python_module + pip if missing. - """ + """Get executor config (type / module / executable) from VenvManagerConfig.""" try: cfg = self._config.get_executor(manager, action=action) if isinstance(cfg, dict) and cfg: return cfg except Exception: pass - return {"type": "python_module", "module": "pip"} + raise ValueError(f"Missing executor config for manager '{manager}'") + + def _resolve_manager_venv_path( + self, workspace_dir: str | None = None + ) -> str | None: + """Resolve the venv path from the active manager YAML definition.""" + try: + base = workspace_dir or getattr(self.parent, "workspace_dir", None) + if not base: + return None + base = os.path.abspath(base) + manager = self.resolve_workspace_manager(base) + if not manager: + return None + return self._config.resolve_venv_path( + manager, + base, + python_interpreter=self._venv_python_exe or sys.executable, + ) + except Exception: + return None def _prepare_manager_command( self, @@ -115,33 +130,32 @@ def _prepare_manager_command( """ build (program, arguments) from executor + commands of YAML file using ExecutorFactory. - - executor.type == "python_module" → -m - - executor.type == "executable" → + - executor.type == "python_module" -> -m + - executor.type == "executable" -> """ extra_args = list(extra_args or []) manager = ( self._detected_manager if isinstance(getattr(self, "_detected_manager", None), str) + and self._detected_manager else self._config.get_default_manager() ) + if not manager: + raise ValueError("No environment manager configured") cmd_args = self._get_manager_command(manager, action) + if not cmd_args: + raise ValueError( + f"Missing command config for manager '{manager}' and action '{action}'" + ) + resolved_args = [] - if cmd_args: - fmt_vars = kwargs or {} - for arg in cmd_args: - formatted_arg = str(arg) - for k, v in fmt_vars.items(): - formatted_arg = formatted_arg.replace(f"{{{k}}}", str(v)) - resolved_args.append(formatted_arg) - elif action != "create_venv": - # Fallbacks minimaux - defaults = { - "install": ["install", "-r"], - "add": ["install"], - "check": ["check"], - } - resolved_args = defaults.get(action, ["install"]) + fmt_vars = kwargs or {} + for arg in cmd_args: + formatted_arg = str(arg) + for k, v in fmt_vars.items(): + formatted_arg = formatted_arg.replace(f"{{{k}}}", str(v)) + resolved_args.append(formatted_arg) if extra_args: resolved_args.extend(extra_args) @@ -254,7 +268,7 @@ def _write_workspace_pref(self, workspace_dir: str, data: dict) -> None: pass def resolve_workspace_manager(self, workspace_dir: str) -> str: - """Resolve environment manager for workspace (User Pref -> Dynamic Detection -> Fallback pip).""" + """Resolve environment manager for workspace (User Pref -> Dynamic Detection -> YAML default).""" if workspace_dir: pref_data = self._read_workspace_pref(workspace_dir) if pref_data and isinstance(pref_data, dict): @@ -272,6 +286,8 @@ def resolve_workspace_manager(self, workspace_dir: str) -> str: return detected default_mgr = self._config.get_default_manager() + if not default_mgr: + raise ValueError("No environment manager configured in YAML") self._detected_manager = default_mgr return default_mgr @@ -360,69 +376,52 @@ def _clear_workspace_pref(self, workspace_dir: str) -> None: def resolve_existing_venv( self, workspace_dir: str | None = None ) -> str | None: - """Resolve an existing venv path (manual/local/manager). - - Returns only if a real, existing environment is found. - Does not return a default path when no venv exists. - """ + """Resolve an existing venv path from the manager definition.""" try: if getattr(self.parent, "use_system_python", False): return None manual = getattr(self.parent, "venv_path_manuel", None) if manual: - return os.path.abspath(manual) - - base = None - if workspace_dir: - base = os.path.abspath(workspace_dir) - elif getattr(self.parent, "workspace_dir", None): - base = os.path.abspath(self.parent.workspace_dir) + manual = os.path.abspath(manual) + if ( + os.path.isdir(manual) + and self.validate_venv_strict(manual)[0] + ): + return manual + return None + base = workspace_dir or getattr(self.parent, "workspace_dir", None) if not base: return None + base = os.path.abspath(base) - # Dynamically resolve manager for workspace - self.resolve_workspace_manager(base) - - # Apply saved workspace preference first (.ark/pref.json) try: if self.apply_workspace_pref(base): if getattr(self.parent, "use_system_python", False): return None manual = getattr(self.parent, "venv_path_manuel", None) if manual: - return os.path.abspath(manual) - except Exception: - pass - - # Prefer local venv if available - try: - cached = self._auto_venv_cache.get(base) - if cached and os.path.isdir(cached): - ok, _ = self.validate_venv_strict(cached) - if ok: - return cached + manual = os.path.abspath(manual) + if ( + os.path.isdir(manual) + and self.validate_venv_strict(manual)[0] + ): + return manual except Exception: pass - # Auto-detect best local venv among common names in workspace - best = self.select_best_venv(base) - if best: - try: - self._auto_venv_cache[base] = best - except Exception: - pass - return best - + resolved = self._resolve_manager_venv_path(base) + if resolved and os.path.isdir(resolved): + ok, _ = self.validate_venv_strict(resolved) + if ok: + return resolved except Exception: return None return None def resolve_project_venv(self) -> str | None: - """Resolve the venv root to use based on manual selection or workspace. - Prefers an existing .venv over venv; if none exists, returns the default path (.venv). - """ + """Resolve the manager-defined venv path for the active workspace.""" try: if getattr(self.parent, "use_system_python", False): return None @@ -431,15 +430,7 @@ def resolve_project_venv(self) -> str | None: return os.path.abspath(manual) if getattr(self.parent, "workspace_dir", None): base = os.path.abspath(self.parent.workspace_dir) - - # First, use an existing environment if available - existing = self.resolve_existing_venv(base) - if existing: - return existing - - # Fallback to default detection (.venv / venv) - existing2, default_path = self._detect_venv_in(base) - return existing2 or default_path + return self._resolve_manager_venv_path(base) except Exception: return None return None @@ -1413,235 +1404,18 @@ def _arm_process_timeout(self, process: Any, timeout_ms: int, label: str): def _query_manager_venv_path(self, base_dir: str) -> str | None: """Query active manager dynamically for environment path via YAML get_venv_path command.""" try: - manager = ( - self._detected_manager - if isinstance(getattr(self, "_detected_manager", None), str) - else "pip" - ) - cmd = self._get_manager_command(manager, "get_venv_path") - if not cmd: + manager = self.resolve_workspace_manager(base_dir) + if not manager: return None - - program, args = self._prepare_manager_command("get_venv_path") - import subprocess - - res = subprocess.run( - [program, *args], - cwd=base_dir, - capture_output=True, - text=True, - timeout=5, + return self._config.resolve_venv_path( + manager, + base_dir, + python_interpreter=self._venv_python_exe or sys.executable, ) - if res.returncode == 0 and res.stdout.strip(): - detected_path = res.stdout.strip().splitlines()[-1].strip() - if os.path.isdir(detected_path): - return detected_path except Exception: pass return None - def _detect_venv_in(self, base: str) -> tuple[str | None, str]: - """Return (existing_venv_path_or_None, default_venv_path). Prefers .venv if present, otherwise venv. Default path is .venv.""" - try: - base = os.path.abspath(base) - except Exception: - pass - p_dot = os.path.join(base, ".venv") - p_std = os.path.join(base, "venv") - existing = ( - p_dot - if os.path.isdir(p_dot) - else (p_std if os.path.isdir(p_std) else None) - ) - - if not existing: - mgr_path = self._query_manager_venv_path(base) - if mgr_path: - existing = mgr_path - - default = p_dot - return existing, default - - def _find_all_venvs_in(self, base: str) -> list[str]: - """Find all potential venv directories in the base path. - Returns a list of valid venv paths, sorted by preference. - """ - try: - base = os.path.abspath(base) - except Exception: - return [] - - venvs = [] - common_names = [".venv", "venv", ".env", "env", "virtualenv"] - - for name in common_names: - venv_path = os.path.join(base, name) - if os.path.isdir(venv_path): - ok, _ = self.validate_venv_strict(venv_path) - if ok: - venvs.append(venv_path) - - if not venvs: - mgr_path = self._query_manager_venv_path(base) - if mgr_path: - ok, _ = self.validate_venv_strict(mgr_path) - if ok: - venvs.append(mgr_path) - - return venvs - - def _score_venv( - self, venv_path: str, workspace_dir: str - ) -> tuple[int, str]: - """Score a venv based on its completeness and requirements satisfaction. - Returns (score, reason) where higher score = better venv. - - Scoring criteria: - - Has requirements.txt satisfied: +100 - - Has required engine python tools: +50 each - - Has pip/setuptools/wheel: +30 - - Is valid venv: +10 - - Has binding verified: +20 - """ - score = 0 - reasons = [] - - try: - # Check if venv is valid - ok, _ = self.validate_venv_strict(venv_path) - if not ok: - return 0, "Invalid venv structure" - score += 10 - reasons.append("valid_structure") - - # Check binding - if self.verify_venv_binding(venv_path): - score += 20 - reasons.append("verified_binding") - else: - return ( - score, - "Invalid binding (python/pip don't point to venv)", - ) - - # Check for requirements.txt (lightweight marker only to avoid blocking UI) - req_path = os.path.join(workspace_dir, "requirements.txt") - if os.path.isfile(req_path): - marker = os.path.join(venv_path, ".requirements.sha256") - if os.path.isfile(marker): - score += 100 - reasons.append("requirements_marker") - else: - reasons.append("requirements_unknown") - - # Check for key tools - tools_to_check = self._discover_engine_required_python_tools() - for tool in tools_to_check: - if self.has_tool_binary(venv_path, tool): - score += 50 - reasons.append(f"has_{tool}") - - # Check for pip/setuptools/wheel - pip_exe = self.pip_path(venv_path) - if os.path.isfile(pip_exe): - score += 30 - reasons.append("has_pip") - - return score, ", ".join(reasons) - except Exception as e: - return 0, f"Scoring error: {e}" - - def select_best_venv(self, workspace_dir: str) -> str | None: - """Select the best venv from multiple candidates. - - Strategy: - 1. Find all valid venvs in workspace - 2. Score each based on completeness and requirements satisfaction - 3. Return the highest-scoring venv - 4. If no valid venv found, return None - """ - try: - venvs = self._find_all_venvs_in(workspace_dir) - - if not venvs: - try: - from pycompiler_ark.Ui import output - - output.info( - "Aucun venv valide trouve dans le workspace.", - ) - except Exception: - pass - return None - - if len(venvs) == 1: - try: - from pycompiler_ark.Ui import output - - output.success(f"Un seul venv trouve: {venvs[0]}") - except Exception: - pass - return venvs[0] - - # Multiple venvs found - score and select the best - try: - from pycompiler_ark.Ui import output - - output.info( - f"{len(venvs)} venv(s) trouve(s), selection du meilleur...", - ) - except Exception: - pass - - scored_venvs = [] - for venv_path in venvs: - score, reason = self._score_venv(venv_path, workspace_dir) - scored_venvs.append((score, venv_path, reason)) - try: - from pycompiler_ark.Ui import output - - output.info( - f" - {os.path.basename(venv_path)}: score={score} ({reason})", - ) - except Exception: - pass - - # Sort by score (descending) - scored_venvs.sort(key=lambda x: x[0], reverse=True) - - best_score, best_venv, best_reason = scored_venvs[0] - - if best_score == 0: - try: - from pycompiler_ark.Ui import output - - output.error( - "Aucun venv valide avec une bonne liaison.", - ) - except Exception: - pass - return None - - try: - from pycompiler_ark.Ui import output - - output.success( - f"Meilleur venv selectionne: {os.path.basename(best_venv)} (score={best_score})", - ) - except Exception: - pass - return best_venv - except Exception as e: - try: - from pycompiler_ark.Ui import output - - output.warn( - f"Erreur lors de la selection du meilleur venv: {e}", - ) - except Exception: - pass - return None - def _on_venv_pkg_installed(self, process, code, status, pkg): """Handle the related event callback.""" if getattr(self.parent, "_closing", False): @@ -1675,26 +1449,24 @@ def _on_venv_pkg_installed(self, process, code, status, pkg): # ---------- Create venv if needed ---------- def create_venv_if_needed(self, path: str): """Execute create_venv_if_needed logic for this component.""" - existing, default_path = self._detect_venv_in(path) - venv_path = existing or default_path + existing = self.resolve_existing_venv(path) + venv_path = existing or self.resolve_project_venv() if existing: - # Validate existing venv; if invalid, propose deletion/recreation + return + if venv_path and os.path.isdir(venv_path): ok, reason = self.validate_venv_strict(venv_path) - if not ok: - try: - from pycompiler_ark.Ui import output + if ok: + return + try: + from pycompiler_ark.Ui import output - output.error( - f"Invalid venv detected: {reason}", - ) - except Exception: - pass - recreated = self._prompt_recreate_invalid_venv( - venv_path, reason + output.error( + f"Invalid venv detected: {reason}", ) - if not recreated: - return - else: + except Exception: + pass + recreated = self._prompt_recreate_invalid_venv(venv_path, reason) + if not recreated: return try: @@ -1806,7 +1578,7 @@ def create_venv_if_needed(self, path: str): ) process.finished.connect( lambda code, status: self._on_venv_created( - process, code, status, venv_path + process, code, status, path ) ) self._venv_progress_lines = 0 @@ -1856,7 +1628,7 @@ def _on_venv_output(self, process, error=False): except Exception: pass - def _on_venv_created(self, process, code, status, venv_path): + def _on_venv_created(self, process, code, status, workspace_dir): """Handle the related event callback.""" if getattr(self.parent, "_closing", False): return @@ -1879,12 +1651,28 @@ def _on_venv_created(self, process, code, status, venv_path): self._update_progress_message("venv_creation", "Venv cree.") self._close_progress("venv_creation") - # Persist workspace preference automatically in .ark/pref.json - ws_dir = getattr( - self.parent, "workspace_dir", None - ) or os.path.dirname(venv_path) + resolved_venv = self.resolve_existing_venv(workspace_dir) + if not resolved_venv: + resolved_venv = self.resolve_project_venv() + ws_dir = ( + workspace_dir + or getattr(self.parent, "workspace_dir", None) + or ( + os.path.dirname(resolved_venv) + if isinstance(resolved_venv, str) and resolved_venv + else None + ) + ) + if not ws_dir or not resolved_venv: + try: + from pycompiler_ark.Ui import output + + output.warn("can't resolve venv path.") + except Exception: + pass + return try: - setattr(self.parent, "venv_path", venv_path) + setattr(self.parent, "venv_path", resolved_venv) except Exception: pass self.save_workspace_pref(ws_dir) @@ -1990,13 +1778,11 @@ def install_requirements_if_needed( if manual: venv_root = os.path.abspath(manual) else: - existing, default_path = self._detect_venv_in(path) - venv_root = existing or default_path + existing = self.resolve_existing_venv(path) if not existing: - # Create default .venv if none exists self.create_venv_if_needed(path) - existing2, _ = self._detect_venv_in(path) - venv_root = existing2 or venv_root + return + venv_root = existing ok, reason = self.validate_venv_strict(venv_root) if not ok: output.warn(f"Invalid venv for requirements: {reason}") @@ -2322,8 +2108,14 @@ def terminate_tasks(self): # ---------- Environment Manager Detection & Handling ---------- def _detect_environment_manager(self, workspace_dir: str) -> str: - """Detect which environment manager is used in the project (Simplified to PIP).""" - return "pip" + """Detect which environment manager is used in the project.""" + detected = self._config.detect_manager_for_workspace(workspace_dir) + if detected: + return detected + default_mgr = self._config.get_default_manager() + if default_mgr: + return default_mgr + raise ValueError("No environment manager configured in YAML") def _is_tool_available(self, tool: str) -> bool: """Check if a tool is available in the system PATH.""" @@ -2373,7 +2165,7 @@ def setup_workspace( # Check and install tools if requested if check_tools: - existing_check, _ = self._detect_venv_in(workspace_dir) + existing_check = self.resolve_existing_venv(workspace_dir) if existing_check: ok, reason = self.validate_venv_strict(existing_check) if ok: diff --git a/pycompiler_ark/Core/Venv_Manager/config.py b/pycompiler_ark/Core/Venv_Manager/config.py index d0a23974..5c132cfc 100644 --- a/pycompiler_ark/Core/Venv_Manager/config.py +++ b/pycompiler_ark/Core/Venv_Manager/config.py @@ -2,11 +2,15 @@ from __future__ import annotations +import os +import sys from pathlib import Path from typing import Any import yaml +from .executor import ExecutorFactory + class VenvManagerConfig: """Load and expose VenvManager configuration from YAML.""" @@ -93,6 +97,54 @@ def get_executor( return {} + def resolve_venv_path( + self, + manager_name: str, + workspace_dir: str, + python_interpreter: str | None = None, + ) -> str | None: + """Resolve the venv path according to the manager YAML definition.""" + try: + workspace_path = Path(workspace_dir) + if not workspace_path.is_dir(): + return None + except Exception: + return None + + command = self.get_command(manager_name, "get_venv_path") + + if not isinstance(command, list) or not all( + isinstance(item, str) for item in command + ): + return None + + executor_cfg = self.get_executor(manager_name, action="get_venv_path") + if not executor_cfg: + executor_cfg = self.get_executor(manager_name) + if not executor_cfg: + return None + + interpreter = python_interpreter or sys.executable + try: + executor = ExecutorFactory.create(executor_cfg, interpreter) + resolved = executor.run( + list(command), + cwd=str(workspace_path), + context={ + "workspace": str(workspace_path), + "cwd": str(workspace_path), + "python": interpreter, + "manager": manager_name, + }, + ) + if not resolved: + return None + if not os.path.isabs(resolved): + resolved = str((workspace_path / resolved).resolve()) + return resolved + except Exception: + return None + def get_command( self, manager_name: str, @@ -112,10 +164,10 @@ def get_available_managers(self) -> list[str]: return [] def get_default_manager(self) -> str: - """Return default fallback manager name from configuration.""" + """Return the fallback manager name from configuration.""" available = self.get_available_managers() if not available: - return "pip" + return "" rules = [ (name, self.get_detection_rules(name).get("priority", 0)) diff --git a/pycompiler_ark/Core/Venv_Manager/executor.py b/pycompiler_ark/Core/Venv_Manager/executor.py index 9c930513..44f069bd 100644 --- a/pycompiler_ark/Core/Venv_Manager/executor.py +++ b/pycompiler_ark/Core/Venv_Manager/executor.py @@ -3,6 +3,8 @@ from __future__ import annotations from abc import ABC, abstractmethod +import os +import subprocess from typing import Any @@ -24,6 +26,16 @@ def build_command( ) -> tuple[str, list[str]]: """Build executable program and arguments.""" + @abstractmethod + def run( + self, + args: list[str], + *, + cwd: str | None = None, + context: dict[str, str] | None = None, + ) -> str | None: + """Execute or resolve the configured strategy and return a result.""" + class PythonModuleExecutor(BaseExecutor): """Executor for Python modules (python -m module).""" @@ -49,6 +61,27 @@ def build_command( ], ) + def run( + self, + args: list[str], + *, + cwd: str | None = None, + context: dict[str, str] | None = None, + ) -> str | None: + program, argv = self.build_command(args) + completed = subprocess.run( + [program, *argv], + cwd=cwd, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + return None + output = completed.stdout.strip().splitlines() + if not output: + return None + return output[-1].strip() or None + class ExecutableExecutor(BaseExecutor): """Executor for external executables.""" @@ -67,6 +100,62 @@ def build_command( args, ) + def run( + self, + args: list[str], + *, + cwd: str | None = None, + context: dict[str, str] | None = None, + ) -> str | None: + program, argv = self.build_command(args) + completed = subprocess.run( + [program, *argv], + cwd=cwd, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + return None + output = completed.stdout.strip().splitlines() + if not output: + return None + return output[-1].strip() or None + + +class WorkspacePathExecutor(BaseExecutor): + """Executor that resolves a workspace-relative path template.""" + + def build_command( + self, + args: list[str], + ) -> tuple[str, list[str]]: + raise NotImplementedError( + "WorkspacePathExecutor does not build subprocess commands" + ) + + def run( + self, + args: list[str], + *, + cwd: str | None = None, + context: dict[str, str] | None = None, + ) -> str | None: + if not args: + return None + template = args[0] + if not isinstance(template, str) or not template.strip(): + return None + base = dict(context or {}) + workspace = base.get("workspace", cwd or "") + if not workspace: + return None + base["workspace"] = workspace + base["cwd"] = base.get("cwd", workspace) + resolved = template.format(**base) + if not os.path.isabs(resolved): + resolved = os.path.abspath(os.path.join(workspace, resolved)) + return resolved + class ExecutorFactory: """Create executor instances from configuration.""" @@ -90,4 +179,10 @@ def create( python_interpreter, ) + if executor_type == "workspace_path": + return WorkspacePathExecutor( + executor_config, + python_interpreter, + ) + raise ValueError(f"Unsupported executor type: {executor_type}") diff --git a/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py b/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py index f39d096d..8d4bd5cb 100644 --- a/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py +++ b/pycompiler_ark/Ui/Gui/Dialogs/VenvDialog.py @@ -426,12 +426,26 @@ def _on_venv_created(self, process, code, status, venv_path): if code == 0 and not self._is_cancel_requested(): try: + resolved_venv = getattr(self.parent, "venv_path", None) + if not resolved_venv: + resolved_venv = self.resolve_existing_venv( + getattr(self.parent, "workspace_dir", None) + ) + if not resolved_venv: + resolved_venv = self.resolve_project_venv() if not getattr(self.parent, "use_system_python", False): - if not getattr(self.parent, "venv_path_manuel", None): - self.parent.venv_path_manuel = venv_path + if resolved_venv and not getattr( + self.parent, "venv_path_manuel", None + ): + self.parent.venv_path_manuel = resolved_venv self._update_venv_label( - f"Venv sélectionné : {venv_path}" + f"Venv sélectionné : {resolved_venv}" ) - self.save_workspace_pref(os.path.dirname(venv_path)) + if resolved_venv: + self.save_workspace_pref(os.path.dirname(resolved_venv)) + else: + self.save_workspace_pref( + getattr(self.parent, "workspace_dir", None) + ) except Exception: pass diff --git a/pycompiler_ark/data/VenvManagers.yml b/pycompiler_ark/data/VenvManagers.yml index 6ac759d1..1ff40fcb 100644 --- a/pycompiler_ark/data/VenvManagers.yml +++ b/pycompiler_ark/data/VenvManagers.yml @@ -34,6 +34,8 @@ managers: create_venv: type: python_module module: venv + get_venv_path: + type: workspace_path detection: priority: 10 files: @@ -48,4 +50,6 @@ managers: add: - install check: - - check \ No newline at end of file + - check + get_venv_path: + - "{workspace}/.venv" diff --git a/tests/test_venv_manager_config.py b/tests/test_venv_manager_config.py index 3c64da75..b36bdc8f 100644 --- a/tests/test_venv_manager_config.py +++ b/tests/test_venv_manager_config.py @@ -1,11 +1,16 @@ import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace from unittest.mock import MagicMock +from unittest.mock import patch from pycompiler_ark.Core.Venv_Manager.config import VenvManagerConfig from pycompiler_ark.Core.Venv_Manager.executor import ( ExecutorFactory, PythonModuleExecutor, ExecutableExecutor, + WorkspacePathExecutor, ) from pycompiler_ark.Core.Venv_Manager.Manager import VenvManager @@ -13,19 +18,27 @@ class TestVenvManagerConfig(unittest.TestCase): def test_loads_default_commands_from_yaml(self): config = VenvManagerConfig() + fallback_manager = config.get_default_manager() + self.assertIn(fallback_manager, config.get_available_managers()) - executor = config.get_executor("pip") - self.assertEqual(executor, {"type": "python_module", "module": "pip"}) + executor = config.get_executor(fallback_manager) + self.assertIsInstance(executor, dict) - commands = config.get_commands("pip") + self.assertEqual( + config.get_executor(fallback_manager, "get_venv_path").get("type"), + "workspace_path", + ) + + commands = config.get_commands(fallback_manager) self.assertEqual(commands["create_venv"], ["{venv_path}"]) self.assertEqual( - config.get_executor("pip", "create_venv"), - {"type": "python_module", "module": "venv"}, + config.get_executor(fallback_manager, "create_venv").get("module"), + "venv", ) self.assertEqual(commands["install"], ["install", "-r"]) self.assertEqual(commands["add"], ["install"]) self.assertEqual(commands["check"], ["check"]) + self.assertEqual(commands["get_venv_path"], ["{workspace}/.venv"]) def test_loads_poetry_config_from_yaml(self): config = VenvManagerConfig() @@ -37,10 +50,32 @@ def test_loads_poetry_config_from_yaml(self): commands = config.get_commands("poetry") self.assertEqual(commands["create_venv"], ["env", "use", "{python}"]) + self.assertEqual(commands["get_venv_path"], ["env", "info", "-p"]) self.assertEqual(commands["install"], ["install"]) self.assertEqual(commands["add"], ["add"]) self.assertEqual(commands["check"], ["check"]) + @patch("pycompiler_ark.Core.Venv_Manager.executor.subprocess.run") + def test_resolve_command_venv_path_from_yaml(self, mock_run): + config = VenvManagerConfig() + mock_run.return_value = SimpleNamespace( + returncode=0, + stdout="/tmp/poetry-env\n", + ) + + with TemporaryDirectory() as tmp: + resolved = config.resolve_venv_path("poetry", tmp) + + self.assertEqual(resolved, "/tmp/poetry-env") + + def test_resolve_workspace_path_venv_from_yaml(self): + config = VenvManagerConfig() + fallback_manager = config.get_default_manager() + with TemporaryDirectory() as tmp: + resolved = config.resolve_venv_path(fallback_manager, tmp) + + self.assertEqual(resolved, str((Path(tmp) / ".venv").resolve())) + class TestExecutorFactory(unittest.TestCase): def test_python_module_executor(self): @@ -61,10 +96,25 @@ def test_executable_executor(self): self.assertEqual(program, "poetry") self.assertEqual(args, ["install"]) + def test_workspace_path_executor(self): + cfg = {"type": "workspace_path"} + executor = ExecutorFactory.create(cfg) + self.assertIsInstance(executor, WorkspacePathExecutor) + + with TemporaryDirectory() as tmp: + result = executor.run( + ["{workspace}/.venv"], + cwd=tmp, + context={"workspace": tmp}, + ) + + self.assertEqual(result, str((Path(tmp) / ".venv").resolve())) + class TestVenvManagerCommandPreparation(unittest.TestCase): def test_prepare_manager_command_pip_install(self): manager = VenvManager(MagicMock()) + manager._detected_manager = VenvManagerConfig().get_default_manager() program, args = manager._prepare_manager_command( "install", extra_args=["reqs.txt"], @@ -75,7 +125,7 @@ def test_prepare_manager_command_pip_install(self): def test_prepare_manager_command_pip_create_venv(self): manager = VenvManager(MagicMock()) - manager._detected_manager = "pip" + manager._detected_manager = VenvManagerConfig().get_default_manager() program, args = manager._prepare_manager_command( "create_venv", kwargs={"venv_path": "/path/to/venv", "python": "/fake/python"}, diff --git a/tests/test_venv_manager_detection.py b/tests/test_venv_manager_detection.py index be7aa4e3..49ac5d34 100644 --- a/tests/test_venv_manager_detection.py +++ b/tests/test_venv_manager_detection.py @@ -51,7 +51,7 @@ def test_fallback_to_default(self): venv_manager = VenvManager(MagicMock()) resolved = venv_manager.resolve_workspace_manager(self.workspace_dir) - self.assertEqual(resolved, "pip") + self.assertEqual(resolved, self.config.get_default_manager()) def test_user_preference_override(self): # Even with poetry detected, user pref in .ark/pref.json should override @@ -63,11 +63,19 @@ def test_user_preference_override(self): ark_dir = Path(self.workspace_dir) / ".ark" ark_dir.mkdir(parents=True, exist_ok=True) pref_file = ark_dir / "pref.json" - pref_file.write_text(json.dumps({"manager": "pip"}), encoding="utf-8") + fallback_manager = self.config.get_default_manager() + pref_file.write_text( + json.dumps({"manager": fallback_manager}), encoding="utf-8" + ) venv_manager = VenvManager(MagicMock()) resolved = venv_manager.resolve_workspace_manager(self.workspace_dir) - self.assertEqual(resolved, "pip") + self.assertEqual(resolved, fallback_manager) + + def test_detect_environment_manager_uses_yaml_default_when_needed(self): + venv_manager = VenvManager(MagicMock()) + resolved = venv_manager._detect_environment_manager(self.workspace_dir) + self.assertEqual(resolved, self.config.get_default_manager()) if __name__ == "__main__": From 633ce1da8bf1e10dcdad6a6770c0dc79242284d3 Mon Sep 17 00:00:00 2001 From: Samuel Amen Ague Date: Sat, 1 Aug 2026 23:18:24 +0000 Subject: [PATCH 9/9] feat(venv_manager): implement CLI virtual environment management and path resolution Signed-off-by: Samuel Amen Ague --- pycompiler_ark/Core/Venv_Manager/Manager.py | 78 ++++++++++ pycompiler_ark/Ui/Cli/helpers.py | 61 ++++++-- tests/test_venv_manager_config.py | 159 -------------------- 3 files changed, 123 insertions(+), 175 deletions(-) delete mode 100644 tests/test_venv_manager_config.py diff --git a/pycompiler_ark/Core/Venv_Manager/Manager.py b/pycompiler_ark/Core/Venv_Manager/Manager.py index ce6465db..92a3e265 100644 --- a/pycompiler_ark/Core/Venv_Manager/Manager.py +++ b/pycompiler_ark/Core/Venv_Manager/Manager.py @@ -4,6 +4,7 @@ import os import platform import shutil +import subprocess import sys from typing import Any @@ -464,6 +465,18 @@ def _using_system_python(self) -> bool: except Exception: return False + def _is_cli_mode(self) -> bool: + """Return whether the manager runs in CLI/synchronous mode.""" + try: + if bool(getattr(self.parent, "_cli_mode", False)): + return True + except Exception: + pass + try: + return os.environ.get("PYCOMPILER_CLI") == "1" + except Exception: + return False + def _pip_break_system_args(self) -> list[str]: """Execute _pip_break_system_args logic for this component.""" if self._using_system_python() and platform.system() == "Linux": @@ -1544,6 +1557,71 @@ def create_venv_if_needed(self, path: str): except Exception: pass + if self._is_cli_mode(): + try: + program, args = self._prepare_manager_command( + "create_venv", + kwargs={ + "venv_path": venv_path, + "python": python_candidate, + }, + python_exe=python_candidate, + ) + if ( + base in ("py", "py.exe") + and program == python_candidate + ): + args = ["-3"] + args + completed = subprocess.run( + [program, *args], + cwd=path, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + err = ( + completed.stderr or completed.stdout or "" + ).strip() + raise RuntimeError(err or "venv creation failed") + try: + from pycompiler_ark.Ui import output + + output.success( + "Environnement virtuel cree avec succes." + ) + except Exception: + pass + resolved_venv = self.resolve_existing_venv(path) + if not resolved_venv: + resolved_venv = self.resolve_project_venv() + if resolved_venv: + try: + setattr(self.parent, "venv_path", resolved_venv) + except Exception: + pass + ws_dir = ( + path + or getattr(self.parent, "workspace_dir", None) + or os.path.dirname(resolved_venv) + ) + if ws_dir: + self.save_workspace_pref(ws_dir) + try: + self.install_requirements_if_needed(ws_dir) + except Exception: + pass + return + except Exception as e: + try: + from pycompiler_ark.Ui import output + + output.error( + f"Echec de creation du venv ou installation des outils : {e}", + ) + except Exception: + pass + return + self._show_progress( "venv_creation", "Creation de l'environnement virtuel", diff --git a/pycompiler_ark/Ui/Cli/helpers.py b/pycompiler_ark/Ui/Cli/helpers.py index f0706539..a4e80e1b 100644 --- a/pycompiler_ark/Ui/Cli/helpers.py +++ b/pycompiler_ark/Ui/Cli/helpers.py @@ -24,7 +24,7 @@ import subprocess import sys import threading -import venv +import time from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path @@ -267,10 +267,20 @@ def relative_to_workspace(workspace: Path, target: Path) -> str: return target.resolve().relative_to(workspace.resolve()).as_posix() -def python_in_venv(venv_dir: Path) -> Path: - if os.name == "nt": - return venv_dir / "Scripts" / "python.exe" - return venv_dir / "bin" / "python" +def _create_cli_venv_manager(workspace: Path): + from ...Core.Venv_Manager.Manager import VenvManager + + class _Bridge: + def __init__(self, ws: str): + self.workspace_dir = ws + self.use_system_python = False + self.venv_path_manuel = None + self.verbose = False + self._cli_mode = True + + bridge = _Bridge(str(workspace)) + manager = VenvManager(bridge) + return manager def init_workspace( @@ -363,20 +373,39 @@ def init_workspace( "# Add your runtime dependencies here.\n", encoding="utf-8" ) - venv_path = workspace / ".ark" / "venv" - if with_venv and not venv_path.exists(): - builder = venv.EnvBuilder(with_pip=True) - builder.create(str(venv_path)) + venv_manager = None + venv_path: str | None = None + if with_venv or install_requirements: + try: + venv_manager = _create_cli_venv_manager(workspace) + except Exception as exc: + raise CliSpecError(f"Unable to initialize venv manager: {exc}") + + if with_venv: + venv_manager.create_venv_if_needed(str(workspace)) + venv_path = venv_manager.resolve_existing_venv(str(workspace)) + if not venv_path: + raise CliSpecError( + "venv creation failed or venv path could not be resolved" + ) + else: + venv_path = venv_manager.resolve_existing_venv(str(workspace)) if install_requirements: if not requirements_path.exists(): raise CliSpecError( "requirements.txt not found. Run 'pycompiler_ark init --generate-requirements' first." ) - if not venv_path.exists(): - builder = venv.EnvBuilder(with_pip=True) - builder.create(str(venv_path)) - python_exe = python_in_venv(venv_path) + if not venv_manager: + venv_manager = _create_cli_venv_manager(workspace) + if not venv_path: + venv_manager.create_venv_if_needed(str(workspace)) + venv_path = venv_manager.resolve_existing_venv(str(workspace)) + if not venv_path: + raise CliSpecError( + "venv creation failed or venv path could not be resolved" + ) + python_exe = venv_manager.python_path(venv_path) result = subprocess.run( [ str(python_exe), @@ -401,8 +430,8 @@ def init_workspace( pref_path = workspace / ".ark" / "pref.json" pref_data = {"venv_mode": "system", "venv_path": None} - if venv_path.exists(): - pref_data["venv_mode"] = "manual" + if venv_path: + pref_data["venv_mode"] = "venv" pref_data["venv_path"] = str(venv_path) else: info( @@ -417,7 +446,7 @@ def init_workspace( return { "workspace": str(workspace), "ark_yml": str(ark_yml), - "venv": str(venv_path) if venv_path.exists() else None, + "venv": str(venv_path) if venv_path else None, "requirements": str(requirements_path) if requirements_path.exists() else None, diff --git a/tests/test_venv_manager_config.py b/tests/test_venv_manager_config.py deleted file mode 100644 index b36bdc8f..00000000 --- a/tests/test_venv_manager_config.py +++ /dev/null @@ -1,159 +0,0 @@ -import unittest -from pathlib import Path -from tempfile import TemporaryDirectory -from types import SimpleNamespace -from unittest.mock import MagicMock -from unittest.mock import patch - -from pycompiler_ark.Core.Venv_Manager.config import VenvManagerConfig -from pycompiler_ark.Core.Venv_Manager.executor import ( - ExecutorFactory, - PythonModuleExecutor, - ExecutableExecutor, - WorkspacePathExecutor, -) -from pycompiler_ark.Core.Venv_Manager.Manager import VenvManager - - -class TestVenvManagerConfig(unittest.TestCase): - def test_loads_default_commands_from_yaml(self): - config = VenvManagerConfig() - fallback_manager = config.get_default_manager() - self.assertIn(fallback_manager, config.get_available_managers()) - - executor = config.get_executor(fallback_manager) - self.assertIsInstance(executor, dict) - - self.assertEqual( - config.get_executor(fallback_manager, "get_venv_path").get("type"), - "workspace_path", - ) - - commands = config.get_commands(fallback_manager) - self.assertEqual(commands["create_venv"], ["{venv_path}"]) - self.assertEqual( - config.get_executor(fallback_manager, "create_venv").get("module"), - "venv", - ) - self.assertEqual(commands["install"], ["install", "-r"]) - self.assertEqual(commands["add"], ["install"]) - self.assertEqual(commands["check"], ["check"]) - self.assertEqual(commands["get_venv_path"], ["{workspace}/.venv"]) - - def test_loads_poetry_config_from_yaml(self): - config = VenvManagerConfig() - - executor = config.get_executor("poetry") - self.assertEqual( - executor, {"type": "executable", "executable": "poetry"} - ) - - commands = config.get_commands("poetry") - self.assertEqual(commands["create_venv"], ["env", "use", "{python}"]) - self.assertEqual(commands["get_venv_path"], ["env", "info", "-p"]) - self.assertEqual(commands["install"], ["install"]) - self.assertEqual(commands["add"], ["add"]) - self.assertEqual(commands["check"], ["check"]) - - @patch("pycompiler_ark.Core.Venv_Manager.executor.subprocess.run") - def test_resolve_command_venv_path_from_yaml(self, mock_run): - config = VenvManagerConfig() - mock_run.return_value = SimpleNamespace( - returncode=0, - stdout="/tmp/poetry-env\n", - ) - - with TemporaryDirectory() as tmp: - resolved = config.resolve_venv_path("poetry", tmp) - - self.assertEqual(resolved, "/tmp/poetry-env") - - def test_resolve_workspace_path_venv_from_yaml(self): - config = VenvManagerConfig() - fallback_manager = config.get_default_manager() - with TemporaryDirectory() as tmp: - resolved = config.resolve_venv_path(fallback_manager, tmp) - - self.assertEqual(resolved, str((Path(tmp) / ".venv").resolve())) - - -class TestExecutorFactory(unittest.TestCase): - def test_python_module_executor(self): - cfg = {"type": "python_module", "module": "pip"} - executor = ExecutorFactory.create(cfg, "/usr/bin/python3") - self.assertIsInstance(executor, PythonModuleExecutor) - - program, args = executor.build_command(["install", "requests"]) - self.assertEqual(program, "/usr/bin/python3") - self.assertEqual(args, ["-m", "pip", "install", "requests"]) - - def test_executable_executor(self): - cfg = {"type": "executable", "executable": "poetry"} - executor = ExecutorFactory.create(cfg) - self.assertIsInstance(executor, ExecutableExecutor) - - program, args = executor.build_command(["install"]) - self.assertEqual(program, "poetry") - self.assertEqual(args, ["install"]) - - def test_workspace_path_executor(self): - cfg = {"type": "workspace_path"} - executor = ExecutorFactory.create(cfg) - self.assertIsInstance(executor, WorkspacePathExecutor) - - with TemporaryDirectory() as tmp: - result = executor.run( - ["{workspace}/.venv"], - cwd=tmp, - context={"workspace": tmp}, - ) - - self.assertEqual(result, str((Path(tmp) / ".venv").resolve())) - - -class TestVenvManagerCommandPreparation(unittest.TestCase): - def test_prepare_manager_command_pip_install(self): - manager = VenvManager(MagicMock()) - manager._detected_manager = VenvManagerConfig().get_default_manager() - program, args = manager._prepare_manager_command( - "install", - extra_args=["reqs.txt"], - python_exe="/fake/python", - ) - self.assertEqual(program, "/fake/python") - self.assertEqual(args, ["-m", "pip", "install", "-r", "reqs.txt"]) - - def test_prepare_manager_command_pip_create_venv(self): - manager = VenvManager(MagicMock()) - manager._detected_manager = VenvManagerConfig().get_default_manager() - program, args = manager._prepare_manager_command( - "create_venv", - kwargs={"venv_path": "/path/to/venv", "python": "/fake/python"}, - python_exe="/fake/python", - ) - self.assertEqual(program, "/fake/python") - self.assertEqual(args, ["-m", "venv", "/path/to/venv"]) - - def test_prepare_manager_command_poetry_create_venv(self): - manager = VenvManager(MagicMock()) - manager._detected_manager = "poetry" - program, args = manager._prepare_manager_command( - "create_venv", - kwargs={"venv_path": "/path/to/venv", "python": "/fake/python"}, - ) - self.assertEqual(program, "poetry") - self.assertEqual(args, ["env", "use", "/fake/python"]) - - def test_prepare_manager_command_poetry_add(self): - manager = VenvManager(MagicMock()) - manager._detected_manager = "poetry" - program, args = manager._prepare_manager_command( - "add", - extra_args=["requests"], - ) - self.assertEqual(program, "poetry") - self.assertEqual(args, ["add", "requests"]) - - -if __name__ == "__main__": - unittest.main()