diff --git a/MANIFEST.in b/MANIFEST.in index 3aee0bf04e..e0dcde6764 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -46,3 +46,5 @@ prune dimos/web/command-center-extension global-exclude test_*.py global-exclude conftest.py + +include dimos_build.py diff --git a/dimos_build.py b/dimos_build.py new file mode 100644 index 0000000000..8d2e30cfb1 --- /dev/null +++ b/dimos_build.py @@ -0,0 +1,36 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Build-time helpers that do not import the DimOS runtime.""" + +from pathlib import Path +import shutil +import subprocess + + +def ensure_web_dist(root: Path) -> None: + """Build the web assets for Git installs and source distributions.""" + for project, artifact in (("sdk", "sdk.js"), ("cockpit", "index.html")): + source = root / "web" / project + if (source / "dist" / artifact).is_file(): + continue + deno = shutil.which("deno") + if deno is None: + raise RuntimeError( + "Deno is required to build DimOS web assets from source. " + "Run dimup setup, then retry dependency installation." + ) + subprocess.run([deno, "task", "--cwd", str(source), "build"], check=True) + if not (source / "dist" / artifact).is_file(): + raise RuntimeError(f"Deno build did not produce {source / 'dist' / artifact}") diff --git a/installer/src/dimup/sdk.py b/installer/src/dimup/sdk.py new file mode 100644 index 0000000000..f76939711b --- /dev/null +++ b/installer/src/dimup/sdk.py @@ -0,0 +1,49 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Read the consumer dependency policy from the selected SDK revision.""" + +from copy import deepcopy +from typing import Any + +from dimup.process import SetupError + +SDK_URL = "https://github.com/dimensionalOS/dimos.git" + + +def consumer_policy(manifest: dict[str, Any]) -> tuple[list[str], dict[str, Any]]: + extras = sorted(set(manifest["project"]["optional-dependencies"]) - {"dds", "unitree-dds"}) + if not extras: + raise SetupError("Selected SDK has no desktop extras.") + upstream = manifest.get("tool", {}).get("uv", {}) + policy = { + key: deepcopy(upstream[key]) + for key in ( + "required-version", + "override-dependencies", + "constraint-dependencies", + "exclude-newer", + "exclude-newer-package", + "sources", + "index", + "extra-build-dependencies", + "extra-build-variables", + ) + if key in upstream + } + for name, source in policy.get("sources", {}).items(): + entries = source if isinstance(source, list) else [source] + if any("path" in entry or "workspace" in entry for entry in entries): + raise SetupError(f"Selected SDK source {name!r} requires a checkout-local dependency.") + return extras, policy diff --git a/installer/src/dimup/test_sdk.py b/installer/src/dimup/test_sdk.py new file mode 100644 index 0000000000..495b00229d --- /dev/null +++ b/installer/src/dimup/test_sdk.py @@ -0,0 +1,58 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from dimup.process import SetupError +from dimup.sdk import consumer_policy + + +def test_consumer_policy_keeps_sdk_extras_and_sources_without_dev_groups(): + manifest = { + "project": { + "optional-dependencies": { + "all": [], + "spot": [], + "learning": [], + "dds": [], + "unitree-dds": [], + } + }, + "dependency-groups": {"docs": ["sphinx"]}, + "tool": { + "uv": { + "sources": {"graspgenx": {"git": "https://example.com/grasp", "rev": "abc"}}, + "override-dependencies": ["numpy>=2"], + "default-groups": ["docs"], + } + }, + } + extras, policy = consumer_policy(manifest) + assert extras == ["all", "learning", "spot"] + assert policy == { + "sources": {"graspgenx": {"git": "https://example.com/grasp", "rev": "abc"}}, + "override-dependencies": ["numpy>=2"], + } + policy["sources"]["graspgenx"]["rev"] = "changed" + assert manifest["tool"]["uv"]["sources"]["graspgenx"]["rev"] == "abc" + + +def test_consumer_policy_rejects_nonportable_sources(): + with pytest.raises(SetupError, match="checkout-local"): + consumer_policy( + { + "project": {"optional-dependencies": {"all": []}}, + "tool": {"uv": {"sources": {"local": {"path": "../local"}}}}, + } + ) diff --git a/setup.py b/setup.py index 6eca175e1e..e2f0c441df 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,11 @@ from pybind11.setup_helpers import Pybind11Extension, build_ext from setuptools import find_packages, setup from setuptools.command.build_py import build_py as _build_py +from setuptools.command.sdist import sdist as _sdist + +# PEP 517 executes this file without adding the source root to sys.path. +sys.path.insert(0, str(Path(__file__).parent)) +from dimos_build import ensure_web_dist def python_is_macos_universal_binary(executable: str | None = None) -> bool: @@ -91,6 +96,8 @@ def find_package_modules(self, package, package_dir): ] def run(self): + if not getattr(self, "editable_mode", False): + ensure_web_dist(Path(__file__).parent) super().run() if not getattr(self, "editable_mode", False): self._copy_relay_dist() @@ -99,27 +106,6 @@ def _copy_relay_dist(self): src = Path(__file__).parent / "web" if not (src / "relay" / "main.ts").is_file(): raise RuntimeError(f"relay sources missing at {src}; refusing to build the wheel") - missing = [ - rel - for rel in ("cockpit/dist/index.html", "sdk/dist/sdk.js") - if not (src / rel).is_file() - ] - if missing: - # Wheels must carry the Cockpit and the SDK bundle: there is no - # fallback debug page, so a dist-less wheel's relay serves no UI - # (and /sdk.js only a build hint). Building a deliberate - # python-only wheel (e.g. where deno is unavailable) requires the - # explicit env-var opt-out, which covers both products. - if os.environ.get("DIMOS_ALLOW_MISSING_COCKPIT") != "1": - raise RuntimeError( - f"web dist missing ({', '.join(missing)}); run `deno task build` " - "in web/sdk and web/cockpit (or `dimos run --local-relay` from a " - "checkout builds them), or set DIMOS_ALLOW_MISSING_COCKPIT=1 to " - "build a UI-less wheel anyway" - ) - self.warn( - f"web dist missing ({', '.join(missing)}); this wheel's relay will have no UI" - ) dst = Path(self.build_lib) / RELAY_DIST_TARGET for name in RELAY_DIST_SOURCES: entry = src / name @@ -139,6 +125,12 @@ def _copy_relay_dist(self): raise RuntimeError(f"relay copy did not produce {dst / 'relay' / 'main.ts'}") +class sdist(_sdist): + def run(self): + ensure_web_dist(Path(__file__).parent) + super().run() + + extra_compile_args = [ "-O3", # Maximum optimization "-ffast-math", # Fast floating point @@ -166,5 +158,5 @@ def _copy_relay_dist(self): packages=find_packages(), package_dir={"": "."}, ext_modules=ext_modules, - cmdclass={"build_ext": build_ext, "build_py": build_py}, + cmdclass={"build_ext": build_ext, "build_py": build_py, "sdist": sdist}, ) diff --git a/tests/packaging/test_source_build.py b/tests/packaging/test_source_build.py new file mode 100644 index 0000000000..68e64bf024 --- /dev/null +++ b/tests/packaging/test_source_build.py @@ -0,0 +1,51 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +import subprocess + +import pytest + +from dimos_build import ensure_web_dist + + +def test_bundled_assets_need_no_deno(tmp_path, monkeypatch): + for project, artifact in (("sdk", "sdk.js"), ("cockpit", "index.html")): + dist = tmp_path / "web" / project / "dist" + dist.mkdir(parents=True) + (dist / artifact).write_text("built") + monkeypatch.setattr("shutil.which", lambda name: None) + ensure_web_dist(tmp_path) + + +def test_missing_deno_explains_machine_setup(tmp_path, monkeypatch): + monkeypatch.setattr("shutil.which", lambda name: None) + with pytest.raises(RuntimeError, match="dimup setup"): + ensure_web_dist(tmp_path) + + +def test_source_build_produces_both_bundles(tmp_path, monkeypatch): + commands = [] + + def build(command, *, check): + commands.append(command) + project = Path(command[3]) + dist = project / "dist" + dist.mkdir(parents=True) + (dist / ("sdk.js" if project.name == "sdk" else "index.html")).write_text("built") + + monkeypatch.setattr("shutil.which", lambda name: "/bin/deno") + monkeypatch.setattr(subprocess, "run", build) + ensure_web_dist(tmp_path) + assert [Path(command[3]).name for command in commands] == ["sdk", "cockpit"]