Skip to content

Commit 2924ba3

Browse files
feat(cli): add --process-type option to override process execution mode (#183)
# Summary Adds `--process-type` CLI option to `plugboard process run` for switching between `LocalProcess` and `RayProcess` execution modes, overriding YAML configuration. Uses `Literal` type for type-safe validation. # Changes - **CLI Enhancement** - Added `--process-type {local|ray}` option to `run` command with `Literal["local", "ray"]` type - Typer automatically validates input and shows helpful error messages for invalid values - **Schema Enhancement** - Added `RAY_CONNECTOR_CLS_PATH` constant to connector schema for consistency - Implemented `ProcessSpec.override_process_type()` method with mapping-based logic - Method handles process type, connector builder, and state backend updates atomically - When switching to `ray`: `LocalProcess` → `RayProcess`, `AsyncioConnector` → `RayConnector`, `DictStateBackend` → `RayStateBackend` - When switching to `local`: reverses the above transformations - Preserves connector builder args during override - Only modifies incompatible components (e.g., won't change `RayConnector` when already on Ray) - **Tests** - Unit tests for local override and ray override - Validation testing handled by typer's built-in Literal type support **Example usage:** ```bash # Override any YAML config to run locally plugboard process run ray-config.yaml --process-type local # Force Ray execution regardless of YAML plugboard process run local-config.yaml --process-type ray ``` **Implementation Note:** The override logic is implemented in the schema layer (`ProcessSpec.override_process_type()`) using a mapping-based approach rather than hard-coded if/else branches in the CLI, keeping business logic close to the data model and improving maintainability. Type safety is enforced using `Literal` types, allowing typer to handle validation automatically. <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>CLI option to switch between local and Ray process</issue_title> > <issue_description>See #49 (comment) > > We would like to be able to switch between Local and Ray processes from the CLI.</issue_description> > > <agent_instructions>We need an option on the CLI to switch between `RayProcess` and `LocalProcess` by overriding whatever is specified in the input YAML. Note that when changing from local to ray, it is not enough just to change the `Process` type: you will also need to make sure the `connector_builder` and `State` type are changed if they are specified in the YAML and not compatible with Ray.</agent_instructions> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #60 <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/plugboard-dev/plugboard/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> Co-authored-by: Toby Coleman <toby@tobycoleman.com> Co-authored-by: Toby Coleman <toby-coleman@users.noreply.github.com>
1 parent bd69137 commit 2924ba3

5 files changed

Lines changed: 117 additions & 1 deletion

File tree

plugboard-schemas/plugboard_schemas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .config import ConfigSpec, ProcessConfigSpec
2121
from .connector import (
2222
DEFAULT_CONNECTOR_CLS_PATH,
23+
RAY_CONNECTOR_CLS_PATH,
2324
ConnectorBuilderArgsDict,
2425
ConnectorBuilderArgsSpec,
2526
ConnectorBuilderSpec,
@@ -83,6 +84,7 @@
8384
"ProcessSpec",
8485
"ProcessArgsDict",
8586
"ProcessArgsSpec",
87+
"RAY_CONNECTOR_CLS_PATH",
8688
"RAY_STATE_BACKEND_CLS_PATH",
8789
"Resource",
8890
"StateBackendSpec",

plugboard-schemas/plugboard_schemas/connector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212

1313
DEFAULT_CONNECTOR_CLS_PATH: str = "plugboard.connector.AsyncioConnector"
14+
RAY_CONNECTOR_CLS_PATH: str = "plugboard.connector.RayConnector"
1415

1516

1617
class ConnectorMode(StrEnum):

plugboard-schemas/plugboard_schemas/process.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88

99
from ._common import PlugboardBaseModel
1010
from .component import ComponentSpec
11-
from .connector import DEFAULT_CONNECTOR_CLS_PATH, ConnectorBuilderSpec, ConnectorSpec
11+
from .connector import (
12+
DEFAULT_CONNECTOR_CLS_PATH,
13+
RAY_CONNECTOR_CLS_PATH,
14+
ConnectorBuilderSpec,
15+
ConnectorSpec,
16+
)
1217
from .state import DEFAULT_STATE_BACKEND_CLS_PATH, RAY_STATE_BACKEND_CLS_PATH, StateBackendSpec
1318

1419

@@ -96,3 +101,52 @@ def _validate_type(cls, value: _t.Any) -> str:
96101
"plugboard.process.ray_process.RayProcess": "plugboard.process.RayProcess",
97102
}.get(value, value)
98103
return value
104+
105+
def override_process_type(self, process_type: _t.Literal["local", "ray"]) -> Self:
106+
"""Override the process type and update connector/state to be compatible.
107+
108+
Args:
109+
process_type: The process type to use ("local" or "ray")
110+
111+
Returns:
112+
A new ProcessSpec with the overridden process type and compatible settings
113+
"""
114+
# Map process type to full class path
115+
type_map = {
116+
"local": "plugboard.process.LocalProcess",
117+
"ray": "plugboard.process.RayProcess",
118+
}
119+
new_process_type = type_map[process_type]
120+
121+
# Map of connector types that should be replaced
122+
connector_type_map = {
123+
"ray": {DEFAULT_CONNECTOR_CLS_PATH: RAY_CONNECTOR_CLS_PATH},
124+
"local": {RAY_CONNECTOR_CLS_PATH: DEFAULT_CONNECTOR_CLS_PATH},
125+
}
126+
127+
# Map of state types that should be replaced
128+
state_type_map = {
129+
"ray": {DEFAULT_STATE_BACKEND_CLS_PATH: RAY_STATE_BACKEND_CLS_PATH},
130+
"local": {RAY_STATE_BACKEND_CLS_PATH: DEFAULT_STATE_BACKEND_CLS_PATH},
131+
}
132+
133+
# Prepare updates
134+
updates: dict[str, _t.Any] = {"type": new_process_type}
135+
136+
# Update connector if needed
137+
current_connector_type = self.connector_builder.type
138+
if current_connector_type in connector_type_map[process_type]:
139+
new_connector_type = connector_type_map[process_type][current_connector_type]
140+
updates["connector_builder"] = self.connector_builder.model_copy(
141+
update={"type": new_connector_type}
142+
)
143+
144+
# Update state if needed
145+
current_state_type = self.args.state.type
146+
if current_state_type in state_type_map[process_type]:
147+
new_state_type = state_type_map[process_type][current_state_type]
148+
new_state = self.args.state.model_copy(update={"type": new_state_type})
149+
updates["args"] = self.args.model_copy(update={"state": new_state})
150+
151+
# Apply updates and return new instance
152+
return self.model_copy(update=updates)

plugboard/cli/process/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ def run(
9292
help="Job ID for the process. If not provided, a random job ID will be generated.",
9393
),
9494
] = None,
95+
process_type: Annotated[
96+
_t.Optional[_t.Literal["local", "ray"]],
97+
typer.Option(
98+
"--process-type",
99+
help=(
100+
"Override the process type. "
101+
"Options: 'local' for LocalProcess, 'ray' for RayProcess."
102+
),
103+
),
104+
] = None,
95105
) -> None:
96106
"""Run a Plugboard process."""
97107
config_spec = _read_yaml(config)
@@ -100,6 +110,12 @@ def run(
100110
# Override job ID in config file if set
101111
config_spec.plugboard.process.args.state.args.job_id = job_id
102112

113+
if process_type is not None:
114+
# Use the ProcessSpec method to override the process type
115+
config_spec.plugboard.process = config_spec.plugboard.process.override_process_type(
116+
process_type # type: ignore[arg-type]
117+
)
118+
103119
with Progress(
104120
SpinnerColumn("arrow3"),
105121
TextColumn("[progress.description]{task.description}"),

tests/unit/test_cli.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,49 @@ def test_cli_process_diagram() -> None:
8888
assert "flowchart" in result.stdout
8989

9090

91+
@pytest.mark.asyncio
92+
async def test_cli_process_run_with_local_override() -> None:
93+
"""Tests the process run command with --process-type local."""
94+
with patch("plugboard.cli.process.ProcessBuilder") as mock_process_builder:
95+
mock_process = AsyncMock()
96+
mock_process_builder.build.return_value = mock_process
97+
result = runner.invoke(
98+
app,
99+
["process", "run", "tests/data/minimal-process.yaml", "--process-type", "local"],
100+
)
101+
# CLI must run without error
102+
assert result.exit_code == 0
103+
assert "Process complete" in result.stdout
104+
# Process must be built with LocalProcess type
105+
mock_process_builder.build.assert_called_once()
106+
call_args = mock_process_builder.build.call_args
107+
process_spec = call_args[0][0]
108+
assert process_spec.type == "plugboard.process.LocalProcess"
109+
assert process_spec.connector_builder.type == "plugboard.connector.AsyncioConnector"
110+
111+
112+
@pytest.mark.asyncio
113+
async def test_cli_process_run_with_ray_override() -> None:
114+
"""Tests the process run command with --process-type ray."""
115+
with patch("plugboard.cli.process.ProcessBuilder") as mock_process_builder:
116+
mock_process = AsyncMock()
117+
mock_process_builder.build.return_value = mock_process
118+
result = runner.invoke(
119+
app,
120+
["process", "run", "tests/data/minimal-process.yaml", "--process-type", "ray"],
121+
)
122+
# CLI must run without error
123+
assert result.exit_code == 0
124+
assert "Process complete" in result.stdout
125+
# Process must be built with RayProcess type
126+
mock_process_builder.build.assert_called_once()
127+
call_args = mock_process_builder.build.call_args
128+
process_spec = call_args[0][0]
129+
assert process_spec.type == "plugboard.process.RayProcess"
130+
assert process_spec.connector_builder.type == "plugboard.connector.RayConnector"
131+
assert process_spec.args.state.type == "plugboard.state.RayStateBackend"
132+
133+
91134
def test_cli_process_validate() -> None:
92135
"""Tests the process validate command."""
93136
result = runner.invoke(app, ["process", "validate", "tests/data/minimal-process.yaml"])

0 commit comments

Comments
 (0)