Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat:Add support for containerized Code execution, and utilities ( upload / download fn ). #459

Closed
wants to merge 15 commits into from
Closed
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
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"python.analysis.typeCheckingMode": "basic"
}
40 changes: 33 additions & 7 deletions interpreter/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
from .core.core import Interpreter
import sys
from .core.core import Interpreter
from .cli.cli import cli


# This is done so when users `import interpreter`,
# they get an instance of interpreter:

sys.modules["interpreter"] = Interpreter()
def create_interpreter(**kwargs):
"""
Factory function to create an instance of Interpreter with the provided keyword arguments.

Parameters:
**kwargs: Keyword arguments to be set as attributes in the Interpreter instance.

Returns:
An instance of Interpreter initialized with the provided arguments.
"""
# Create a new interpreter instance
new_interpreter = Interpreter()

# Iterate through the provided keyword arguments
for key, value in kwargs.items():
# Check if the attribute exists in the interpreter
if hasattr(new_interpreter, key):
# Check if the provided value is of the correct type
if isinstance(value, type(getattr(new_interpreter, key))):
setattr(new_interpreter, key, value)
else:
print(
f"Type mismatch: '{key}' should be of type {type(getattr(new_interpreter, key))}. Using the default value instead.")

else:
print(
f"Unknown attribute: '{key}'. Ignoring.")


return new_interpreter

# **This is a controversial thing to do,**
# because perhaps modules ought to behave like modules.

# But I think it saves a step, removes friction, and looks good.

# ____ ____ __ __
# / __ \____ ___ ____ / _/___ / /____ _________ ________ / /____ _____
Expand Down
24 changes: 22 additions & 2 deletions interpreter/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
from ..utils.get_config import get_config_path
from ..terminal_interface.conversation_navigator import conversation_navigator

import sys
import pysqlite3

# Alias pysqlite3 as sqlite3 in sys.modules. this fixes a chromadb error where it whines about the wrong version being installed, but we cant change the containers sqlite.
# 'pysqlite3' is a drop in replacement for default python sqlite3 lib. ( identical apis )
sys.modules['sqlite3'] = pysqlite3
Comment on lines +11 to +16
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just putting a note here so that we don't forget to remove this when we switch to a different base container image.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why can't the container sqlite be changed?





arguments = [
{
"name": "system_message",
Expand Down Expand Up @@ -71,6 +81,13 @@
"type": str,
},
{
"name": "use_containers",
"nickname": "uc",
"help_text": "optionally use a Docker Container for the interpreters code execution. this will seperate execution from your main computer. this also allows execution on a remote server via the 'DOCKER_HOST' environment variable and the dockerengine api.",
"type": bool
},
{

"name": "safe_mode",
"nickname": "safe",
"help_text": "optionally enable safety mechanisms like code scanning; valid options are off, ask, and auto",
Expand All @@ -91,10 +108,11 @@
},
]


def cli(interpreter):
def cli():
parser = argparse.ArgumentParser(description="Open Interpreter")

from ..core.core import Interpreter

# Add arguments
for arg in arguments:
if arg["type"] == bool:
Expand Down Expand Up @@ -158,6 +176,8 @@ def cli(interpreter):

args = parser.parse_args()

interpreter = Interpreter()

# This should be pushed into an open_config.py util
# If --config is used, open the config.yaml file in the Open Interpreter folder of the user's config dir
if args.config:
Expand Down
1 change: 1 addition & 0 deletions interpreter/code_interpreters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

38 changes: 38 additions & 0 deletions interpreter/code_interpreters/container_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import appdirs
import shutil
import atexit
import os
import re

import docker
from docker.tls import TLSConfig
from docker.utils import kwargs_from_env


def destroy(): # this fn is called when the entire program exits. registered with atexit in the __init__.py
# Prepare the Docker client
client_kwargs = kwargs_from_env()
if client_kwargs.get('tls'):
client_kwargs['tls'] = TLSConfig(**client_kwargs['tls'])
client = docker.APIClient(**client_kwargs)

# Get all containers
all_containers = client.containers(all=True)

# Filter containers based on the label
for container in all_containers:
labels = container['Labels']
if labels:
session_id = labels.get('session_id')
if session_id and re.match(r'^ses-', session_id):
# Stop the container if it's running
if container['State'] == 'running':
client.stop(container=container['Id'])
# Remove the container
client.remove_container(container=container['Id'])
session_path = os.path.join(appdirs.user_data_dir("Open Interpreter"), "sessions", session_id)
if os.path.exists(session_path):
shutil.rmtree(session_path)

atexit.register(destroy)

68 changes: 68 additions & 0 deletions interpreter/code_interpreters/container_utils/auto_remove.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import threading
import time
from functools import wraps

def access_aware(cls):
class AccessAwareWrapper:
def __init__(self, wrapped, auto_remove_timeout, close_callback=None):
self._wrapped = wrapped
self._last_accessed = time.time()
self._auto_remove = auto_remove_timeout is not None
self._timeout = auto_remove_timeout
self.close_callback = close_callback # Store the callback
if self._auto_remove:
self._monitor_thread = threading.Thread(target=self._monitor_object, daemon=True)
self._monitor_thread.start()

def _monitor_object(self):
while True:
time.sleep(1) # Check every second
if self._auto_remove and self.check_timeout():
# If a close_callback is defined, call it
if self.close_callback:
try:
self.close_callback() # Call the callback
except Exception as e:
# Log or handle the exception as required
return f"An error occurred during callback: {e}"

try:
self._wrapped.stop()
except Exception:
continue # why care? we are removing it anyway

# If the wrapped object has a __del__ method, call it
if self._wrapped and hasattr(self._wrapped, '__del__'):
try:
self._wrapped.__del__()
except Exception as e:
# Log or handle the exception as required
return f"An error occurred during deletion: {e}"

# Remove the strong reference to the wrapped object. this makes it go bye bye.
self._wrapped = None
break

def touch(self):
self._last_accessed = time.time()

def check_timeout(self):
return time.time() - self._last_accessed > self._timeout

def __getattr__(self, attr):
if self._wrapped is None:
raise ValueError("Object has been removed due to inactivity.")
self.touch() # Update last accessed time
return getattr(self._wrapped, attr) # Use the actual object here

def __del__(self):
if self._auto_remove:
self._monitor_thread.join() # Ensure the monitoring thread is cleaned up

@wraps(cls)
def wrapper(*args, **kwargs):
auto_remove_timeout = kwargs.pop('auto_remove_timeout', None) # Extract the auto_remove_timeout argument
close_callback = kwargs.pop('close_callback', None) # Extract the close_callback argument
obj = cls(*args, **kwargs) # Create an instance of the original class
return AccessAwareWrapper(obj, auto_remove_timeout, close_callback) # Wrap it
return wrapper
108 changes: 108 additions & 0 deletions interpreter/code_interpreters/container_utils/build_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import os
import json
import hashlib
import subprocess
from docker import DockerClient
from docker.errors import DockerException
from rich import print as Print

def get_files_hash(*file_paths):
"""Return the SHA256 hash of multiple files."""
hasher = hashlib.sha256()
for file_path in file_paths:
with open(file_path, "rb") as f:
while chunk := f.read(4096):
hasher.update(chunk)
return hasher.hexdigest()


def build_docker_images(
dockerfile_dir = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), "dockerfiles")
,
):
"""
Builds a Docker image for the Open Interpreter runtime container if needed.

Args:
dockerfile_dir (str): The directory containing the Dockerfile and requirements.txt files.

Returns:
None
"""
try:
client = DockerClient.from_env()
except DockerException:
Print("[bold red]ERROR[/bold red]: Could not connect to Docker daemon. Is Docker Engine installed and running?")
Print(
"\nFor information on Docker installation, visit: https://docs.docker.com/engine/install/ and follow the instructions for your system."
)
return

image_name = "openinterpreter-runtime-container"
hash_file_path = os.path.join(dockerfile_dir, "hash.json")

dockerfile_name = "Dockerfile"
requirements_name = "requirements.txt"
dockerfile_path = os.path.join(dockerfile_dir, dockerfile_name)
requirements_path = os.path.join(dockerfile_dir, requirements_name)

if not os.path.exists(dockerfile_path) or not os.path.exists(requirements_path):
Print("ERROR: Dockerfile or requirements.txt not found. Did you delete or rename them?")
raise RuntimeError(
"No container Dockerfiles or requirements.txt found. Make sure they are in the dockerfiles/ subdir of the module."
)

current_hash = get_files_hash(dockerfile_path, requirements_path)

stored_hashes = {}
if os.path.exists(hash_file_path):
with open(hash_file_path, "rb") as f:
stored_hashes = json.load(f)

original_hash = stored_hashes.get("original_hash")
previous_hash = stored_hashes.get("last_hash")

if current_hash == original_hash:
images = client.images.list(name=image_name, all=True)
if not images:
Print("Downloading default image from Docker Hub, please wait...")

subprocess.run(["docker", "pull", "unaidedelf/openinterpreter-runtime-container:latest"])
subprocess.run(["docker", "tag", "unaidedelf/openinterpreter-runtime-container:latest", image_name ],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
elif current_hash != previous_hash:
Print("Dockerfile or requirements.txt has changed. Building container...")

try:
# Run the subprocess without capturing stdout and stderr
# This will allow Docker's output to be printed to the console in real-time
subprocess.run(
[
"docker",
"build",
"-t",
f"{image_name}:latest",
dockerfile_dir,
],
check=True, # This will raise a CalledProcessError if the command returns a non-zero exit code
text=True,
)

# Update the stored current hash
stored_hashes["last_hash"] = current_hash
with open(hash_file_path, "w") as f:
json.dump(stored_hashes, f)

except subprocess.CalledProcessError:
# Suppress Docker's error messages and display your own error message
Print("Docker Build Error: Building Docker image failed. Please review the error message above and resolve the issue.")

except FileNotFoundError:
Print("ERROR: The 'docker' command was not found on your system.")
Print(
"Please ensure Docker Engine is installed and the 'docker' command is available in your PATH."
)
Print(
"For information on Docker installation, visit: https://docs.docker.com/engine/install/"
)
Print("If Docker is installed, try starting a new terminal session.")
Loading