ros2docker 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ros2docker/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """ros2docker package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ _DISTRIBUTION_NAME = "ros2docker"
8
+
9
+
10
+ def _package_version() -> str:
11
+ try:
12
+ return version(_DISTRIBUTION_NAME)
13
+ except PackageNotFoundError:
14
+ return "0+unknown"
15
+
16
+
17
+ __version__ = _package_version()
18
+
19
+ __all__ = ["__version__"]
ros2docker/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Module entry point for ``python -m ros2docker``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
ros2docker/api.py ADDED
@@ -0,0 +1,152 @@
1
+ """Public Python API for ros2docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ from collections.abc import Iterator, Mapping, Sequence
11
+ from contextlib import contextmanager
12
+ from importlib import resources
13
+ from pathlib import Path
14
+
15
+ from .commands import (
16
+ make_build_command,
17
+ make_exec_shell_command,
18
+ make_run_command,
19
+ make_stop_command,
20
+ )
21
+ from .config import get_config_dir, load_config
22
+
23
+ RunResult = list[str] | subprocess.CompletedProcess[bytes]
24
+
25
+
26
+ def build(
27
+ config_file: str | os.PathLike[str] | None = None,
28
+ override: str | Mapping[str, object] | None = None,
29
+ *,
30
+ dry_run: bool = False,
31
+ ) -> RunResult:
32
+ with build_context(config_file, override) as context_dir:
33
+ command = make_build_command(config_file, override, context_dir=context_dir)
34
+ return _run(command, dry_run=dry_run)
35
+
36
+
37
+ def run(
38
+ config_file: str | os.PathLike[str] | None = None,
39
+ override: str | Mapping[str, object] | None = None,
40
+ *,
41
+ mount: str | os.PathLike[str] | None = None,
42
+ extra_run_args: Sequence[str] | None = None,
43
+ dry_run: bool = False,
44
+ ) -> RunResult:
45
+ command = make_run_command(
46
+ config_file,
47
+ override,
48
+ mount=mount,
49
+ extra_run_args=extra_run_args,
50
+ )
51
+ return _run(command, dry_run=dry_run, cwd=get_config_dir(config_file))
52
+
53
+
54
+ def build_run(
55
+ config_file: str | os.PathLike[str] | None = None,
56
+ override: str | Mapping[str, object] | None = None,
57
+ *,
58
+ mount: str | os.PathLike[str] | None = None,
59
+ extra_run_args: Sequence[str] | None = None,
60
+ dry_run: bool = False,
61
+ ) -> tuple[RunResult, RunResult]:
62
+ build_result = build(config_file, override, dry_run=dry_run)
63
+ run_result = run(
64
+ config_file,
65
+ override,
66
+ mount=mount,
67
+ extra_run_args=extra_run_args,
68
+ dry_run=dry_run,
69
+ )
70
+ return build_result, run_result
71
+
72
+
73
+ def stop(
74
+ config_file: str | os.PathLike[str] | None = None,
75
+ override: str | Mapping[str, object] | None = None,
76
+ *,
77
+ dry_run: bool = False,
78
+ ) -> RunResult:
79
+ command = make_stop_command(config_file, override)
80
+ return _run(command, dry_run=dry_run, check=False)
81
+
82
+
83
+ def exec_shell(
84
+ config_file: str | os.PathLike[str] | None = None,
85
+ override: str | Mapping[str, object] | None = None,
86
+ *,
87
+ command: Sequence[str] | None = None,
88
+ interactive: bool = True,
89
+ dry_run: bool = False,
90
+ ) -> RunResult:
91
+ docker_command = make_exec_shell_command(
92
+ config_file,
93
+ override,
94
+ command=command,
95
+ interactive=interactive,
96
+ )
97
+ return _run(docker_command, dry_run=dry_run)
98
+
99
+
100
+ @contextmanager
101
+ def build_context(
102
+ config_file: str | os.PathLike[str] | None = None,
103
+ override: str | Mapping[str, object] | None = None,
104
+ ) -> Iterator[Path]:
105
+ config = load_config(config_file, override, resolve_run_args=False)
106
+ with tempfile.TemporaryDirectory(prefix="ros2docker-build-") as temp_dir:
107
+ context_dir = Path(temp_dir)
108
+ _copy_build_resources(context_dir)
109
+ _stage_bake_packages(config, context_dir / "bake_packages")
110
+ yield context_dir
111
+
112
+
113
+ def _copy_build_resources(context_dir: Path) -> None:
114
+ build_resource = resources.files("ros2docker").joinpath("resources").joinpath("build")
115
+ for item in build_resource.iterdir():
116
+ destination = context_dir / item.name
117
+ with resources.as_file(item) as source:
118
+ if source.is_dir():
119
+ shutil.copytree(source, destination)
120
+ else:
121
+ shutil.copy2(source, destination)
122
+ (context_dir / "bake_packages").mkdir(exist_ok=True)
123
+
124
+
125
+ def _stage_bake_packages(config: Mapping[str, object], bake_dir: Path) -> None:
126
+ seen_names: set[str] = set()
127
+ package_paths = config.get("bake_ros_packages", [])
128
+ if not isinstance(package_paths, list):
129
+ raise ValueError("'bake_ros_packages' must be a list.")
130
+
131
+ for package_path in package_paths:
132
+ if not isinstance(package_path, str | os.PathLike):
133
+ raise TypeError(f"bake package path must be path-like, got {type(package_path).__name__}.")
134
+ source = Path(package_path)
135
+ package_name = source.name
136
+ if package_name in seen_names:
137
+ raise ValueError(f"Duplicate bake package directory name: {package_name}")
138
+ seen_names.add(package_name)
139
+ shutil.copytree(source, bake_dir / package_name, symlinks=True)
140
+
141
+
142
+ def _run(
143
+ command: Sequence[str],
144
+ *,
145
+ dry_run: bool,
146
+ cwd: str | os.PathLike[str] | None = None,
147
+ check: bool = True,
148
+ ) -> RunResult:
149
+ print(shlex.join(list(command)), flush=True)
150
+ if dry_run:
151
+ return list(command)
152
+ return subprocess.run(command, check=check, cwd=cwd)
ros2docker/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ """Command line interface for ros2docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .api import build, build_run, exec_shell, run, stop
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ parser = _make_parser()
14
+ args = parser.parse_args(argv)
15
+
16
+ if not hasattr(args, "func"):
17
+ parser.print_help()
18
+ return 0
19
+
20
+ try:
21
+ args.func(args)
22
+ except Exception as exc: # noqa: BLE001 - CLI should report concise user errors.
23
+ print(f"ros2docker: error: {exc}", file=sys.stderr)
24
+ return 1
25
+ return 0
26
+
27
+
28
+ def _make_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(prog="ros2docker")
30
+ parser.add_argument("--version", action="version", version=f"ros2docker {__version__}")
31
+ subparsers = parser.add_subparsers(dest="command_name")
32
+
33
+ build_parser = subparsers.add_parser("build", help="Build the configured Docker image.")
34
+ _add_config_options(build_parser)
35
+ _add_dry_run(build_parser)
36
+ build_parser.set_defaults(func=_build)
37
+
38
+ run_parser = subparsers.add_parser("run", help="Build and run the configured Docker container.")
39
+ _add_config_options(run_parser)
40
+ _add_dry_run(run_parser)
41
+ run_parser.add_argument("-m", "--mount", metavar="PATH", help="Mount PATH into /ws.")
42
+ run_parser.add_argument("--no-build", action="store_true", help="Run without building first.")
43
+ run_parser.add_argument("extra_run_args", nargs=argparse.REMAINDER, help="Extra docker run args after --.")
44
+ run_parser.set_defaults(func=_run)
45
+
46
+ stop_parser = subparsers.add_parser("stop", help="Stop the configured Docker container.")
47
+ _add_config_options(stop_parser)
48
+ _add_dry_run(stop_parser)
49
+ stop_parser.set_defaults(func=_stop)
50
+
51
+ exec_parser = subparsers.add_parser("exec", help="Execute a command in the configured Docker container.")
52
+ _add_config_options(exec_parser)
53
+ _add_dry_run(exec_parser)
54
+ exec_parser.add_argument("command", nargs=argparse.REMAINDER, help="Command after --, defaults to bash.")
55
+ exec_parser.set_defaults(func=_exec)
56
+
57
+ return parser
58
+
59
+
60
+ def _add_config_options(parser: argparse.ArgumentParser) -> None:
61
+ parser.add_argument("-f", "--config", metavar="CONFIG", help="Path to ros2docker config.")
62
+ parser.add_argument("-o", "--override", metavar="JSON", help="JSON object overriding config values.")
63
+
64
+
65
+ def _add_dry_run(parser: argparse.ArgumentParser) -> None:
66
+ parser.add_argument("--dry-run", action="store_true", help="Print Docker argv without running Docker.")
67
+
68
+
69
+ def _strip_separator(args: list[str]) -> list[str]:
70
+ if args and args[0] == "--":
71
+ return args[1:]
72
+ return args
73
+
74
+
75
+ def _build(args: argparse.Namespace) -> None:
76
+ build(args.config, args.override, dry_run=args.dry_run)
77
+
78
+
79
+ def _run(args: argparse.Namespace) -> None:
80
+ extra_run_args = _strip_separator(args.extra_run_args)
81
+ if args.no_build:
82
+ run(
83
+ args.config,
84
+ args.override,
85
+ mount=args.mount,
86
+ extra_run_args=extra_run_args,
87
+ dry_run=args.dry_run,
88
+ )
89
+ else:
90
+ build_run(
91
+ args.config,
92
+ args.override,
93
+ mount=args.mount,
94
+ extra_run_args=extra_run_args,
95
+ dry_run=args.dry_run,
96
+ )
97
+
98
+
99
+ def _stop(args: argparse.Namespace) -> None:
100
+ stop(args.config, args.override, dry_run=args.dry_run)
101
+
102
+
103
+ def _exec(args: argparse.Namespace) -> None:
104
+ command = _strip_separator(args.command)
105
+ exec_shell(
106
+ args.config,
107
+ args.override,
108
+ command=command or None,
109
+ interactive=not command,
110
+ dry_run=args.dry_run,
111
+ )
112
+
113
+
114
+ if __name__ == "__main__":
115
+ raise SystemExit(main())
ros2docker/commands.py ADDED
@@ -0,0 +1,191 @@
1
+ """Docker command rendering for ros2docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ from collections.abc import Mapping, Sequence
8
+ from pathlib import Path
9
+
10
+ from .config import ConfigError, get_config_dir, load_config, normalize_docker_host_paths, resolve_host_path
11
+
12
+
13
+ def make_build_command(
14
+ config_file: str | os.PathLike[str] | None = None,
15
+ override: str | Mapping[str, object] | None = None,
16
+ *,
17
+ context_dir: str | os.PathLike[str],
18
+ ) -> list[str]:
19
+ config = load_config(config_file, override, resolve_run_args=False)
20
+ build_args = [
21
+ "-t",
22
+ _image_name(config),
23
+ "--build-arg",
24
+ f"USER_UID={os.getuid()}",
25
+ "--build-arg",
26
+ f"USER_GID={os.getgid()}",
27
+ ]
28
+
29
+ for key, value in config.get("build_args", {}).items():
30
+ build_args.extend(["--build-arg", f"{key}={value}"])
31
+
32
+ return ["docker", "build", *build_args, str(Path(context_dir))]
33
+
34
+
35
+ def make_run_command(
36
+ config_file: str | os.PathLike[str] | None = None,
37
+ override: str | Mapping[str, object] | None = None,
38
+ *,
39
+ mount: str | os.PathLike[str] | None = None,
40
+ extra_run_args: Sequence[str] | None = None,
41
+ ) -> list[str]:
42
+ config = load_config(config_file, override)
43
+ run_args = [
44
+ "docker",
45
+ "run",
46
+ *_core_run_args(config),
47
+ *_local_run_args(config),
48
+ *_workspace_mount_args(config_file, config, mount),
49
+ *normalize_docker_host_paths(extra_run_args or [], Path.cwd()),
50
+ *_run_type_args(config),
51
+ _image_name(config),
52
+ *_run_command(config),
53
+ ]
54
+ return run_args
55
+
56
+
57
+ def make_stop_command(
58
+ config_file: str | os.PathLike[str] | None = None,
59
+ override: str | Mapping[str, object] | None = None,
60
+ ) -> list[str]:
61
+ config = load_config(config_file, override, resolve_run_args=False)
62
+ return ["docker", "stop", _container_name(config)]
63
+
64
+
65
+ def make_exec_shell_command(
66
+ config_file: str | os.PathLike[str] | None = None,
67
+ override: str | Mapping[str, object] | None = None,
68
+ *,
69
+ command: Sequence[str] | None = None,
70
+ interactive: bool = True,
71
+ ) -> list[str]:
72
+ config = load_config(config_file, override, resolve_run_args=False)
73
+ exec_args = ["docker", "exec"]
74
+ if interactive:
75
+ exec_args.append("-it")
76
+ exec_args.append(_container_name(config))
77
+ exec_args.extend(command or ["bash"])
78
+ return exec_args
79
+
80
+
81
+ def _image_name(config: Mapping[str, object]) -> str:
82
+ return str(config.get("image_name") or "ros2docker")
83
+
84
+
85
+ def _container_name(config: Mapping[str, object]) -> str:
86
+ return str(config.get("container_name") or config.get("image_name") or "ros2docker")
87
+
88
+
89
+ def _core_run_args(config: Mapping[str, object]) -> list[str]:
90
+ return [
91
+ "--name",
92
+ _container_name(config),
93
+ "--user",
94
+ f"{os.getuid()}:{os.getgid()}",
95
+ "--rm",
96
+ "-e",
97
+ "LIBGL_ALWAYS_SOFTWARE=1",
98
+ ]
99
+
100
+
101
+ def _local_run_args(config: Mapping[str, object]) -> list[str]:
102
+ args: list[str] = []
103
+
104
+ if config.get("enable_gui_forwarding"):
105
+ x11_socket = Path("/tmp/.X11-unix")
106
+ if not x11_socket.exists():
107
+ raise FileNotFoundError("GUI forwarding requested but /tmp/.X11-unix does not exist.")
108
+ args.extend(["-v", "/tmp/.X11-unix:/tmp/.X11-unix", "-e", "DISPLAY"])
109
+
110
+ if config.get("forward_ssh_agent"):
111
+ ssh_auth_sock = os.environ.get("SSH_AUTH_SOCK")
112
+ if not ssh_auth_sock:
113
+ raise ConfigError("forward_ssh_agent is true but SSH_AUTH_SOCK is not set.")
114
+ sock_path = Path(ssh_auth_sock)
115
+ if not sock_path.exists():
116
+ raise FileNotFoundError(f"forward_ssh_agent is true but SSH_AUTH_SOCK does not exist: {ssh_auth_sock}")
117
+ args.extend(["-e", "SSH_AUTH_SOCK", "-v", f"{ssh_auth_sock}:{ssh_auth_sock}"])
118
+
119
+ args.extend(_string_list(config, "run_args"))
120
+ args.extend(_string_list(config, "extra_run_args"))
121
+ return args
122
+
123
+
124
+ def _workspace_mount_args(
125
+ config_file: str | os.PathLike[str] | None,
126
+ config: Mapping[str, object],
127
+ mount: str | os.PathLike[str] | None,
128
+ ) -> list[str]:
129
+ if mount is not None:
130
+ mount_path = resolve_host_path(os.fspath(mount), Path.cwd())
131
+ return ["-v", f"{mount_path}:/ws", "-w", "/ws"]
132
+
133
+ if config.get("mount_ws"):
134
+ ws_host = Path(get_config_dir(config_file)) / "ws"
135
+ if not ws_host.exists():
136
+ raise FileNotFoundError(f"mount_ws is true but workspace directory does not exist: {ws_host}")
137
+ return ["-v", f"{ws_host.resolve()}:/ws", "-w", "/ws"]
138
+
139
+ return []
140
+
141
+
142
+ def _run_type_args(config: Mapping[str, object]) -> list[str]:
143
+ run_type = str(config.get("run_type") or "bash")
144
+ if run_type in {"bash", "catmux"}:
145
+ return ["-it"]
146
+ if run_type == "command":
147
+ return []
148
+ if run_type == "up":
149
+ return ["-d"]
150
+ raise ConfigError(f"Unsupported run_type: {run_type!r}")
151
+
152
+
153
+ def _run_command(config: Mapping[str, object]) -> list[str]:
154
+ run_type = str(config.get("run_type") or "bash")
155
+
156
+ if run_type == "catmux":
157
+ catmux_file = str(config["catmux_file"])
158
+ catmux_command = [
159
+ "catmux_create_session",
160
+ catmux_file,
161
+ "--session_name",
162
+ _container_name(config),
163
+ ]
164
+ catmux_params = config.get("catmux_params")
165
+ if isinstance(catmux_params, Mapping) and catmux_params:
166
+ params = ",".join(f"{key}={value}" for key, value in catmux_params.items())
167
+ catmux_command.extend(["--overwrite", params])
168
+ return catmux_command
169
+
170
+ if run_type == "bash":
171
+ return ["bash"]
172
+
173
+ if run_type == "up":
174
+ return ["tail", "-f", "/dev/null"]
175
+
176
+ if run_type == "command":
177
+ raw_command = config["command"]
178
+ if isinstance(raw_command, str):
179
+ return shlex.split(raw_command)
180
+ if isinstance(raw_command, list):
181
+ return [str(part) for part in raw_command]
182
+ raise ConfigError("'command' must be a string or list.")
183
+
184
+ raise ConfigError(f"Unsupported run_type: {run_type!r}")
185
+
186
+
187
+ def _string_list(config: Mapping[str, object], key: str) -> list[str]:
188
+ value = config.get(key, [])
189
+ if not isinstance(value, list):
190
+ raise ConfigError(f"{key!r} must be a list.")
191
+ return [str(item) for item in value]