Skip to content
Closed
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
1 change: 1 addition & 0 deletions installer/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ dimup = ["templates/*"]
[tool.pytest.ini_options]
testpaths = ["src"]
addopts = "--import-mode=importlib"
norecursedirs = ["templates"]
11 changes: 9 additions & 2 deletions installer/src/dimup/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,24 @@
from rich.text import Text

from dimup.process import Runner, SetupError
from dimup.project import create
from dimup.setup import prepare


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
Expand Down
196 changes: 196 additions & 0 deletions installer/src/dimup/project.py
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 27 additions & 0 deletions installer/src/dimup/templates/README.md
Original file line number Diff line number Diff line change
@@ -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 <package>`.

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`.
19 changes: 19 additions & 0 deletions installer/src/dimup/templates/activate.sh
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions installer/src/dimup/templates/demo.py
Original file line number Diff line number Diff line change
@@ -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())
61 changes: 61 additions & 0 deletions installer/src/dimup/templates/environment.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading