cycls 0.0.2.83__py3-none-any.whl → 0.0.2.85__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 +12 -18
- cycls/app.py +88 -0
- cycls/{runtime.py → function.py} +35 -38
- cycls/web.py +19 -5
- {cycls-0.0.2.83.dist-info → cycls-0.0.2.85.dist-info}/METADATA +17 -17
- cycls-0.0.2.85.dist-info/RECORD +14 -0
- cycls-0.0.2.85.dist-info/entry_points.txt +3 -0
- cycls/sdk.py +0 -186
- cycls-0.0.2.83.dist-info/RECORD +0 -14
- cycls-0.0.2.83.dist-info/entry_points.txt +0 -3
- /cycls/{chat.py → cli.py} +0 -0
- /cycls/{default-theme → themes/default}/assets/index-C2r4Daz3.js +0 -0
- /cycls/{default-theme → themes/default}/assets/index-DWGS8zpa.css +0 -0
- /cycls/{default-theme → themes/default}/index.html +0 -0
- /cycls/{dev-theme → themes/dev}/index.html +0 -0
- {cycls-0.0.2.83.dist-info → cycls-0.0.2.85.dist-info}/WHEEL +0 -0
cycls/__init__.py
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
from
|
|
3
|
-
from .
|
|
4
|
-
from .runtime import Runtime
|
|
1
|
+
from . import function as _function_module
|
|
2
|
+
from .function import function, Function
|
|
3
|
+
from .app import app, App
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return getattr(sdk, name)
|
|
11
|
-
raise AttributeError(f"module 'cycls' has no attribute '{name}'")
|
|
12
|
-
|
|
13
|
-
def __setattr__(self, name, value):
|
|
14
|
-
from . import sdk
|
|
15
|
-
if name in ("api_key", "base_url"):
|
|
16
|
-
setattr(sdk, name, value)
|
|
17
|
-
return
|
|
18
|
-
super().__setattr__(name, value)
|
|
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}'")
|
|
19
9
|
|
|
20
|
-
|
|
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,88 @@
|
|
|
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
|
+
if theme not in THEMES:
|
|
19
|
+
raise ValueError(f"Unknown theme: {theme}. Available: {THEMES}")
|
|
20
|
+
self.user_func = func
|
|
21
|
+
self.theme = theme
|
|
22
|
+
self.copy_public = copy_public or []
|
|
23
|
+
|
|
24
|
+
self.config = Config(
|
|
25
|
+
header=header,
|
|
26
|
+
intro=intro,
|
|
27
|
+
title=title,
|
|
28
|
+
auth=auth,
|
|
29
|
+
plan=plan,
|
|
30
|
+
analytics=analytics,
|
|
31
|
+
org=org,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Build files dict for Function (theme is inside cycls/)
|
|
35
|
+
files = {str(CYCLS_PATH): "cycls"}
|
|
36
|
+
files.update({f: f for f in copy or []})
|
|
37
|
+
files.update({f: f"public/{f}" for f in self.copy_public})
|
|
38
|
+
|
|
39
|
+
super().__init__(
|
|
40
|
+
func=func,
|
|
41
|
+
name=name,
|
|
42
|
+
pip=["fastapi[standard]", "pyjwt", "cryptography", "uvicorn", "python-dotenv", "docker", *(pip or [])],
|
|
43
|
+
apt=apt,
|
|
44
|
+
copy=files,
|
|
45
|
+
base_url=_get_base_url(),
|
|
46
|
+
api_key=_get_api_key()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __call__(self, *args, **kwargs):
|
|
50
|
+
return self.user_func(*args, **kwargs)
|
|
51
|
+
|
|
52
|
+
def _prepare_func(self, prod):
|
|
53
|
+
self.config.set_prod(prod)
|
|
54
|
+
self.config.public_path = f"cycls/themes/{self.theme}"
|
|
55
|
+
user_func, config, name = self.user_func, self.config, self.name
|
|
56
|
+
self.func = lambda port: __import__("cycls").web.serve(user_func, config, name, port)
|
|
57
|
+
|
|
58
|
+
def _local(self, port=8080):
|
|
59
|
+
"""Run directly with uvicorn (no Docker)."""
|
|
60
|
+
print(f"Starting local server at localhost:{port}")
|
|
61
|
+
self.config.public_path = str(CYCLS_PATH.joinpath(f"themes/{self.theme}"))
|
|
62
|
+
self.config.set_prod(False)
|
|
63
|
+
uvicorn.run(web(self.user_func, self.config), host="0.0.0.0", port=port)
|
|
64
|
+
|
|
65
|
+
def local(self, port=8080, watch=True):
|
|
66
|
+
"""Run locally in Docker with file watching by default."""
|
|
67
|
+
if os.environ.get('_CYCLS_WATCH'):
|
|
68
|
+
watch = False
|
|
69
|
+
self._prepare_func(prod=False)
|
|
70
|
+
self.watch(port=port) if watch else self.run(port=port)
|
|
71
|
+
|
|
72
|
+
def deploy(self, port=8080):
|
|
73
|
+
"""Deploy to production."""
|
|
74
|
+
if self.api_key is None:
|
|
75
|
+
raise RuntimeError("Missing API key. Set cycls.api_key or CYCLS_API_KEY environment variable.")
|
|
76
|
+
self._prepare_func(prod=True)
|
|
77
|
+
return super().deploy(port=port)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def app(name=None, **kwargs):
|
|
81
|
+
"""Decorator that transforms a function into a deployable App."""
|
|
82
|
+
if kwargs.get("plan") == "cycls_pass":
|
|
83
|
+
kwargs["auth"] = True
|
|
84
|
+
kwargs["analytics"] = True
|
|
85
|
+
|
|
86
|
+
def decorator(func):
|
|
87
|
+
return App(func=func, name=name or func.__name__, **kwargs)
|
|
88
|
+
return decorator
|
cycls/{runtime.py → function.py}
RENAMED
|
@@ -16,14 +16,12 @@ BASE_IMAGE = "ghcr.io/cycls/base:python3.12"
|
|
|
16
16
|
BASE_PACKAGES = {"cloudpickle", "cryptography", "fastapi", "fastapi[standard]",
|
|
17
17
|
"pydantic", "pyjwt", "uvicorn", "uvicorn[standard]", "httpx"}
|
|
18
18
|
|
|
19
|
-
# Entrypoint for deployed services - loads pickled function+args and runs it
|
|
20
19
|
ENTRYPOINT_PY = '''import cloudpickle
|
|
21
20
|
with open("/app/function.pkl", "rb") as f:
|
|
22
21
|
func, args, kwargs = cloudpickle.load(f)
|
|
23
22
|
func(*args, **kwargs)
|
|
24
23
|
'''
|
|
25
24
|
|
|
26
|
-
# Runner script for local dev - reads pickle from volume, writes result back
|
|
27
25
|
RUNNER_PY = '''import cloudpickle
|
|
28
26
|
import sys
|
|
29
27
|
import traceback
|
|
@@ -44,6 +42,15 @@ except Exception:
|
|
|
44
42
|
sys.exit(1)
|
|
45
43
|
'''
|
|
46
44
|
|
|
45
|
+
# Module-level configuration
|
|
46
|
+
api_key = None
|
|
47
|
+
base_url = None
|
|
48
|
+
|
|
49
|
+
def _get_api_key():
|
|
50
|
+
return api_key or os.getenv("CYCLS_API_KEY")
|
|
51
|
+
|
|
52
|
+
def _get_base_url():
|
|
53
|
+
return base_url or os.getenv("CYCLS_BASE_URL")
|
|
47
54
|
|
|
48
55
|
def _hash_path(path_str: str) -> str:
|
|
49
56
|
h = hashlib.sha256()
|
|
@@ -73,35 +80,34 @@ def _copy_path(src_path: Path, dest_path: Path):
|
|
|
73
80
|
shutil.copy(src_path, dest_path)
|
|
74
81
|
|
|
75
82
|
|
|
76
|
-
class
|
|
77
|
-
"""Executes functions in Docker containers.
|
|
83
|
+
class Function:
|
|
84
|
+
"""Executes functions in Docker containers."""
|
|
78
85
|
|
|
79
|
-
def __init__(self, func, name, python_version=None,
|
|
86
|
+
def __init__(self, func, name, python_version=None, pip=None, apt=None,
|
|
80
87
|
run_commands=None, copy=None, base_url=None, api_key=None, base_image=None):
|
|
81
88
|
self.func = func
|
|
82
|
-
self.name = name
|
|
89
|
+
self.name = name.replace('_', '-')
|
|
83
90
|
self.python_version = python_version or f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
84
|
-
self.
|
|
91
|
+
self.apt = sorted(apt or [])
|
|
85
92
|
self.run_commands = sorted(run_commands or [])
|
|
86
|
-
self.copy = copy or {}
|
|
93
|
+
self.copy = {f: f for f in copy} if isinstance(copy, list) else (copy or {})
|
|
87
94
|
self.base_image = base_image or BASE_IMAGE
|
|
88
95
|
self.base_url = base_url or "https://service-core-280879789566.me-central1.run.app"
|
|
89
96
|
self.api_key = api_key
|
|
90
97
|
|
|
91
|
-
user_packages = set(
|
|
98
|
+
user_packages = set(pip or [])
|
|
92
99
|
if self.base_image == BASE_IMAGE:
|
|
93
|
-
self.
|
|
100
|
+
self.pip = sorted(user_packages - BASE_PACKAGES)
|
|
94
101
|
else:
|
|
95
|
-
self.
|
|
102
|
+
self.pip = sorted(user_packages | {"cloudpickle"})
|
|
96
103
|
|
|
97
|
-
self.image_prefix = f"cycls/{name}"
|
|
98
|
-
self.managed_label = "cycls.
|
|
104
|
+
self.image_prefix = f"cycls/{self.name}"
|
|
105
|
+
self.managed_label = "cycls.function"
|
|
99
106
|
self._docker_client = None
|
|
100
107
|
self._container = None
|
|
101
108
|
|
|
102
109
|
@property
|
|
103
110
|
def docker_client(self):
|
|
104
|
-
"""Lazily initializes and returns a Docker client."""
|
|
105
111
|
if self._docker_client is None:
|
|
106
112
|
try:
|
|
107
113
|
print("Initializing Docker client...")
|
|
@@ -115,7 +121,6 @@ class Runtime:
|
|
|
115
121
|
return self._docker_client
|
|
116
122
|
|
|
117
123
|
def _perform_auto_cleanup(self, keep_tag=None):
|
|
118
|
-
"""Clean up old containers and dev images (preserve deploy-* images)."""
|
|
119
124
|
try:
|
|
120
125
|
current_id = self._container.id if self._container else None
|
|
121
126
|
for container in self.docker_client.containers.list(all=True, filters={"label": self.managed_label}):
|
|
@@ -135,9 +140,8 @@ class Runtime:
|
|
|
135
140
|
print(f"Warning: cleanup error: {e}")
|
|
136
141
|
|
|
137
142
|
def _image_tag(self, extra_parts=None) -> str:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
"".join(self.apt_packages), "".join(self.run_commands)]
|
|
143
|
+
parts = [self.base_image, self.python_version, "".join(self.pip),
|
|
144
|
+
"".join(self.apt), "".join(self.run_commands)]
|
|
141
145
|
for src, dst in sorted(self.copy.items()):
|
|
142
146
|
if not Path(src).exists():
|
|
143
147
|
raise FileNotFoundError(f"Path in 'copy' not found: {src}")
|
|
@@ -147,18 +151,17 @@ class Runtime:
|
|
|
147
151
|
return f"{self.image_prefix}:{hashlib.sha256(''.join(parts).encode()).hexdigest()[:16]}"
|
|
148
152
|
|
|
149
153
|
def _dockerfile_preamble(self) -> str:
|
|
150
|
-
"""Common Dockerfile setup: base image, apt, pip, run commands, copy."""
|
|
151
154
|
lines = [f"FROM {self.base_image}"]
|
|
152
155
|
|
|
153
156
|
if self.base_image != BASE_IMAGE:
|
|
154
157
|
lines.append("ENV PIP_ROOT_USER_ACTION=ignore PYTHONUNBUFFERED=1")
|
|
155
158
|
lines.append("WORKDIR /app")
|
|
156
159
|
|
|
157
|
-
if self.
|
|
158
|
-
lines.append(f"RUN apt-get update && apt-get install -y --no-install-recommends {' '.join(self.
|
|
160
|
+
if self.apt:
|
|
161
|
+
lines.append(f"RUN apt-get update && apt-get install -y --no-install-recommends {' '.join(self.apt)}")
|
|
159
162
|
|
|
160
|
-
if self.
|
|
161
|
-
lines.append(f"RUN uv pip install --system --no-cache {' '.join(self.
|
|
163
|
+
if self.pip:
|
|
164
|
+
lines.append(f"RUN uv pip install --system --no-cache {' '.join(self.pip)}")
|
|
162
165
|
|
|
163
166
|
for cmd in self.run_commands:
|
|
164
167
|
lines.append(f"RUN {cmd}")
|
|
@@ -169,14 +172,12 @@ class Runtime:
|
|
|
169
172
|
return "\n".join(lines)
|
|
170
173
|
|
|
171
174
|
def _dockerfile_local(self) -> str:
|
|
172
|
-
"""Dockerfile for local dev: runner script with volume mount."""
|
|
173
175
|
return f"""{self._dockerfile_preamble()}
|
|
174
176
|
COPY runner.py /app/runner.py
|
|
175
177
|
ENTRYPOINT ["python", "/app/runner.py", "/io"]
|
|
176
178
|
"""
|
|
177
179
|
|
|
178
180
|
def _dockerfile_deploy(self, port: int) -> str:
|
|
179
|
-
"""Dockerfile for deploy: baked-in function via pickle."""
|
|
180
181
|
return f"""{self._dockerfile_preamble()}
|
|
181
182
|
COPY function.pkl /app/function.pkl
|
|
182
183
|
COPY entrypoint.py /app/entrypoint.py
|
|
@@ -185,14 +186,12 @@ CMD ["python", "entrypoint.py"]
|
|
|
185
186
|
"""
|
|
186
187
|
|
|
187
188
|
def _copy_user_files(self, workdir: Path):
|
|
188
|
-
"""Copy user-specified files to build context."""
|
|
189
189
|
context_files_dir = workdir / "context_files"
|
|
190
190
|
context_files_dir.mkdir()
|
|
191
191
|
for src, dst in self.copy.items():
|
|
192
192
|
_copy_path(Path(src).resolve(), context_files_dir / dst)
|
|
193
193
|
|
|
194
194
|
def _build_image(self, tag: str, workdir: Path) -> str:
|
|
195
|
-
"""Build a Docker image from a prepared context."""
|
|
196
195
|
print("--- Docker Build Logs ---")
|
|
197
196
|
try:
|
|
198
197
|
for chunk in self.docker_client.api.build(
|
|
@@ -209,7 +208,6 @@ CMD ["python", "entrypoint.py"]
|
|
|
209
208
|
raise
|
|
210
209
|
|
|
211
210
|
def _ensure_local_image(self) -> str:
|
|
212
|
-
"""Build local dev image if needed."""
|
|
213
211
|
tag = self._image_tag(extra_parts=["local-v1"])
|
|
214
212
|
try:
|
|
215
213
|
self.docker_client.images.get(tag)
|
|
@@ -226,8 +224,7 @@ CMD ["python", "entrypoint.py"]
|
|
|
226
224
|
return self._build_image(tag, workdir)
|
|
227
225
|
|
|
228
226
|
def _cleanup_container(self):
|
|
229
|
-
|
|
230
|
-
if self._container:
|
|
227
|
+
if getattr(self, '_container', None):
|
|
231
228
|
try:
|
|
232
229
|
self._container.stop(timeout=3)
|
|
233
230
|
self._container.remove()
|
|
@@ -239,7 +236,6 @@ CMD ["python", "entrypoint.py"]
|
|
|
239
236
|
|
|
240
237
|
@contextlib.contextmanager
|
|
241
238
|
def runner(self, *args, **kwargs):
|
|
242
|
-
"""Context manager for running a function in a container."""
|
|
243
239
|
service_port = kwargs.get('port')
|
|
244
240
|
tag = self._ensure_local_image()
|
|
245
241
|
self._perform_auto_cleanup(keep_tag=tag)
|
|
@@ -267,7 +263,6 @@ CMD ["python", "entrypoint.py"]
|
|
|
267
263
|
self._cleanup_container()
|
|
268
264
|
|
|
269
265
|
def run(self, *args, **kwargs):
|
|
270
|
-
"""Execute the function in a container and return the result."""
|
|
271
266
|
service_port = kwargs.get('port')
|
|
272
267
|
print(f"Running '{self.name}'...")
|
|
273
268
|
|
|
@@ -284,7 +279,7 @@ CMD ["python", "entrypoint.py"]
|
|
|
284
279
|
return None
|
|
285
280
|
|
|
286
281
|
if service_port:
|
|
287
|
-
return None
|
|
282
|
+
return None
|
|
288
283
|
|
|
289
284
|
if result_path.exists():
|
|
290
285
|
with open(result_path, 'rb') as f:
|
|
@@ -302,7 +297,6 @@ CMD ["python", "entrypoint.py"]
|
|
|
302
297
|
return None
|
|
303
298
|
|
|
304
299
|
def watch(self, *args, **kwargs):
|
|
305
|
-
"""Run with file watching - restarts on changes."""
|
|
306
300
|
if os.environ.get('_CYCLS_WATCH'):
|
|
307
301
|
return self.run(*args, **kwargs)
|
|
308
302
|
|
|
@@ -335,7 +329,6 @@ CMD ["python", "entrypoint.py"]
|
|
|
335
329
|
return
|
|
336
330
|
|
|
337
331
|
def _prepare_deploy_context(self, workdir: Path, port: int, args=(), kwargs=None):
|
|
338
|
-
"""Prepare build context for deploy: pickle function+args + entrypoint."""
|
|
339
332
|
kwargs = kwargs or {}
|
|
340
333
|
kwargs['port'] = port
|
|
341
334
|
self._copy_user_files(workdir)
|
|
@@ -345,7 +338,6 @@ CMD ["python", "entrypoint.py"]
|
|
|
345
338
|
cloudpickle.dump((self.func, args, kwargs), f)
|
|
346
339
|
|
|
347
340
|
def build(self, *args, **kwargs):
|
|
348
|
-
"""Build a deployable Docker image locally."""
|
|
349
341
|
port = kwargs.pop('port', 8080)
|
|
350
342
|
payload = cloudpickle.dumps((self.func, args, {**kwargs, 'port': port}))
|
|
351
343
|
tag = f"{self.image_prefix}:deploy-{hashlib.sha256(payload).hexdigest()[:16]}"
|
|
@@ -365,7 +357,6 @@ CMD ["python", "entrypoint.py"]
|
|
|
365
357
|
return tag
|
|
366
358
|
|
|
367
359
|
def deploy(self, *args, **kwargs):
|
|
368
|
-
"""Deploy the function to a remote build server."""
|
|
369
360
|
import requests
|
|
370
361
|
|
|
371
362
|
port = kwargs.pop('port', 8080)
|
|
@@ -411,5 +402,11 @@ CMD ["python", "entrypoint.py"]
|
|
|
411
402
|
return None
|
|
412
403
|
|
|
413
404
|
def __del__(self):
|
|
414
|
-
"""Cleanup on garbage collection."""
|
|
415
405
|
self._cleanup_container()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def function(name=None, **kwargs):
|
|
409
|
+
"""Decorator that transforms a Python function into a containerized Function."""
|
|
410
|
+
def decorator(func):
|
|
411
|
+
return Function(func, name or func.__name__, **kwargs, base_url=_get_base_url(), api_key=_get_api_key())
|
|
412
|
+
return decorator
|
cycls/web.py
CHANGED
|
@@ -2,19 +2,25 @@ import json, inspect
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
from pydantic import BaseModel
|
|
4
4
|
from typing import Optional
|
|
5
|
+
from .auth import PK_LIVE, PK_TEST, JWKS_PROD, JWKS_TEST
|
|
5
6
|
|
|
6
7
|
class Config(BaseModel):
|
|
7
8
|
public_path: str = "theme"
|
|
8
|
-
header: str =
|
|
9
|
-
intro: str =
|
|
10
|
-
title: str =
|
|
9
|
+
header: Optional[str] = None
|
|
10
|
+
intro: Optional[str] = None
|
|
11
|
+
title: Optional[str] = None
|
|
11
12
|
prod: bool = False
|
|
12
13
|
auth: bool = False
|
|
13
14
|
plan: str = "free"
|
|
14
15
|
analytics: bool = False
|
|
15
16
|
org: Optional[str] = None
|
|
16
|
-
pk: str =
|
|
17
|
-
jwks: str =
|
|
17
|
+
pk: Optional[str] = None
|
|
18
|
+
jwks: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
def set_prod(self, prod: bool):
|
|
21
|
+
self.prod = prod
|
|
22
|
+
self.pk = PK_LIVE if prod else PK_TEST
|
|
23
|
+
self.jwks = JWKS_PROD if prod else JWKS_TEST
|
|
18
24
|
|
|
19
25
|
async def openai_encoder(stream):
|
|
20
26
|
if inspect.isasyncgen(stream):
|
|
@@ -84,6 +90,12 @@ def web(func, config):
|
|
|
84
90
|
messages: Any
|
|
85
91
|
user: Optional[User] = None
|
|
86
92
|
|
|
93
|
+
@property
|
|
94
|
+
def last_message(self) -> str:
|
|
95
|
+
if self.messages:
|
|
96
|
+
return self.messages[-1].get("content", "")
|
|
97
|
+
return ""
|
|
98
|
+
|
|
87
99
|
app = FastAPI()
|
|
88
100
|
bearer_scheme = HTTPBearer()
|
|
89
101
|
|
|
@@ -128,6 +140,8 @@ def web(func, config):
|
|
|
128
140
|
|
|
129
141
|
def serve(func, config, name, port):
|
|
130
142
|
import uvicorn, logging
|
|
143
|
+
from dotenv import load_dotenv
|
|
144
|
+
load_dotenv()
|
|
131
145
|
if isinstance(config, dict):
|
|
132
146
|
config = Config(**config)
|
|
133
147
|
logging.getLogger("uvicorn.error").addFilter(lambda r: "0.0.0.0" not in r.getMessage())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cycls
|
|
3
|
-
Version: 0.0.2.
|
|
3
|
+
Version: 0.0.2.85
|
|
4
4
|
Summary: Distribute Intelligence
|
|
5
5
|
Author: Mohammed J. AlRujayi
|
|
6
6
|
Author-email: mj@cycls.com
|
|
@@ -12,12 +12,10 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
-
Provides-Extra: modal
|
|
16
15
|
Requires-Dist: cloudpickle (>=3.1.1,<4.0.0)
|
|
17
16
|
Requires-Dist: docker (>=7.1.0,<8.0.0)
|
|
18
17
|
Requires-Dist: fastapi (>=0.111.0,<0.112.0)
|
|
19
18
|
Requires-Dist: httpx (>=0.27.0,<0.28.0)
|
|
20
|
-
Requires-Dist: modal (>=1.1.0,<2.0.0) ; extra == "modal"
|
|
21
19
|
Requires-Dist: pyjwt (>=2.8.0,<3.0.0)
|
|
22
20
|
Description-Content-Type: text/markdown
|
|
23
21
|
|
|
@@ -47,6 +45,8 @@ The open-source SDK for distributing AI agents.
|
|
|
47
45
|
|
|
48
46
|
The function is the unit of abstraction in cycls. Your agent logic lives in a plain Python function — the decorator layers on everything else: containerization, authentication, deployment, analytics. You write the function, the `@` handles the infrastructure.
|
|
49
47
|
|
|
48
|
+
D
|
|
49
|
+
|
|
50
50
|
## Distribute Intelligence
|
|
51
51
|
|
|
52
52
|
Write a function. Deploy it as an API, a web interface, or both. Add authentication, analytics, and monetization with flags.
|
|
@@ -56,8 +56,8 @@ import cycls
|
|
|
56
56
|
|
|
57
57
|
cycls.api_key = "YOUR_CYCLS_API_KEY"
|
|
58
58
|
|
|
59
|
-
@cycls.
|
|
60
|
-
async def
|
|
59
|
+
@cycls.app(pip=["openai"])
|
|
60
|
+
async def app(context):
|
|
61
61
|
from openai import AsyncOpenAI
|
|
62
62
|
client = AsyncOpenAI()
|
|
63
63
|
|
|
@@ -74,7 +74,7 @@ async def agent(context):
|
|
|
74
74
|
elif event.type == "response.output_text.delta":
|
|
75
75
|
yield event.delta
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
app.deploy() # Live at https://agent.cycls.ai
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
## Installation
|
|
@@ -97,9 +97,9 @@ Requires Docker.
|
|
|
97
97
|
## Running
|
|
98
98
|
|
|
99
99
|
```python
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
app.local() # Development with hot-reload (localhost:8080)
|
|
101
|
+
app.local(watch=False) # Development without hot-reload
|
|
102
|
+
app.deploy() # Production: https://agent.cycls.ai
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
Get an API key at [cycls.com](https://cycls.com).
|
|
@@ -107,8 +107,8 @@ Get an API key at [cycls.com](https://cycls.com).
|
|
|
107
107
|
## Authentication & Analytics
|
|
108
108
|
|
|
109
109
|
```python
|
|
110
|
-
@cycls.
|
|
111
|
-
async def
|
|
110
|
+
@cycls.app(pip=["openai"], auth=True, analytics=True)
|
|
111
|
+
async def app(context):
|
|
112
112
|
# context.user available when auth=True
|
|
113
113
|
user = context.user # User(id, email, name, plans)
|
|
114
114
|
yield f"Hello {user.name}!"
|
|
@@ -125,7 +125,7 @@ async def agent(context):
|
|
|
125
125
|
Yield structured objects for rich streaming responses:
|
|
126
126
|
|
|
127
127
|
```python
|
|
128
|
-
@cycls.
|
|
128
|
+
@cycls.app()
|
|
129
129
|
async def demo(context):
|
|
130
130
|
yield {"type": "thinking", "thinking": "Analyzing the request..."}
|
|
131
131
|
yield "Here's what I found:\n\n"
|
|
@@ -167,7 +167,7 @@ This works seamlessly with OpenAI's reasoning models - just map reasoning summar
|
|
|
167
167
|
## Context Object
|
|
168
168
|
|
|
169
169
|
```python
|
|
170
|
-
@cycls.
|
|
170
|
+
@cycls.app()
|
|
171
171
|
async def chat(context):
|
|
172
172
|
context.messages # [{"role": "user", "content": "..."}]
|
|
173
173
|
context.messages.raw # Full data including UI component parts
|
|
@@ -200,13 +200,13 @@ See [docs/streaming-protocol.md](docs/streaming-protocol.md) for frontend integr
|
|
|
200
200
|
Define your entire runtime in the decorator:
|
|
201
201
|
|
|
202
202
|
```python
|
|
203
|
-
@cycls.
|
|
203
|
+
@cycls.app(
|
|
204
204
|
pip=["openai", "pandas", "numpy"],
|
|
205
205
|
apt=["ffmpeg", "libmagic1"],
|
|
206
206
|
copy=["./utils.py", "./models/", "/absolute/path/to/config.json"],
|
|
207
207
|
copy_public=["./assets/logo.png", "./static/"],
|
|
208
208
|
)
|
|
209
|
-
async def
|
|
209
|
+
async def my_app(context):
|
|
210
210
|
...
|
|
211
211
|
```
|
|
212
212
|
|
|
@@ -241,7 +241,7 @@ copy=[
|
|
|
241
241
|
Then import them in your function:
|
|
242
242
|
|
|
243
243
|
```python
|
|
244
|
-
@cycls.
|
|
244
|
+
@cycls.app(copy=["./utils.py"])
|
|
245
245
|
async def chat(context):
|
|
246
246
|
from utils import helper_function # Your bundled module
|
|
247
247
|
...
|
|
@@ -255,7 +255,7 @@ Files and directories served at the `/public` endpoint. Perfect for images, down
|
|
|
255
255
|
copy_public=["./assets/logo.png", "./downloads/"]
|
|
256
256
|
```
|
|
257
257
|
|
|
258
|
-
Access them at `https://your-
|
|
258
|
+
Access them at `https://your-app.cycls.ai/public/logo.png`.
|
|
259
259
|
|
|
260
260
|
---
|
|
261
261
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
cycls/__init__.py,sha256=efbq0vRijGOByKtz9bRF8WQFYmnPSgZV1DH-54s6iwQ,493
|
|
2
|
+
cycls/app.py,sha256=cYzHJboFRAKXhImJOkTLdMxpFZFlkVt_oulJrhF14KU,3124
|
|
3
|
+
cycls/auth.py,sha256=xkndHZyCfnlertMMEKerCJjf23N3fVcTRVTTSXTTuzg,247
|
|
4
|
+
cycls/cli.py,sha256=cVbIkTDnVofohvByyYUrXF_RYDQZVQECJqo7cPBPJfs,4781
|
|
5
|
+
cycls/function.py,sha256=3N8PqpVSVaU6RSrIwCvTCUwTm0kOchQN6jSz89v_hXE,15120
|
|
6
|
+
cycls/themes/default/assets/index-C2r4Daz3.js,sha256=OGzjspxo0uUTdtQIzWZNgFMhGUtW4wSVuW33iA7oLFM,1351283
|
|
7
|
+
cycls/themes/default/assets/index-DWGS8zpa.css,sha256=SxylXQV1qgQ0sw9QlMxcu9jwWTu8XPOnWZju8upUpCM,6504
|
|
8
|
+
cycls/themes/default/index.html,sha256=WPUqVzJyh-aO2ulWTbXwOVAfWHaj0yWTGd9e8HOEro4,828
|
|
9
|
+
cycls/themes/dev/index.html,sha256=QJBHkdNuMMiwQU7o8dN8__8YQeQB45D37D-NCXIWB2Q,11585
|
|
10
|
+
cycls/web.py,sha256=svDytrmrUoeZQtl9lBb9pGLb1JRKtcB8nZmHnErrweA,5540
|
|
11
|
+
cycls-0.0.2.85.dist-info/METADATA,sha256=FKg9e22cII94oIkagB7Dn6WyHRnsP-3rr-uEyzeulqM,8578
|
|
12
|
+
cycls-0.0.2.85.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
13
|
+
cycls-0.0.2.85.dist-info/entry_points.txt,sha256=vEhqUxFhhuzCKWtq02LbMnT3wpUqdfgcM3Yh-jjXom8,40
|
|
14
|
+
cycls-0.0.2.85.dist-info/RECORD,,
|
cycls/sdk.py
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import os, time, uvicorn
|
|
2
|
-
from .runtime import Runtime
|
|
3
|
-
from .web import web, Config
|
|
4
|
-
from .auth import PK_LIVE, PK_TEST, JWKS_PROD, JWKS_TEST
|
|
5
|
-
import importlib.resources
|
|
6
|
-
|
|
7
|
-
CYCLS_PATH = importlib.resources.files('cycls')
|
|
8
|
-
|
|
9
|
-
# Module-level configuration
|
|
10
|
-
api_key = None
|
|
11
|
-
base_url = None
|
|
12
|
-
|
|
13
|
-
themes = {
|
|
14
|
-
"default": CYCLS_PATH.joinpath('default-theme'),
|
|
15
|
-
"dev": CYCLS_PATH.joinpath('dev-theme'),
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
def _resolve_theme(theme):
|
|
19
|
-
"""Resolve theme - accepts string name or path"""
|
|
20
|
-
if isinstance(theme, str):
|
|
21
|
-
if theme in themes:
|
|
22
|
-
return themes[theme]
|
|
23
|
-
raise ValueError(f"Unknown theme: {theme}. Available: {list(themes.keys())}")
|
|
24
|
-
return theme
|
|
25
|
-
|
|
26
|
-
def _set_prod(config: Config, prod: bool):
|
|
27
|
-
config.prod = prod
|
|
28
|
-
config.pk = PK_LIVE if prod else PK_TEST
|
|
29
|
-
config.jwks = JWKS_PROD if prod else JWKS_TEST
|
|
30
|
-
|
|
31
|
-
class AgentRuntime:
|
|
32
|
-
"""Wraps an agent function with local/deploy/modal capabilities."""
|
|
33
|
-
|
|
34
|
-
def __init__(self, func, name, theme, pip, apt, copy, copy_public, modal_keys, auth, org, domain, header, intro, title, plan, analytics):
|
|
35
|
-
self.func = func
|
|
36
|
-
self.name = name
|
|
37
|
-
self.theme = _resolve_theme(theme)
|
|
38
|
-
self.pip = pip
|
|
39
|
-
self.apt = apt
|
|
40
|
-
self.copy = copy
|
|
41
|
-
self.copy_public = copy_public
|
|
42
|
-
self.modal_keys = modal_keys
|
|
43
|
-
self.domain = domain or f"{name}.cycls.ai"
|
|
44
|
-
|
|
45
|
-
self.config = Config(
|
|
46
|
-
header=header,
|
|
47
|
-
intro=intro,
|
|
48
|
-
title=title,
|
|
49
|
-
auth=auth,
|
|
50
|
-
plan=plan,
|
|
51
|
-
analytics=analytics,
|
|
52
|
-
org=org,
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
def __call__(self, *args, **kwargs):
|
|
56
|
-
"""Make the runtime callable - delegates to the wrapped function."""
|
|
57
|
-
return self.func(*args, **kwargs)
|
|
58
|
-
|
|
59
|
-
def _local(self, port=8080):
|
|
60
|
-
"""Run directly with uvicorn (no Docker)."""
|
|
61
|
-
print(f"Starting local server at localhost:{port}")
|
|
62
|
-
self.config.public_path = self.theme
|
|
63
|
-
_set_prod(self.config, False)
|
|
64
|
-
uvicorn.run(web(self.func, self.config), host="0.0.0.0", port=port)
|
|
65
|
-
|
|
66
|
-
def _runtime(self, prod=False):
|
|
67
|
-
"""Create a Runtime instance for deployment."""
|
|
68
|
-
_set_prod(self.config, prod)
|
|
69
|
-
config_dict = self.config.model_dump()
|
|
70
|
-
|
|
71
|
-
# Extract to local variables to avoid capturing self in lambda (cloudpickle issue)
|
|
72
|
-
func = self.func
|
|
73
|
-
name = self.name
|
|
74
|
-
|
|
75
|
-
files = {str(self.theme): "theme", str(CYCLS_PATH)+"/web.py": "web.py"}
|
|
76
|
-
files.update({f: f for f in self.copy})
|
|
77
|
-
files.update({f: f"public/{f}" for f in self.copy_public})
|
|
78
|
-
|
|
79
|
-
return Runtime(
|
|
80
|
-
func=lambda port: __import__("web").serve(func, config_dict, name, port),
|
|
81
|
-
name=name,
|
|
82
|
-
apt_packages=self.apt,
|
|
83
|
-
pip_packages=["fastapi[standard]", "pyjwt", "cryptography", "uvicorn", *self.pip],
|
|
84
|
-
copy=files,
|
|
85
|
-
base_url=base_url,
|
|
86
|
-
api_key=api_key
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
def local(self, port=8080, watch=True):
|
|
90
|
-
"""Run locally in Docker with file watching by default."""
|
|
91
|
-
if os.environ.get('_CYCLS_WATCH'):
|
|
92
|
-
watch = False
|
|
93
|
-
runtime = self._runtime(prod=False)
|
|
94
|
-
runtime.watch(port=port) if watch else runtime.run(port=port)
|
|
95
|
-
|
|
96
|
-
def deploy(self, port=8080):
|
|
97
|
-
"""Deploy to production."""
|
|
98
|
-
if api_key is None:
|
|
99
|
-
raise RuntimeError("Missing API key. Set cycls.api_key before calling deploy().")
|
|
100
|
-
runtime = self._runtime(prod=True)
|
|
101
|
-
return runtime.deploy(port=port)
|
|
102
|
-
|
|
103
|
-
def modal(self, prod=False):
|
|
104
|
-
import modal
|
|
105
|
-
from modal.runner import run_app
|
|
106
|
-
|
|
107
|
-
# Extract to local variables to avoid capturing self in lambda
|
|
108
|
-
func = self.func
|
|
109
|
-
name = self.name
|
|
110
|
-
domain = self.domain
|
|
111
|
-
|
|
112
|
-
client = modal.Client.from_credentials(*self.modal_keys)
|
|
113
|
-
image = (modal.Image.debian_slim()
|
|
114
|
-
.pip_install("fastapi[standard]", "pyjwt", "cryptography", *self.pip)
|
|
115
|
-
.apt_install(*self.apt)
|
|
116
|
-
.add_local_dir(self.theme, "/root/theme")
|
|
117
|
-
.add_local_file(str(CYCLS_PATH)+"/web.py", "/root/web.py"))
|
|
118
|
-
|
|
119
|
-
for item in self.copy:
|
|
120
|
-
image = image.add_local_file(item, f"/root/{item}") if "." in item else image.add_local_dir(item, f'/root/{item}')
|
|
121
|
-
|
|
122
|
-
for item in self.copy_public:
|
|
123
|
-
image = image.add_local_file(item, f"/root/public/{item}") if "." in item else image.add_local_dir(item, f'/root/public/{item}')
|
|
124
|
-
|
|
125
|
-
app = modal.App("development", image=image)
|
|
126
|
-
|
|
127
|
-
_set_prod(self.config, prod)
|
|
128
|
-
config_dict = self.config.model_dump()
|
|
129
|
-
|
|
130
|
-
app.function(serialized=True, name=name)(
|
|
131
|
-
modal.asgi_app(label=name, custom_domains=[domain])
|
|
132
|
-
(lambda: __import__("web").web(func, config_dict))
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
if prod:
|
|
136
|
-
print(f"Deployed to => https://{domain}")
|
|
137
|
-
app.deploy(client=client, name=name)
|
|
138
|
-
else:
|
|
139
|
-
with modal.enable_output():
|
|
140
|
-
run_app(app=app, client=client)
|
|
141
|
-
print("Modal development server is running. Press Ctrl+C to stop.")
|
|
142
|
-
with modal.enable_output(), run_app(app=app, client=client):
|
|
143
|
-
while True: time.sleep(10)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def agent(name=None, pip=None, apt=None, copy=None, copy_public=None, theme="default", modal_keys=None, auth=False, org=None, domain=None, header="", intro="", title="", plan="free", analytics=False):
|
|
147
|
-
"""Decorator that transforms a function into a deployable agent."""
|
|
148
|
-
pip = pip or []
|
|
149
|
-
apt = apt or []
|
|
150
|
-
copy = copy or []
|
|
151
|
-
copy_public = copy_public or []
|
|
152
|
-
modal_keys = modal_keys or ["", ""]
|
|
153
|
-
|
|
154
|
-
if plan == "cycls_pass":
|
|
155
|
-
auth = True
|
|
156
|
-
analytics = True
|
|
157
|
-
|
|
158
|
-
def decorator(func):
|
|
159
|
-
agent_name = name or func.__name__.replace('_', '-')
|
|
160
|
-
return AgentRuntime(
|
|
161
|
-
func=func,
|
|
162
|
-
name=agent_name,
|
|
163
|
-
theme=theme,
|
|
164
|
-
pip=pip,
|
|
165
|
-
apt=apt,
|
|
166
|
-
copy=copy,
|
|
167
|
-
copy_public=copy_public,
|
|
168
|
-
modal_keys=modal_keys,
|
|
169
|
-
auth=auth,
|
|
170
|
-
org=org,
|
|
171
|
-
domain=domain,
|
|
172
|
-
header=header,
|
|
173
|
-
intro=intro,
|
|
174
|
-
title=title,
|
|
175
|
-
plan=plan,
|
|
176
|
-
analytics=analytics,
|
|
177
|
-
)
|
|
178
|
-
return decorator
|
|
179
|
-
|
|
180
|
-
def function(python_version=None, pip=None, apt=None, run_commands=None, copy=None, name=None):
|
|
181
|
-
"""Decorator that transforms a Python function into a containerized, remotely executable object."""
|
|
182
|
-
def decorator(func):
|
|
183
|
-
func_name = name or func.__name__
|
|
184
|
-
copy_dict = {i: i for i in copy or []}
|
|
185
|
-
return Runtime(func, func_name.replace('_', '-'), python_version, pip, apt, run_commands, copy_dict, base_url, api_key)
|
|
186
|
-
return decorator
|
cycls-0.0.2.83.dist-info/RECORD
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
cycls/__init__.py,sha256=vyI1d_8VP4XW7MliFuUs_P3O9KQxyCwQu-JkxrCyhPQ,597
|
|
2
|
-
cycls/auth.py,sha256=xkndHZyCfnlertMMEKerCJjf23N3fVcTRVTTSXTTuzg,247
|
|
3
|
-
cycls/chat.py,sha256=cVbIkTDnVofohvByyYUrXF_RYDQZVQECJqo7cPBPJfs,4781
|
|
4
|
-
cycls/default-theme/assets/index-C2r4Daz3.js,sha256=OGzjspxo0uUTdtQIzWZNgFMhGUtW4wSVuW33iA7oLFM,1351283
|
|
5
|
-
cycls/default-theme/assets/index-DWGS8zpa.css,sha256=SxylXQV1qgQ0sw9QlMxcu9jwWTu8XPOnWZju8upUpCM,6504
|
|
6
|
-
cycls/default-theme/index.html,sha256=WPUqVzJyh-aO2ulWTbXwOVAfWHaj0yWTGd9e8HOEro4,828
|
|
7
|
-
cycls/dev-theme/index.html,sha256=QJBHkdNuMMiwQU7o8dN8__8YQeQB45D37D-NCXIWB2Q,11585
|
|
8
|
-
cycls/runtime.py,sha256=-di1VI9aJm2vZ108MMAS98YByCZReAQ2ese_t2awRbU,15963
|
|
9
|
-
cycls/sdk.py,sha256=yOLAOx26qaAgddLu7QcAroeaQ2eWqIjCvM7QmCe-OJs,6760
|
|
10
|
-
cycls/web.py,sha256=mRzuOVZPEMW6bLkzEDHI25AWYBREqbtoHsBzgGbUH4g,5038
|
|
11
|
-
cycls-0.0.2.83.dist-info/METADATA,sha256=SAmy7MFt-vQIF7U5T7sFMDJcGhHydVs8My-enlPm2Zo,8682
|
|
12
|
-
cycls-0.0.2.83.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
13
|
-
cycls-0.0.2.83.dist-info/entry_points.txt,sha256=0NBXjzFFxdtO57z3vdaQEy68KmYTEgwK9Gvo34AOTb4,41
|
|
14
|
-
cycls-0.0.2.83.dist-info/RECORD,,
|
/cycls/{chat.py → cli.py}
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|