Skip to content

Commit c49a689

Browse files
refactoring
1 parent 6f1dcf5 commit c49a689

6 files changed

Lines changed: 268 additions & 0 deletions

File tree

clarifai/cli/pipeline.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,61 @@ def pipeline():
9393
"""Create and manage pipelines."""
9494

9595

96+
@pipeline.command('local-run')
97+
@click.argument("step_path", type=click.Path(exists=True), required=False, default=".")
98+
@click.option(
99+
'--mode',
100+
type=click.Choice(['container']),
101+
default='container',
102+
help='Execution mode. Currently only "container" is supported.',
103+
)
104+
@click.option(
105+
'--keep-image',
106+
is_flag=True,
107+
default=False,
108+
help='Keep the Docker image after the pipeline step finishes.',
109+
)
110+
def local_run(step_path, mode, keep_image):
111+
"""Run a pipeline step locally in a Docker container.
112+
113+
STEP_PATH: Path to the pipeline step directory (containing config.yaml,
114+
requirements.txt, and 1/pipeline_step.py). Defaults to current directory.
115+
116+
This reuses the same Docker build infrastructure as ``clarifai model serve
117+
--mode container`` but executes pipeline_step.py once and exits.
118+
"""
119+
from clarifai.runners.pipeline_steps.pipeline_run_locally import PipelineStepRunLocally
120+
121+
manager = PipelineStepRunLocally(step_path)
122+
123+
if not manager.is_docker_installed():
124+
raise click.ClickException("Docker is not installed.")
125+
126+
# Generate Dockerfile if missing
127+
manager.builder.create_dockerfile()
128+
129+
image_tag = manager._docker_hash()
130+
step_id = manager.config['pipeline_step']['id'].lower()
131+
image_name = f"{step_id}:{image_tag}"
132+
container_name = f"{step_id}-local-run"
133+
134+
if not manager.docker_image_exists(image_name):
135+
logger.info("Building Docker image...")
136+
manager.build_docker_image(image_name=image_name)
137+
138+
try:
139+
manager.run_pipeline_step_container(
140+
image_name=image_name,
141+
container_name=container_name,
142+
)
143+
finally:
144+
if manager.container_exists(container_name):
145+
manager.stop_docker_container(container_name)
146+
manager.remove_docker_container(container_name)
147+
if not keep_image:
148+
manager.remove_docker_image(image_name)
149+
150+
96151
@pipeline.command()
97152
@click.argument("path", type=click.Path(exists=True), required=False, default=".")
98153
@click.option(
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Run a pipeline step locally in a Docker container.
2+
3+
Reuses Docker infrastructure from ModelRunLocally for building and managing
4+
containers, but runs pipeline_step.py instead of the model server.
5+
"""
6+
7+
import os
8+
import signal
9+
import subprocess
10+
import sys
11+
import time
12+
13+
from clarifai.runners.models.model_run_locally import ModelRunLocally
14+
from clarifai.runners.pipeline_steps.pipeline_step_builder import PipelineStepBuilder
15+
from clarifai.utils.logging import logger
16+
17+
18+
class PipelineStepRunLocally:
19+
"""Run a single pipeline step locally in a Docker container.
20+
21+
Reuses ModelRunLocally for Docker build/image/container management,
22+
but overrides the container command to run pipeline_step.py.
23+
"""
24+
25+
def __init__(self, step_path):
26+
self.step_path = os.path.abspath(step_path)
27+
self.builder = PipelineStepBuilder(self.step_path)
28+
self.config = self.builder.config
29+
30+
# Create a ModelRunLocally instance for Docker utilities.
31+
# We bypass __init__ since it expects a ModelBuilder, and set the
32+
# attributes that the Docker methods rely on directly.
33+
self._docker = ModelRunLocally.__new__(ModelRunLocally)
34+
self._docker.model_path = self.step_path
35+
self._docker.requirements_file = os.path.join(self.step_path, "requirements.txt")
36+
37+
# ── Delegated Docker utilities ──────────────────────────────────────
38+
39+
def is_docker_installed(self):
40+
return self._docker.is_docker_installed()
41+
42+
def docker_image_exists(self, image_name):
43+
return self._docker.docker_image_exists(image_name)
44+
45+
def build_docker_image(self, image_name):
46+
return self._docker.build_docker_image(image_name=image_name)
47+
48+
def container_exists(self, container_name):
49+
return self._docker.container_exists(container_name)
50+
51+
def stop_docker_container(self, container_name):
52+
return self._docker.stop_docker_container(container_name)
53+
54+
def remove_docker_container(self, container_name):
55+
return self._docker.remove_docker_container(container_name)
56+
57+
def remove_docker_image(self, image_name):
58+
return self._docker.remove_docker_image(image_name)
59+
60+
def _docker_hash(self):
61+
return self._docker._docker_hash()
62+
63+
def _gpu_is_available(self):
64+
return self._docker._gpu_is_available()
65+
66+
# ── Pipeline-step-specific container run ────────────────────────────
67+
68+
def run_pipeline_step_container(
69+
self,
70+
image_name,
71+
container_name="clarifai-pipeline-step-container",
72+
env_vars=None,
73+
):
74+
"""Run pipeline_step.py inside a Docker container and wait for it to finish.
75+
76+
Unlike ``ModelRunLocally.run_docker_container`` which starts a long-running
77+
server, this method executes the pipeline step script once and exits.
78+
"""
79+
try:
80+
cmd = ["docker", "run", "--name", container_name, "--rm", "--network", "host"]
81+
82+
if self._gpu_is_available():
83+
cmd.extend(["--gpus", "all"])
84+
85+
# Mount step directory into container (same target as model serve)
86+
cmd.extend(
87+
[
88+
"--mount",
89+
f"type=bind,source={self.step_path},target=/home/nonroot/main",
90+
]
91+
)
92+
93+
if env_vars:
94+
for key, value in env_vars.items():
95+
cmd.extend(["-e", f"{key}={value}"])
96+
97+
cmd.extend(["-e", "PYTHONDONTWRITEBYTECODE=1"])
98+
99+
# Override entrypoint to run pipeline_step.py directly
100+
cmd.extend(["--entrypoint", "python"])
101+
cmd.append(image_name)
102+
cmd.extend(["/home/nonroot/main/1/pipeline_step.py"])
103+
104+
logger.info(f"Running pipeline step in container '{container_name}'...")
105+
logger.info(f"Docker command: {cmd}")
106+
107+
process = subprocess.Popen(cmd)
108+
109+
# Graceful Ctrl+C handling
110+
original_sigint = signal.getsignal(signal.SIGINT)
111+
112+
def signal_handler(sig, frame):
113+
logger.info(f"Stopping container '{container_name}'...")
114+
subprocess.run(["docker", "stop", container_name], check=False)
115+
process.terminate()
116+
signal.signal(signal.SIGINT, original_sigint)
117+
time.sleep(1)
118+
sys.exit(0)
119+
120+
signal.signal(signal.SIGINT, signal_handler)
121+
122+
process.wait()
123+
124+
# Restore original handler
125+
signal.signal(signal.SIGINT, original_sigint)
126+
127+
if process.returncode != 0:
128+
logger.error(f"Pipeline step failed with exit code {process.returncode}")
129+
sys.exit(process.returncode)
130+
131+
logger.info("Pipeline step completed successfully!")
132+
133+
except subprocess.CalledProcessError as e:
134+
logger.error(f"Error running pipeline step container: {e}")
135+
sys.exit(1)
136+
except Exception as e:
137+
logger.error(f"Error running pipeline step container: {e}")
138+
sys.exit(1)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Dummy pipeline step for testing local-run."""
2+
3+
print("Pipeline step started")
4+
print("Pipeline step completed successfully")
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
pipeline_step:
2+
id: "dummy-pipeline-step"
3+
user_id: "test_user"
4+
app_id: "test_app"
5+
6+
build_info:
7+
python_version: "3.12"
8+
9+
pipeline_step_compute_info:
10+
cpu_limit: "500m"
11+
cpu_memory: "500Mi"
12+
num_accelerators: 0
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
clarifai
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import shutil
2+
import subprocess
3+
import sys
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from clarifai.runners.pipeline_steps.pipeline_run_locally import PipelineStepRunLocally
9+
10+
11+
@pytest.fixture
12+
def dummy_pipeline_step_path(tmp_path):
13+
"""Copy the dummy_pipeline_step folder to a temp directory."""
14+
tests_dir = Path(__file__).parent.resolve()
15+
original_path = tests_dir / "dummy_pipeline_step"
16+
if not original_path.exists():
17+
raise FileNotFoundError(f"Could not find dummy_pipeline_step at {original_path}.")
18+
target_folder = tmp_path / "dummy_pipeline_step"
19+
shutil.copytree(original_path, target_folder)
20+
return str(target_folder)
21+
22+
23+
@pytest.fixture
24+
def pipeline_run_locally(dummy_pipeline_step_path):
25+
"""Instantiate PipelineRunLocally with the dummy pipeline step."""
26+
return PipelineStepRunLocally(dummy_pipeline_step_path)
27+
28+
29+
@pytest.mark.skipif(shutil.which("docker") is None, reason="Docker not installed or not in PATH.")
30+
@pytest.mark.skipif(
31+
sys.platform not in ["linux", "darwin"],
32+
reason="Test only runs on Linux and macOS.",
33+
)
34+
def test_pipeline_step_docker_build_and_run(pipeline_run_locally):
35+
"""Test building a Docker image and running a pipeline step in a container."""
36+
assert pipeline_run_locally.is_docker_installed(), "Docker not installed."
37+
38+
pipeline_run_locally.builder.create_dockerfile()
39+
image_tag = pipeline_run_locally._docker_hash()
40+
step_id = pipeline_run_locally.config['pipeline_step']['id'].lower()
41+
image_name = f"{step_id}:{image_tag}"
42+
container_name = "test-pipeline-step-container"
43+
44+
if not pipeline_run_locally.docker_image_exists(image_name):
45+
pipeline_run_locally.build_docker_image(image_name=image_name)
46+
47+
try:
48+
pipeline_run_locally.run_pipeline_step_container(
49+
image_name=image_name,
50+
container_name=container_name,
51+
)
52+
except subprocess.CalledProcessError:
53+
pytest.fail("Failed to run pipeline step inside the docker container.")
54+
finally:
55+
if pipeline_run_locally.container_exists(container_name):
56+
pipeline_run_locally.stop_docker_container(container_name)
57+
pipeline_run_locally.remove_docker_container(container_name)
58+
pipeline_run_locally.remove_docker_image(image_name)

0 commit comments

Comments
 (0)