Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docker/scripts/run_odoo_data_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion docker/scripts/run_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@

UNSAFE_MASTER_PASSWORDS = {"admin"}
LOCAL_INSTANCE_NAMES = {"", "local", "dev", "development"}
RUNTIME_SCRIPTS_PATH = "/volumes/scripts"


@dataclass(frozen=True)
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs/tooling/workspace-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions tests/test_odoo_data_workflows.py
Original file line number Diff line number Diff line change
@@ -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()
26 changes: 26 additions & 0 deletions tests/test_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading