diff --git a/.github/github.json b/.github/github.json index 41dbeeb..1f9cfd3 100644 --- a/.github/github.json +++ b/.github/github.json @@ -1,6 +1,22 @@ { "defaultBranch": "main", "projectType": "odoo-workspace-devkit", + "commands": { + "dependencies": { + "inspect": { + "command": "uv run platform dependencies inspect --manifest ", + "mutates": false + }, + "check": { + "command": "uv run platform dependencies check --manifest ", + "mutates": false + }, + "normalize": { + "command": "uv run platform dependencies normalize --manifest [--output-dir ]", + "mutates": true + } + } + }, "docs": { "index": "docs/README.md", "overview": "README.md", diff --git a/README.md b/README.md index a86116e..3ed2234 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ uv run platform workspace clean --manifest /path/to/workspace.toml uv run platform workspace run --manifest /path/to/workspace.toml -- pwd uv run platform dependencies inspect --manifest /path/to/workspace.toml uv run platform dependencies check --manifest /path/to/workspace.toml +uv run platform dependencies normalize --manifest /path/to/workspace.toml uv run platform runtime select --manifest /path/to/workspace.toml uv run platform runtime build --manifest /path/to/workspace.toml --no-cache uv run platform runtime up --manifest /path/to/workspace.toml --build diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index fa77c8b..a1ede37 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -37,6 +37,9 @@ uv run platform workspace clean --manifest /path/to/workspace.toml uv run platform workspace run --manifest /path/to/workspace.toml -- pwd uv run platform dependencies inspect --manifest /path/to/workspace.toml uv run platform dependencies check --manifest /path/to/workspace.toml +uv run platform dependencies normalize --manifest /path/to/workspace.toml +uv run platform dependencies normalize --manifest /path/to/workspace.toml \ + --output-dir /path/to/normalization-output uv run platform runtime select --manifest /path/to/workspace.toml uv run platform runtime build --manifest /path/to/workspace.toml --no-cache uv run platform runtime up --manifest /path/to/workspace.toml --build @@ -140,6 +143,32 @@ Purpose - `inspect` prints structured JSON. `check` prints the same report and exits nonzero when `current` is false. +## `dependencies normalize` + +Purpose + +- Regenerate the canonical tenant root `uv.lock` inside the same staged + tenant/shared-addon workspace used by dependency inspection, then atomically + copy only the verified lock back to the tenant repo. +- Preserve strict tenant CI: normalization proceeds only when the workspace is + already publishable or stale lock state is its sole finding, and the written + lock must pass the existing offline, no-config publishability check. +- Run `uv lock --python --no-config` without broad upgrade + flags. Pinning the manifest's Python version keeps canonical lock generation + stable across hosts while the existing lock continues to constrain + unaffected packages. +- Produce the frozen export shape used by tenant CI with `uv export --frozen + --all-packages --no-emit-workspace --no-default-groups --no-config`. The + export is verified and hashed on every run; pass `--output-dir` to retain it + as `tenant-requirements.txt`. +- Emit sorted JSON with source input hashes, tenant/shared source commits and + dirty-state flags, uv version and arguments, final artifact hashes, strict + post-check results, and `changed = false` for a measured no-change run. + Output omits credentials, repository URLs, and machine-specific source paths. +- Restore the original tenant `uv.lock` if the strict post-check or retained + export write fails. Tenant manifests, addon metadata, shared-addon sources, + and CI workflows remain owned by their existing repositories. + ## `workspace scaffold-cockpit-root` Purpose diff --git a/odoo_devkit/cli.py b/odoo_devkit/cli.py index 0b2a901..205f57e 100644 --- a/odoo_devkit/cli.py +++ b/odoo_devkit/cli.py @@ -6,7 +6,7 @@ from dataclasses import replace from pathlib import Path -from .dependency_workspace import inspect_dependency_workspace +from .dependency_workspace import inspect_dependency_workspace, normalize_dependency_workspace from .manifest import WorkspaceManifest, load_workspace_manifest from .runtime import ( run_native_runtime_build, @@ -105,6 +105,20 @@ def build_parser() -> argparse.ArgumentParser: ) dependencies_check_parser.set_defaults(handler=_handle_dependencies_check) + dependencies_normalize_parser = _add_manifest_argument( + dependencies_subparsers.add_parser( + "normalize", + help="Regenerate the canonical tenant lock and frozen dependency export", + ) + ) + dependencies_normalize_parser.add_argument( + "--output-dir", + type=Path, + default=None, + help="Retain tenant-requirements.txt in this directory", + ) + dependencies_normalize_parser.set_defaults(handler=_handle_dependencies_normalize) + runtime_parser = subparsers.add_parser("runtime", help="Run local runtime workflows via the workspace manifest") runtime_subparsers = runtime_parser.add_subparsers(dest="runtime_command") @@ -367,6 +381,12 @@ def _handle_dependencies_check(arguments: argparse.Namespace) -> None: raise SystemExit(1) +def _handle_dependencies_normalize(arguments: argparse.Namespace) -> None: + manifest = _load_manifest(arguments.manifest) + result = normalize_dependency_workspace(manifest=manifest, output_directory=arguments.output_dir) + print(json.dumps(result.to_dict(), indent=2, sort_keys=True)) + + def _handle_runtime_select(arguments: argparse.Namespace) -> None: manifest = _load_runtime_manifest(arguments) exit_code = _run_runtime_handler(lambda: run_native_runtime_select(manifest=manifest)) diff --git a/odoo_devkit/dependency_workspace.py b/odoo_devkit/dependency_workspace.py index a0d9c45..6e0e30b 100644 --- a/odoo_devkit/dependency_workspace.py +++ b/odoo_devkit/dependency_workspace.py @@ -8,7 +8,7 @@ import tomllib from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, TypedDict from urllib.parse import urlsplit from .manifest import WorkspaceManifest @@ -51,12 +51,49 @@ "dev-dependencies", "override-dependencies", ) +_STALE_TENANT_LOCK_FINDING = "Tenant uv.lock is not current for the combined owned-addon workspace." class DependencyWorkspaceError(ValueError): pass +class DependencyNormalizationInput(TypedDict): + owner: str + path: str + sha256: str + + +class DependencyNormalizationRepository(TypedDict): + role: str + commit: str + dirty: bool + + +class DependencyNormalizationSource(TypedDict): + tenant: str + repositories: list[DependencyNormalizationRepository] + inputs: list[DependencyNormalizationInput] + + +class DependencyNormalizationTool(TypedDict): + command: str + uv_version: str + lock_arguments: list[str] + export_arguments: list[str] + + +class DependencyNormalizationArtifact(TypedDict): + path: str + sha256: str + + +class DependencyNormalizationProvenance(TypedDict): + source: DependencyNormalizationSource + tool: DependencyNormalizationTool + artifacts: list[DependencyNormalizationArtifact] + + @dataclass(frozen=True) class DependencyProjectInspection: owner: DependencyProjectOwner @@ -103,6 +140,21 @@ def to_dict(self) -> dict[str, object]: } +@dataclass(frozen=True) +class DependencyWorkspaceNormalization: + changed: bool + inspection: DependencyWorkspaceInspection + provenance: DependencyNormalizationProvenance + + def to_dict(self) -> dict[str, object]: + return { + "schema_version": 1, + "changed": self.changed, + "inspection": self.inspection.to_dict(), + "provenance": self.provenance, + } + + @dataclass(frozen=True) class _ProjectInput: owner: DependencyProjectOwner @@ -231,9 +283,12 @@ def inspect_dependency_workspace(*, manifest: WorkspaceManifest) -> DependencyWo except DependencyWorkspaceError as error: findings.append(str(error)) if not findings: - tenant_lock_current = _uv_lock_is_current(staged_root) + tenant_lock_current = _uv_lock_is_current( + staged_root, + python_version=manifest.workspace.python_version, + ) if not tenant_lock_current: - findings.append("Tenant uv.lock is not current for the combined owned-addon workspace.") + findings.append(_STALE_TENANT_LOCK_FINDING) else: tenant_lock_current = False @@ -265,6 +320,107 @@ def require_publishable_dependency_workspace(*, manifest: WorkspaceManifest) -> return inspection +def _require_normalizable_dependency_workspace(*, inspection: DependencyWorkspaceInspection) -> None: + if inspection.publishable: + return + if ( + inspection.tenant_root_pyproject_present + and inspection.tenant_lock_present + and inspection.findings == (_STALE_TENANT_LOCK_FINDING,) + ): + return + findings = "; ".join(inspection.findings) or "a tracked tenant pyproject.toml and uv.lock are required" + raise DependencyWorkspaceError(f"Dependency workspace cannot be normalized: {findings}") + + +def normalize_dependency_workspace( + *, + manifest: WorkspaceManifest, + output_directory: Path | None = None, +) -> DependencyWorkspaceNormalization: + tenant_repo_path = manifest.tenant_repo.resolve_path(manifest_directory=manifest.manifest_directory) + if tenant_repo_path is None or not tenant_repo_path.is_dir(): + raise DependencyWorkspaceError("Tenant repo path must exist before dependency normalization.") + tenant_repo_path = tenant_repo_path.resolve() + shared_addons_repo_path = _resolve_shared_addons_repo_path(manifest) + shared_addons_repo_path = shared_addons_repo_path.resolve() if shared_addons_repo_path is not None else None + project_inputs = _discover_project_inputs( + tenant_repo_path=tenant_repo_path, + shared_addons_repo_path=shared_addons_repo_path, + ) + initial_inspection = inspect_dependency_workspace(manifest=manifest) + _require_normalizable_dependency_workspace(inspection=initial_inspection) + + tenant_lock_path = tenant_repo_path / "uv.lock" + original_lock_bytes = tenant_lock_path.read_bytes() + source_inputs = _normalization_source_inputs( + manifest=manifest, + tenant_repo_path=tenant_repo_path, + project_inputs=project_inputs, + ) + + with tempfile.TemporaryDirectory(prefix="odoo-dependency-normalize-") as temporary_directory_name: + staged_root = Path(temporary_directory_name) + _stage_dependency_directory_layout( + tenant_repo_path=tenant_repo_path, + shared_addons_repo_path=shared_addons_repo_path, + staged_root=staged_root, + ) + _stage_dependency_metadata( + root_pyproject_path=tenant_repo_path / "pyproject.toml", + tenant_lock_path=tenant_lock_path, + project_inputs=project_inputs, + staged_root=staged_root, + ) + _run_uv_lock( + staged_root=staged_root, + python_version=manifest.workspace.python_version, + ) + require_staged_dependency_workspace_current( + staged_root=staged_root, + label="normalized dependency", + python_version=manifest.workspace.python_version, + ) + export_path = staged_root / "tenant-requirements.txt" + _write_frozen_dependency_export(staged_root=staged_root, export_path=export_path) + + normalized_lock_bytes = (staged_root / "uv.lock").read_bytes() + changed = normalized_lock_bytes != original_lock_bytes + provenance = _normalization_provenance( + manifest=manifest, + tenant_repo_path=tenant_repo_path, + shared_addons_repo_path=shared_addons_repo_path, + source_inputs=source_inputs, + normalized_lock_path=staged_root / "uv.lock", + export_path=export_path, + ) + + lock_replaced = False + try: + if changed: + _atomic_write_bytes(path=tenant_lock_path, content=normalized_lock_bytes) + lock_replaced = True + inspection = require_publishable_dependency_workspace(manifest=manifest) + if output_directory is not None: + retained_export_path = output_directory.expanduser().resolve() / "tenant-requirements.txt" + _atomic_write_bytes(path=retained_export_path, content=export_path.read_bytes()) + except BaseException as error: + if lock_replaced: + try: + _atomic_write_bytes(path=tenant_lock_path, content=original_lock_bytes) + except OSError as rollback_error: + raise DependencyWorkspaceError( + f"Dependency normalization failed and uv.lock rollback was incomplete: {rollback_error}" + ) from error + raise + + return DependencyWorkspaceNormalization( + changed=changed, + inspection=inspection, + provenance=provenance, + ) + + def stage_publishable_dependency_workspace( *, manifest: WorkspaceManifest, @@ -310,8 +466,13 @@ def stage_publishable_dependency_workspace( return inspection -def require_staged_dependency_workspace_current(*, staged_root: Path, label: str = "dependency") -> None: - if not _uv_lock_is_current(staged_root): +def require_staged_dependency_workspace_current( + *, + staged_root: Path, + label: str = "dependency", + python_version: str | None = None, +) -> None: + if not _uv_lock_is_current(staged_root, python_version=python_version): raise DependencyWorkspaceError(f"Staged {label} uv.lock changed or is not current for the exact artifact inputs.") @@ -929,26 +1090,184 @@ def _git_head_commit(repo_path: Path) -> str: return commit -def _uv_lock_is_current(staged_root: Path) -> bool: +def _run_uv_lock(*, staged_root: Path, python_version: str) -> None: + result = _run_uv(["uv", "lock", "--python", python_version, "--no-config"], cwd=staged_root) + if result.returncode != 0: + message = result.stderr.strip() or result.stdout.strip() or "uv lock failed" + raise DependencyWorkspaceError(f"Dependency workspace normalization failed: {message}") + + +def _write_frozen_dependency_export(*, staged_root: Path, export_path: Path) -> None: + result = _run_uv( + [ + "uv", + "export", + "--frozen", + "--all-packages", + "--no-emit-workspace", + "--no-default-groups", + "--no-config", + "--output-file", + export_path.name, + ], + cwd=staged_root, + ) + if result.returncode != 0 or not export_path.is_file(): + message = result.stderr.strip() or result.stdout.strip() or "uv export failed" + raise DependencyWorkspaceError(f"Frozen dependency export failed: {message}") + + +def _run_uv(command: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run(command, cwd=cwd, capture_output=True, text=True, env=_uv_command_env()) + except FileNotFoundError as error: + raise DependencyWorkspaceError("uv is required for dependency workspace normalization") from error + + +def _uv_command_env() -> dict[str, str]: environment = { key: value for key, value in os.environ.items() if not key.startswith(("PIP_", "UV_")) and key not in {"PYTHONPATH", "VIRTUAL_ENV"} } environment["UV_NO_PROGRESS"] = "1" + return environment + + +def _uv_lock_is_current(staged_root: Path, *, python_version: str | None = None) -> bool: + command = ["uv", "lock", "--check", "--offline"] + if python_version is not None: + command.extend(["--python", python_version]) + command.extend(["--no-config", "--project", str(staged_root)]) try: result = subprocess.run( - ["uv", "lock", "--check", "--offline", "--no-config", "--project", str(staged_root)], + command, cwd=staged_root, capture_output=True, text=True, - env=environment, + env=_uv_command_env(), ) except FileNotFoundError as error: raise DependencyWorkspaceError("uv is required for dependency workspace checks") from error return result.returncode == 0 +def _normalization_source_inputs( + *, + manifest: WorkspaceManifest, + tenant_repo_path: Path, + project_inputs: tuple[_ProjectInput, ...], +) -> list[DependencyNormalizationInput]: + inputs: list[tuple[str, str, Path]] = [ + ("manifest", manifest.manifest_path.name, manifest.manifest_path), + ("tenant", "pyproject.toml", tenant_repo_path / "pyproject.toml"), + ("tenant", "uv.lock", tenant_repo_path / "uv.lock"), + ] + inputs.extend( + (project.owner, project.staged_pyproject_path.as_posix(), project.source_pyproject_path) for project in project_inputs + ) + devkit_repo_path = _resolve_devkit_repo_path(manifest) + if devkit_repo_path is not None: + support_pyproject_path = devkit_repo_path.resolve() / "docker" / "runtime-python" / "pyproject.toml" + if support_pyproject_path.is_file(): + inputs.append(("devkit", "docker/runtime-python/pyproject.toml", support_pyproject_path)) + return [ + {"owner": owner, "path": path_label, "sha256": _sha256_file(path)} + for owner, path_label, path in sorted(inputs, key=lambda item: (item[0], item[1])) + ] + + +def _normalization_provenance( + *, + manifest: WorkspaceManifest, + tenant_repo_path: Path, + shared_addons_repo_path: Path | None, + source_inputs: list[DependencyNormalizationInput], + normalized_lock_path: Path, + export_path: Path, +) -> DependencyNormalizationProvenance: + repositories: list[DependencyNormalizationRepository] = [ + { + "role": "tenant", + "commit": _git_head_commit(tenant_repo_path), + "dirty": _git_worktree_dirty(tenant_repo_path), + } + ] + if shared_addons_repo_path is not None and any(item["owner"] == "shared_addons" for item in source_inputs): + repositories.append( + { + "role": "shared_addons", + "commit": _git_head_commit(shared_addons_repo_path), + "dirty": _git_worktree_dirty(shared_addons_repo_path), + } + ) + return { + "source": { + "tenant": manifest.tenant, + "repositories": repositories, + "inputs": source_inputs, + }, + "tool": { + "command": "platform dependencies normalize", + "uv_version": _uv_version(), + "lock_arguments": ["uv", "lock", "--python", manifest.workspace.python_version, "--no-config"], + "export_arguments": [ + "uv", + "export", + "--frozen", + "--all-packages", + "--no-emit-workspace", + "--no-default-groups", + "--no-config", + "--output-file", + "tenant-requirements.txt", + ], + }, + "artifacts": [ + {"path": "uv.lock", "sha256": _sha256_file(normalized_lock_path)}, + {"path": "tenant-requirements.txt", "sha256": _sha256_file(export_path)}, + ], + } + + +def _uv_version() -> str: + result = _run_uv(["uv", "--version"], cwd=Path.cwd()) + if result.returncode != 0: + message = result.stderr.strip() or result.stdout.strip() or "uv --version failed" + raise DependencyWorkspaceError(f"Unable to determine uv version: {message}") + return result.stdout.strip() + + +def _git_worktree_dirty(repo_path: Path) -> bool: + result = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=normal"], + cwd=repo_path, + capture_output=True, + text=True, + env=_git_command_env(), + ) + if result.returncode != 0: + raise DependencyWorkspaceError("Dependency normalization requires readable Git worktree status") + return bool(result.stdout) + + +def _atomic_write_bytes(*, path: Path, content: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 + file_descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary_path = Path(temporary_name) + try: + with os.fdopen(file_descriptor, "wb") as temporary_file: + temporary_file.write(content) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + temporary_path.chmod(mode) + os.replace(temporary_path, path) + except BaseException: + temporary_path.unlink(missing_ok=True) + raise + + def _git_command_env() -> dict[str, str]: environment = dict(os.environ) repository_context_keys = { diff --git a/tests/test_dependency_workspace.py b/tests/test_dependency_workspace.py index 3759444..224d8a7 100644 --- a/tests/test_dependency_workspace.py +++ b/tests/test_dependency_workspace.py @@ -14,6 +14,7 @@ from odoo_devkit.cli import build_parser from odoo_devkit.dependency_workspace import ( inspect_dependency_workspace, + normalize_dependency_workspace, require_publishable_dependency_workspace, require_staged_build_requirements_supplied, stage_publishable_dependency_workspace, @@ -318,7 +319,8 @@ def test_combined_tenant_and_shared_workspace_uses_uv_as_lock_authority(self) -> shared_repo_path=shared_repo_path, ) - def validate_staged_workspace(staged_root: Path) -> bool: + def validate_staged_workspace(staged_root: Path, *, python_version: str | None = None) -> bool: + self.assertEqual(python_version, "3.13") self.assertTrue((staged_root / "addons" / "tenant_addon" / "pyproject.toml").is_file()) self.assertTrue((staged_root / "addons" / "shared" / "shared_addon" / "pyproject.toml").is_file()) return True @@ -650,6 +652,272 @@ def test_owned_requirements_file_fails(self) -> None: self.assertFalse(inspection.current) self.assertIn("requirements must move into pyproject.toml", inspection.findings[0]) + def test_normalize_uses_staged_combined_workspace_and_reports_provenance(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + shared_repo_path = temp_root / "shared" + self._write_member_pyproject( + tenant_repo_path / "addons" / "tenant_addon", + project_name="tenant-addon", + dependencies=("httpx==0.28.1",), + ) + self._write_member_pyproject( + shared_repo_path / "shared_addon", + project_name="shared-addon", + dependencies=("requests==2.32.5",), + ) + self._write_root_workspace( + tenant_repo_path=tenant_repo_path, + members=("addons/tenant_addon", "addons/shared/shared_addon"), + ) + self._commit_repo(tenant_repo_path) + self._commit_repo(shared_repo_path) + manifest = self._write_manifest( + temp_root=temp_root, + tenant_repo_path=tenant_repo_path, + shared_repo_path=shared_repo_path, + ) + normalized_lock_bytes = b"version = 1\nrevision = 2\n" + output_directory = temp_root / "normalized" + + def lock_is_current(staged_root: Path, *, python_version: str | None = None) -> bool: + self.assertEqual(python_version, "3.13") + return (staged_root / "uv.lock").read_bytes() == normalized_lock_bytes + + def normalize_staged_lock(*, staged_root: Path, python_version: str) -> None: + self.assertEqual(python_version, "3.13") + self.assertTrue((staged_root / "addons" / "shared" / "shared_addon" / "pyproject.toml").is_file()) + (staged_root / "uv.lock").write_bytes(normalized_lock_bytes) + + def write_export(*, staged_root: Path, export_path: Path) -> None: + self.assertEqual(export_path, staged_root / "tenant-requirements.txt") + export_path.write_text("# frozen tenant dependencies\n", encoding="utf-8") + + with ( + mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current", side_effect=lock_is_current), + mock.patch("odoo_devkit.dependency_workspace._run_uv_lock", side_effect=normalize_staged_lock), + mock.patch("odoo_devkit.dependency_workspace._write_frozen_dependency_export", side_effect=write_export), + mock.patch("odoo_devkit.dependency_workspace._uv_version", return_value="uv 0.10.7"), + ): + result = normalize_dependency_workspace(manifest=manifest, output_directory=output_directory) + + self.assertTrue(result.changed) + self.assertTrue(result.inspection.publishable) + self.assertEqual((tenant_repo_path / "uv.lock").read_bytes(), normalized_lock_bytes) + self.assertEqual( + (output_directory / "tenant-requirements.txt").read_text(encoding="utf-8"), + "# frozen tenant dependencies\n", + ) + self.assertEqual(result.provenance["tool"]["uv_version"], "uv 0.10.7") + self.assertEqual( + [repository["role"] for repository in result.provenance["source"]["repositories"]], + ["tenant", "shared_addons"], + ) + self.assertTrue(result.provenance["source"]["repositories"][0]["dirty"]) + source_inputs = result.provenance["source"]["inputs"] + self.assertIn( + { + "owner": "shared_addons", + "path": "addons/shared/shared_addon/pyproject.toml", + "sha256": hashlib.sha256((shared_repo_path / "shared_addon" / "pyproject.toml").read_bytes()).hexdigest(), + }, + source_inputs, + ) + self.assertEqual(result.provenance["artifacts"][0]["path"], "uv.lock") + self.assertEqual(result.provenance["artifacts"][1]["path"], "tenant-requirements.txt") + + def test_normalize_real_uv_nested_workspace_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + member_pyproject_path = tenant_repo_path / "addons" / "tenant_addon" / "pyproject.toml" + self._write_member_pyproject(member_pyproject_path.parent) + self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=("addons/tenant_addon",)) + (tenant_repo_path / "uv.lock").unlink() + subprocess.run( + ["uv", "lock", "--no-config"], + cwd=tenant_repo_path, + check=True, + capture_output=True, + ) + self._commit_repo(tenant_repo_path) + member_pyproject_path.write_text( + member_pyproject_path.read_text(encoding="utf-8").replace('version = "0.0.0"', 'version = "0.0.1"'), + encoding="utf-8", + ) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + output_directory = temp_root / "normalized" + + first_result = normalize_dependency_workspace(manifest=manifest, output_directory=output_directory) + first_lock_bytes = (tenant_repo_path / "uv.lock").read_bytes() + first_export_bytes = (output_directory / "tenant-requirements.txt").read_bytes() + second_result = normalize_dependency_workspace(manifest=manifest, output_directory=output_directory) + + self.assertTrue(first_result.changed) + self.assertFalse(second_result.changed) + self.assertTrue(second_result.inspection.publishable) + self.assertIn('requires-python = ">=3.13"', first_lock_bytes.decode()) + self.assertEqual((tenant_repo_path / "uv.lock").read_bytes(), first_lock_bytes) + self.assertEqual((output_directory / "tenant-requirements.txt").read_bytes(), first_export_bytes) + + def test_normalize_is_idempotent_for_a_current_lock(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject(tenant_repo_path / "addons" / "tenant_addon") + self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=("addons/tenant_addon",)) + self._commit_repo(tenant_repo_path) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + original_lock_bytes = (tenant_repo_path / "uv.lock").read_bytes() + + def write_export(*, staged_root: Path, export_path: Path) -> None: + self.assertEqual(export_path.parent, staged_root) + export_path.write_text("# frozen tenant dependencies\n", encoding="utf-8") + + with ( + mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current", return_value=True), + mock.patch("odoo_devkit.dependency_workspace._run_uv_lock") as run_uv_lock, + mock.patch("odoo_devkit.dependency_workspace._write_frozen_dependency_export", side_effect=write_export), + mock.patch("odoo_devkit.dependency_workspace._uv_version", return_value="uv 0.10.7"), + ): + result = normalize_dependency_workspace(manifest=manifest) + + self.assertFalse(result.changed) + self.assertEqual((tenant_repo_path / "uv.lock").read_bytes(), original_lock_bytes) + run_uv_lock.assert_called_once() + + def test_normalize_rolls_back_when_the_strict_post_check_fails(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject( + tenant_repo_path / "addons" / "tenant_addon", + dependencies=("httpx==0.28.1",), + ) + self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=("addons/tenant_addon",)) + self._commit_repo(tenant_repo_path) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + original_lock_bytes = (tenant_repo_path / "uv.lock").read_bytes() + + def normalize_staged_lock(*, staged_root: Path, python_version: str) -> None: + self.assertEqual(python_version, "3.13") + (staged_root / "uv.lock").write_text("version = 1\nrevision = 2\n", encoding="utf-8") + + def write_export(*, staged_root: Path, export_path: Path) -> None: + self.assertEqual(export_path.parent, staged_root) + export_path.write_text("# frozen tenant dependencies\n", encoding="utf-8") + + with ( + mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current", side_effect=(False, True, False)), + mock.patch("odoo_devkit.dependency_workspace._run_uv_lock", side_effect=normalize_staged_lock), + mock.patch("odoo_devkit.dependency_workspace._write_frozen_dependency_export", side_effect=write_export), + mock.patch("odoo_devkit.dependency_workspace._uv_version", return_value="uv 0.10.7"), + ): + with self.assertRaisesRegex(ValueError, "Dependency workspace check failed"): + normalize_dependency_workspace(manifest=manifest, output_directory=temp_root / "normalized") + + self.assertEqual((tenant_repo_path / "uv.lock").read_bytes(), original_lock_bytes) + self.assertFalse((temp_root / "normalized" / "tenant-requirements.txt").exists()) + + def test_normalize_rejects_non_lock_findings_before_generation(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject( + tenant_repo_path / "addons" / "tenant_addon", + dependencies=("httpx==0.28.1",), + ) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + with mock.patch("odoo_devkit.dependency_workspace._run_uv_lock") as run_uv_lock: + with self.assertRaisesRegex(ValueError, "cannot be normalized"): + normalize_dependency_workspace(manifest=manifest) + + run_uv_lock.assert_not_called() + + def test_normalize_uv_commands_match_generation_and_tenant_ci_export(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + staged_root = Path(temporary_directory_name) + export_path = staged_root / "tenant-requirements.txt" + + def run_uv( + command: list[str], + *, + cwd: Path, + capture_output: bool, + text: bool, + env: dict[str, str], + ) -> object: + self.assertEqual(cwd, staged_root) + self.assertTrue(capture_output) + self.assertTrue(text) + self.assertEqual(env["KEEP_ME"], "yes") + if command[1] == "export": + export_path.write_text("# frozen tenant dependencies\n", encoding="utf-8") + return mock.Mock(returncode=0, stderr="", stdout="") + + with mock.patch.dict( + dependency_workspace.os.environ, + {"KEEP_ME": "yes", "PIP_INDEX_URL": "https://private.invalid", "UV_INDEX_URL": "https://private.invalid"}, + clear=True, + ): + with mock.patch("odoo_devkit.dependency_workspace.subprocess.run", side_effect=run_uv) as run_mock: + dependency_workspace._run_uv_lock(staged_root=staged_root, python_version="3.13") + dependency_workspace._write_frozen_dependency_export( + staged_root=staged_root, + export_path=export_path, + ) + + lock_call, export_call = run_mock.call_args_list + self.assertEqual(lock_call.args[0], ["uv", "lock", "--python", "3.13", "--no-config"]) + self.assertEqual( + export_call.args[0], + [ + "uv", + "export", + "--frozen", + "--all-packages", + "--no-emit-workspace", + "--no-default-groups", + "--no-config", + "--output-file", + "tenant-requirements.txt", + ], + ) + for call in (lock_call, export_call): + self.assertEqual(call.kwargs["cwd"], staged_root) + self.assertEqual(call.kwargs["env"]["KEEP_ME"], "yes") + self.assertNotIn("PIP_INDEX_URL", call.kwargs["env"]) + self.assertNotIn("UV_INDEX_URL", call.kwargs["env"]) + + def test_cli_normalize_emits_machine_readable_result(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + arguments = build_parser().parse_args( + [ + "dependencies", + "normalize", + "--manifest", + str(manifest.manifest_path), + "--output-dir", + str(temp_root / "normalized"), + ] + ) + result = mock.Mock() + result.to_dict.return_value = {"schema_version": 1, "changed": False} + + with ( + mock.patch("odoo_devkit.cli.normalize_dependency_workspace", return_value=result) as normalize, + contextlib.redirect_stdout(io.StringIO()) as output, + ): + arguments.handler(arguments) + + self.assertEqual(json.loads(output.getvalue()), result.to_dict.return_value) + normalize.assert_called_once_with(manifest=manifest, output_directory=temp_root / "normalized") + def test_cli_inspect_and_check_emit_structured_status(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory_name: temp_root = Path(temporary_directory_name)