Skip to content

Commit 00986ce

Browse files
committed
feat: auto pull image
1 parent ab069cd commit 00986ce

File tree

8 files changed

+377
-167
lines changed

8 files changed

+377
-167
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ A FastAPI-based code interpreter service that provides code execution and file m
1010
## Features
1111

1212
- Easy deployment with single docker compose file
13-
-
13+
- Code execution in Docker container sandbox
14+
- File upload and download
1415
- Multi-user support
1516
- RESTful API with OpenAPI documentation
1617

@@ -22,7 +23,7 @@ A FastAPI-based code interpreter service that provides code execution and file m
2223

2324
Run the project with docker compose using `docker compose -f compose.prod.yml up`
2425

25-
It's possible to overwrite the default environment variables defined in ./app/shared/config.py by creating a `.env` file in the root directory.
26+
It's possible to overwrite the default environment variables defined in [./app/shared/config.py](./app/shared/config.py) by creating a `.env` file in the root directory.
2627
By default the project will create two directories in the root directory: `./config` and `./uploads`.
2728

2829
`config` directory will hold the sqlite database and temp uploaded files.

app/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .services.database import db_manager
1212
from .services.cleanup import cleanup_service
1313
from .utils.logging import setup_logging, RequestLoggingMiddleware
14+
from .services.docker_executor import docker_executor
1415

1516

1617
@asynccontextmanager
@@ -23,6 +24,9 @@ async def lifespan(app: FastAPI):
2324
# Initialize database
2425
await db_manager.initialize()
2526

27+
# Initialize Docker executor
28+
await docker_executor.initialize()
29+
2630
# Start cleanup service
2731
await cleanup_service.start()
2832

@@ -34,6 +38,7 @@ async def lifespan(app: FastAPI):
3438
# Cleanup
3539
logger.info("Shutting down application")
3640
await cleanup_service.stop()
41+
await docker_executor.close()
3742
await db_manager.close()
3843

3944

app/services/docker_executor.py

Lines changed: 253 additions & 153 deletions
Large diffs are not rendered by default.

app/services/file_manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@
99
from ..shared.config import get_settings
1010
from .database import db_manager
1111
from app.utils.generate_id import generate_id
12-
12+
from app.shared.const import UPLOAD_PATH
1313
settings = get_settings()
1414

1515

1616
class FileManager:
1717
"""Manages file operations for code interpreter sessions."""
1818

1919
def __init__(self):
20-
self.upload_path = Path("/app/uploads")
20+
self.upload_path = UPLOAD_PATH
2121
self.upload_path.mkdir(parents=True, exist_ok=True)
2222
self._mime = magic.Magic(mime=True)
2323

app/shared/config.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
1212

1313
# Configuration
1414
HOST_PATH: Path = (
15-
None # This is used to fully qualify relative paths for Docker code execution container volume mounts
15+
"." # This is used to fully qualify relative paths for Docker code execution container volume mounts
1616
)
1717
HOST_CONFIG_PATH: Path = Path("config") # Base directory for configuration files
1818
LOG_LEVEL: str = "INFO" # Log level for logging
@@ -32,7 +32,42 @@ def CONFIG_PATH_ABS(self) -> Path:
3232

3333
# File management
3434
FILE_MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 # 10MB
35-
FILE_ALLOWED_EXTENSIONS: Set[str] = {"py", "txt", "json", "csv", "ipynb", "xlsx", "yml", "yaml", "md"}
35+
FILE_ALLOWED_EXTENSIONS: Set[str] = {
36+
# Programming languages
37+
"py",
38+
"c",
39+
"cpp",
40+
"java",
41+
"php",
42+
"rb",
43+
"js",
44+
"ts",
45+
# Documents
46+
"txt",
47+
"md",
48+
"html",
49+
"css",
50+
"tex",
51+
"json",
52+
"csv",
53+
"xml",
54+
"docx",
55+
"xlsx",
56+
"pptx",
57+
"pdf",
58+
# Data formats
59+
"ipynb",
60+
"yml",
61+
"yaml",
62+
# Archives
63+
"zip",
64+
"tar",
65+
# Images
66+
"jpg",
67+
"jpeg",
68+
"png",
69+
"gif",
70+
}
3671

3772
HOST_FILE_UPLOAD_PATH: Path = Path("uploads") # Base directory for uploaded files
3873

@@ -49,6 +84,8 @@ def HOST_FILE_UPLOAD_PATH_ABS(self) -> Path:
4984
CLEANUP_RUN_INTERVAL: int = 3600 # How often to run the cleanup in seconds
5085
CLEANUP_FILE_MAX_AGE: int = 86400 # How old files can be before they are deleted in seconds
5186

87+
PYTHON_CONTAINER_IMAGE: str = "jupyter/scipy-notebook:latest"
88+
5289

5390
@lru_cache()
5491
def get_settings() -> Settings:

app/shared/const.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from pathlib import Path
2+
import os
23

4+
# Get the current working directory or use environment variable if set
5+
HOST_PATH = Path(os.environ.get("HOST_PATH", os.getcwd()))
36

4-
UPLOAD_PATH = Path('/app/uploads')
5-
CONFIG_PATH = Path('/app/config')
7+
# Use relative paths that will be resolved at runtime
8+
UPLOAD_PATH = Path("uploads")
9+
CONFIG_PATH = Path("config")

tests/conftest.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import os
22
from pathlib import Path
3+
4+
from app.shared.const import CONFIG_PATH
5+
36
os.environ["HOST_PATH"] = str(Path.cwd())
47

58
import pytest
@@ -10,15 +13,17 @@
1013

1114

1215
logger.remove()
13-
logger.add("logs/test.log")
14-
16+
logs_path = Path("logs/test.log")
17+
logs_path.parent.mkdir(exist_ok=True, parents=True)
18+
# logs_path.unlink(missing_ok=True)
19+
logger.add(logs_path)
1520

1621

1722
@pytest.fixture(autouse=True)
1823
async def init_db():
1924
"""Initialize the database before running tests."""
2025
# Ensure the data directory exists
21-
Path("data").mkdir(exist_ok=True)
26+
Path(CONFIG_PATH).mkdir(exist_ok=True, parents=True)
2227

2328
# Initialize the database
2429
await db_manager.initialize()
@@ -27,7 +32,7 @@ async def init_db():
2732

2833
# Cleanup after tests
2934
try:
30-
Path("data/files.db").unlink(missing_ok=True)
35+
(CONFIG_PATH / "test_database.db").unlink(missing_ok=True)
3136
except Exception as e:
3237
print(f"Failed to cleanup database: {e}")
3338

tests/main/test_code_execution.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import pytest
12
from fastapi.testclient import TestClient
23
from app.main import app
4+
from app.services.docker_executor import docker_executor
35

46
client = TestClient(app)
57

@@ -35,7 +37,7 @@ def test_code_execution_error():
3537
result = response.json()
3638
assert result["run"]["status"] == "error"
3739
assert "ZeroDivisionError" in result["run"]["stderr"]
38-
assert result["run"]["stdout"] == ""
40+
assert result["run"]["stdout"] == "Empty. Make sure to explicitly print the results"
3941
assert isinstance(result["files"], list)
4042

4143
def test_syntax_error():
@@ -51,4 +53,60 @@ def test_syntax_error():
5153
assert response.status_code == 200
5254
result = response.json()
5355
assert result["run"]["status"] == "error"
54-
assert "SyntaxError" in result["run"]["stderr"]
56+
assert "SyntaxError" in result["run"]["stderr"]
57+
assert result["run"]["stdout"] == "Empty. Make sure to explicitly print the results"
58+
assert isinstance(result["files"], list)
59+
60+
# Create a fixture for the Docker executor
61+
@pytest.fixture(scope="function")
62+
async def docker_exec():
63+
"""Initialize and clean up the Docker executor."""
64+
# Initialize Docker executor
65+
await docker_executor.initialize()
66+
yield docker_executor
67+
# Clean up Docker executor
68+
await docker_executor.close()
69+
70+
def test_multiple_sequential_requests(docker_exec):
71+
"""Test executing multiple code requests in sequence to verify event loop handling."""
72+
# First request
73+
response1 = client.post(
74+
"/v1/execute",
75+
json={
76+
"code": "print('First request')",
77+
"lang": "py"
78+
}
79+
)
80+
81+
assert response1.status_code == 200
82+
result1 = response1.json()
83+
assert result1["run"]["status"] == "ok"
84+
assert "First request" in result1["run"]["stdout"]
85+
86+
# Second request
87+
response2 = client.post(
88+
"/v1/execute",
89+
json={
90+
"code": "print('Second request')",
91+
"lang": "py"
92+
}
93+
)
94+
95+
assert response2.status_code == 200
96+
result2 = response2.json()
97+
assert result2["run"]["status"] == "ok"
98+
assert "Second request" in result2["run"]["stdout"]
99+
100+
# Third request
101+
response3 = client.post(
102+
"/v1/execute",
103+
json={
104+
"code": "print('Third request')",
105+
"lang": "py"
106+
}
107+
)
108+
109+
assert response3.status_code == 200
110+
result3 = response3.json()
111+
assert result3["run"]["status"] == "ok"
112+
assert "Third request" in result3["run"]["stdout"]

0 commit comments

Comments
 (0)