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 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
@@ -0,0 +1,4 @@
1
+ JWKS_PROD = "https://clerk.cycls.ai/.well-known/jwks.json"
2
+ JWKS_TEST = "https://select-sloth-58.clerk.accounts.dev/.well-known/jwks.json"
3
+ PK_LIVE = "pk_live_Y2xlcmsuY3ljbHMuYWkk"
4
+ PK_TEST = "pk_test_c2VsZWN0LXNsb3RoLTU4LmNsZXJrLmFjY291bnRzLmRldiQ"
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
cycls/state.py ADDED
@@ -0,0 +1,6 @@
1
+ from agentfs_sdk import AgentFS, AgentFSOptions
2
+
3
+
4
+ async def create_state(user_id: str = "default"):
5
+ """Create state instance scoped to end-user."""
6
+ return await AgentFS.open(AgentFSOptions(id=user_id))