Skip to content
Merged
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 examples/beluga/beluga_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)
@lambkin.option("--clock-rate", default=100.0)
@lambkin.option("--sensor-topic", default="/scan")
@lambkin.option("--dry-run", default=True)
def nominal(ctx):
"""Run a nominal Beluga AMCL benchmark across sensor models and particle counts.

Expand Down
2 changes: 2 additions & 0 deletions src/lambkin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@

from lambkin.common import named_product
from lambkin.core.decorators import benchmark, option
from lambkin.core.shell.proxy import ShellProxy

__all__ = [
"benchmark",
"named_product",
"option",
"ShellProxy",
]
4 changes: 3 additions & 1 deletion src/lambkin/core/ctx/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from types import SimpleNamespace
from typing import Any

from lambkin.core.shell import ShellProxy

from .source import Source


Expand Down Expand Up @@ -176,7 +178,7 @@ def __init__(
# as parameters.

# ctx.shell
# TODO(teresa-ortega): self.shell = Shell(self)
self.shell = ShellProxy(dry_run=getattr(self.options, "dry_run", False))

def _setup_directories(self) -> None:
"""Create variant and iteration directories."""
Expand Down
26 changes: 26 additions & 0 deletions src/lambkin/core/shell/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright 2026 Ekumen, 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.

"""Shell subpackage for the lambkin SDK.

Provides shell abstractions for executing system commands within benchmarks.
Each shell implementation exposes the same interface, allowing benchmarks to
switch between dry-run and real execution without changing any benchmark code.
"""

from .proxy import ShellProxy

__all__ = [
"ShellProxy",
]
63 changes: 63 additions & 0 deletions src/lambkin/core/shell/proxy.py
Comment thread
teresa-ortega marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,66 @@
Provides an abstraction over shell command dispatch, allowing benchmark
processes to be launched and managed through a consistent interface.
"""

from __future__ import annotations

import shlex
from typing import Any


class _CommandProxy:
"""Builds a shell command lazily by chaining attribute access and calls."""

def __init__(self, parts: list[str], dry_run: bool = False) -> None:
"""Initialize the proxy with the command words accumulated so far."""
self._parts = parts
self._dry_run = dry_run

def __getattr__(self, name: str) -> _CommandProxy:
"""Append a new word to the command and return a new proxy."""
return _CommandProxy(self._parts + [name], self._dry_run)

def __call__(self, *args: Any, **kwargs: Any) -> None:
"""Finalize and print the command.

Positional args are appended as quoted tokens to handle paths with
spaces correctly. Keyword args are converted to --flag value pairs,
with underscores replaced by hyphens. Boolean True values produce a
standalone flag, False values are ignored.

This is a dry-run implementation that will be extended to dispatch
commands to BackgroundProcess for real execution.
"""
extra = []

for arg in args:
extra.append(shlex.quote(str(arg)))

for key, value in kwargs.items():
flag = "--" + key.replace("_", "-")
if value is True:
extra.append(flag)
elif value is not False:
extra.extend([flag, shlex.quote(str(value))])
Comment thread
teresa-ortega marked this conversation as resolved.

command = " ".join(self._parts + extra)

if self._dry_run:
# TODO: extend to dispatch commands to BackgroundProcess for real execution.
print(f"[CMD]: {command}")
Comment thread
teresa-ortega marked this conversation as resolved.
else:
raise NotImplementedError(
"Real execution is not yet supported, use dry_run=True."
)


class ShellProxy:
"""Dry-run mock shell that prints commands instead of executing them."""
Comment thread
teresa-ortega marked this conversation as resolved.

def __init__(self, dry_run: bool = False) -> None:
"""Initialize the ShellProxy with the given dry_run flag."""
self._dry_run = dry_run

def __getattr__(self, name: str) -> _CommandProxy:
"""Start building a new command from the given top-level tool name."""
return _CommandProxy([name], self._dry_run)
85 changes: 85 additions & 0 deletions test/core/test_shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Copyright 2026 Ekumen, 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.

"""Unit tests for the shell class in lambkin.core.shell."""

import pytest

from lambkin.core.shell import ShellProxy


@pytest.fixture
def shell():
"""Return a fresh ShellProxy instance for each test."""
return ShellProxy(dry_run=True)


def test_simple_command(shell, capsys):
"""Test that a simple single-level command is printed correctly."""
shell.ros2.launch("beluga.launch.xml")
assert capsys.readouterr().out == "[CMD]: ros2 launch beluga.launch.xml\n"


def test_chained_command(shell, capsys):
"""Test that chained attribute access builds the command word by word."""
shell.ros2.bag.play("my_bag")
assert capsys.readouterr().out == "[CMD]: ros2 bag play my_bag\n"


def test_multiple_args(shell, capsys):
"""Test that multiple positional arguments are appended in order."""
shell.ros2.bag.play("my_bag", "--clock", "-r", "1.0")
assert capsys.readouterr().out == "[CMD]: ros2 bag play my_bag --clock -r 1.0\n"


def test_kwarg_becomes_flag(shell, capsys):
Comment thread
teresa-ortega marked this conversation as resolved.
"""Test that keyword arguments are converted to --flag value pairs."""
shell.evo_ape.bag2("output.mcap", save_results="out.zip")
assert (
capsys.readouterr().out
== "[CMD]: evo_ape bag2 output.mcap --save-results out.zip\n"
)


def test_arbitrary_tool(shell, capsys):
"""Test that any top-level tool name works without hardcoding."""
shell.evo.traj("output.mcap")
assert capsys.readouterr().out == "[CMD]: evo traj output.mcap\n"


def test_kwarg_true_is_standalone_flag(shell, capsys):
"""Test that a boolean True kwarg produces a standalone flag."""
shell.ros2.bag.play("my_bag", clock=True)
assert capsys.readouterr().out == "[CMD]: ros2 bag play my_bag --clock\n"


def test_kwarg_false_is_ignored(shell, capsys):
"""Test that a boolean False kwarg is ignored."""
shell.ros2.bag.play("my_bag", clock=False)
assert capsys.readouterr().out == "[CMD]: ros2 bag play my_bag\n"


def test_path_with_spaces(shell, capsys):
"""Test that positional args with spaces are correctly quoted."""
shell.ros2.bag.play("/my path/to/bag.mcap")
assert capsys.readouterr().out == "[CMD]: ros2 bag play '/my path/to/bag.mcap'\n"


def test_kwarg_multiple_underscores_converted(shell, capsys):
"""Test that multiple underscores in kwarg names are converted to dashes."""
shell.evo_ape.bag2("output.mcap", save_all_results="out.zip")
assert (
capsys.readouterr().out
== "[CMD]: evo_ape bag2 output.mcap --save-all-results out.zip\n"
)
Loading