Skip to content

Commit cc29351

Browse files
committed
Bump major version
- Added functionality to run commands and set environment variables - Using client.disconnect removes the client from the server - Introduced `CILLOW_DISABLE_AUTO_INSTALL` environment variable to disable auto installation of import packages - Resolved process cleanup issue in docker containers - Added tests for server utilities - Updated documentation
1 parent 083035f commit cc29351

19 files changed

+669
-149
lines changed

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ Cillow is an open-source library that enables you to execute AI-generated code i
2323

2424
It offers key features such as:
2525

26-
- **Environment Switching**: Easily switch between multiple python environments.
27-
- **Automated Package Management**: Automatic installation of imported packages through `uv` or `pip`.
28-
- **Functionality Patches**: Apply patches to limit the scope of AI-generated code, capture outputs like `stdout`, `stderr`, images, plots, etc., and more.
26+
- **Environment Switching**: Effortlessly switch between multiple Python environments.
27+
- **Automated Package Installation**: Automatically install imported packages using `uv` or `pip`.
28+
- **Functionality Patches**: Apply patches to restrict the scope of AI-generated code, capture outputs such as `stdout`, `stderr`, images, plots, and more.
2929

3030
### Check Documentation
3131

@@ -78,6 +78,6 @@ img.show()
7878

7979
---
8080

81-
At the moment, Cillow only supports Python since it doesn't use Jupyter Kernel.
81+
At the moment, Cillow only supports Python, as it does not rely on Jupyter Kernel/Lab.
8282

83-
This project began as an exploration of [E2B](https://e2b.dev/)'s code interpreter. I implemented the Python interpreter from scratch using ZeroMQ, taking a different approach by adding features like environment switching and functionality patching. Seeing the potential in this project, I evolved it into a client-server architecture using threading and multiprocessing.
83+
This project began as an exploration of [E2B](https://e2b.dev/)'s code interpreter. I implemented the Python interpreter from scratch, taking a different approach by adding features like environment switching and functionality patching. Recognizing the potential of the project, I expanded it into a client-server architecture using ZeroMQ, threading, and multiprocessing.

cillow/client.py

Lines changed: 99 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
Disconnect,
1414
ExceptionInfo,
1515
Execution,
16-
GetEnvrionment,
16+
GetPythonEnvironment,
1717
InstallRequirements,
1818
ModifyInterpreter,
1919
PythonEnvironment,
2020
Result,
2121
RunCode,
22+
RunCommand,
23+
SetEnvironmentVariables,
2224
Stream,
2325
)
2426

@@ -52,9 +54,16 @@ class Client:
5254
...
5355
... img.show()
5456
... \"\"\")
55-
>>> client.switch_interpreter("/path/to/python/env") # Switch to interpreter process with given environment
56-
>>> client.delete_interpreter("/path/to/python/env") # Stop interpreter process running in given environment
57-
>>> client.install_requirements(["pkg-name1", "pkg-name2"]) # Install requirements in the current interpreter process
57+
>>> # Switch to interpreter process with given environment
58+
>>> client.switch_interpreter("/path/to/python/env")
59+
>>> # Stop interpreter process running in given environment
60+
>>> client.delete_interpreter("/path/to/python/env")
61+
>>> # Install requirements in the current selected environment
62+
>>> client.install_requirements("pkg-name1", "pkg-name2")
63+
>>> # Run commands
64+
>>> client.run_command("echo", "Hello World")
65+
>>> # Set environment variables
66+
>>> client.set_environment_variables({"VAR1": "value1", "VAR2": "value2"})
5867
"""
5968

6069
def __init__(
@@ -82,13 +91,19 @@ def __init__(
8291
self.__id = id
8392
self.__timeout: int | None = None
8493
self.__current_environment: PythonEnvironment | None = None
94+
self.__default_environment: PythonEnvironment | None = None
8595

8696
self.switch_interpreter(environment)
8797

98+
# fmt: off
8899
@classmethod
89100
def new(
90-
cls, host: str | None = None, port: int | None = None, environment: PythonEnvironment | str = "$system"
101+
cls,
102+
host: str | None = None,
103+
port: int | None = None,
104+
environment: PythonEnvironment | str = "$system"
91105
) -> Client:
106+
# fmt: on
92107
"""
93108
Connect to the server as a new client.
94109
@@ -104,29 +119,36 @@ def __enter__(self) -> Client:
104119

105120
@property
106121
def id(self) -> str:
107-
"""Identifier of the client."""
122+
"""Client's identifier."""
108123
return self.__id
109124

110125
@property
111-
def timeout(self) -> int | None:
126+
def request_timeout(self) -> int | None:
112127
"""Timeout for request in milliseconds."""
113128
return self.__timeout
114129

115-
@timeout.setter
116-
def timeout(self, value: int) -> None:
130+
@request_timeout.setter
131+
def request_timeout(self, value: int) -> None:
117132
self.__timeout = value
118133

134+
@property
135+
def default_environment(self) -> PythonEnvironment:
136+
"""Default Python environment."""
137+
if self.__default_environment is None:
138+
self.__default_environment = self._get_return_value(GetPythonEnvironment(type="default"))
139+
return self.__default_environment
140+
119141
@property
120142
def current_environment(self) -> PythonEnvironment:
121-
"""Current Python environment"""
143+
"""Current interpreter's python environment."""
122144
if self.__current_environment is None:
123-
self.__current_environment = self._get_return_value(GetEnvrionment(environment_type="current"))
145+
self.__current_environment = self._get_return_value(GetPythonEnvironment(type="current"))
124146
return self.__current_environment
125147

126148
@property
127149
def all_environments(self) -> list[PythonEnvironment]:
128-
"""All running Python environments"""
129-
return self._get_return_value(GetEnvrionment(environment_type="all")) # type: ignore[no-any-return]
150+
"""All running interpreter's python environments."""
151+
return self._get_return_value(GetPythonEnvironment(type="all")) # type: ignore[no-any-return]
130152

131153
def _send_request(self, request_dataclass: Any) -> Generator[tuple[bytes, bytes], None, bytes]:
132154
"""
@@ -167,40 +189,82 @@ def _get_return_value(self, request_dataclass: Any) -> Any:
167189

168190
def switch_interpreter(self, environment: PythonEnvironment | str) -> None:
169191
"""
170-
Switch to interpreter associated with the given Python environment.
192+
Switch to specified python environment's interpreter process.
171193
172-
Creates a new interpreter process if it doesn't exists.
194+
Creates a new interpreter process if it is not already running.
173195
174196
Args:
175197
environment: The Python environment to use
176198
"""
177-
self.__current_environment = self._get_return_value(ModifyInterpreter(environment=environment, mode="switch"))
199+
self.__current_environment = self._get_return_value(ModifyInterpreter(environment, mode="switch"))
178200

179201
def delete_interpreter(self, environment: PythonEnvironment | str) -> None:
180202
"""
181-
Delete the interpreter associated with the given Python environment.
182-
After deletion, the current environment is set to `$system`.
203+
Stop the specified python environment's interpreter process.
204+
205+
Switches to default python environment's interpreter process.
183206
184207
Args:
185208
environment: The Python environment to use
186209
"""
187-
self.__current_environment = self._get_return_value(ModifyInterpreter(environment=environment, mode="delete"))
210+
self.__current_environment = self._get_return_value(ModifyInterpreter(environment, mode="delete"))
188211

189-
def install_requirements(self, requirements: list[str], on_stream: Callable[[Stream], None] | None = None) -> None:
212+
def set_environment_variables(self, environment_variables: dict[str, str]) -> None:
213+
"""
214+
Set environment variables for the current interpreter.
215+
216+
Args:
217+
environment_variables: The environment variables to set
218+
"""
219+
for _ in self._send_request(SetEnvironmentVariables(environment_variables)):
220+
...
221+
222+
def run_command(self, *cmd: str, on_stream: Callable[[Stream], None] | None = None) -> None:
223+
"""
224+
Run the given command.
225+
226+
⚠️ WARNING: This class allows execution of system commands and should be used with EXTREME CAUTION.
227+
228+
- Never run commands with user-supplied or untrusted input
229+
- Always validate and sanitize any command arguments
230+
- Be aware of potential security risks, especially with privilege escalation
231+
232+
Args:
233+
cmd: The command to run
234+
on_stream: The callback to capture streaming output.
235+
"""
236+
on_stream = on_stream or default_stream_processor
237+
for msg_type, body in self._send_request(RunCommand(cmd=cmd)):
238+
if msg_type != b"interpreter":
239+
continue
240+
241+
on_stream(pickle.loads(body))
242+
243+
# fmt: off
244+
def install_requirements(
245+
self, *requirements: str, on_stream: Callable[[Stream], None] | None = None
246+
) -> None:
247+
# fmt: on
190248
"""
191249
Install the given requirements in the current Python environment.
192250
193251
Args:
194252
requirements: The requirements to install
195253
"""
196254
on_stream = on_stream or default_stream_processor
197-
for msg_type, body in self._send_request(InstallRequirements(requirements=requirements)):
255+
for msg_type, body in self._send_request(InstallRequirements(requirements)):
198256
if msg_type != b"interpreter":
199257
continue
200258

201259
on_stream(pickle.loads(body))
202260

203-
def run_code(self, code: str, on_stream: Callable[[Stream | ByteStream], None] | None = None) -> Execution:
261+
# fmt: off
262+
def run_code(
263+
self,
264+
code: str,
265+
on_stream: Callable[[Stream | ByteStream], None] | None = None
266+
) -> Execution:
267+
# fmt: on
204268
"""
205269
Run the code in the current selected interpreter.
206270
@@ -212,19 +276,18 @@ def run_code(self, code: str, on_stream: Callable[[Stream | ByteStream], None] |
212276
The execution result containing the result, streams, byte streams and exception.
213277
"""
214278
on_stream = on_stream or default_stream_processor
215-
streams, byte_streams = [], [] # type: ignore[var-annotated]
279+
result, streams, byte_streams, exception = Result(value=None), [], [], None
216280
for msg_type, body in self._send_request(RunCode(code=code)):
217281
if msg_type != b"interpreter":
218282
continue
219283

220284
response = pickle.loads(body)
221285
if isinstance(response, Result):
222-
return Execution(result=response, streams=streams, byte_streams=byte_streams)
223-
286+
result = response
287+
continue
224288
elif isinstance(response, ExceptionInfo):
225-
return Execution(
226-
result=Result(value=None), streams=streams, byte_streams=byte_streams, exception=response
227-
)
289+
exception = response
290+
continue
228291

229292
if isinstance(response, Stream):
230293
streams.append(response)
@@ -233,14 +296,19 @@ def run_code(self, code: str, on_stream: Callable[[Stream | ByteStream], None] |
233296

234297
on_stream(response)
235298

236-
return Execution( # This should never happen
237-
result=Result(value=None), streams=streams, byte_streams=byte_streams, exception=None
299+
return Execution(
300+
result=result, streams=streams, byte_streams=byte_streams, exception=exception
238301
)
239302

240303
def disconnect(self) -> None:
241-
"""Close the connection to the server and clean up all the resources being used by the client."""
304+
"""
305+
Close the connection to the server and remove the client.
306+
307+
Don't use this if you want to reconnect to the server later.
308+
"""
242309
for _ in self._send_request(Disconnect()):
243310
...
311+
244312
self._socket.close()
245313
self._socket.context.term()
246314

cillow/interpreter.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import os
34
import sys
45
from shutil import which as find_executable
56
from tempfile import NamedTemporaryFile
@@ -16,7 +17,7 @@
1617
__all__ = ("Interpreter",)
1718

1819

19-
PIP_INSTALL_CMD = ["uv", "pip", "install"] if find_executable("uv") else ["pip", "install"]
20+
PIP_INSTALL_CMD = ("uv", "pip", "install") if find_executable("uv") else ("pip", "install")
2021

2122

2223
class Interpreter:
@@ -74,7 +75,33 @@ def environment(self) -> PythonEnvironment:
7475
"""The current Python environment"""
7576
return getattr(self._import_hook, "environment", "$system")
7677

77-
def install_requirements(self, requirements: list[str], on_stream: Callable[[Stream], None] | None = None) -> None:
78+
# fmt: off
79+
def run_command(
80+
self, *cmd: str, on_stream: Callable[[Stream], Any] | None = None
81+
) -> None:
82+
# fmt: on
83+
"""
84+
Run the given command.
85+
86+
⚠️ WARNING: This class allows execution of system commands and should be used with EXTREME CAUTION.
87+
88+
- Never run commands with user-supplied or untrusted input
89+
- Always validate and sanitize any command arguments
90+
- Be aware of potential security risks, especially with privilege escalation
91+
92+
Args:
93+
cmd: The command to run
94+
on_stream: The callback to capture streaming output.
95+
"""
96+
on_stream = on_stream or default_stream_processor
97+
for line in shell.stream(*cmd):
98+
on_stream(Stream(type="cmd_exec", data=line))
99+
100+
# fmt: off
101+
def install_requirements(
102+
self, *requirements: str, on_stream: Callable[[Stream], None] | None = None
103+
) -> None:
104+
# fmt: on
78105
"""
79106
Install the given requirements.
80107
@@ -93,9 +120,7 @@ def install_requirements(self, requirements: list[str], on_stream: Callable[[Str
93120
handler.flush()
94121
install_args.extend(["-r", handler.name])
95122

96-
on_stream = on_stream or default_stream_processor
97-
for line in shell.stream(*PIP_INSTALL_CMD, *install_args):
98-
on_stream(Stream(type="cmd_exec", data=line))
123+
self.run_command(*PIP_INSTALL_CMD, *install_args, on_stream=on_stream)
99124

100125
def run_code(
101126
self, code: str, on_stream: Callable[[Stream | ByteStream], None] | None = None
@@ -115,12 +140,12 @@ def run_code(
115140
except Exception as exc:
116141
return ExceptionInfo(type=exc.__class__.__name__, message=str(exc))
117142

118-
on_stream = on_stream or default_stream_processor # TODO: create a function that can process byte stream
119-
if module_names := code_meta.module_names:
143+
on_stream = on_stream or default_stream_processor
144+
if not is_auto_install_disabled() and (module_names := code_meta.module_names):
120145
to_install = (module_names - sys.stdlib_module_names) - get_installed_modules()
121146
if to_install:
122147
packages = [MODULE_TO_PACKAGE_MAP.get(name, name) for name in to_install]
123-
self.install_requirements(packages, on_stream=on_stream)
148+
self.install_requirements(*packages, on_stream=on_stream)
124149

125150
try:
126151
with patch.load_patches(on_stream=on_stream):
@@ -149,6 +174,11 @@ def __del__(self) -> None:
149174
sys.path.pop(0)
150175

151176

177+
def is_auto_install_disabled() -> bool:
178+
"""Check if auto-install is disabled."""
179+
return os.environ.get("CILLOW_DISABLE_AUTO_INSTALL", "").lower() in ("1", "true", "yes")
180+
181+
152182
def is_running_in_jupyter() -> bool:
153183
"""Check if the interpreter is running in a Jupyter notebook"""
154184
try:
@@ -159,7 +189,7 @@ def is_running_in_jupyter() -> bool:
159189

160190

161191
def default_stream_processor(stream: Stream | ByteStream) -> None:
162-
"""Default stream processor for the interpreter"""
192+
"""Interpreter's default stream processor."""
163193
if isinstance(stream, Stream):
164194
if stream.type == "stdout":
165195
original = patch.prebuilt.stdout_write_switchable.original

cillow/patch/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@ class PatchProtocol(Protocol):
1414
def __call__(self) -> ContextManager[None]: ...
1515

1616

17-
class PatchWithStreamCaptureProtcol(Protocol):
17+
class StreamCapturePatchProtcol(Protocol):
1818
"""Patch callable protocol with stream capture callback"""
1919

2020
def __call__(self, on_stream: Callable[[Stream | ByteStream], Any]) -> ContextManager[None]: ...
2121

2222

23-
_patches_with_callback: list[PatchWithStreamCaptureProtcol] = []
23+
_patches_with_callback: list[StreamCapturePatchProtcol] = []
2424
_patches_without_callback: list[PatchProtocol] = []
2525

2626

27-
def add_patches(*patches: PatchProtocol | PatchWithStreamCaptureProtcol) -> None:
27+
def add_patches(*patches: PatchProtocol | StreamCapturePatchProtcol) -> None:
2828
"""
2929
Add new patches to be used by all Interpreter instances.
3030

cillow/server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def __init__(
8383
max_queue_size: Maximum queue size (defaults to `max_clients * interpreters_per_client * 2`)
8484
"""
8585
self.socket = zmq.Context().socket(zmq.ROUTER)
86-
self._url = f"tcp://127.0.0.1:{port}"
86+
self._url = f"tcp://0.0.0.0:{port}"
8787
self.socket.bind(self._url)
8888

8989
self._client_manager = ClientManager(max_interpreters, interpreters_per_client)

0 commit comments

Comments
 (0)