From c08986d79e398eef3369c612593334f7cc0f5780 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sun, 10 May 2026 14:10:40 -0400 Subject: [PATCH] Fix Odoo shell runtime script imports --- docker/scripts/run_odoo_data_workflows.py | 10 ++++ docker/scripts/run_odoo_startup.py | 13 ++++- docs/tooling/workspace-cli.md | 3 ++ tests/test_odoo_data_workflows.py | 65 +++++++++++++++++++++++ tests/test_odoo_startup.py | 26 +++++++++ 5 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/test_odoo_data_workflows.py diff --git a/docker/scripts/run_odoo_data_workflows.py b/docker/scripts/run_odoo_data_workflows.py index 7e2579a..109c641 100644 --- a/docker/scripts/run_odoo_data_workflows.py +++ b/docker/scripts/run_odoo_data_workflows.py @@ -51,6 +51,15 @@ class ExitCode(IntEnum): RESTORE_FAILED = 40 +RUNTIME_SCRIPTS_PATH = "/volumes/scripts" + + +def _prepend_pythonpath(environment: dict[str, str], path: str) -> None: + python_path_parts = [part for part in environment.get("PYTHONPATH", "").split(os.pathsep) if part and part != path] + python_path_parts.insert(0, path) + environment["PYTHONPATH"] = os.pathsep.join(python_path_parts) + + def _format_bytes(num_bytes: int) -> str: units = ["B", "KiB", "MiB", "GiB", "TiB"] value = float(num_bytes) @@ -375,6 +384,7 @@ def __init__( self.env_file = env_file self.os_env = os.environ.copy() self.os_env["PGPASSWORD"] = self.local.db_password.get_secret_value() + _prepend_pythonpath(self.os_env, RUNTIME_SCRIPTS_PATH) if self.local.data_workflow_ssh_dir: self.os_env["DATA_WORKFLOW_SSH_DIR"] = str(self.local.data_workflow_ssh_dir) self._ssh_identity: Path | None = None diff --git a/docker/scripts/run_odoo_startup.py b/docker/scripts/run_odoo_startup.py index 546bb4d..3960d0d 100644 --- a/docker/scripts/run_odoo_startup.py +++ b/docker/scripts/run_odoo_startup.py @@ -37,6 +37,7 @@ UNSAFE_MASTER_PASSWORDS = {"admin"} LOCAL_INSTANCE_NAMES = {"", "local", "dev", "development"} +RUNTIME_SCRIPTS_PATH = "/volumes/scripts" @dataclass(frozen=True) @@ -283,9 +284,19 @@ def _build_odoo_shell_command(settings: StartupSettings) -> list[str]: ] +def _odoo_shell_environment() -> dict[str, str]: + environment = os.environ.copy() + python_path_parts = [ + part for part in environment.get("PYTHONPATH", "").split(os.pathsep) if part and part != RUNTIME_SCRIPTS_PATH + ] + python_path_parts.insert(0, RUNTIME_SCRIPTS_PATH) + environment["PYTHONPATH"] = os.pathsep.join(python_path_parts) + return environment + + def _run_odoo_shell(settings: StartupSettings, script_text: str, *, label: str) -> None: print(f"[platform-startup] running {label}", flush=True) - subprocess.run(_build_odoo_shell_command(settings), input=script_text.encode(), check=True) + subprocess.run(_build_odoo_shell_command(settings), input=script_text.encode(), env=_odoo_shell_environment(), check=True) def _addon_has_optional_dependency(pyproject_path: Path, extra_name: str) -> bool: diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index 6e1a3eb..1a74e51 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -218,6 +218,9 @@ Notes password must be configured before the startup wrapper marks the runtime usable. Local developer runtimes may omit the admin password, but previews, testing, and prod must not expose an Odoo database with default credentials. +- Devkit-managed startup and data workflow Odoo shell subprocesses prepend + `/volumes/scripts` to `PYTHONPATH` so shipped runtime helpers remain + importable from generated shell snippets. - Release/deploy ownership for remote environments stays in `launchplane`, even when the same tenant manifest is used to anchor local runtime context. diff --git a/tests/test_odoo_data_workflows.py b/tests/test_odoo_data_workflows.py new file mode 100644 index 0000000..55d9033 --- /dev/null +++ b/tests/test_odoo_data_workflows.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import importlib.util +import os +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import patch + + +def _load_data_workflows_module() -> types.ModuleType: + module_path = Path(__file__).resolve().parents[1] / "docker" / "scripts" / "run_odoo_data_workflows.py" + spec = importlib.util.spec_from_file_location("odoo_devkit_run_odoo_data_workflows_test_module", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load module from {module_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + + psycopg2_module = types.ModuleType("psycopg2") + psycopg2_module.sql = types.SimpleNamespace(SQL=lambda value: value, Identifier=lambda value: value) + psycopg2_extensions_module = types.ModuleType("psycopg2.extensions") + psycopg2_extensions_module.connection = object + + with patch.dict( + sys.modules, + { + "psycopg2": psycopg2_module, + "psycopg2.extensions": psycopg2_extensions_module, + }, + ): + spec.loader.exec_module(module) + return module + + +odoo_data_workflows = _load_data_workflows_module() + + +class OdooDataWorkflowShellEnvironmentTests(unittest.TestCase): + @staticmethod + def _local_settings() -> object: + return odoo_data_workflows.LocalServerSettings( + ODOO_DB_HOST="database", + ODOO_DB_PORT="5432", + ODOO_DB_USER="odoo", + ODOO_DB_PASSWORD="database-password", + ODOO_DB_NAME="cm", + ODOO_FILESTORE_PATH="/volumes/data/filestore/cm", + ) + + def test_data_workflow_shell_can_import_runtime_script_helpers(self) -> None: + with patch.dict(os.environ, {"PYTHONPATH": "/opt/custom:/volumes/scripts"}, clear=True): + runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(), upstream=None, env_file=None) + + self.assertEqual(runner.os_env["PYTHONPATH"], "/volumes/scripts:/opt/custom") + + def test_data_workflow_shell_prepends_runtime_scripts_to_pythonpath(self) -> None: + with patch.dict(os.environ, {"PYTHONPATH": "/opt/custom"}, clear=True): + runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(), upstream=None, env_file=None) + + self.assertEqual(runner.os_env["PYTHONPATH"], "/volumes/scripts:/opt/custom") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_odoo_startup.py b/tests/test_odoo_startup.py index 2b89ecd..24e0b27 100644 --- a/tests/test_odoo_startup.py +++ b/tests/test_odoo_startup.py @@ -133,6 +133,32 @@ def test_local_runtime_allows_missing_admin_password(self) -> None: odoo_startup._enforce_public_credential_preflight(settings) + def test_odoo_shell_subprocess_can_import_runtime_script_helpers(self) -> None: + settings = self._settings() + + with ( + patch.dict(os.environ, {"PYTHONPATH": "/opt/custom:/volumes/scripts"}, clear=True), + patch.object(odoo_startup.subprocess, "run") as run_mock, + ): + odoo_startup._run_odoo_shell(settings, "from odoo_website_bootstrap import apply_website_bootstrap", label="test") + + run_mock.assert_called_once() + environment = run_mock.call_args.kwargs["env"] + self.assertEqual(environment["PYTHONPATH"], "/volumes/scripts:/opt/custom") + + def test_odoo_shell_subprocess_prepends_runtime_scripts_to_pythonpath(self) -> None: + settings = self._settings() + + with ( + patch.dict(os.environ, {"PYTHONPATH": "/opt/custom"}, clear=True), + patch.object(odoo_startup.subprocess, "run") as run_mock, + ): + odoo_startup._run_odoo_shell(settings, "from odoo_website_bootstrap import apply_website_bootstrap", label="test") + + run_mock.assert_called_once() + environment = run_mock.call_args.kwargs["env"] + self.assertEqual(environment["PYTHONPATH"], "/volumes/scripts:/opt/custom") + if __name__ == "__main__": unittest.main()