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
4 changes: 4 additions & 0 deletions docs/tooling/workspace-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ Purpose
projects. An explicit empty `members = []` is the exact valid set when the
tenant and shared-addon trees contain no Python project metadata; pure-addon
tenants do not need to invent a fake workspace member.
- Evaluate member patterns against the real tenant/shared addon directory
shape, not only copied dependency metadata. A pattern may not match an addon
directory without `pyproject.toml`; use explicit member paths instead of a
broad glob over mixed Python-project and ordinary Odoo addon directories.
- Run `uv lock --check --offline --no-config` against that combined staged
layout with operator `UV_*`/`PIP_*` overrides removed. Devkit does not parse
uv's lock internals as a substitute for uv's own currentness decision.
Expand Down
136 changes: 118 additions & 18 deletions odoo_devkit/dependency_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,13 @@ def inspect_dependency_workspace(*, manifest: WorkspaceManifest) -> DependencyWo
)
with tempfile.TemporaryDirectory(prefix="odoo-dependency-workspace-") as temporary_directory_name:
staged_root = Path(temporary_directory_name)
_stage_dependency_metadata(
root_pyproject_path=root_pyproject_path,
tenant_lock_path=tenant_lock_path,
project_inputs=project_inputs,
staged_root=staged_root,
)
try:
_stage_dependency_metadata(
root_pyproject_path=root_pyproject_path,
tenant_lock_path=tenant_lock_path,
project_inputs=project_inputs,
staged_root=staged_root,
)
root_payload = _load_pyproject(staged_root / "pyproject.toml")
root_runtime_dependencies = _validate_root_pyproject(payload=root_payload)
requires_tenant_lock = requires_tenant_lock or bool(root_runtime_dependencies)
Expand All @@ -209,6 +209,11 @@ def inspect_dependency_workspace(*, manifest: WorkspaceManifest) -> DependencyWo
support_root=devkit_repo_path / "docker" / "runtime-python",
tenant_root=staged_root,
)
_stage_dependency_directory_layout(
tenant_repo_path=tenant_repo_path,
shared_addons_repo_path=shared_addons_repo_path,
staged_root=staged_root,
)
workspace_member_set = _workspace_members(root=staged_root, payload=root_payload)
workspace_members = tuple(sorted(path.as_posix() for path in workspace_member_set))
expected_members = {
Expand Down Expand Up @@ -276,6 +281,22 @@ def stage_publishable_dependency_workspace(
if destination_root.exists() and any(destination_root.iterdir()):
raise DependencyWorkspaceError("Dependency workspace staging destination must be empty")
destination_root.mkdir(parents=True, exist_ok=True)
source_commits = {
tenant_repo_path: tenant_commit or _git_head_commit(tenant_repo_path),
**(
{
shared_addons_repo_path: shared_addons_commit or _git_head_commit(shared_addons_repo_path),
}
if shared_addons_repo_path is not None
else {}
),
}
_stage_dependency_directory_layout(
tenant_repo_path=tenant_repo_path,
shared_addons_repo_path=shared_addons_repo_path,
staged_root=destination_root,
source_commits=source_commits,
)
_stage_dependency_metadata(
root_pyproject_path=tenant_repo_path / "pyproject.toml",
tenant_lock_path=tenant_repo_path / "uv.lock",
Expand All @@ -284,16 +305,7 @@ def stage_publishable_dependency_workspace(
shared_addons_repo_path=shared_addons_repo_path,
),
staged_root=destination_root,
source_commits={
tenant_repo_path: tenant_commit or _git_head_commit(tenant_repo_path),
**(
{
shared_addons_repo_path: shared_addons_commit or _git_head_commit(shared_addons_repo_path),
}
if shared_addons_repo_path is not None
else {}
),
},
source_commits=source_commits,
)
return inspection

Expand Down Expand Up @@ -662,8 +674,15 @@ def _workspace_members(*, root: Path, payload: dict[str, Any]) -> set[Path]:
for pattern in raw_members:
for path in root.glob(pattern):
relative_path = path.relative_to(root)
if path.is_dir() and (path / "pyproject.toml").is_file() and relative_path not in excluded:
members.add(relative_path)
if any(relative_path == excluded_path or relative_path.is_relative_to(excluded_path) for excluded_path in excluded):
continue
if not path.is_dir():
continue
if not (path / "pyproject.toml").is_file():
raise DependencyWorkspaceError(
f"pyproject.toml workspace member pattern matches a directory without pyproject.toml: {relative_path.as_posix()}"
)
members.add(relative_path)
return members


Expand Down Expand Up @@ -766,6 +785,87 @@ def _stage_dependency_metadata(
)


def _stage_dependency_directory_layout(
*,
tenant_repo_path: Path,
shared_addons_repo_path: Path | None,
staged_root: Path,
source_commits: dict[Path, str] | None = None,
) -> None:
_mirror_tracked_directory_layout(
repo_path=tenant_repo_path,
source_commit=(source_commits or {}).get(tenant_repo_path.resolve()),
source_path=tenant_repo_path / "addons",
destination_root=staged_root / "addons",
excluded_top_level=frozenset({"shared"}),
)
if shared_addons_repo_path is not None:
_mirror_tracked_directory_layout(
repo_path=shared_addons_repo_path,
source_commit=(source_commits or {}).get(shared_addons_repo_path.resolve()),
source_path=shared_addons_repo_path,
destination_root=staged_root / "addons" / "shared",
)


def _mirror_tracked_directory_layout(
*,
repo_path: Path,
source_commit: str | None,
source_path: Path,
destination_root: Path,
excluded_top_level: frozenset[str] = frozenset(),
) -> None:
normalized_repo_path = repo_path.resolve()
normalized_source_path = source_path.resolve()
try:
relative_source_path = normalized_source_path.relative_to(normalized_repo_path)
except ValueError as error:
raise DependencyWorkspaceError("Dependency directory layout escapes its source repository") from error
if source_commit is None:
command = ["git", "ls-files", "--stage", "-z"]
else:
command = ["git", "ls-tree", "-r", "-z", "--full-tree", source_commit]
if relative_source_path != Path("."):
command.extend(["--", relative_source_path.as_posix()])
result = subprocess.run(
command,
cwd=normalized_repo_path,
capture_output=True,
env=_git_command_env(),
)
if result.returncode != 0:
raise DependencyWorkspaceError("Dependency directory layout requires a readable Git tree")
directory_paths: set[Path] = set()
for raw_entry in (entry for entry in result.stdout.split(b"\0") if entry):
try:
raw_metadata, raw_path = raw_entry.split(b"\t", 1)
metadata_parts = os.fsdecode(raw_metadata).split()
mode = metadata_parts[0]
if source_commit is None and (len(metadata_parts) != 3 or metadata_parts[2] != "0"):
raise ValueError
if source_commit is not None and (len(metadata_parts) != 3 or metadata_parts[1] != "blob"):
raise ValueError
tracked_path = Path(os.fsdecode(raw_path))
relative_path = tracked_path if relative_source_path == Path(".") else tracked_path.relative_to(relative_source_path)
except ValueError as error:
raise DependencyWorkspaceError("Unable to parse tracked dependency directory layout") from error
if mode not in {"100644", "100755"}:
raise DependencyWorkspaceError(
f"Dependency directory layout accepts only tracked regular files: {tracked_path.as_posix()}"
)
if not relative_path.parts or relative_path.parts[0] in excluded_top_level:
continue
for parent_path in relative_path.parents:
if parent_path == Path("."):
continue
if any(part.startswith(".") for part in parent_path.parts):
continue
directory_paths.add(parent_path)
for directory_path in sorted(directory_paths, key=lambda path: (len(path.parts), path.as_posix())):
(destination_root / directory_path).mkdir(parents=True, exist_ok=True)


def _copy_regular_dependency_file(
*,
repo_path: Path,
Expand Down
99 changes: 96 additions & 3 deletions tests/test_dependency_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ def test_pure_addon_empty_workspace_is_publishable_with_tenant_lock(self) -> Non
with tempfile.TemporaryDirectory() as temporary_directory_name:
temp_root = Path(temporary_directory_name)
tenant_repo_path = temp_root / "tenant"
pure_addon_root = tenant_repo_path / "addons" / "pure_addon"
pure_addon_root.mkdir(parents=True)
(pure_addon_root / "__manifest__.py").write_text("{}\n", encoding="utf-8")
self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=())
(tenant_repo_path / "uv.lock").unlink()
subprocess.run(
Expand All @@ -123,7 +126,97 @@ def test_pure_addon_empty_workspace_is_publishable_with_tenant_lock(self) -> Non
self.assertEqual(inspection.projects, ())
self.assertTrue((destination_root / "pyproject.toml").is_file())
self.assertTrue((destination_root / "uv.lock").is_file())
self.assertFalse((destination_root / "addons").exists())
self.assertTrue((destination_root / "addons" / "pure_addon").is_dir())
self.assertFalse((destination_root / "addons" / "pure_addon" / "pyproject.toml").exists())

def test_shared_workspace_glob_rejects_non_project_addon_directory(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")
shared_addon_root = shared_repo_path / "authentik_sso"
shared_addon_root.mkdir(parents=True)
(shared_addon_root / "__manifest__.py").write_text("{}\n", encoding="utf-8")
self._write_root_workspace(
tenant_repo_path=tenant_repo_path,
members=("addons/tenant_addon", "addons/shared/*"),
)
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,
)

with mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current") as uv_lock_is_current:
inspection = inspect_dependency_workspace(manifest=manifest)

uv_lock_is_current.assert_not_called()
self.assertFalse(inspection.current)
self.assertIn(
"pyproject.toml workspace member pattern matches a directory without pyproject.toml: addons/shared/authentik_sso",
inspection.findings,
)

def test_explicit_members_ignore_non_project_shared_addon_directories(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")
shared_addon_root = shared_repo_path / "authentik_sso"
shared_addon_root.mkdir(parents=True)
(shared_addon_root / "__manifest__.py").write_text("{}\n", encoding="utf-8")
self._write_root_workspace(
tenant_repo_path=tenant_repo_path,
members=("addons/tenant_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,
)

with mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current", return_value=True):
inspection = inspect_dependency_workspace(manifest=manifest)

self.assertTrue(inspection.current)
self.assertTrue(inspection.publishable)
self.assertEqual(inspection.workspace_members, ("addons/tenant_addon",))

def test_untracked_shared_addon_directory_does_not_affect_preflight(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")
self._write_root_workspace(
tenant_repo_path=tenant_repo_path,
members=("addons/tenant_addon", "addons/shared/*"),
)
shared_repo_path.mkdir(parents=True)
(shared_repo_path / "README.md").write_text("shared\n", encoding="utf-8")
self._commit_repo(tenant_repo_path)
self._commit_repo(shared_repo_path)
untracked_addon_root = shared_repo_path / "authentik_sso"
untracked_addon_root.mkdir()
(untracked_addon_root / "__manifest__.py").write_text("{}\n", encoding="utf-8")
manifest = self._write_manifest(
temp_root=temp_root,
tenant_repo_path=tenant_repo_path,
shared_repo_path=shared_repo_path,
)

with mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current", return_value=True):
inspection = inspect_dependency_workspace(manifest=manifest)

self.assertTrue(inspection.current)
self.assertTrue(inspection.publishable)
self.assertEqual(inspection.workspace_members, ("addons/tenant_addon",))

def test_workspace_members_must_be_explicit(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory_name:
Expand Down Expand Up @@ -214,7 +307,7 @@ def test_combined_tenant_and_shared_workspace_uses_uv_as_lock_authority(self) ->
lock_bytes = b"version = 1\n"
self._write_root_workspace(
tenant_repo_path=tenant_repo_path,
members=("addons/*", "addons/shared/*"),
members=("addons/tenant_addon", "addons/shared/shared_addon"),
lock_bytes=lock_bytes,
)
self._commit_repo(tenant_repo_path)
Expand Down Expand Up @@ -351,7 +444,7 @@ def test_publish_stage_preserves_exact_tenant_and_shared_members(self) -> None:
self._write_member_pyproject(shared_repo_path / "shared_addon", project_name="shared_addon")
self._write_root_workspace(
tenant_repo_path=tenant_repo_path,
members=("addons/*", "addons/shared/*"),
members=("addons/tenant_addon", "addons/shared/shared_addon"),
)
self._commit_repo(tenant_repo_path)
self._commit_repo(shared_repo_path)
Expand Down