From c8348c300aa88d8c47d6675cfb86373a5645374c Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 9 Sep 2026 22:30:11 -0700 Subject: [PATCH 1/4] feat(installer): create pinned SDK applications with shared activation --- installer/pyproject.toml | 1 + installer/src/dimup/cli.py | 11 +- installer/src/dimup/project.py | 165 +++++++++++++++++++ installer/src/dimup/templates/README.md | 27 +++ installer/src/dimup/templates/activate.sh | 19 +++ installer/src/dimup/templates/demo.py | 59 +++++++ installer/src/dimup/templates/environment.py | 61 +++++++ installer/src/dimup/templates/envrc | 3 + installer/src/dimup/templates/test_demo.py | 21 +++ installer/src/dimup/test_project.py | 115 +++++++++++++ 10 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 installer/src/dimup/project.py create mode 100644 installer/src/dimup/templates/README.md create mode 100644 installer/src/dimup/templates/activate.sh create mode 100644 installer/src/dimup/templates/demo.py create mode 100644 installer/src/dimup/templates/environment.py create mode 100644 installer/src/dimup/templates/envrc create mode 100644 installer/src/dimup/templates/test_demo.py create mode 100644 installer/src/dimup/test_project.py diff --git a/installer/pyproject.toml b/installer/pyproject.toml index 819266766e..838aec4421 100644 --- a/installer/pyproject.toml +++ b/installer/pyproject.toml @@ -22,3 +22,4 @@ dimup = ["templates/*"] [tool.pytest.ini_options] testpaths = ["src"] addopts = "--import-mode=importlib" +norecursedirs = ["templates"] diff --git a/installer/src/dimup/cli.py b/installer/src/dimup/cli.py index 2445c09729..ff9fe84109 100644 --- a/installer/src/dimup/cli.py +++ b/installer/src/dimup/cli.py @@ -20,6 +20,7 @@ import sys from dimup.process import Runner, SetupError +from dimup.project import create from dimup.setup import prepare @@ -27,10 +28,16 @@ def main() -> None: parser = argparse.ArgumentParser(prog="dimup", description=__doc__) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("setup", help="Prepare this machine for DimOS development") - parser.parse_args() + init = commands.add_parser("init", help="Create a DimOS SDK application") + init.add_argument("directory", type=Path) + init.add_argument("--ref", default="main", help="SDK branch or commit (default: main)") + args = parser.parse_args() state = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) try: - prepare(Runner(state / "dimup/setup.log")) + if args.command == "setup": + prepare(Runner(state / "dimup/setup.log")) + else: + create(args.directory, args.ref) except (SetupError, OSError) as error: print(str(error), file=sys.stderr) raise SystemExit(1) from error diff --git a/installer/src/dimup/project.py b/installer/src/dimup/project.py new file mode 100644 index 0000000000..c7830c6294 --- /dev/null +++ b/installer/src/dimup/project.py @@ -0,0 +1,165 @@ +# 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. + +"""Generate an editable SDK application pinned to one Git revision.""" + +from importlib.resources import files +import os +from pathlib import Path +import re +import shlex +import tempfile +from typing import Any + +import tomli_w +import tomllib + +from dimup.process import Runner, SetupError, executable +from dimup.sdk import SDK_URL, consumer_policy +from dimup.setup import supported_platform + + +def package_name(directory: Path) -> str: + name = re.sub(r"[^a-z0-9]+", "-", directory.name.lower()).strip("-") + if not name or not name[0].isalpha() or name in {"dimos", "dimup"}: + raise SetupError( + "Choose a directory name starting with a letter, other than dimos or dimup." + ) + return name + + +def resolve_sdk(ref: str, runner: Runner) -> tuple[str, dict[str, Any]]: + with tempfile.TemporaryDirectory(prefix="dimup-sdk-") as temporary: + checkout = Path(temporary) + git = executable("git") + runner.run("Prepare SDK metadata", [git, "init", "--quiet", str(checkout)]) + runner.run( + "Resolve SDK revision", + [git, "fetch", "--depth=1", "--filter=blob:none", "--no-tags", "--", SDK_URL, ref], + cwd=checkout, + ) + sha = runner.run("Read SDK commit", [git, "rev-parse", "FETCH_HEAD"], cwd=checkout) + source = runner.run( + "Read SDK dependencies", [git, "show", f"{sha}:pyproject.toml"], cwd=checkout + ) + return sha, tomllib.loads(source) + + +def manifest(name: str, sha: str, sdk: dict[str, Any]) -> dict[str, Any]: + extras, policy = consumer_policy(sdk) + # uv sources apply to direct requirements, not arbitrary transitive packages. + sourced_dependencies = sorted(policy.get("sources", {})) + policy["environments"] = [ + "sys_platform == 'linux' and platform_machine == 'x86_64'", + "sys_platform == 'darwin' and platform_machine == 'arm64'", + ] + policy.setdefault("sources", {})["dimos"] = {"git": SDK_URL, "rev": sha} + return { + "project": { + "name": name, + "version": "0.1.0", + "requires-python": ">=3.12,<3.13", + "dependencies": [f"dimos[{','.join(extras)}]", *sourced_dependencies], + "entry-points": { + "dimos.blueprints": {"demo": f"{name.replace('-', '_')}.demo:blueprint"} + }, + }, + "build-system": {"requires": ["setuptools>=70"], "build-backend": "setuptools.build_meta"}, + "dependency-groups": {"dev": ["pytest>=8"]}, + "tool": { + "uv": policy, + "setuptools": {"packages": {"find": {"where": ["src"]}}}, + "pytest": {"ini_options": {"testpaths": ["tests"]}}, + }, + } + + +def write_project(root: Path, name: str, sha: str, sdk: dict[str, Any]) -> None: + module = name.replace("-", "_") + source = root / "src" / module + source.mkdir(parents=True) + (root / "tests").mkdir() + (root / ".dimos").mkdir(exist_ok=True) + (root / "pyproject.toml").write_text(tomli_w.dumps(manifest(name, sha, sdk))) + templates = files("dimup") / "templates" + for template, destination in ( + ("demo.py", source / "demo.py"), + ("activate.sh", root / ".dimos/activate.sh"), + ("environment.py", root / ".dimos/environment.py"), + ("envrc", root / ".envrc"), + ("test_demo.py", root / "tests/test_demo.py"), + ("README.md", root / "README.md"), + ): + text = ( + (templates / template) + .read_text() + .replace("APP_MODULE", module) + .replace("APP_NAME", name) + ) + destination.write_text(text) + (source / "__init__.py").touch() + (root / ".gitignore").write_text( + ".venv/\n.direnv/\n.dimos/*.log\n.env\n__pycache__/\n*.egg-info/\n.pytest_cache/\nbuild/\ndist/\n" + ) + + +def create(directory: Path, ref: str) -> None: + supported_platform() + root = directory.expanduser().absolute() + name = package_name(root) + if root.is_symlink() or (root.exists() and (not root.is_dir() or any(root.iterdir()))): + raise SetupError(f"Destination must be new or empty: {root}") + root.mkdir(parents=True, exist_ok=True) + runner = Runner(root / ".dimos/setup.log") + try: + for tool in ("uv", "git", "cargo", "nix", "deno"): + executable(tool) + sha, sdk = resolve_sdk(ref, runner) + print(f"SDK: {ref} -> {sha}") + write_project(root, name, sha, sdk) + env = dict(os.environ) + tool_paths = [ + str(Path(executable(tool)).parent) for tool in ("uv", "git", "cargo", "nix", "deno") + ] + env["PATH"] = os.pathsep.join([*tool_paths, env.get("PATH", "")]) + env["GIT_LFS_SKIP_SMUDGE"] = "1" + runner.run( + "Install application dependencies", + [executable("uv"), "sync", "--python", "3.12"], + cwd=root, + env=env, + ) + python = root / ".venv/bin/python" + runner.run( + "Verify application registration", + [ + str(python), + "-c", + "from importlib.metadata import distribution; import sys; " + "ep = next(e for e in distribution(sys.argv[1]).entry_points " + "if e.group == 'dimos.blueprints' and e.name == 'demo'); ep.load()", + name, + ], + cwd=root, + env=env, + ) + except (SetupError, OSError, ValueError, KeyError) as error: + raise SetupError( + f"{error}\nThe project directory has been kept: {root}\n" + "Fix the problem, then remove this directory or choose a new empty directory before retrying." + ) from error + print( + f"\nApplication created. Log: {runner.log}\n\ncd {shlex.quote(str(directory))}\n" + f"source .dimos/activate.sh\ndimos run {name}.demo" + ) diff --git a/installer/src/dimup/templates/README.md b/installer/src/dimup/templates/README.md new file mode 100644 index 0000000000..3f94a71a47 --- /dev/null +++ b/installer/src/dimup/templates/README.md @@ -0,0 +1,27 @@ +# APP_NAME + +```bash +source .dimos/activate.sh +dimos run APP_NAME.demo +pytest +``` + +Edit `src/APP_MODULE/demo.py` to change the image producer or listener, then rerun +the application. Stop it with Ctrl-C. Add dependencies with `uv add `. + +This application is installed editable. Register additional blueprints under +`[project.entry-points."dimos.blueprints"]`, then run `uv sync` to refresh metadata. + +Commit the source, manifest, lockfile, `.envrc`, and `.dimos` activation files. +On a new machine, run the DimOS bootstrap first. After cloning: + +```bash +uv sync --locked +source .dimos/activate.sh +dimos run APP_NAME.demo +pytest +``` + +For automatic activation, install direnv, configure its shell hook, review +`.envrc`, and run `direnv allow`. Leaving the directory restores the previous +environment. Manual activation can be undone with `deactivate`. diff --git a/installer/src/dimup/templates/activate.sh b/installer/src/dimup/templates/activate.sh new file mode 100644 index 0000000000..49a6791191 --- /dev/null +++ b/installer/src/dimup/templates/activate.sh @@ -0,0 +1,19 @@ +# Source in Bash or Zsh; activation never installs anything. +if [ -n "${ZSH_VERSION:-}" ]; then + _dimos_source=${(%):-%x} +else + _dimos_source=${BASH_SOURCE[0]} +fi +_dimos_root=$(cd "$(dirname "$_dimos_source")/.." && pwd -P) || return +if [ ! -x "$_dimos_root/.venv/bin/python" ]; then + echo 'Application environment is missing. Run uv sync --locked first.' >&2 + unset _dimos_source _dimos_root + return 1 +fi +if typeset -f deactivate >/dev/null 2>&1; then deactivate; fi +_dimos_exports=$("$_dimos_root/.venv/bin/python" "$_dimos_root/.dimos/environment.py" "$_dimos_root") +_dimos_status=$? +if [ "$_dimos_status" -eq 0 ]; then eval "$_dimos_exports"; fi +unset _dimos_source _dimos_root _dimos_exports +if [ "$_dimos_status" -ne 0 ]; then unset _dimos_status; return 1; fi +unset _dimos_status diff --git a/installer/src/dimup/templates/demo.py b/installer/src/dimup/templates/demo.py new file mode 100644 index 0000000000..74d5b13e0d --- /dev/null +++ b/installer/src/dimup/templates/demo.py @@ -0,0 +1,59 @@ +# 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. + +"""A synthetic image producer connected to a listener. No hardware required.""" + +import numpy as np +import reactivex as rx +from reactivex.disposable import Disposable + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.core import rpc +from dimos.core.module import Module +from dimos.core.stream import In, Out +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat + + +def make_image() -> Image: + return Image(data=np.zeros((120, 160, 3), dtype=np.uint8), format=ImageFormat.RGB) + + +def describe(image: Image) -> str: + height, width = image.data.shape[:2] + return f"image {width}x{height}" + + +class Producer(Module): + color_image: Out[Image] + + @rpc + def start(self) -> None: + super().start() + self.register_disposable( + rx.interval(1.0).subscribe(lambda _: self.color_image.publish(make_image())) + ) + + +class Listener(Module): + color_image: In[Image] + + @rpc + def start(self) -> None: + super().start() + self.register_disposable( + Disposable(self.color_image.subscribe(lambda image: print(describe(image), flush=True))) + ) + + +blueprint = autoconnect(Producer.blueprint(), Listener.blueprint()) diff --git a/installer/src/dimup/templates/environment.py b/installer/src/dimup/templates/environment.py new file mode 100644 index 0000000000..dc376c44df --- /dev/null +++ b/installer/src/dimup/templates/environment.py @@ -0,0 +1,61 @@ +# 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. + +"""Emit a shell environment delta and its inverse for manual deactivation.""" + +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys + + +def changes(before: dict[str, str], after: dict[str, str]) -> str: + commands = [] + for key in sorted(before.keys() | after.keys()): + if key in {"_", "SHLVL", "PWD", "OLDPWD"} or before.get(key) == after.get(key): + continue + if not key.isascii() or not key.replace("_", "a").isalnum() or key[0].isdigit(): + continue + commands.append( + f"export {key}={shlex.quote(after[key])}" if key in after else f"unset {key}" + ) + return "\n".join(commands) + + +def main() -> None: + root = Path(sys.argv[1]).resolve() + before = dict(os.environ) + script = """set -e +source "$1/.venv/bin/activate" +export PATH="$VIRTUAL_ENV/bin:$HOME/.cargo/bin:$HOME/.local/bin:$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/opt/homebrew/bin:$PATH" +export NIX_CONFIG="${NIX_CONFIG:-} +extra-experimental-features = nix-command flakes" +if [ "$(uname -s)" = Darwin ]; then + export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/opt/jpeg-turbo/lib:/opt/homebrew/lib${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" + export PKG_CONFIG_PATH="/opt/homebrew/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" +fi +exec "$1/.venv/bin/python" -c 'import json, os; print(json.dumps(dict(os.environ)))' +""" + output = subprocess.check_output( + ["bash", "--noprofile", "--norc", "-c", script, "activate", str(root)], text=True + ) + after = json.loads(output) + print(changes(before, after)) + print("deactivate() {\n" + changes(after, before) + "\nunset -f deactivate\n}") + + +if __name__ == "__main__": + main() diff --git a/installer/src/dimup/templates/envrc b/installer/src/dimup/templates/envrc new file mode 100644 index 0000000000..4064533c29 --- /dev/null +++ b/installer/src/dimup/templates/envrc @@ -0,0 +1,3 @@ +watch_file pyproject.toml +watch_file uv.lock +source_env .dimos/activate.sh diff --git a/installer/src/dimup/templates/test_demo.py b/installer/src/dimup/templates/test_demo.py new file mode 100644 index 0000000000..11c14fc806 --- /dev/null +++ b/installer/src/dimup/templates/test_demo.py @@ -0,0 +1,21 @@ +# 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 APP_MODULE.demo import describe, make_image + + +def test_image_dimensions(): + image = make_image() + assert image.data.shape == (120, 160, 3) + assert describe(image) == "image 160x120" diff --git a/installer/src/dimup/test_project.py b/installer/src/dimup/test_project.py new file mode 100644 index 0000000000..19f8f7e057 --- /dev/null +++ b/installer/src/dimup/test_project.py @@ -0,0 +1,115 @@ +# 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 os +import shutil +import subprocess +import sys +import venv + +import pytest +import tomllib + +from dimup.process import SetupError +from dimup.project import create, manifest, package_name, write_project + + +def test_generated_application_pins_sdk_and_registers_blueprint(tmp_path): + write_project( + tmp_path, + "my-robot", + "a" * 40, + {"project": {"optional-dependencies": {"all": [], "spot": [], "dds": []}}}, + ) + config = tomllib.loads((tmp_path / "pyproject.toml").read_text()) + assert config["project"]["entry-points"]["dimos.blueprints"] == { + "demo": "my_robot.demo:blueprint" + } + assert config["tool"]["uv"]["sources"]["dimos"]["rev"] == "a" * 40 + assert config["project"]["dependencies"] == ["dimos[all,spot]"] + assert "APP_MODULE" not in (tmp_path / "tests/test_demo.py").read_text() + + +@pytest.mark.parametrize("name", ["123", "dimos", "dimup"]) +def test_rejects_invalid_package_name(name): + with pytest.raises(SetupError): + package_name(Path(name)) + + +def test_nonempty_destination_is_untouched(tmp_path, monkeypatch): + monkeypatch.setattr("dimup.project.supported_platform", lambda: "ubuntu") + existing = tmp_path / "notes" + existing.write_text("my work") + with pytest.raises(SetupError, match="new or empty"): + create(tmp_path, "main") + assert list(tmp_path.iterdir()) == [existing] + assert existing.read_text() == "my work" + + +def test_init_requires_directory(): + result = subprocess.run( + [sys.executable, "-m", "dimup.cli", "init"], capture_output=True, check=False + ) + assert result.returncode == 2 + + +def test_source_backed_dependencies_are_direct_requirements(): + sdk = { + "project": {"optional-dependencies": {"all": [], "graspgenx": []}}, + "tool": { + "uv": {"sources": {"graspgenx": {"git": "https://example.com/grasp", "rev": "abc"}}} + }, + } + result = manifest("my-robot", "a" * 40, sdk) + assert result["project"]["dependencies"] == ["dimos[all,graspgenx]", "graspgenx"] + + +@pytest.mark.parametrize("shell", ["bash", "zsh"]) +def test_activation_restores_environment_and_handles_spaces(tmp_path, shell): + root = tmp_path / "my robot" + root.mkdir() + write_project(root, "my-robot", "a" * 40, {"project": {"optional-dependencies": {"all": []}}}) + venv.EnvBuilder(with_pip=False).create(root / ".venv") + script = """set -e +before=$PATH +unset VIRTUAL_ENV NIX_CONFIG +source "$1/.dimos/activate.sh" +test "$VIRTUAL_ENV" = "$1/.venv" +test "$(command -v python)" = "$1/.venv/bin/python" +test -n "$NIX_CONFIG" +deactivate +test "$PATH" = "$before" +test -z "${VIRTUAL_ENV+x}" +test -z "${NIX_CONFIG+x}" +""" + result = subprocess.run( + [shell, "-f", "-c", script, "test", str(root)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_direnv_selects_application_python(tmp_path): + if shutil.which("direnv") is None: + pytest.skip("direnv integration is exercised by CI with direnv installed") + write_project(tmp_path, "my-robot", "a" * 40, {"project": {"optional-dependencies": {"all": []}}}) + venv.EnvBuilder(with_pip=False).create(tmp_path / ".venv") + env = {**os.environ, "XDG_CONFIG_HOME": str(tmp_path / "config")} + subprocess.run(["direnv", "allow", str(tmp_path)], env=env, check=True, capture_output=True) + result = subprocess.run(["direnv", "exec", str(tmp_path), "python", "-c", "import sys; print(sys.prefix)"], + env=env, check=True, capture_output=True, text=True) + assert result.stdout.strip() == str(tmp_path / ".venv") From 8973edd4be458737fc488054341fcf7b8684d023 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 9 Sep 2026 22:58:21 -0700 Subject: [PATCH 2/4] fix(installer): allow application creation on manually prepared platforms --- installer/src/dimup/project.py | 2 -- installer/src/dimup/test_project.py | 25 +++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/installer/src/dimup/project.py b/installer/src/dimup/project.py index c7830c6294..6fcfba1cc2 100644 --- a/installer/src/dimup/project.py +++ b/installer/src/dimup/project.py @@ -27,7 +27,6 @@ from dimup.process import Runner, SetupError, executable from dimup.sdk import SDK_URL, consumer_policy -from dimup.setup import supported_platform def package_name(directory: Path) -> str: @@ -115,7 +114,6 @@ def write_project(root: Path, name: str, sha: str, sdk: dict[str, Any]) -> None: def create(directory: Path, ref: str) -> None: - supported_platform() root = directory.expanduser().absolute() name = package_name(root) if root.is_symlink() or (root.exists() and (not root.is_dir() or any(root.iterdir()))): diff --git a/installer/src/dimup/test_project.py b/installer/src/dimup/test_project.py index 19f8f7e057..2fc15b827c 100644 --- a/installer/src/dimup/test_project.py +++ b/installer/src/dimup/test_project.py @@ -48,8 +48,7 @@ def test_rejects_invalid_package_name(name): package_name(Path(name)) -def test_nonempty_destination_is_untouched(tmp_path, monkeypatch): - monkeypatch.setattr("dimup.project.supported_platform", lambda: "ubuntu") +def test_nonempty_destination_is_untouched(tmp_path): existing = tmp_path / "notes" existing.write_text("my work") with pytest.raises(SetupError, match="new or empty"): @@ -58,6 +57,28 @@ def test_nonempty_destination_is_untouched(tmp_path, monkeypatch): assert existing.read_text() == "my work" +def test_init_on_arch_installs_and_verifies_application(tmp_path, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr("platform.machine", lambda: "x86_64") + monkeypatch.setattr("platform.freedesktop_os_release", lambda: {"ID": "arch"}) + monkeypatch.setattr("dimup.project.executable", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr( + "dimup.project.resolve_sdk", + lambda ref, runner: ("a" * 40, {"project": {"optional-dependencies": {"all": []}}}), + ) + commands = [] + + def run(self, stage, command, **kwargs): + commands.append(command) + return "" + + monkeypatch.setattr("dimup.project.Runner.run", run) + create(tmp_path / "my-robot", "main") + assert (tmp_path / "my-robot/pyproject.toml").is_file() + assert commands[0] == ["/usr/bin/uv", "sync", "--python", "3.12"] + assert commands[1][0] == str(tmp_path / "my-robot/.venv/bin/python") + + def test_init_requires_directory(): result = subprocess.run( [sys.executable, "-m", "dimup.cli", "init"], capture_output=True, check=False From ef1371d2f517035dd7c19d8a0335d0b3e7dc98e9 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 10 Sep 2026 10:27:55 -0700 Subject: [PATCH 3/4] fix(installer): use managed Python for reproducible SDK environments --- installer/src/dimup/project.py | 3 +++ installer/src/dimup/test_project.py | 1 + 2 files changed, 4 insertions(+) diff --git a/installer/src/dimup/project.py b/installer/src/dimup/project.py index 6fcfba1cc2..e7b7803f55 100644 --- a/installer/src/dimup/project.py +++ b/installer/src/dimup/project.py @@ -57,6 +57,9 @@ def resolve_sdk(ref: str, runner: Runner) -> tuple[str, dict[str, Any]]: def manifest(name: str, sha: str, sdk: dict[str, Any]) -> dict[str, Any]: extras, policy = consumer_policy(sdk) + # System Python builds (notably python.org macOS builds) may lack SQLite + # extension loading. Keep the interpreter policy when applications are cloned. + policy["python-preference"] = "only-managed" # uv sources apply to direct requirements, not arbitrary transitive packages. sourced_dependencies = sorted(policy.get("sources", {})) policy["environments"] = [ diff --git a/installer/src/dimup/test_project.py b/installer/src/dimup/test_project.py index 2fc15b827c..f072a818e9 100644 --- a/installer/src/dimup/test_project.py +++ b/installer/src/dimup/test_project.py @@ -39,6 +39,7 @@ def test_generated_application_pins_sdk_and_registers_blueprint(tmp_path): } assert config["tool"]["uv"]["sources"]["dimos"]["rev"] == "a" * 40 assert config["project"]["dependencies"] == ["dimos[all,spot]"] + assert config["tool"]["uv"]["python-preference"] == "only-managed" assert "APP_MODULE" not in (tmp_path / "tests/test_demo.py").read_text() From 84ad423638f3130e8538a1334556811cb4513960 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 10 Sep 2026 11:21:11 -0700 Subject: [PATCH 4/4] feat(installer): present project creation as clear CLI stages --- installer/src/dimup/project.py | 58 ++++++++++++---- installer/src/dimup/test_project.py | 100 ++++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 20 deletions(-) diff --git a/installer/src/dimup/project.py b/installer/src/dimup/project.py index e7b7803f55..34dfd1f159 100644 --- a/installer/src/dimup/project.py +++ b/installer/src/dimup/project.py @@ -22,6 +22,8 @@ import tempfile from typing import Any +from rich.table import Table +from rich.text import Text import tomli_w import tomllib @@ -42,15 +44,21 @@ def resolve_sdk(ref: str, runner: Runner) -> tuple[str, dict[str, Any]]: with tempfile.TemporaryDirectory(prefix="dimup-sdk-") as temporary: checkout = Path(temporary) git = executable("git") - runner.run("Prepare SDK metadata", [git, "init", "--quiet", str(checkout)]) + runner.run("Prepare SDK metadata", [git, "init", "--quiet", str(checkout)], capture=True) runner.run( "Resolve SDK revision", [git, "fetch", "--depth=1", "--filter=blob:none", "--no-tags", "--", SDK_URL, ref], cwd=checkout, + capture=True, + ) + sha = runner.run( + "Read SDK commit", [git, "rev-parse", "FETCH_HEAD"], cwd=checkout, capture=True ) - sha = runner.run("Read SDK commit", [git, "rev-parse", "FETCH_HEAD"], cwd=checkout) source = runner.run( - "Read SDK dependencies", [git, "show", f"{sha}:pyproject.toml"], cwd=checkout + "Read SDK dependencies", + [git, "show", f"{sha}:pyproject.toml"], + cwd=checkout, + capture=True, ) return sha, tomllib.loads(source) @@ -123,12 +131,22 @@ def create(directory: Path, ref: str) -> None: raise SetupError(f"Destination must be new or empty: {root}") root.mkdir(parents=True, exist_ok=True) runner = Runner(root / ".dimos/setup.log") + runner.console.print("\n dimup · Create application\n", style="bold cyan") + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim") + details.add_column() + details.add_row(" Project", Text(str(root))) + details.add_row(" SDK", Text(ref)) + runner.console.print(details) try: for tool in ("uv", "git", "cargo", "nix", "deno"): executable(tool) - sha, sdk = resolve_sdk(ref, runner) - print(f"SDK: {ref} -> {sha}") - write_project(root, name, sha, sdk) + with runner.stage("Resolve SDK"): + sha, sdk = resolve_sdk(ref, runner) + if ref != sha: + runner.console.print(Text(f" {ref} → {sha[:12]}", style="dim")) + with runner.stage("Create project files"): + write_project(root, name, sha, sdk) env = dict(os.environ) tool_paths = [ str(Path(executable(tool)).parent) for tool in ("uv", "git", "cargo", "nix", "deno") @@ -136,31 +154,43 @@ def create(directory: Path, ref: str) -> None: env["PATH"] = os.pathsep.join([*tool_paths, env.get("PATH", "")]) env["GIT_LFS_SKIP_SMUDGE"] = "1" runner.run( - "Install application dependencies", + "Install dependencies · uv sync", [executable("uv"), "sync", "--python", "3.12"], cwd=root, env=env, ) python = root / ".venv/bin/python" runner.run( - "Verify application registration", + "Verify application", [ str(python), "-c", - "from importlib.metadata import distribution; import sys; " - "ep = next(e for e in distribution(sys.argv[1]).entry_points " - "if e.group == 'dimos.blueprints' and e.name == 'demo'); ep.load()", + ( + "from importlib.metadata import distribution; import sys; " + "ep = next(e for e in distribution(sys.argv[1]).entry_points " + "if e.group == 'dimos.blueprints' and e.name == 'demo'); ep.load()" + ), name, ], cwd=root, env=env, ) + except KeyboardInterrupt: + runner.console.print(Text(f"Project kept: {root}\nLog: {runner.log}", style="dim")) + raise except (SetupError, OSError, ValueError, KeyError) as error: raise SetupError( f"{error}\nThe project directory has been kept: {root}\n" "Fix the problem, then remove this directory or choose a new empty directory before retrying." ) from error - print( - f"\nApplication created. Log: {runner.log}\n\ncd {shlex.quote(str(directory))}\n" - f"source .dimos/activate.sh\ndimos run {name}.demo" + runner.console.print(Text(f"\n Ready · {name}\n", style="bold green")) + runner.console.print( + Text( + f" cd {shlex.quote(str(directory.expanduser()))}\n" + f" source .dimos/activate.sh\n" + f" dimos run {name}.demo\n" + ), + soft_wrap=True, ) + runner.console.print(" Optional: run direnv allow to activate automatically", style="dim") + runner.console.print(Text(f" Log: {runner.log}", style="dim"), soft_wrap=True) diff --git a/installer/src/dimup/test_project.py b/installer/src/dimup/test_project.py index f072a818e9..175d0cf978 100644 --- a/installer/src/dimup/test_project.py +++ b/installer/src/dimup/test_project.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from pathlib import Path import os +from pathlib import Path import shutil import subprocess import sys @@ -22,8 +22,8 @@ import pytest import tomllib -from dimup.process import SetupError -from dimup.project import create, manifest, package_name, write_project +from dimup.process import Runner, SetupError +from dimup.project import create, manifest, package_name, resolve_sdk, write_project def test_generated_application_pins_sdk_and_registers_blueprint(tmp_path): @@ -128,10 +128,98 @@ def test_activation_restores_environment_and_handles_spaces(tmp_path, shell): def test_direnv_selects_application_python(tmp_path): if shutil.which("direnv") is None: pytest.skip("direnv integration is exercised by CI with direnv installed") - write_project(tmp_path, "my-robot", "a" * 40, {"project": {"optional-dependencies": {"all": []}}}) + write_project( + tmp_path, "my-robot", "a" * 40, {"project": {"optional-dependencies": {"all": []}}} + ) venv.EnvBuilder(with_pip=False).create(tmp_path / ".venv") env = {**os.environ, "XDG_CONFIG_HOME": str(tmp_path / "config")} subprocess.run(["direnv", "allow", str(tmp_path)], env=env, check=True, capture_output=True) - result = subprocess.run(["direnv", "exec", str(tmp_path), "python", "-c", "import sys; print(sys.prefix)"], - env=env, check=True, capture_output=True, text=True) + result = subprocess.run( + ["direnv", "exec", str(tmp_path), "python", "-c", "import sys; print(sys.prefix)"], + env=env, + check=True, + capture_output=True, + text=True, + ) assert result.stdout.strip() == str(tmp_path / ".venv") + + +@pytest.mark.parametrize("exit_code", [0, 7]) +def test_init_presents_live_installation_and_preserves_failures( + tmp_path, monkeypatch, capsys, exit_code +): + root = tmp_path / "my [robot]" + sha = "a" * 40 + monkeypatch.setenv("COLUMNS", "40") + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setattr("dimup.project.executable", lambda name: name) + monkeypatch.setattr( + "dimup.project.resolve_sdk", + lambda ref, runner: (sha, {"project": {"optional-dependencies": {"all": []}}}), + ) + popen = subprocess.Popen + + def run_tool(command, **kwargs): + script = ( + f"import sys; print('Installed 2 packages', file=sys.stderr); sys.exit({exit_code})" + if command[0] == "uv" + else "print('application registered')" + ) + return popen([sys.executable, "-c", script], **kwargs) + + monkeypatch.setattr("dimup.process.subprocess.Popen", run_tool) + if exit_code: + with pytest.raises(SetupError, match="exit 7") as error: + create(root, sha) + assert "directory has been kept" in str(error.value) + assert "Ready" not in capsys.readouterr().out + else: + create(root, sha) + output = capsys.readouterr().out + assert "Installed 2 packages" in output + assert "Ready · my-robot" in output + assert f"cd '{root}'" in output + assert "dimos run my-robot.demo" in output + assert "direnv allow" in output + assert "→" not in output + assert "\x1b" not in output + assert (root / "pyproject.toml").is_file() + assert "Installed 2 packages" in (root / ".dimos/setup.log").read_text() + + +def test_sdk_resolution_keeps_git_metadata_out_of_console(tmp_path, monkeypatch, capsys): + sdk = tmp_path / "sdk" + sdk.mkdir() + (sdk / "pyproject.toml").write_text('[project]\nname = "metadata-only"\n') + subprocess.run(["git", "init", "--quiet", str(sdk)], check=True) + subprocess.run(["git", "-C", str(sdk), "add", "pyproject.toml"], check=True) + subprocess.run( + [ + "git", + "-C", + str(sdk), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + "commit", + "--quiet", + "-m", + "SDK", + ], + check=True, + ) + monkeypatch.setattr("dimup.project.SDK_URL", str(sdk)) + runner = Runner(tmp_path / "setup.log") + with runner.stage("Resolve SDK"): + sha, metadata = resolve_sdk("HEAD", runner) + assert len(sha) == 40 + assert metadata == {"project": {"name": "metadata-only"}} + output = capsys.readouterr().out + assert "Read SDK dependencies" not in output + assert "metadata-only" not in output + assert "metadata-only" in runner.log.read_text()