From 7f13d137bc3e1964f57ba6709035e021eefadb70 Mon Sep 17 00:00:00 2001 From: ahgsql Date: Fri, 30 Oct 2026 23:28:43 +0300 Subject: [PATCH 1/2] modly-cli: start the API with the same setup as the desktop app `dev serve-api` / `dev ensure-server --start` ignored dependenciesDir from settings.json. If the dependencies live on another drive (mine are on F:), the venv isn't found and it falls back to whatever Python runs the CLI, which doesn't have the backend deps. It now checks dependenciesDir first, and passes the env vars the Electron bridge sets: PYTHONIOENCODING=utf-8, MODLY_LLM_DIR (agentDir) and MODLY_NODE_EXECUTABLE (Modly.exe) for the installed app. Added two tests; the existing ones still pass. --- tools/modly-cli/agent.py | 19 ++++++++++++++++--- tools/modly-cli/test_agent.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/tools/modly-cli/agent.py b/tools/modly-cli/agent.py index e424aa25..09f35755 100644 --- a/tools/modly-cli/agent.py +++ b/tools/modly-cli/agent.py @@ -283,11 +283,16 @@ def _default_api_dir() -> Path | None: return None -def _default_python(api_dir: Path) -> Path | None: +def _default_python(api_dir: Path, settings: dict[str, Any] | None = None) -> Path | None: candidates = [ api_dir / ".venv" / "Scripts" / "python.exe", api_dir / ".venv" / "bin" / "python", ] + # The desktop app can keep its dependencies on another drive (Settings > dependenciesDir). + deps_dir = (settings or {}).get("dependenciesDir") + if deps_dir: + candidates.append(Path(deps_dir) / "venv" / "Scripts" / "python.exe") + candidates.append(Path(deps_dir) / "venv" / "bin" / "python") for appdata in _windows_env_paths("APPDATA"): candidates.append(appdata / "Modly" / "dependencies" / "venv" / "Scripts" / "python.exe") candidates.append(Path(sys.executable)) @@ -317,11 +322,11 @@ def _resolve_serve_config(args: argparse.Namespace) -> tuple[Path, Path, dict[st if not api_dir or not (api_dir / "main.py").exists(): raise ModlyCliError("Could not find Modly api directory; pass --api-dir") - python = Path(args.python).expanduser().resolve() if getattr(args, "python", None) else _default_python(api_dir) + settings = _load_modly_settings() + python = Path(args.python).expanduser().resolve() if getattr(args, "python", None) else _default_python(api_dir, settings) if not python or not python.exists(): raise ModlyCliError("Could not find Modly Python environment; pass --python") - settings = _load_modly_settings() env = os.environ.copy() hf_token = getattr(args, "hf_token", None) or settings.get("hfToken") or os.environ.get("HF_TOKEN", "") env.update({ @@ -332,7 +337,15 @@ def _resolve_serve_config(args: argparse.Namespace) -> tuple[Path, Path, dict[st "SELECTED_MODEL_ID": getattr(args, "model", None) or os.environ.get("SELECTED_MODEL_ID", ""), "HUGGING_FACE_HUB_TOKEN": hf_token, "HF_TOKEN": hf_token, + # Same as the Electron PythonBridge: UTF-8 logs, the local LLM folder, and Modly.exe as the Node + # runtime of the mesh optimizer (installed app only; otherwise mesh_ops falls back to `node`). + "PYTHONIOENCODING": "utf-8", }) + if settings.get("agentDir") and "MODLY_LLM_DIR" not in os.environ: + env["MODLY_LLM_DIR"] = settings["agentDir"] + modly_exe = api_dir.parent.parent / ("Modly.exe" if os.name == "nt" else "modly") + if modly_exe.exists() and "MODLY_NODE_EXECUTABLE" not in os.environ: + env["MODLY_NODE_EXECUTABLE"] = str(modly_exe) cmd = [str(python), "-m", "uvicorn", "main:app", "--host", args.host, "--port", str(args.port)] base_url = f"http://{args.host}:{args.port}" return api_dir, python, env, cmd, base_url diff --git a/tools/modly-cli/test_agent.py b/tools/modly-cli/test_agent.py index 9537ec1a..432492ba 100644 --- a/tools/modly-cli/test_agent.py +++ b/tools/modly-cli/test_agent.py @@ -483,6 +483,35 @@ def test_malformed_timeout_environment_does_not_break_module_import(self) -> Non self.assertEqual(module.DEFAULT_TIMEOUT_SECONDS, 1800) self.assertEqual(module.DEFAULT_POLL_SECONDS, 2.0) + def test_default_python_uses_settings_dependencies_dir(self) -> None: + with tempfile.TemporaryDirectory() as td: + api_dir = Path(td) / "api" + api_dir.mkdir() + deps_python = Path(td) / "deps" / "venv" / "Scripts" / "python.exe" + deps_python.parent.mkdir(parents=True) + deps_python.write_text("# python") + with patch.object(agent, "_windows_env_paths", return_value=[]): + found = agent._default_python(api_dir, {"dependenciesDir": str(Path(td) / "deps")}) + self.assertEqual(found, deps_python) + + def test_resolve_serve_config_matches_desktop_env(self) -> None: + with tempfile.TemporaryDirectory() as td: + api_dir = Path(td) / "resources" / "api" + api_dir.mkdir(parents=True) + (api_dir / "main.py").write_text("# api") + (Path(td) / ("Modly.exe" if os.name == "nt" else "modly")).write_text("# exe") + python = Path(td) / "python.exe" + python.write_text("# python") + args = SimpleNamespace(api_dir=str(api_dir), python=str(python), host="127.0.0.1", port=8765, + models_dir=None, workspace_dir=None, extensions_dir=None, model=None, hf_token=None) + clean = {k: v for k, v in os.environ.items() if k not in ("MODLY_LLM_DIR", "MODLY_NODE_EXECUTABLE")} + with patch.dict(os.environ, clean, clear=True), \ + patch.object(agent, "_load_modly_settings", return_value={"agentDir": "D:/modly/agent"}): + _api_dir, _python, env, _cmd, _url = agent._resolve_serve_config(args) + self.assertEqual(env["PYTHONIOENCODING"], "utf-8") + self.assertEqual(env["MODLY_LLM_DIR"], "D:/modly/agent") + self.assertTrue(env["MODLY_NODE_EXECUTABLE"].lower().endswith(("modly.exe", "modly"))) + def test_resolve_serve_config_explicit_paths(self) -> None: with tempfile.TemporaryDirectory() as td: api_dir = Path(td) / "api" From 2fcc0fb058eb99838ed362e35c4c4ca5386727e5 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sat, 31 Oct 2026 22:32:34 +0100 Subject: [PATCH 2/2] modly-cli: drop the caller's Python env and prefer the app venv, like the desktop bridge The started API inherited PYTHONHOME, PYTHONPATH, VIRTUAL_ENV, CONDA_PREFIX and the PIP_* variables of whatever shell ran the CLI. From a conda env or an activated venv, these point Modly's venv interpreter at foreign site-packages, or keep it from starting at all. They are now removed, the same list Electron's cleanPythonEnv() strips. _default_python also tried the api's dev .venv before the app's venv, the reverse of the PythonBridge. The app's venv (dependenciesDir, then the default dependencies folder) now comes first, the dev .venv second. --- tools/modly-cli/agent.py | 21 +++++++++++++++------ tools/modly-cli/test_agent.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/tools/modly-cli/agent.py b/tools/modly-cli/agent.py index 09f35755..b382272b 100644 --- a/tools/modly-cli/agent.py +++ b/tools/modly-cli/agent.py @@ -284,10 +284,8 @@ def _default_api_dir() -> Path | None: def _default_python(api_dir: Path, settings: dict[str, Any] | None = None) -> Path | None: - candidates = [ - api_dir / ".venv" / "Scripts" / "python.exe", - api_dir / ".venv" / "bin" / "python", - ] + # Same order as the Electron PythonBridge: the app's venv first, the api's dev .venv as fallback. + candidates: list[Path] = [] # The desktop app can keep its dependencies on another drive (Settings > dependenciesDir). deps_dir = (settings or {}).get("dependenciesDir") if deps_dir: @@ -295,6 +293,8 @@ def _default_python(api_dir: Path, settings: dict[str, Any] | None = None) -> Pa candidates.append(Path(deps_dir) / "venv" / "bin" / "python") for appdata in _windows_env_paths("APPDATA"): candidates.append(appdata / "Modly" / "dependencies" / "venv" / "Scripts" / "python.exe") + candidates.append(api_dir / ".venv" / "Scripts" / "python.exe") + candidates.append(api_dir / ".venv" / "bin" / "python") candidates.append(Path(sys.executable)) for candidate in candidates: if candidate.exists(): @@ -317,6 +317,13 @@ def _load_modly_settings() -> dict[str, Any]: return {} +_FOREIGN_PYTHON_ENV = ( + "PYTHONHOME", "PYTHONPATH", "PYTHONSTARTUP", "PYTHONUSERBASE", + "PIP_USER", "PIP_TARGET", "PIP_PREFIX", "PIP_REQUIRE_VIRTUALENV", + "VIRTUAL_ENV", "CONDA_PREFIX", +) + + def _resolve_serve_config(args: argparse.Namespace) -> tuple[Path, Path, dict[str, str], list[str], str]: api_dir = Path(args.api_dir).expanduser().resolve() if getattr(args, "api_dir", None) else _default_api_dir() if not api_dir or not (api_dir / "main.py").exists(): @@ -327,8 +334,10 @@ def _resolve_serve_config(args: argparse.Namespace) -> tuple[Path, Path, dict[st if not python or not python.exists(): raise ModlyCliError("Could not find Modly Python environment; pass --python") - env = os.environ.copy() - hf_token = getattr(args, "hf_token", None) or settings.get("hfToken") or os.environ.get("HF_TOKEN", "") + # Like Electron's cleanPythonEnv(): variables from the caller's own Python setup (a conda env, + # an activated venv) would point Modly's venv interpreter at foreign site-packages. + env = {k: v for k, v in os.environ.items() if k not in _FOREIGN_PYTHON_ENV} + hf_token =getattr(args, "hf_token", None) or settings.get("hfToken") or os.environ.get("HF_TOKEN", "") env.update({ "PYTHONUNBUFFERED": "1", "MODELS_DIR": getattr(args, "models_dir", None) or settings.get("modelsDir") or str(Path.home() / ".modly" / "models"), diff --git a/tools/modly-cli/test_agent.py b/tools/modly-cli/test_agent.py index 432492ba..990bfbb0 100644 --- a/tools/modly-cli/test_agent.py +++ b/tools/modly-cli/test_agent.py @@ -494,6 +494,39 @@ def test_default_python_uses_settings_dependencies_dir(self) -> None: found = agent._default_python(api_dir, {"dependenciesDir": str(Path(td) / "deps")}) self.assertEqual(found, deps_python) + def test_default_python_prefers_app_venv_over_api_dev_venv(self) -> None: + with tempfile.TemporaryDirectory() as td: + api_dir = Path(td) / "api" + dev_python = api_dir / ".venv" / "Scripts" / "python.exe" + dev_python.parent.mkdir(parents=True) + dev_python.write_text("# python") + deps_python = Path(td) / "deps" / "venv" / "Scripts" / "python.exe" + deps_python.parent.mkdir(parents=True) + deps_python.write_text("# python") + with patch.object(agent, "_windows_env_paths", return_value=[]): + found = agent._default_python(api_dir, {"dependenciesDir": str(Path(td) / "deps")}) + fallback = agent._default_python(api_dir, {}) + self.assertEqual(found, deps_python) + self.assertEqual(fallback, dev_python) + + def test_resolve_serve_config_drops_callers_python_env(self) -> None: + with tempfile.TemporaryDirectory() as td: + api_dir = Path(td) / "api" + api_dir.mkdir() + (api_dir / "main.py").write_text("# api") + python = Path(td) / "python.exe" + python.write_text("# python") + args = SimpleNamespace(api_dir=str(api_dir), python=str(python), host="127.0.0.1", port=8765, + models_dir=None, workspace_dir=None, extensions_dir=None, model=None, hf_token=None) + foreign = {"PYTHONHOME": "C:/conda", "PYTHONPATH": "C:/elsewhere", "VIRTUAL_ENV": "C:/venv", + "CONDA_PREFIX": "C:/conda", "PIP_USER": "1"} + with patch.dict(os.environ, foreign), \ + patch.object(agent, "_load_modly_settings", return_value={}): + _api_dir, _python, env, _cmd, _url = agent._resolve_serve_config(args) + for key in foreign: + self.assertNotIn(key, env) + self.assertIn("PATH", env) + def test_resolve_serve_config_matches_desktop_env(self) -> None: with tempfile.TemporaryDirectory() as td: api_dir = Path(td) / "resources" / "api"