cycls 0.0.2.93__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/__init__.py +14 -0
- cycls/app.py +91 -0
- cycls/auth.py +4 -0
- cycls/cli.py +104 -0
- cycls/function.py +407 -0
- cycls/state.py +6 -0
- cycls/themes/default/assets/index-Xh0IeurI.js +435 -0
- cycls/themes/default/assets/index-oGkkm3Z8.css +1 -0
- cycls/themes/default/index.html +32 -0
- cycls/themes/dev/index.html +298 -0
- cycls/web.py +171 -0
- cycls-0.0.2.93.dist-info/METADATA +282 -0
- cycls-0.0.2.93.dist-info/RECORD +15 -0
- cycls-0.0.2.93.dist-info/WHEEL +4 -0
- cycls-0.0.2.93.dist-info/entry_points.txt +2 -0
cycls/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from . import function as _function_module
|
|
2
|
+
from .function import function, Function
|
|
3
|
+
from .app import app, App
|
|
4
|
+
|
|
5
|
+
def __getattr__(name):
|
|
6
|
+
if name in ("api_key", "base_url"):
|
|
7
|
+
return getattr(_function_module, name)
|
|
8
|
+
raise AttributeError(f"module 'cycls' has no attribute '{name}'")
|
|
9
|
+
|
|
10
|
+
def __setattr__(name, value):
|
|
11
|
+
if name in ("api_key", "base_url"):
|
|
12
|
+
setattr(_function_module, name, value)
|
|
13
|
+
else:
|
|
14
|
+
raise AttributeError(f"module 'cycls' has no attribute '{name}'")
|
cycls/app.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import uvicorn
|
|
3
|
+
import importlib.resources
|
|
4
|
+
|
|
5
|
+
from .function import Function, _get_api_key, _get_base_url
|
|
6
|
+
from .web import web, Config
|
|
7
|
+
|
|
8
|
+
CYCLS_PATH = importlib.resources.files('cycls')
|
|
9
|
+
|
|
10
|
+
THEMES = ["default", "dev"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class App(Function):
|
|
14
|
+
"""App extends Function with web UI serving capabilities."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, func, name, theme="default", pip=None, apt=None, copy=None, copy_public=None,
|
|
17
|
+
auth=False, org=None, header=None, intro=None, title=None, plan="free", analytics=False,
|
|
18
|
+
state=False):
|
|
19
|
+
if theme not in THEMES:
|
|
20
|
+
raise ValueError(f"Unknown theme: {theme}. Available: {THEMES}")
|
|
21
|
+
self.user_func = func
|
|
22
|
+
self.theme = theme
|
|
23
|
+
self.copy_public = copy_public or []
|
|
24
|
+
self.state = state
|
|
25
|
+
|
|
26
|
+
self.config = Config(
|
|
27
|
+
header=header,
|
|
28
|
+
intro=intro,
|
|
29
|
+
title=title,
|
|
30
|
+
auth=auth,
|
|
31
|
+
plan=plan,
|
|
32
|
+
analytics=analytics,
|
|
33
|
+
org=org,
|
|
34
|
+
state=state,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Build files dict for Function (theme is inside cycls/)
|
|
38
|
+
files = {str(CYCLS_PATH): "cycls"}
|
|
39
|
+
files.update({f: f for f in copy or []})
|
|
40
|
+
files.update({f: f"public/{f}" for f in self.copy_public})
|
|
41
|
+
|
|
42
|
+
super().__init__(
|
|
43
|
+
func=func,
|
|
44
|
+
name=name,
|
|
45
|
+
pip=["fastapi[standard]", "pyjwt", "cryptography", "uvicorn", "python-dotenv", "docker", "agentfs-sdk", "pyturso==0.4.0rc17", *(pip or [])],
|
|
46
|
+
apt=apt,
|
|
47
|
+
copy=files,
|
|
48
|
+
base_url=_get_base_url(),
|
|
49
|
+
api_key=_get_api_key()
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __call__(self, *args, **kwargs):
|
|
53
|
+
return self.user_func(*args, **kwargs)
|
|
54
|
+
|
|
55
|
+
def _prepare_func(self, prod):
|
|
56
|
+
self.config.set_prod(prod)
|
|
57
|
+
self.config.public_path = f"cycls/themes/{self.theme}"
|
|
58
|
+
user_func, config, name = self.user_func, self.config, self.name
|
|
59
|
+
self.func = lambda port: __import__("cycls").web.serve(user_func, config, name, port)
|
|
60
|
+
|
|
61
|
+
def _local(self, port=8080):
|
|
62
|
+
"""Run directly with uvicorn (no Docker)."""
|
|
63
|
+
print(f"Starting local server at localhost:{port}")
|
|
64
|
+
self.config.public_path = str(CYCLS_PATH.joinpath(f"themes/{self.theme}"))
|
|
65
|
+
self.config.set_prod(False)
|
|
66
|
+
uvicorn.run(web(self.user_func, self.config), host="0.0.0.0", port=port)
|
|
67
|
+
|
|
68
|
+
def local(self, port=8080, watch=True):
|
|
69
|
+
"""Run locally in Docker with file watching by default."""
|
|
70
|
+
if os.environ.get('_CYCLS_WATCH'):
|
|
71
|
+
watch = False
|
|
72
|
+
self._prepare_func(prod=False)
|
|
73
|
+
self.watch(port=port) if watch else self.run(port=port)
|
|
74
|
+
|
|
75
|
+
def deploy(self, port=8080):
|
|
76
|
+
"""Deploy to production."""
|
|
77
|
+
if self.api_key is None:
|
|
78
|
+
raise RuntimeError("Missing API key. Set cycls.api_key or CYCLS_API_KEY environment variable.")
|
|
79
|
+
self._prepare_func(prod=True)
|
|
80
|
+
return super().deploy(port=port)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def app(name=None, **kwargs):
|
|
84
|
+
"""Decorator that transforms a function into a deployable App."""
|
|
85
|
+
if kwargs.get("plan") == "cycls_pass":
|
|
86
|
+
kwargs["auth"] = True
|
|
87
|
+
kwargs["analytics"] = True
|
|
88
|
+
|
|
89
|
+
def decorator(func):
|
|
90
|
+
return App(func=func, name=name or func.__name__, **kwargs)
|
|
91
|
+
return decorator
|
cycls/auth.py
ADDED
cycls/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cycls chat - Claude Code style CLI for cycls agents"""
|
|
3
|
+
|
|
4
|
+
import json, os, re, sys
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
|
|
8
|
+
BLUE, GREEN, YELLOW, RED = "\033[34m", "\033[32m", "\033[33m", "\033[31m"
|
|
9
|
+
CALLOUTS = {"success": ("✓", GREEN), "warning": ("⚠", YELLOW), "info": ("ℹ", BLUE), "error": ("✗", RED)}
|
|
10
|
+
|
|
11
|
+
separator = lambda: f"{DIM}{'─' * min(os.get_terminal_size().columns if sys.stdout.isatty() else 80, 80)}{RESET}"
|
|
12
|
+
markdown = lambda text: re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
|
|
13
|
+
header = lambda title, meta, color=GREEN, dim=False: print(f"{color}●{RESET} {BOLD}{title}{RESET}\n ⎿ {meta}{DIM if dim else ''}", flush=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def table(headers, rows):
|
|
17
|
+
if not headers: return
|
|
18
|
+
widths = [max(len(str(h)), *(len(str(r[i])) for r in rows if i < len(r))) for i, h in enumerate(headers)]
|
|
19
|
+
line = lambda left, mid, right: left + mid.join("─" * (w + 2) for w in widths) + right
|
|
20
|
+
row = lambda cells, bold=False: "│" + "│".join(f" {BOLD if bold else ''}{str(cells[i] if i < len(cells) else '').ljust(widths[i])}{RESET if bold else ''} " for i in range(len(widths))) + "│"
|
|
21
|
+
print(f"{line('┌', '┬', '┐')}\n{row(headers, True)}\n{line('├', '┼', '┤')}")
|
|
22
|
+
for r in rows: print(row(r))
|
|
23
|
+
print(line("└", "┴", "┘"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def chat(url):
|
|
27
|
+
messages, endpoint = [], f"{url.rstrip('/')}/chat/cycls"
|
|
28
|
+
print(f"\n{BOLD}cycls{RESET} {DIM}|{RESET} {url}\n")
|
|
29
|
+
|
|
30
|
+
while True:
|
|
31
|
+
try:
|
|
32
|
+
print(separator())
|
|
33
|
+
user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
|
|
34
|
+
print(separator())
|
|
35
|
+
|
|
36
|
+
if not user_input: continue
|
|
37
|
+
if user_input in ("/q", "exit", "quit"): break
|
|
38
|
+
if user_input == "/c": messages, _ = [], print(f"{GREEN}⏺ Cleared{RESET}"); continue
|
|
39
|
+
|
|
40
|
+
messages.append({"role": "user", "content": user_input})
|
|
41
|
+
block, tbl = None, ([], [])
|
|
42
|
+
|
|
43
|
+
def close():
|
|
44
|
+
nonlocal block, tbl
|
|
45
|
+
if block == "thinking": print(RESET)
|
|
46
|
+
if block == "text": print()
|
|
47
|
+
if block == "table" and tbl[0]: table(*tbl); tbl = ([], [])
|
|
48
|
+
if block: print()
|
|
49
|
+
block = None
|
|
50
|
+
|
|
51
|
+
with httpx.stream("POST", endpoint, json={"messages": messages}, timeout=None) as response:
|
|
52
|
+
for line in response.iter_lines():
|
|
53
|
+
if not line.startswith("data: ") or line == "data: [DONE]": continue
|
|
54
|
+
data = json.loads(line[6:])
|
|
55
|
+
type = data.get("type")
|
|
56
|
+
|
|
57
|
+
if type is None:
|
|
58
|
+
print(markdown(data if isinstance(data, str) else data.get("text", "")), end="", flush=True); continue
|
|
59
|
+
|
|
60
|
+
if type != block: close()
|
|
61
|
+
|
|
62
|
+
if type in ("thinking", "text"):
|
|
63
|
+
if block != type: header(type.capitalize(), "Live", dim=(type == "thinking")); block = type
|
|
64
|
+
print((markdown if type == "text" else str)(data.get(type, "")), end="", flush=True)
|
|
65
|
+
elif type == "code":
|
|
66
|
+
code = data.get("code", ""); header(f"Code({data.get('language', '')})", f"{code.count(chr(10))+1} lines"); print(code, flush=True); block = type
|
|
67
|
+
elif type == "status":
|
|
68
|
+
print(f"{DIM}[{data.get('status', '')}]{RESET} ", end="", flush=True)
|
|
69
|
+
elif type == "table":
|
|
70
|
+
if "headers" in data:
|
|
71
|
+
if tbl[0]: table(*tbl)
|
|
72
|
+
header("Table", f"{len(data['headers'])} cols"); tbl, block = (data["headers"], []), type
|
|
73
|
+
elif "row" in data: tbl[1].append(data["row"])
|
|
74
|
+
elif type == "callout":
|
|
75
|
+
style = data.get("style", "info"); icon, color = CALLOUTS.get(style, ("•", RESET))
|
|
76
|
+
header(style.capitalize(), f"{icon} {data.get('callout', '')}", color=color); block = type
|
|
77
|
+
elif type == "image":
|
|
78
|
+
header("Image", data.get("src", "")); block = type
|
|
79
|
+
|
|
80
|
+
close()
|
|
81
|
+
|
|
82
|
+
except KeyboardInterrupt: print()
|
|
83
|
+
except EOFError: break
|
|
84
|
+
except (httpx.ReadError, httpx.ConnectError) as e: print(f"{RED}⏺ Connection error: {e}{RESET}"); messages and messages.pop()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main():
|
|
88
|
+
if len(sys.argv) < 3:
|
|
89
|
+
print("Usage: cycls chat <url|port>")
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
arg = sys.argv[2]
|
|
92
|
+
if arg.isdigit():
|
|
93
|
+
port = int(arg)
|
|
94
|
+
if not (1 <= port <= 65535):
|
|
95
|
+
print(f"Error: Invalid port {port}. Must be between 1 and 65535.")
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
url = f"http://localhost:{port}"
|
|
98
|
+
else:
|
|
99
|
+
url = arg
|
|
100
|
+
chat(url)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
cycls/function.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import docker
|
|
3
|
+
import cloudpickle
|
|
4
|
+
import tempfile
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import tarfile
|
|
12
|
+
|
|
13
|
+
os.environ["DOCKER_BUILDKIT"] = "1"
|
|
14
|
+
|
|
15
|
+
ENTRYPOINT_PY = '''import sys
|
|
16
|
+
sys.path.insert(0, '/app')
|
|
17
|
+
import cloudpickle
|
|
18
|
+
with open("/app/function.pkl", "rb") as f:
|
|
19
|
+
func, args, kwargs = cloudpickle.load(f)
|
|
20
|
+
func(*args, **kwargs)
|
|
21
|
+
'''
|
|
22
|
+
|
|
23
|
+
RUNNER_PY = '''import sys
|
|
24
|
+
sys.path.insert(0, '/app')
|
|
25
|
+
import cloudpickle
|
|
26
|
+
import traceback
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
io_dir = Path(sys.argv[1])
|
|
30
|
+
payload_path = io_dir / "payload.pkl"
|
|
31
|
+
result_path = io_dir / "result.pkl"
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with open(payload_path, "rb") as f:
|
|
35
|
+
func, args, kwargs = cloudpickle.load(f)
|
|
36
|
+
result = func(*args, **kwargs)
|
|
37
|
+
with open(result_path, "wb") as f:
|
|
38
|
+
cloudpickle.dump(result, f)
|
|
39
|
+
except Exception:
|
|
40
|
+
traceback.print_exc()
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
'''
|
|
43
|
+
|
|
44
|
+
# Module-level configuration
|
|
45
|
+
api_key = None
|
|
46
|
+
base_url = None
|
|
47
|
+
|
|
48
|
+
def _get_api_key():
|
|
49
|
+
return api_key or os.getenv("CYCLS_API_KEY")
|
|
50
|
+
|
|
51
|
+
def _get_base_url():
|
|
52
|
+
return base_url or os.getenv("CYCLS_BASE_URL")
|
|
53
|
+
|
|
54
|
+
def _hash_path(path_str: str) -> str:
|
|
55
|
+
h = hashlib.sha256()
|
|
56
|
+
p = Path(path_str)
|
|
57
|
+
if p.is_file():
|
|
58
|
+
with p.open('rb') as f:
|
|
59
|
+
while chunk := f.read(65536):
|
|
60
|
+
h.update(chunk)
|
|
61
|
+
elif p.is_dir():
|
|
62
|
+
for root, dirs, files in os.walk(p, topdown=True):
|
|
63
|
+
dirs.sort()
|
|
64
|
+
files.sort()
|
|
65
|
+
for name in files:
|
|
66
|
+
filepath = Path(root) / name
|
|
67
|
+
h.update(str(filepath.relative_to(p)).encode())
|
|
68
|
+
with filepath.open('rb') as f:
|
|
69
|
+
while chunk := f.read(65536):
|
|
70
|
+
h.update(chunk)
|
|
71
|
+
return h.hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _copy_path(src_path: Path, dest_path: Path):
|
|
75
|
+
if src_path.is_dir():
|
|
76
|
+
shutil.copytree(src_path, dest_path, dirs_exist_ok=True)
|
|
77
|
+
else:
|
|
78
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
shutil.copy(src_path, dest_path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Function:
|
|
83
|
+
"""Executes functions in Docker containers."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, func, name, python_version=None, pip=None, apt=None,
|
|
86
|
+
run_commands=None, copy=None, base_url=None, api_key=None):
|
|
87
|
+
self.func = func
|
|
88
|
+
self.name = name.replace('_', '-')
|
|
89
|
+
self.python_version = python_version or f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
90
|
+
self.base_image = f"python:{self.python_version}-slim"
|
|
91
|
+
self.apt = sorted(apt or [])
|
|
92
|
+
self.run_commands = sorted(run_commands or [])
|
|
93
|
+
self.copy = {f: f for f in copy} if isinstance(copy, list) else (copy or {})
|
|
94
|
+
self.base_url = base_url or "https://service-core-280879789566.me-central1.run.app"
|
|
95
|
+
self.api_key = api_key
|
|
96
|
+
self.pip = sorted(set(pip or []) | {"cloudpickle"})
|
|
97
|
+
|
|
98
|
+
self.image_prefix = f"cycls/{self.name}"
|
|
99
|
+
self.managed_label = "cycls.function"
|
|
100
|
+
self._docker_client = None
|
|
101
|
+
self._container = None
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def docker_client(self):
|
|
105
|
+
if self._docker_client is None:
|
|
106
|
+
try:
|
|
107
|
+
print("Initializing Docker client...")
|
|
108
|
+
client = docker.from_env()
|
|
109
|
+
client.ping()
|
|
110
|
+
self._docker_client = client
|
|
111
|
+
except docker.errors.DockerException:
|
|
112
|
+
print("\nError: Docker is not running or is not installed.")
|
|
113
|
+
print("Please start the Docker daemon and try again.")
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
return self._docker_client
|
|
116
|
+
|
|
117
|
+
def _perform_auto_cleanup(self, keep_tag=None):
|
|
118
|
+
try:
|
|
119
|
+
current_id = self._container.id if self._container else None
|
|
120
|
+
for container in self.docker_client.containers.list(all=True, filters={"label": self.managed_label}):
|
|
121
|
+
if container.id != current_id:
|
|
122
|
+
container.remove(force=True)
|
|
123
|
+
|
|
124
|
+
cleaned = 0
|
|
125
|
+
for image in self.docker_client.images.list(filters={"label": self.managed_label}):
|
|
126
|
+
is_deploy = any(":deploy-" in t for t in image.tags)
|
|
127
|
+
is_current = keep_tag and keep_tag in image.tags
|
|
128
|
+
if not is_deploy and not is_current:
|
|
129
|
+
self.docker_client.images.remove(image.id, force=True)
|
|
130
|
+
cleaned += 1
|
|
131
|
+
if cleaned:
|
|
132
|
+
print(f"Cleaned up {cleaned} old dev image(s).")
|
|
133
|
+
except Exception as e:
|
|
134
|
+
print(f"Warning: cleanup error: {e}")
|
|
135
|
+
|
|
136
|
+
def _image_tag(self, extra_parts=None) -> str:
|
|
137
|
+
parts = [self.base_image, self.python_version, "".join(self.pip),
|
|
138
|
+
"".join(self.apt), "".join(self.run_commands)]
|
|
139
|
+
for src, dst in sorted(self.copy.items()):
|
|
140
|
+
if not Path(src).exists():
|
|
141
|
+
raise FileNotFoundError(f"Path in 'copy' not found: {src}")
|
|
142
|
+
parts.append(f"{src}>{dst}:{_hash_path(src)}")
|
|
143
|
+
if extra_parts:
|
|
144
|
+
parts.extend(extra_parts)
|
|
145
|
+
return f"{self.image_prefix}:{hashlib.sha256(''.join(parts).encode()).hexdigest()[:16]}"
|
|
146
|
+
|
|
147
|
+
def _dockerfile_preamble(self) -> str:
|
|
148
|
+
lines = [
|
|
149
|
+
f"FROM {self.base_image}",
|
|
150
|
+
"ENV PIP_ROOT_USER_ACTION=ignore PYTHONUNBUFFERED=1",
|
|
151
|
+
"WORKDIR /app",
|
|
152
|
+
"RUN pip install uv",
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
if self.apt:
|
|
156
|
+
lines.append(f"RUN apt-get update && apt-get install -y --no-install-recommends {' '.join(self.apt)}")
|
|
157
|
+
|
|
158
|
+
if self.pip:
|
|
159
|
+
lines.append(f"RUN uv pip install --system --no-cache {' '.join(self.pip)}")
|
|
160
|
+
|
|
161
|
+
for cmd in self.run_commands:
|
|
162
|
+
lines.append(f"RUN {cmd}")
|
|
163
|
+
|
|
164
|
+
for dst in self.copy.values():
|
|
165
|
+
lines.append(f"COPY context_files/{dst} /app/{dst}")
|
|
166
|
+
|
|
167
|
+
return "\n".join(lines)
|
|
168
|
+
|
|
169
|
+
def _dockerfile_local(self) -> str:
|
|
170
|
+
return f"""{self._dockerfile_preamble()}
|
|
171
|
+
COPY runner.py /runner.py
|
|
172
|
+
ENTRYPOINT ["python", "/runner.py", "/io"]
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def _dockerfile_deploy(self, port: int) -> str:
|
|
176
|
+
return f"""{self._dockerfile_preamble()}
|
|
177
|
+
COPY function.pkl /app/function.pkl
|
|
178
|
+
COPY entrypoint.py /app/entrypoint.py
|
|
179
|
+
EXPOSE {port}
|
|
180
|
+
CMD ["python", "entrypoint.py"]
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def _copy_user_files(self, workdir: Path):
|
|
184
|
+
context_files_dir = workdir / "context_files"
|
|
185
|
+
context_files_dir.mkdir()
|
|
186
|
+
for src, dst in self.copy.items():
|
|
187
|
+
_copy_path(Path(src).resolve(), context_files_dir / dst)
|
|
188
|
+
|
|
189
|
+
def _build_image(self, tag: str, workdir: Path) -> str:
|
|
190
|
+
print("--- Docker Build Logs ---")
|
|
191
|
+
try:
|
|
192
|
+
for chunk in self.docker_client.api.build(
|
|
193
|
+
path=str(workdir), tag=tag, forcerm=True, decode=True,
|
|
194
|
+
labels={self.managed_label: "true"}
|
|
195
|
+
):
|
|
196
|
+
if 'stream' in chunk:
|
|
197
|
+
print(chunk['stream'].strip())
|
|
198
|
+
print("-------------------------")
|
|
199
|
+
print(f"Image built: {tag}")
|
|
200
|
+
return tag
|
|
201
|
+
except docker.errors.BuildError as e:
|
|
202
|
+
print(f"\nDocker build failed: {e}")
|
|
203
|
+
raise
|
|
204
|
+
|
|
205
|
+
def _ensure_local_image(self) -> str:
|
|
206
|
+
tag = self._image_tag(extra_parts=["local-v1"])
|
|
207
|
+
try:
|
|
208
|
+
self.docker_client.images.get(tag)
|
|
209
|
+
print(f"Found cached image: {tag}")
|
|
210
|
+
return tag
|
|
211
|
+
except docker.errors.ImageNotFound:
|
|
212
|
+
print(f"Building new image: {tag}")
|
|
213
|
+
|
|
214
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
215
|
+
workdir = Path(tmpdir)
|
|
216
|
+
self._copy_user_files(workdir)
|
|
217
|
+
(workdir / "Dockerfile").write_text(self._dockerfile_local())
|
|
218
|
+
(workdir / "runner.py").write_text(RUNNER_PY)
|
|
219
|
+
return self._build_image(tag, workdir)
|
|
220
|
+
|
|
221
|
+
def _cleanup_container(self):
|
|
222
|
+
if getattr(self, '_container', None):
|
|
223
|
+
try:
|
|
224
|
+
self._container.stop(timeout=3)
|
|
225
|
+
self._container.remove()
|
|
226
|
+
except docker.errors.NotFound:
|
|
227
|
+
pass
|
|
228
|
+
except docker.errors.APIError:
|
|
229
|
+
pass
|
|
230
|
+
self._container = None
|
|
231
|
+
|
|
232
|
+
@contextlib.contextmanager
|
|
233
|
+
def runner(self, *args, **kwargs):
|
|
234
|
+
service_port = kwargs.get('port')
|
|
235
|
+
tag = self._ensure_local_image()
|
|
236
|
+
self._perform_auto_cleanup(keep_tag=tag)
|
|
237
|
+
|
|
238
|
+
ports = {f'{service_port}/tcp': service_port} if service_port else None
|
|
239
|
+
|
|
240
|
+
with tempfile.TemporaryDirectory() as io_dir:
|
|
241
|
+
io_path = Path(io_dir)
|
|
242
|
+
payload_path = io_path / "payload.pkl"
|
|
243
|
+
result_path = io_path / "result.pkl"
|
|
244
|
+
|
|
245
|
+
with open(payload_path, 'wb') as f:
|
|
246
|
+
cloudpickle.dump((self.func, args, kwargs), f)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
self._container = self.docker_client.containers.create(
|
|
250
|
+
image=tag,
|
|
251
|
+
volumes={str(io_path): {'bind': '/io', 'mode': 'rw'}},
|
|
252
|
+
ports=ports,
|
|
253
|
+
labels={self.managed_label: "true"}
|
|
254
|
+
)
|
|
255
|
+
self._container.start()
|
|
256
|
+
yield self._container, result_path
|
|
257
|
+
finally:
|
|
258
|
+
self._cleanup_container()
|
|
259
|
+
|
|
260
|
+
def run(self, *args, **kwargs):
|
|
261
|
+
service_port = kwargs.get('port')
|
|
262
|
+
print(f"Running '{self.name}'...")
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
with self.runner(*args, **kwargs) as (container, result_path):
|
|
266
|
+
print("--- Container Logs ---")
|
|
267
|
+
for chunk in container.logs(stream=True, follow=True):
|
|
268
|
+
print(chunk.decode(), end='')
|
|
269
|
+
print("----------------------")
|
|
270
|
+
|
|
271
|
+
status = container.wait()
|
|
272
|
+
if status['StatusCode'] != 0:
|
|
273
|
+
print(f"Error: Container exited with code {status['StatusCode']}")
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
if service_port:
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
if result_path.exists():
|
|
280
|
+
with open(result_path, 'rb') as f:
|
|
281
|
+
return cloudpickle.load(f)
|
|
282
|
+
else:
|
|
283
|
+
print("Error: Result file not found")
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
except KeyboardInterrupt:
|
|
287
|
+
print("\n----------------------")
|
|
288
|
+
print("Stopping...")
|
|
289
|
+
return None
|
|
290
|
+
except Exception as e:
|
|
291
|
+
print(f"Error: {e}")
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
def watch(self, *args, **kwargs):
|
|
295
|
+
if os.environ.get('_CYCLS_WATCH'):
|
|
296
|
+
return self.run(*args, **kwargs)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
from watchfiles import watch as watchfiles_watch
|
|
300
|
+
except ImportError:
|
|
301
|
+
print("watchfiles not installed. pip install watchfiles")
|
|
302
|
+
return self.run(*args, **kwargs)
|
|
303
|
+
|
|
304
|
+
import subprocess
|
|
305
|
+
|
|
306
|
+
script = Path(sys.argv[0]).resolve()
|
|
307
|
+
watch_paths = [script] + [Path(p).resolve() for p in self.copy if Path(p).exists()]
|
|
308
|
+
|
|
309
|
+
print(f"Watching: {[p.name for p in watch_paths]}\n")
|
|
310
|
+
|
|
311
|
+
while True:
|
|
312
|
+
proc = subprocess.Popen([sys.executable, str(script)], env={**os.environ, '_CYCLS_WATCH': '1'})
|
|
313
|
+
try:
|
|
314
|
+
for changes in watchfiles_watch(*watch_paths):
|
|
315
|
+
print(f"\nChanged: {[Path(c[1]).name for c in changes]}")
|
|
316
|
+
break
|
|
317
|
+
proc.terminate()
|
|
318
|
+
proc.wait(timeout=3)
|
|
319
|
+
except subprocess.TimeoutExpired:
|
|
320
|
+
proc.kill()
|
|
321
|
+
except KeyboardInterrupt:
|
|
322
|
+
proc.terminate()
|
|
323
|
+
proc.wait(timeout=3)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
def _prepare_deploy_context(self, workdir: Path, port: int, args=(), kwargs=None):
|
|
327
|
+
kwargs = kwargs or {}
|
|
328
|
+
kwargs['port'] = port
|
|
329
|
+
self._copy_user_files(workdir)
|
|
330
|
+
(workdir / "Dockerfile").write_text(self._dockerfile_deploy(port))
|
|
331
|
+
(workdir / "entrypoint.py").write_text(ENTRYPOINT_PY)
|
|
332
|
+
with open(workdir / "function.pkl", "wb") as f:
|
|
333
|
+
cloudpickle.dump((self.func, args, kwargs), f)
|
|
334
|
+
|
|
335
|
+
def build(self, *args, **kwargs):
|
|
336
|
+
port = kwargs.pop('port', 8080)
|
|
337
|
+
payload = cloudpickle.dumps((self.func, args, {**kwargs, 'port': port}))
|
|
338
|
+
tag = f"{self.image_prefix}:deploy-{hashlib.sha256(payload).hexdigest()[:16]}"
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
self.docker_client.images.get(tag)
|
|
342
|
+
print(f"Found cached image: {tag}")
|
|
343
|
+
return tag
|
|
344
|
+
except docker.errors.ImageNotFound:
|
|
345
|
+
print(f"Building: {tag}")
|
|
346
|
+
|
|
347
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
348
|
+
workdir = Path(tmpdir)
|
|
349
|
+
self._prepare_deploy_context(workdir, port, args, kwargs)
|
|
350
|
+
self._build_image(tag, workdir)
|
|
351
|
+
print(f"Run: docker run --rm -p {port}:{port} {tag}")
|
|
352
|
+
return tag
|
|
353
|
+
|
|
354
|
+
def deploy(self, *args, **kwargs):
|
|
355
|
+
import requests
|
|
356
|
+
|
|
357
|
+
port = kwargs.pop('port', 8080)
|
|
358
|
+
print(f"Deploying '{self.name}'...")
|
|
359
|
+
|
|
360
|
+
payload = cloudpickle.dumps((self.func, args, {**kwargs, 'port': port}))
|
|
361
|
+
archive_name = f"{self.name}-{hashlib.sha256(payload).hexdigest()[:16]}.tar.gz"
|
|
362
|
+
|
|
363
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
364
|
+
workdir = Path(tmpdir)
|
|
365
|
+
self._prepare_deploy_context(workdir, port, args, kwargs)
|
|
366
|
+
|
|
367
|
+
archive_path = workdir / archive_name
|
|
368
|
+
with tarfile.open(archive_path, "w:gz") as tar:
|
|
369
|
+
for f in workdir.glob("**/*"):
|
|
370
|
+
if f.is_file() and f != archive_path:
|
|
371
|
+
tar.add(f, arcname=f.relative_to(workdir))
|
|
372
|
+
|
|
373
|
+
print("Uploading build context...")
|
|
374
|
+
try:
|
|
375
|
+
with open(archive_path, 'rb') as f:
|
|
376
|
+
response = requests.post(
|
|
377
|
+
f"{self.base_url}/v1/deploy",
|
|
378
|
+
data={"function_name": self.name, "port": port},
|
|
379
|
+
files={'source_archive': (archive_name, f, 'application/gzip')},
|
|
380
|
+
headers={"X-API-Key": self.api_key},
|
|
381
|
+
timeout=9000
|
|
382
|
+
)
|
|
383
|
+
response.raise_for_status()
|
|
384
|
+
result = response.json()
|
|
385
|
+
print(f"Deployed: {result['url']}")
|
|
386
|
+
return result['url']
|
|
387
|
+
|
|
388
|
+
except requests.exceptions.HTTPError as e:
|
|
389
|
+
print(f"Deploy failed: {e.response.status_code}")
|
|
390
|
+
try:
|
|
391
|
+
print(f" {e.response.json()['detail']}")
|
|
392
|
+
except (json.JSONDecodeError, KeyError):
|
|
393
|
+
print(f" {e.response.text}")
|
|
394
|
+
return None
|
|
395
|
+
except requests.exceptions.RequestException as e:
|
|
396
|
+
print(f"Connection error: {e}")
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
def __del__(self):
|
|
400
|
+
self._cleanup_container()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def function(name=None, **kwargs):
|
|
404
|
+
"""Decorator that transforms a Python function into a containerized Function."""
|
|
405
|
+
def decorator(func):
|
|
406
|
+
return Function(func, name or func.__name__, **kwargs, base_url=_get_base_url(), api_key=_get_api_key())
|
|
407
|
+
return decorator
|