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
8 changes: 7 additions & 1 deletion docs/tooling/workspace-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ Purpose
- When a tenant root `pyproject.toml` and `uv.lock` exist, require them as a
complete pair, expand workspace members against the combined staged layout,
and require the expanded members to exactly match all tenant and shared-addon
projects.
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.
- 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 All @@ -127,6 +129,10 @@ Purpose
reported as `publishable = false` because Launchplane artifact schema v2
requires both support/runtime and tenant lock evidence; devkit never invents
a tenant lock that is absent from the tenant repository.
- A pure-addon tenant that publishes schema-v2 artifacts instead tracks a
minimal root `pyproject.toml` with `tool.uv.package = false`, explicit empty
workspace members, and its generated `uv.lock`. Those files provide exact
tenant evidence without claiming runtime dependencies that do not exist.
- `inspect` prints structured JSON. `check` prints the same report and exits
nonzero when `current` is false.

Expand Down
8 changes: 5 additions & 3 deletions odoo_devkit/dependency_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,10 +644,12 @@ def _workspace_members(*, root: Path, payload: dict[str, Any]) -> set[Path]:
workspace = _uv_config(payload).get("workspace")
if not isinstance(workspace, dict):
raise DependencyWorkspaceError("pyproject.toml must define tool.uv.workspace")
raw_members = workspace.get("members", [])
if "members" not in workspace:
raise DependencyWorkspaceError("pyproject.toml workspace must define members")
raw_members = workspace["members"]
raw_exclude = workspace.get("exclude", [])
if not isinstance(raw_members, list) or not raw_members or not all(isinstance(value, str) for value in raw_members):
raise DependencyWorkspaceError("pyproject.toml workspace members must be a non-empty string array")
if not isinstance(raw_members, list) or not all(isinstance(value, str) and value for value in raw_members):
raise DependencyWorkspaceError("pyproject.toml workspace members must be a string array of non-empty patterns")
if not isinstance(raw_exclude, list) or not all(isinstance(value, str) for value in raw_exclude):
raise DependencyWorkspaceError("pyproject.toml workspace exclude values must be a string array")
for pattern in [*raw_members, *raw_exclude]:
Expand Down
66 changes: 66 additions & 0 deletions tests/test_dependency_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,72 @@ def test_lockless_pure_addon_workspace_is_current_but_not_publishable(self) -> N
with self.assertRaisesRegex(ValueError, "Artifact schema v2 requires"):
require_publishable_dependency_workspace(manifest=manifest)

def test_pure_addon_empty_workspace_is_publishable_with_tenant_lock(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory_name:
temp_root = Path(temporary_directory_name)
tenant_repo_path = temp_root / "tenant"
self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=())
(tenant_repo_path / "uv.lock").unlink()
subprocess.run(
["uv", "lock", "--offline", "--no-config"],
cwd=tenant_repo_path,
check=True,
capture_output=True,
)
self._commit_repo(tenant_repo_path)
manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path)
destination_root = temp_root / "staged"

inspection = stage_publishable_dependency_workspace(
manifest=manifest,
destination_root=destination_root,
)

self.assertTrue(inspection.current)
self.assertTrue(inspection.publishable)
self.assertFalse(inspection.requires_tenant_lock)
self.assertEqual(inspection.workspace_members, ())
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())

def test_workspace_members_must_be_explicit(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory_name:
temp_root = Path(temporary_directory_name)
tenant_repo_path = temp_root / "tenant"
self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=())
pyproject_path = tenant_repo_path / "pyproject.toml"
pyproject_path.write_text(
pyproject_path.read_text(encoding="utf-8").replace("members = []\n", ""),
encoding="utf-8",
)
self._commit_repo(tenant_repo_path)
manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_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 must define members", inspection.findings)

def test_empty_workspace_members_cannot_hide_discovered_projects(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=())
self._commit_repo(tenant_repo_path)
manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_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("missing=['addons/tenant_addon']", inspection.findings[-1])

def test_runtime_dependencies_require_root_workspace_and_lock(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory_name:
temp_root = Path(temporary_directory_name)
Expand Down