diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index d8ed707..c4012ee 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -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. @@ -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. diff --git a/odoo_devkit/dependency_workspace.py b/odoo_devkit/dependency_workspace.py index 0ec9b4d..e66a74e 100644 --- a/odoo_devkit/dependency_workspace.py +++ b/odoo_devkit/dependency_workspace.py @@ -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]: diff --git a/tests/test_dependency_workspace.py b/tests/test_dependency_workspace.py index 17c5af1..96d50e2 100644 --- a/tests/test_dependency_workspace.py +++ b/tests/test_dependency_workspace.py @@ -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)