cycls 0.0.2.67__py3-none-any.whl → 0.0.2.69__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cycls/runtime.py
CHANGED
|
@@ -9,6 +9,9 @@ from pathlib import Path
|
|
|
9
9
|
from contextlib import contextmanager
|
|
10
10
|
import tarfile
|
|
11
11
|
|
|
12
|
+
# Enable BuildKit for faster builds with better caching
|
|
13
|
+
os.environ["DOCKER_BUILDKIT"] = "1"
|
|
14
|
+
|
|
12
15
|
# --- Top-Level Helper Functions ---
|
|
13
16
|
|
|
14
17
|
def _bootstrap_script(payload_file: str, result_file: str) -> str:
|
|
@@ -68,16 +71,22 @@ def _copy_path(src_path: Path, dest_path: Path):
|
|
|
68
71
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
72
|
shutil.copy(src_path, dest_path)
|
|
70
73
|
|
|
74
|
+
# Pre-built base image with common dependencies
|
|
75
|
+
BASE_IMAGE = "ghcr.io/cycls/base:python3.12"
|
|
76
|
+
BASE_PACKAGES = {
|
|
77
|
+
"cloudpickle", "cryptography", "fastapi", "fastapi[standard]",
|
|
78
|
+
"pydantic", "pyjwt", "uvicorn", "uvicorn[standard]", "httpx"
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
# --- Main Runtime Class ---
|
|
72
82
|
|
|
73
83
|
class Runtime:
|
|
74
84
|
"""
|
|
75
85
|
Handles building a Docker image and executing a function within a container.
|
|
76
86
|
"""
|
|
77
|
-
def __init__(self, func, name, python_version=None, pip_packages=None, apt_packages=None, run_commands=None, copy=None, base_url=None, api_key=None):
|
|
87
|
+
def __init__(self, func, name, python_version=None, pip_packages=None, apt_packages=None, run_commands=None, copy=None, base_url=None, api_key=None, base_image=None):
|
|
78
88
|
self.func = func
|
|
79
89
|
self.python_version = python_version or f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
80
|
-
self.pip_packages = sorted(pip_packages or [])
|
|
81
90
|
self.apt_packages = sorted(apt_packages or [])
|
|
82
91
|
self.run_commands = sorted(run_commands or [])
|
|
83
92
|
self.copy = copy or {}
|
|
@@ -85,6 +94,11 @@ class Runtime:
|
|
|
85
94
|
self.base_url = base_url or "https://service-core-280879789566.me-central1.run.app"
|
|
86
95
|
self.image_prefix = f"cycls/{name}"
|
|
87
96
|
|
|
97
|
+
# Use pre-built base image by default, filter out already-installed packages
|
|
98
|
+
self.base_image = base_image or BASE_IMAGE
|
|
99
|
+
all_pip = set(pip_packages or [])
|
|
100
|
+
self.pip_packages = sorted(all_pip - BASE_PACKAGES) if self.base_image == BASE_IMAGE else sorted(all_pip)
|
|
101
|
+
|
|
88
102
|
# Standard paths and filenames used inside the container
|
|
89
103
|
self.io_dir = "/app/io"
|
|
90
104
|
self.runner_filename = "runner.py"
|
|
@@ -145,6 +159,7 @@ class Runtime:
|
|
|
145
159
|
def _generate_base_tag(self) -> str:
|
|
146
160
|
"""Creates a unique tag for the base Docker image based on its dependencies."""
|
|
147
161
|
signature_parts = [
|
|
162
|
+
self.base_image,
|
|
148
163
|
"".join(self.python_version),
|
|
149
164
|
"".join(self.pip_packages),
|
|
150
165
|
"".join(self.apt_packages),
|
|
@@ -163,7 +178,11 @@ class Runtime:
|
|
|
163
178
|
|
|
164
179
|
def _generate_dockerfile(self, port=None) -> str:
|
|
165
180
|
"""Generates a multi-stage Dockerfile string."""
|
|
166
|
-
|
|
181
|
+
# Only install extra packages not in base image
|
|
182
|
+
run_pip_install = (
|
|
183
|
+
f"RUN pip install --no-cache-dir {' '.join(self.pip_packages)}"
|
|
184
|
+
if self.pip_packages else ""
|
|
185
|
+
)
|
|
167
186
|
run_apt_install = (
|
|
168
187
|
f"RUN apt-get update && apt-get install -y --no-install-recommends {' '.join(self.apt_packages)}"
|
|
169
188
|
if self.apt_packages else ""
|
|
@@ -174,14 +193,14 @@ class Runtime:
|
|
|
174
193
|
|
|
175
194
|
return f"""
|
|
176
195
|
# STAGE 1: Base image with all dependencies
|
|
177
|
-
FROM
|
|
196
|
+
FROM {self.base_image} as base
|
|
178
197
|
ENV PIP_ROOT_USER_ACTION=ignore
|
|
179
198
|
ENV PYTHONUNBUFFERED=1
|
|
180
199
|
RUN mkdir -p {self.io_dir}
|
|
181
200
|
{run_apt_install}
|
|
182
201
|
{run_pip_install}
|
|
183
202
|
{run_shell_commands}
|
|
184
|
-
WORKDIR app
|
|
203
|
+
WORKDIR /app
|
|
185
204
|
{copy_lines}
|
|
186
205
|
COPY {self.runner_filename} {self.runner_path}
|
|
187
206
|
ENTRYPOINT ["python", "{self.runner_path}", "{self.io_dir}"]
|
|
@@ -309,6 +328,110 @@ COPY {self.payload_file} {self.io_dir}/
|
|
|
309
328
|
print(f"\n🛑 Operation stopped: {e}")
|
|
310
329
|
return None
|
|
311
330
|
|
|
331
|
+
def watch(self, *args, **kwargs):
|
|
332
|
+
"""Runs the container with file watching - restarts script on changes."""
|
|
333
|
+
try:
|
|
334
|
+
from watchfiles import watch as watchfiles_watch
|
|
335
|
+
except ImportError:
|
|
336
|
+
print("❌ watchfiles not installed. Run: pip install watchfiles")
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
import inspect
|
|
340
|
+
|
|
341
|
+
# Get the main script (the outermost .py file in the stack)
|
|
342
|
+
main_script = None
|
|
343
|
+
for frame_info in inspect.stack():
|
|
344
|
+
filename = frame_info.filename
|
|
345
|
+
if filename.endswith('.py') and not filename.startswith('<'):
|
|
346
|
+
main_script = Path(filename).resolve()
|
|
347
|
+
# main_script is now the outermost/first script in the call chain
|
|
348
|
+
|
|
349
|
+
# Build watch paths: main script + copy sources
|
|
350
|
+
watch_paths = []
|
|
351
|
+
if main_script and main_script.exists():
|
|
352
|
+
watch_paths.append(main_script)
|
|
353
|
+
watch_paths.extend([Path(src).resolve() for src in self.copy.keys() if Path(src).exists()])
|
|
354
|
+
|
|
355
|
+
if not watch_paths:
|
|
356
|
+
print("⚠️ No files to watch. Running without watch mode.")
|
|
357
|
+
return self.run(*args, **kwargs)
|
|
358
|
+
|
|
359
|
+
print(f"👀 Watching for changes:")
|
|
360
|
+
for p in watch_paths:
|
|
361
|
+
print(f" {p}")
|
|
362
|
+
print()
|
|
363
|
+
|
|
364
|
+
while True:
|
|
365
|
+
# Run the container in a subprocess-like manner
|
|
366
|
+
print(f"🚀 Running function '{self.name}' in container...")
|
|
367
|
+
self._perform_auto_cleanup()
|
|
368
|
+
self._build_image_if_needed()
|
|
369
|
+
|
|
370
|
+
port = kwargs.get('port', None)
|
|
371
|
+
ports_mapping = {f'{port}/tcp': port} if port else None
|
|
372
|
+
|
|
373
|
+
with tempfile.TemporaryDirectory() as tmpdir_str:
|
|
374
|
+
tmpdir = Path(tmpdir_str)
|
|
375
|
+
payload_path = tmpdir / self.payload_file
|
|
376
|
+
|
|
377
|
+
with payload_path.open('wb') as f:
|
|
378
|
+
cloudpickle.dump((self.func, args, kwargs), f)
|
|
379
|
+
|
|
380
|
+
container = self.docker_client.containers.create(
|
|
381
|
+
image=self.tag,
|
|
382
|
+
volumes={str(tmpdir): {'bind': self.io_dir, 'mode': 'rw'}},
|
|
383
|
+
ports=ports_mapping,
|
|
384
|
+
labels={self.managed_label: "true"}
|
|
385
|
+
)
|
|
386
|
+
container.start()
|
|
387
|
+
print(f"✅ Container running on port {port}")
|
|
388
|
+
print("👀 Waiting for file changes... (Ctrl+C to stop)\n")
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
# Watch for changes in a separate thread
|
|
392
|
+
import threading
|
|
393
|
+
import time
|
|
394
|
+
change_detected = threading.Event()
|
|
395
|
+
|
|
396
|
+
def watch_thread():
|
|
397
|
+
for changes in watchfiles_watch(*watch_paths):
|
|
398
|
+
changed_files = [str(c[1]) for c in changes]
|
|
399
|
+
print(f"\n🔄 Changes detected:")
|
|
400
|
+
for f in changed_files:
|
|
401
|
+
print(f" {f}")
|
|
402
|
+
change_detected.set()
|
|
403
|
+
break
|
|
404
|
+
|
|
405
|
+
watcher = threading.Thread(target=watch_thread, daemon=True)
|
|
406
|
+
watcher.start()
|
|
407
|
+
|
|
408
|
+
# Stream container logs in separate thread
|
|
409
|
+
def log_thread():
|
|
410
|
+
for chunk in container.logs(stream=True, follow=True):
|
|
411
|
+
if change_detected.is_set():
|
|
412
|
+
break
|
|
413
|
+
print(chunk.decode('utf-8').strip())
|
|
414
|
+
|
|
415
|
+
logger = threading.Thread(target=log_thread, daemon=True)
|
|
416
|
+
logger.start()
|
|
417
|
+
|
|
418
|
+
# Wait for change or interrupt
|
|
419
|
+
while not change_detected.is_set():
|
|
420
|
+
time.sleep(0.5)
|
|
421
|
+
|
|
422
|
+
print("\n🔄 Restarting...")
|
|
423
|
+
container.stop(timeout=2)
|
|
424
|
+
container.remove()
|
|
425
|
+
# Re-exec the main script
|
|
426
|
+
print(f"🔄 Re-executing {main_script.name}...\n")
|
|
427
|
+
os.execv(sys.executable, [sys.executable, str(main_script)])
|
|
428
|
+
|
|
429
|
+
except KeyboardInterrupt:
|
|
430
|
+
print("\n🛑 Stopping...")
|
|
431
|
+
container.stop(timeout=2)
|
|
432
|
+
container.remove()
|
|
433
|
+
return
|
|
434
|
+
|
|
312
435
|
def build(self, *args, **kwargs):
|
|
313
436
|
"""Builds a self-contained, deployable Docker image locally."""
|
|
314
437
|
print("📦 Building self-contained image for deployment...")
|
cycls/sdk.py
CHANGED
|
@@ -88,13 +88,16 @@ class Agent:
|
|
|
88
88
|
uvicorn.run(web(agent.func, agent.config), host="0.0.0.0", port=port)
|
|
89
89
|
return
|
|
90
90
|
|
|
91
|
-
def deploy(self, prod=False, port=8080):
|
|
91
|
+
def deploy(self, prod=False, port=8080, watch=False):
|
|
92
92
|
if not self.registered_functions:
|
|
93
93
|
print("Error: No @agent decorated function found.")
|
|
94
94
|
return
|
|
95
95
|
if (self.key is None) and prod:
|
|
96
96
|
print("🛑 Error: Please add your Cycls API key")
|
|
97
97
|
return
|
|
98
|
+
if prod and watch:
|
|
99
|
+
print("⚠️ Warning: watch=True ignored in production mode.")
|
|
100
|
+
watch = False
|
|
98
101
|
|
|
99
102
|
agent = self.registered_functions[0]
|
|
100
103
|
if len(self.registered_functions) > 1:
|
|
@@ -118,7 +121,12 @@ class Agent:
|
|
|
118
121
|
base_url=self.base_url,
|
|
119
122
|
api_key=self.key
|
|
120
123
|
)
|
|
121
|
-
|
|
124
|
+
if prod:
|
|
125
|
+
new.deploy(port=port)
|
|
126
|
+
elif watch:
|
|
127
|
+
new.watch(port=port)
|
|
128
|
+
else:
|
|
129
|
+
new.run(port=port)
|
|
122
130
|
return
|
|
123
131
|
|
|
124
132
|
def modal(self, prod=False):
|
|
@@ -4,9 +4,9 @@ cycls/default-theme/assets/index-B0ZKcm_V.css,sha256=wK9-NhEB8xPcN9Zv69zpOcfGTlF
|
|
|
4
4
|
cycls/default-theme/assets/index-D5EDcI4J.js,sha256=sN4qRcAXa7DBd9JzmVcCoCwH4l8cNCM-U9QGUjBvWSo,1346506
|
|
5
5
|
cycls/default-theme/index.html,sha256=bM-yW_g0cGrV40Q5yY3ccY0fM4zI1Wuu5I8EtGFJIxs,828
|
|
6
6
|
cycls/dev-theme/index.html,sha256=QJBHkdNuMMiwQU7o8dN8__8YQeQB45D37D-NCXIWB2Q,11585
|
|
7
|
-
cycls/runtime.py,sha256=
|
|
8
|
-
cycls/sdk.py,sha256=
|
|
7
|
+
cycls/runtime.py,sha256=BaP5sLG3NLPQZwuKwoYueC3R5WeuNg2-YzOQes6paUE,23221
|
|
8
|
+
cycls/sdk.py,sha256=iOSFFPSN2iGCTIL2ZBOpb2X5jjUEm7gQbAuanYHF4qg,6974
|
|
9
9
|
cycls/web.py,sha256=3M3qaWTNY3dpgd7Vq5aXREp-cIFsHrDqBQ1YkGrOaUk,4659
|
|
10
|
-
cycls-0.0.2.
|
|
11
|
-
cycls-0.0.2.
|
|
12
|
-
cycls-0.0.2.
|
|
10
|
+
cycls-0.0.2.69.dist-info/METADATA,sha256=3nG949VOjQ1K-sftwDGsAQheGAt-GCKWGUmDgUBgOP4,7943
|
|
11
|
+
cycls-0.0.2.69.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
12
|
+
cycls-0.0.2.69.dist-info/RECORD,,
|
|
File without changes
|