Skip to content

Commit aa28ef0

Browse files
authored
Merge pull request #225 from AllenNeuralDynamics/fix-xml-rpc-server
Fix edge cases with `xml-rpc-server` remote execution
2 parents cdeb921 + 26cc0cc commit aa28ef0

File tree

15 files changed

+608
-231
lines changed

15 files changed

+608
-231
lines changed

examples/_mocks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,4 @@ def create_fake_rig():
9797
computer_name = os.getenv("COMPUTERNAME")
9898
os.makedirs(_dir := f"{LIB_CONFIG}/Rig/{computer_name}", exist_ok=True)
9999
with open(f"{_dir}/rig1.json", "w", encoding="utf-8") as f:
100-
f.write(RigModel().model_dump_json(indent=2))
100+
f.write(RigModel(data_directory=r"./local/data").model_dump_json(indent=2))
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import logging
2+
from pathlib import Path
3+
4+
from _mocks import (
5+
LIB_CONFIG,
6+
AindBehaviorSessionModel,
7+
RigModel,
8+
TaskLogicModel,
9+
create_fake_rig,
10+
create_fake_subjects,
11+
)
12+
from pydantic_settings import CliApp
13+
14+
from clabe import resource_monitor
15+
from clabe.apps import BonsaiApp
16+
from clabe.launcher import Launcher, LauncherCliArgs, experiment
17+
from clabe.pickers import DefaultBehaviorPicker, DefaultBehaviorPickerSettings
18+
from clabe.xml_rpc import XmlRpcClient, XmlRpcClientSettings
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
@experiment()
24+
async def client_experiment(launcher: Launcher) -> None:
25+
"""Demo experiment showcasing CLABE functionality."""
26+
picker = DefaultBehaviorPicker(
27+
launcher=launcher,
28+
settings=DefaultBehaviorPickerSettings(config_library_dir=LIB_CONFIG),
29+
experimenter_validator=lambda _: True,
30+
)
31+
32+
session = picker.pick_session(AindBehaviorSessionModel)
33+
rig = picker.pick_rig(RigModel)
34+
launcher.register_session(session, rig.data_directory)
35+
trainer_state, task_logic = picker.pick_trainer_state(TaskLogicModel)
36+
37+
resource_monitor.ResourceMonitor(
38+
constrains=[
39+
resource_monitor.available_storage_constraint_factory_from_rig(rig, 2e11),
40+
]
41+
).run()
42+
43+
xml_rpc_client = XmlRpcClient(settings=XmlRpcClientSettings(server_url="http://localhost:8000", token="42"))
44+
45+
bonsai_root = Path(r"C:\git\AllenNeuralDynamics\Aind.Behavior.VrForaging")
46+
session_response = xml_rpc_client.upload_model(session, "session.json")
47+
rig_response = xml_rpc_client.upload_model(rig, "rig.json")
48+
task_logic_response = xml_rpc_client.upload_model(task_logic, "task_logic.json")
49+
assert rig_response.path is not None
50+
assert session_response.path is not None
51+
assert task_logic_response.path is not None
52+
53+
bonsai_app_result = await xml_rpc_client.run_async(
54+
BonsaiApp(
55+
workflow=bonsai_root / "src/test_deserialization.bonsai",
56+
executable=bonsai_root / "bonsai/bonsai.exe",
57+
additional_externalized_properties={
58+
"RigPath": rig_response.path,
59+
"SessionPath": session_response.path,
60+
"TaskLogicPath": task_logic_response.path,
61+
},
62+
).command
63+
)
64+
print(bonsai_app_result)
65+
return
66+
67+
68+
def main():
69+
create_fake_subjects()
70+
create_fake_rig()
71+
behavior_cli_args = CliApp.run(
72+
LauncherCliArgs,
73+
cli_args=[
74+
"--debug-mode",
75+
"--allow-dirty",
76+
"--skip-hardware-validation",
77+
],
78+
)
79+
80+
launcher = Launcher(settings=behavior_cli_args)
81+
launcher.run_experiment(client_experiment)
82+
return None
83+
84+
85+
if __name__ == "__main__":
86+
main()

src/clabe/apps/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
CommandResult,
66
ExecutableApp,
77
Executor,
8-
OutputParser,
98
StdCommand,
9+
_OutputParser,
1010
identity_parser,
1111
)
1212
from ._bonsai import AindBehaviorServicesBonsaiApp, BonsaiApp
@@ -26,7 +26,7 @@
2626
"AsyncExecutor",
2727
"Executor",
2828
"identity_parser",
29-
"OutputParser",
29+
"_OutputParser",
3030
"PythonScriptApp",
3131
"ExecutableApp",
3232
"StdCommand",

src/clabe/apps/_base.py

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ class ExecutableApp(Protocol):
111111
class MyApp(ExecutableApp):
112112
@property
113113
def command(self) -> Command:
114-
return Command(cmd="echo hello", output_parser=identity_parser)
114+
return Command(cmd=["echo", "hello"], output_parser=identity_parser)
115115
```
116116
"""
117117

@@ -170,7 +170,7 @@ async def run_async(self, command: "Command") -> CommandResult:
170170

171171
TOutput = TypeVar("TOutput")
172172

173-
OutputParser: TypeAlias = Callable[[CommandResult], TOutput]
173+
_OutputParser: TypeAlias = Callable[[CommandResult], TOutput]
174174

175175

176176
class Command(Generic[TOutput]):
@@ -181,14 +181,20 @@ class Command(Generic[TOutput]):
181181
Supports both synchronous and asynchronous execution patterns with type-safe
182182
output parsing.
183183
184+
Commands are provided as a list of strings, which is consistent with subprocess
185+
and executed directly without shell interpretation. This approach:
186+
- Avoids shell injection vulnerabilities
187+
- Handles arguments with spaces correctly without manual quoting
188+
- Is more portable across platforms
189+
184190
Attributes:
185-
cmd: The command string to execute
191+
cmd: The command to execute as a list of strings
186192
result: The result of command execution (available after execution)
187193
188194
Example:
189195
```python
190-
# Create a simple command
191-
cmd = Command(cmd="echo hello", output_parser=identity_parser)
196+
# Create a command
197+
cmd = Command(cmd=["python", "-c", "print('hello')"], output_parser=identity_parser)
192198
193199
# Execute with a synchronous executor
194200
executor = LocalExecutor()
@@ -198,24 +204,25 @@ class Command(Generic[TOutput]):
198204
def parse_json(result: CommandResult) -> dict:
199205
return json.loads(result.stdout)
200206
201-
cmd = Command(cmd="get-data --json", output_parser=parse_json)
207+
cmd = Command(cmd=["get-data", "--json"], output_parser=parse_json)
202208
data = cmd.execute(executor)
203209
```
204210
"""
205211

206-
def __init__(self, cmd: str, output_parser: OutputParser[TOutput]) -> None:
212+
def __init__(self, cmd: list[str], output_parser: _OutputParser[TOutput]) -> None:
207213
"""Initialize the Command instance.
214+
208215
Args:
209-
cmd: The command string to execute
216+
cmd: The command to execute as a list of strings. The first element
217+
is the program to run, followed by its arguments.
210218
output_parser: Function to parse the command result into desired output type
211219
212220
Example:
213221
```python
214-
# Create a simple command
215-
cmd = Command(cmd="echo hello", output_parser=identity_parser)
222+
cmd = Command(cmd=["echo", "hello"], output_parser=identity_parser)
216223
```
217224
"""
218-
self._cmd = cmd
225+
self._cmd: list[str] = cmd
219226
self._output_parser = output_parser
220227
self._result: Optional[CommandResult] = None
221228

@@ -227,16 +234,30 @@ def result(self) -> CommandResult:
227234
return self._result
228235

229236
@property
230-
def cmd(self) -> str:
231-
"""Get the command string."""
237+
def cmd(self) -> list[str]:
238+
"""Get the command as a list of strings."""
232239
return self._cmd
233240

234241
def append_arg(self, args: str | list[str]) -> Self:
235-
"""Append an argument to the command."""
242+
"""Append arguments to the command.
243+
244+
Args:
245+
args: Argument(s) to append. Can be a single string or list of strings.
246+
Empty strings are filtered out.
247+
248+
Returns:
249+
Self for method chaining.
250+
251+
Example:
252+
```python
253+
cmd = Command(cmd=["python"], output_parser=identity_parser)
254+
cmd.append_arg(["-m", "pytest"]) # Results in ["python", "-m", "pytest"]
255+
```
256+
"""
236257
if isinstance(args, str):
237258
args = [args]
238259
args = [arg for arg in args if arg]
239-
self._cmd = (self.cmd + f" {' '.join(args)}").strip()
260+
self._cmd = self._cmd + args
240261
return self
241262

242263
def execute(self, executor: Executor) -> TOutput:
@@ -267,9 +288,18 @@ def _parse_output(self, result: CommandResult) -> TOutput:
267288

268289

269290
class StdCommand(Command[CommandResult]):
270-
"""Standard command that returns the raw CommandResult."""
291+
"""Standard command that returns the raw CommandResult.
292+
293+
A convenience class that creates a Command with the identity_parser,
294+
returning the raw CommandResult without transformation.
295+
296+
Example:
297+
```python
298+
cmd = StdCommand(["echo", "hello"])
299+
```
300+
"""
271301

272-
def __init__(self, cmd: str) -> None:
302+
def __init__(self, cmd: list[str]) -> None:
273303
super().__init__(cmd, identity_parser)
274304

275305

src/clabe/apps/_bonsai.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import random
55
from os import PathLike
66
from pathlib import Path
7-
from typing import Dict, Optional
7+
from typing import Dict, List, Optional
88

99
import pydantic
1010
from aind_behavior_services import AindBehaviorRigModel, AindBehaviorSessionModel, AindBehaviorTaskLogicModel
@@ -117,13 +117,18 @@ def _build_bonsai_process_command(
117117
is_editor_mode: bool = True,
118118
is_start_flag: bool = True,
119119
additional_properties: Optional[Dict[str, str]] = None,
120-
) -> str:
120+
) -> List[str]:
121121
"""
122-
Builds a shell command for running a Bonsai workflow via subprocess.
122+
Builds a command list for running a Bonsai workflow via subprocess.
123123
124-
Constructs the complete command string with all necessary flags and properties
125-
for executing a Bonsai workflow. Handles editor mode, start flag, and
126-
externalized properties.
124+
Constructs the complete command as a list of arguments with all necessary
125+
flags and properties for executing a Bonsai workflow. Handles editor mode,
126+
start flag, and externalized properties.
127+
128+
Using list format is preferred over string format as it:
129+
- Avoids shell injection vulnerabilities
130+
- Handles paths with spaces correctly without manual quoting
131+
- Is more portable across platforms
127132
128133
Args:
129134
workflow_file: Path to the Bonsai workflow file
@@ -133,7 +138,7 @@ def _build_bonsai_process_command(
133138
additional_properties: Dictionary of externalized properties to pass. Defaults to None
134139
135140
Returns:
136-
str: The complete command string
141+
List[str]: The complete command as a list of arguments
137142
138143
Example:
139144
```python
@@ -142,19 +147,20 @@ def _build_bonsai_process_command(
142147
is_editor_mode=False,
143148
additional_properties={"SubjectName": "Mouse123"}
144149
)
145-
# Returns: '"bonsai.exe" "workflow.bonsai" --no-editor -p:"SubjectName"="Mouse123"'
150+
# Returns: ["bonsai.exe", "workflow.bonsai", "--no-editor", "-p:SubjectName=Mouse123"]
146151
```
147152
"""
148-
output_cmd: str = f'"{bonsai_exe}" "{workflow_file}"'
153+
output_cmd: List[str] = [str(bonsai_exe), str(workflow_file)]
154+
149155
if is_editor_mode:
150156
if is_start_flag:
151-
output_cmd += " --start"
157+
output_cmd.append("--start")
152158
else:
153-
output_cmd += " --no-editor"
159+
output_cmd.append("--no-editor")
154160

155161
if additional_properties:
156162
for param, value in additional_properties.items():
157-
output_cmd += f' -p:"{param}"="{value}"'
163+
output_cmd.append(f"-p:{param}={value}")
158164

159165
return output_cmd
160166

src/clabe/apps/_curriculum.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class CurriculumSettings(ServiceSettings):
6969

7070
__yml_section__: t.ClassVar[t.Optional[str]] = "curriculum"
7171

72-
script: str = "curriculum run"
72+
script: list[str] = ["curriculum", "run"]
7373
project_directory: os.PathLike = Path(".")
7474
input_trainer_state: t.Optional[os.PathLike] = None
7575
data_directory: t.Optional[os.PathLike] = None
@@ -145,18 +145,18 @@ def __init__(
145145
raise ValueError("Data directory is not set.")
146146

147147
kwargs: dict[str, t.Any] = { # Must use kebab casing
148-
"data-directory": f'"{self._settings.data_directory}"',
149-
"input-trainer-state": f'"{self._settings.input_trainer_state}"',
148+
"data-directory": str(self._settings.data_directory),
149+
"input-trainer-state": str(self._settings.input_trainer_state),
150150
}
151151
if self._settings.curriculum is not None:
152-
kwargs["curriculum"] = f'"{self._settings.curriculum}"'
152+
kwargs["curriculum"] = str(self._settings.curriculum)
153153

154154
python_script_app_kwargs = python_script_app_kwargs or {}
155155
self._python_script_app = PythonScriptApp(
156156
script=settings.script,
157157
project_directory=settings.project_directory,
158158
extra_uv_arguments="-q",
159-
additional_arguments=" ".join(f"--{key} {value}" for key, value in kwargs.items()),
159+
additional_arguments=[arg for kv in kwargs.items() for arg in ("--" + kv[0], str(kv[1]))],
160160
**python_script_app_kwargs,
161161
)
162162

0 commit comments

Comments
 (0)