Skip to content

Commit 6e77fc7

Browse files
committed
New Endpoint Added :)
1 parent 4d96624 commit 6e77fc7

File tree

5 files changed

+368
-3
lines changed

5 files changed

+368
-3
lines changed

carbonserver/carbonserver/api/routers/runs.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import dateutil.relativedelta
55
from dependency_injector.wiring import Provide, inject
6-
from fastapi import APIRouter, Depends, Header
6+
from fastapi import APIRouter, Depends, Header, File, UploadFile, Form
77
from starlette import status
88

99
from carbonserver.api.errors import EmptyResultException
@@ -135,3 +135,31 @@ def read_project_last_run(
135135
except EmptyResultException as e:
136136
logger.warning(f"read_project_last_run : {e}")
137137
return Empty()
138+
139+
@router.post(
140+
"/runs/remote",
141+
tags=RUNS_ROUTER_TAGS,
142+
status_code=status.HTTP_200_OK,
143+
)
144+
@inject
145+
def run_remote(
146+
codecarbon_api_key: str = Form(...),
147+
experiment_id: str = Form(...),
148+
injected_code_file: UploadFile = File(..., description="Python code file to inject"),
149+
kaggle_api_key: str = Form(...),
150+
kaggle_username: str = Form(...),
151+
notebook_title: str = Form(...),
152+
api_endpoint: str = Form('https://api.codecarbon.io'),
153+
run_service: RunService = Depends(Provide[ServerContainer.run_service]),
154+
) -> dict:
155+
try:
156+
# Read the file content as string
157+
# Seek to beginning in case file was partially read
158+
injected_code_file.file.seek(0)
159+
injected_code = injected_code_file.file.read().decode('utf-8')
160+
if not injected_code or not injected_code.strip():
161+
return {"status": "error", "message": "Uploaded file is empty"}, status.HTTP_400_BAD_REQUEST
162+
return run_service.run_remote(codecarbon_api_key, experiment_id, injected_code, kaggle_api_key, kaggle_username, notebook_title, api_endpoint)
163+
except Exception as e:
164+
logger.error(f"run_remote : {e}")
165+
return {"status": "error", "message": str(e)}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import libcst as cst
2+
import tempfile
3+
import os
4+
import shutil
5+
from typing import Dict, Optional
6+
7+
class Injector:
8+
"""
9+
Unified injector for Python files using libcst.
10+
Handles both variable and function injection.
11+
All operations work on temp files automatically.
12+
"""
13+
14+
def __init__(self, python_file_path: str = None, code: str = None,
15+
module: cst.Module = None, filename: str = "script.py"):
16+
"""
17+
Args:
18+
python_file_path: Path to original Python file (read-only, copied to temp)
19+
code: Python code as string (alternative to file_path)
20+
module: CST Module object (most efficient - no parsing needed)
21+
filename: Name for temp file (used when code/module is provided)
22+
"""
23+
# Validate arguments
24+
provided = sum([bool(python_file_path), bool(code), bool(module)])
25+
if provided > 1:
26+
raise ValueError("Cannot provide multiple sources (python_file_path, code, or module)")
27+
if provided == 0:
28+
raise ValueError("Must provide either python_file_path, code, or module")
29+
30+
# Get module from file, string, or use provided CST Module
31+
if python_file_path:
32+
self.python_file_path = python_file_path
33+
# Read original file (read-only)
34+
with open(python_file_path, 'r', encoding='utf-8') as f:
35+
self._original_code = f.read()
36+
temp_filename = os.path.basename(python_file_path)
37+
# Parse using libcst
38+
self._module = cst.parse_module(self._original_code)
39+
elif module:
40+
self.python_file_path = None
41+
# Use provided CST Module (no parsing needed!)
42+
self._module = module
43+
self._original_code = module.code
44+
temp_filename = filename
45+
else: # code string
46+
self.python_file_path = None
47+
self._original_code = code
48+
# Parse using libcst
49+
self._module = cst.parse_module(code)
50+
temp_filename = filename
51+
52+
# Create temp directory and file immediately
53+
self._temp_dir = tempfile.mkdtemp()
54+
self._temp_file_path = os.path.join(self._temp_dir, temp_filename)
55+
56+
# Write initial copy to temp file
57+
with open(self._temp_file_path, 'w', encoding='utf-8') as f:
58+
f.write(self._original_code)
59+
60+
# File pointer is closed, all future ops use temp file
61+
62+
def _create_value_node(self, value):
63+
"""Helper to create CST value node from Python value"""
64+
type_map = {
65+
str: lambda v: cst.SimpleString(f'"{v}"'),
66+
int: lambda v: cst.Integer(str(v)),
67+
float: lambda v: cst.Float(str(v)),
68+
bool: lambda v: cst.Name("True" if v else "False"),
69+
type(None): lambda v: cst.Name("None"),
70+
}
71+
return type_map.get(type(value), lambda v: cst.SimpleString(f'"{str(v)}"'))(value)
72+
73+
def inject_variables(self, variables: Dict[str, any]):
74+
"""
75+
Inject variable assignments into the file.
76+
77+
Args:
78+
variables: Dictionary of variable names and values
79+
at_top: If True, injects at top of file; if False, at end
80+
81+
Returns:
82+
self (for chaining)
83+
"""
84+
assignments = [
85+
cst.SimpleStatementLine(body=[
86+
cst.Assign(
87+
targets=[cst.AssignTarget(target=cst.Name(var_name))],
88+
value=self._create_value_node(var_value)
89+
)
90+
])
91+
for var_name, var_value in variables.items()
92+
]
93+
94+
# Apply transformation directly by modifying module body
95+
new_body = list(self._module.body)
96+
# Insert at beginning
97+
new_body = assignments + new_body
98+
99+
self._module = self._module.with_changes(body=new_body)
100+
self._save_to_temp()
101+
102+
return self
103+
104+
def add_dependency(self, packages: list):
105+
"""
106+
Add pip install command at the top of the file using os.system.
107+
Also ensures 'import os' is present.
108+
109+
Args:
110+
packages: List of package names to install
111+
112+
Returns:
113+
self (for chaining)
114+
"""
115+
if not packages:
116+
return self
117+
118+
# Check if 'import os' already exists
119+
has_os_import = False
120+
for item in self._module.body:
121+
if isinstance(item, cst.SimpleStatementLine):
122+
for stmt in item.body:
123+
if isinstance(stmt, cst.Import):
124+
for alias in stmt.names:
125+
if alias.name.value == 'os':
126+
has_os_import = True
127+
break
128+
elif isinstance(stmt, cst.ImportFrom) and stmt.module and stmt.module.value == 'os':
129+
has_os_import = True
130+
break
131+
132+
# Create pip install command
133+
packages_str = ' '.join(packages)
134+
pip_command = f'pip install {packages_str}'
135+
136+
# Create os.system call
137+
os_system_call = cst.SimpleStatementLine(body=[
138+
cst.Expr(value=cst.Call(
139+
func=cst.Attribute(
140+
value=cst.Name('os'),
141+
attr=cst.Name('system')
142+
),
143+
args=[cst.Arg(value=cst.SimpleString(f'"{pip_command}"'))]
144+
))
145+
])
146+
147+
# Build new body
148+
new_body = list(self._module.body)
149+
150+
# Add import os if not present
151+
if not has_os_import:
152+
os_import = cst.SimpleStatementLine(body=[
153+
cst.Import(names=[cst.ImportAlias(name=cst.Name('os'))])
154+
])
155+
new_body.insert(0, os_import)
156+
# Insert os.system call after import
157+
new_body.insert(1, os_system_call)
158+
else:
159+
# Just insert os.system call at top
160+
new_body.insert(0, os_system_call)
161+
162+
self._module = self._module.with_changes(body=new_body)
163+
self._save_to_temp()
164+
165+
return self
166+
167+
def inject_function(self, code: str, func_name: str):
168+
"""
169+
Inject code into existing function's body by replacing its body content.
170+
171+
Args:
172+
code: Python code string to inject into function body
173+
func_name: Name of the existing function to modify
174+
175+
Returns:
176+
self (for chaining)
177+
"""
178+
# Parse injected code as module to get statements
179+
injected_module = cst.parse_module(code)
180+
body_statements = list(injected_module.body)
181+
182+
# Replace function body directly
183+
new_body = [
184+
item.with_changes(body=cst.IndentedBlock(body=body_statements))
185+
if isinstance(item, cst.FunctionDef) and item.name.value == func_name
186+
else item
187+
for item in self._module.body
188+
]
189+
self._module = self._module.with_changes(body=new_body)
190+
191+
self._save_to_temp()
192+
return self
193+
194+
def _save_to_temp(self):
195+
"""Internal: Save modified code to temp file"""
196+
with open(self._temp_file_path, 'w', encoding='utf-8') as f:
197+
f.write(self._module.code)
198+
199+
def get_temp_file_path(self) -> str:
200+
"""Get path to temporary file"""
201+
return self._temp_file_path
202+
203+
def get_temp_dir(self) -> str:
204+
"""Get path to temporary directory"""
205+
return self._temp_dir
206+
207+
def get_code(self) -> str:
208+
"""Get the modified code as string (for inspection)"""
209+
return self._module.code
210+
211+
def destroy(self):
212+
"""
213+
Destroy all temporary files and directory.
214+
Call this when done with temp files.
215+
"""
216+
if self._temp_dir and os.path.exists(self._temp_dir):
217+
shutil.rmtree(self._temp_dir)
218+
self._temp_dir = None
219+
self._temp_file_path = None
220+
221+
def __del__(self):
222+
"""Automatically clean up temp files when object is destroyed"""
223+
# Only destroy if temp_dir still exists (destroy() not already called)
224+
if hasattr(self, '_temp_dir') and self._temp_dir and os.path.exists(self._temp_dir):
225+
try:
226+
shutil.rmtree(self._temp_dir)
227+
except (OSError, AttributeError):
228+
# Ignore errors during destruction (temp files may already be cleaned up)
229+
pass
230+
231+
def __enter__(self):
232+
"""Context manager support"""
233+
return self
234+
235+
def __exit__(self, exc_type, exc_val, exc_tb):
236+
"""Context manager cleanup"""
237+
self.destroy()

carbonserver/carbonserver/api/services/run_service.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from typing import List
22
from uuid import UUID
3+
import os
4+
import json
5+
import subprocess
36

47
from carbonserver.api.infra.repositories.repository_runs import SqlAlchemyRepository
58
from carbonserver.api.schemas import Run, RunCreate, User
69
from carbonserver.api.services.auth_context import AuthContext
7-
10+
from carbonserver.api.services.injector_service import Injector
11+
from carbonserver.kaggle_template import KaggleScriptTemplate
812

913
class RunService:
1014
def __init__(
@@ -32,3 +36,40 @@ def read_project_last_run(
3236
self, project_id: str, start_date, end_date, user: User = None
3337
) -> Run:
3438
return self._repository.get_project_last_run(project_id, start_date, end_date)
39+
40+
def run_remote(self,codecarbon_api_key: str, experiment_id: str, injected_code: str, kaggle_api_key: str, kaggle_username: str, notebook_title: str, api_endpoint: str = 'https://api.codecarbon.io') -> dict:
41+
template_module = KaggleScriptTemplate.get_template()
42+
injector = Injector(module=template_module, filename="test.py")
43+
variables = {
44+
'api_endpoint': api_endpoint,
45+
'api_key': codecarbon_api_key,
46+
'experiment_id': experiment_id
47+
}
48+
injector.inject_variables(variables)
49+
50+
# injected_code is already clean Python code (no quote handling needed)
51+
injector.inject_function(injected_code, func_name='injected_kernel')
52+
metadata_config = KaggleScriptTemplate.get_metadata()
53+
metadata_config['id'] = f"{kaggle_username}/{notebook_title}"
54+
metadata_config['title'] = notebook_title
55+
metadata_config['code_file'] = "test.py"
56+
temp_dir = injector.get_temp_dir()
57+
temp_metadata_path = os.path.join(temp_dir, "kernel-metadata.json")
58+
with open(temp_metadata_path, 'w', encoding='utf-8') as f:
59+
json.dump(metadata_config, f, indent=2, ensure_ascii=False)
60+
61+
env = os.environ.copy()
62+
env["KAGGLE_API_TOKEN"] = kaggle_api_key
63+
subprocess.run(
64+
["kaggle", "kernels", "push", "-p", temp_dir],
65+
env=env,
66+
text=True,
67+
capture_output=True,
68+
check=False,
69+
)
70+
71+
return {
72+
"status": "success",
73+
"message": f"Kaggle kernel '{notebook_title}' has been launched",
74+
"kaggle_url": f"https://www.kaggle.com/{kaggle_username}/{notebook_title}"
75+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Template class for Kaggle script boilerplate.
3+
Can be imported and used programmatically instead of reading from file.
4+
Uses CST Module to avoid parsing on every use.
5+
"""
6+
7+
import libcst as cst
8+
9+
class KaggleScriptTemplate:
10+
"""Template for Kaggle script with codecarbon tracking"""
11+
12+
# Store as CST Module (pre-parsed) for efficiency
13+
TEMPLATE_MODULE = cst.parse_module("""from codecarbon import EmissionsTracker
14+
tracker = EmissionsTracker(api_endpoint=api_endpoint, api_key=api_key, experiment_id=experiment_id, output_dir='./', save_to_api=True)
15+
def injected_kernel():
16+
#INJECTED KERNEL CODE
17+
print("Hello From Kaggle")
18+
tracker.start()
19+
try:
20+
injected_kernel()
21+
finally:
22+
emissions = tracker.stop()
23+
print(f'CO2 emissions: {emissions} kg')
24+
""")
25+
26+
# Kernel metadata configuration
27+
METADATA_CONFIG = {
28+
"id": "demo_user/test_notebook", # username/kernel-slug
29+
"title": "test_notebook",
30+
"code_file": "test.py",
31+
"language": "python",
32+
"kernel_type": "script",
33+
"is_private": "true",
34+
"enable_gpu": "false",
35+
"enable_tpu": "false",
36+
"enable_internet": "true",
37+
"dataset_sources": [],
38+
"competition_sources": [],
39+
"kernel_sources": [],
40+
"model_sources": []
41+
}
42+
43+
@classmethod
44+
def get_template(cls) -> cst.Module:
45+
"""Get the template as CST Module (no parsing needed)"""
46+
return cls.TEMPLATE_MODULE
47+
48+
@classmethod
49+
def get_template_code(cls) -> str:
50+
"""Get the template code as string (if needed for debugging)"""
51+
return cls.TEMPLATE_MODULE.code
52+
53+
@classmethod
54+
def get_metadata(cls) -> dict:
55+
"""Get the kernel metadata configuration"""
56+
return cls.METADATA_CONFIG.copy() # Return copy to prevent accidental modification

0 commit comments

Comments
 (0)