diff --git a/installer/pyproject.toml b/installer/pyproject.toml index ca02e004c8..9726648c5f 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 636796cc4b..85dd9f1eb6 100644 --- a/installer/src/dimup/cli.py +++ b/installer/src/dimup/cli.py @@ -22,6 +22,7 @@ from rich.text import Text from dimup.process import Runner, SetupError +from dimup.project import create from dimup.setup import prepare @@ -29,10 +30,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: Console(stderr=True).print(Text(str(error), style="red")) 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..34dfd1f159 --- /dev/null +++ b/installer/src/dimup/project.py @@ -0,0 +1,196 @@ +# 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 + +from rich.table import Table +from rich.text import Text +import tomli_w +import tomllib + +from dimup.process import Runner, SetupError, executable +from dimup.sdk import SDK_URL, consumer_policy + + +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)], 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 + ) + source = runner.run( + "Read SDK dependencies", + [git, "show", f"{sha}:pyproject.toml"], + cwd=checkout, + capture=True, + ) + return sha, tomllib.loads(source) + + +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"] = [ + "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: + 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") + 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) + 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") + ] + env["PATH"] = os.pathsep.join([*tool_paths, env.get("PATH", "")]) + env["GIT_LFS_SKIP_SMUDGE"] = "1" + runner.run( + "Install dependencies · uv sync", + [executable("uv"), "sync", "--python", "3.12"], + cwd=root, + env=env, + ) + python = root / ".venv/bin/python" + runner.run( + "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()" + ), + 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 + 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/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..175d0cf978 --- /dev/null +++ b/installer/src/dimup/test_project.py @@ -0,0 +1,225 @@ +# 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 os +from pathlib import Path +import shutil +import subprocess +import sys +import venv + +import pytest +import tomllib + +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): + 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 config["tool"]["uv"]["python-preference"] == "only-managed" + 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): + 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_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 + ) + 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") + + +@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()