cycls 0.0.2.30__py3-none-any.whl → 0.0.2.32__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 +2 -1
- cycls/cycls.py +27 -20
- cycls/runtime.py +395 -0
- cycls/sdk.py +22 -0
- cycls-0.0.2.32.dist-info/METADATA +148 -0
- cycls-0.0.2.32.dist-info/RECORD +9 -0
- {cycls-0.0.2.30.dist-info → cycls-0.0.2.32.dist-info}/WHEEL +1 -1
- cycls-0.0.2.30.dist-info/METADATA +0 -103
- cycls-0.0.2.30.dist-info/RECORD +0 -7
cycls/__init__.py
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
from .cycls import Agent
|
|
1
|
+
from .cycls import Agent
|
|
2
|
+
from .sdk import function
|
cycls/cycls.py
CHANGED
|
@@ -100,25 +100,18 @@ def web(func, front_end_path="", prod=False, org=None, api_token=None, header=""
|
|
|
100
100
|
return app
|
|
101
101
|
|
|
102
102
|
class Agent:
|
|
103
|
-
def __init__(self,
|
|
104
|
-
self.
|
|
105
|
-
self.
|
|
103
|
+
def __init__(self, theme=theme_path, org=None, api_token=None, pip=[], apt=[], copy=[], keys=["",""]):
|
|
104
|
+
self.org, self.api_token = org, api_token
|
|
105
|
+
self.theme = theme
|
|
106
|
+
self.keys, self.pip, self.apt, self.copy = keys, pip, apt, copy
|
|
107
|
+
|
|
106
108
|
self.registered_functions = []
|
|
107
|
-
self.client = modal.Client.from_credentials(*keys)
|
|
108
|
-
image = (modal.Image.debian_slim()
|
|
109
|
-
.pip_install("fastapi[standard]", "pyjwt", "cryptography", *pip)
|
|
110
|
-
.apt_install(*apt)
|
|
111
|
-
.add_local_dir(front_end, "/root/public")
|
|
112
|
-
.add_local_python_source("cycls"))
|
|
113
|
-
for item in copy:
|
|
114
|
-
image = image.add_local_file(item, f"/root/{item}") if "." in item else image.add_local_dir(item, f'/root/{item}')
|
|
115
|
-
self.app = modal.App("development", image=image)
|
|
116
109
|
|
|
117
110
|
def __call__(self, name="", header="", intro="", domain=None, auth=False):
|
|
118
111
|
def decorator(f):
|
|
119
112
|
self.registered_functions.append({
|
|
120
113
|
"func": f,
|
|
121
|
-
"config": ["public",
|
|
114
|
+
"config": ["public", False, self.org, self.api_token, header, intro, auth],
|
|
122
115
|
"name": name,
|
|
123
116
|
"domain": domain or f"{name}.cycls.ai",
|
|
124
117
|
})
|
|
@@ -127,26 +120,39 @@ class Agent:
|
|
|
127
120
|
|
|
128
121
|
def run(self, port=8000):
|
|
129
122
|
if not self.registered_functions:
|
|
130
|
-
|
|
123
|
+
print("Error: No @agent decorated function found.")
|
|
124
|
+
return
|
|
131
125
|
|
|
132
126
|
i = self.registered_functions[0]
|
|
133
127
|
if len(self.registered_functions) > 1:
|
|
134
128
|
print(f"⚠️ Warning: Multiple agents found. Running '{i['name']}'.")
|
|
135
|
-
print(f"🚀 Starting local server at
|
|
136
|
-
i["config"][0] = self.
|
|
137
|
-
uvicorn.run(web(i["func"], *i["config"]), host="
|
|
129
|
+
print(f"🚀 Starting local server at localhost:{port}")
|
|
130
|
+
i["config"][0], i["config"][6] = self.theme, False
|
|
131
|
+
uvicorn.run(web(i["func"], *i["config"]), host="0.0.0.0", port=port)
|
|
138
132
|
return
|
|
139
133
|
|
|
140
|
-
def push(self):
|
|
134
|
+
def push(self, prod=False):
|
|
135
|
+
self.client = modal.Client.from_credentials(*self.keys)
|
|
136
|
+
image = (modal.Image.debian_slim()
|
|
137
|
+
.pip_install("fastapi[standard]", "pyjwt", "cryptography", *self.pip)
|
|
138
|
+
.apt_install(*self.apt)
|
|
139
|
+
.add_local_dir(self.theme, "/root/public")
|
|
140
|
+
.add_local_python_source("cycls"))
|
|
141
|
+
for item in self.copy:
|
|
142
|
+
image = image.add_local_file(item, f"/root/{item}") if "." in item else image.add_local_dir(item, f'/root/{item}')
|
|
143
|
+
self.app = modal.App("development", image=image)
|
|
144
|
+
|
|
141
145
|
if not self.registered_functions:
|
|
142
|
-
|
|
146
|
+
print("Error: No @agent decorated function found.")
|
|
147
|
+
return
|
|
143
148
|
|
|
144
149
|
for i in self.registered_functions:
|
|
150
|
+
i["config"][1] = True if prod else False
|
|
145
151
|
self.app.function(serialized=True, name=i["name"])(
|
|
146
152
|
modal.asgi_app(label=i["name"], custom_domains=[i["domain"]])
|
|
147
153
|
(lambda: web(i["func"], *i["config"]))
|
|
148
154
|
)
|
|
149
|
-
if
|
|
155
|
+
if prod:
|
|
150
156
|
for i in self.registered_functions:
|
|
151
157
|
print(f"✅ Deployed to ⇒ https://{i['domain']}")
|
|
152
158
|
self.app.deploy(client=self.client, name=self.registered_functions[0]["name"])
|
|
@@ -158,5 +164,6 @@ class Agent:
|
|
|
158
164
|
with modal.enable_output(), run_app(app=self.app, client=self.client):
|
|
159
165
|
while True: time.sleep(10)
|
|
160
166
|
|
|
167
|
+
# poetry config pypi-token.pypi <your-token>
|
|
161
168
|
# poetry run python agent.py
|
|
162
169
|
# poetry publish --build
|
cycls/runtime.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import docker
|
|
2
|
+
import cloudpickle
|
|
3
|
+
import tempfile
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
import tarfile
|
|
11
|
+
|
|
12
|
+
# --- Docker Client Initialization ---
|
|
13
|
+
try:
|
|
14
|
+
docker_client = docker.from_env()
|
|
15
|
+
except docker.errors.DockerException:
|
|
16
|
+
print("❌ Error: Docker is not running or not installed.")
|
|
17
|
+
print("Please start the Docker daemon and try again.")
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
|
|
20
|
+
# --- Top-Level Helper Functions ---
|
|
21
|
+
|
|
22
|
+
def _bootstrap_script(payload_file: str, result_file: str) -> str:
|
|
23
|
+
"""Generates the Python script that runs inside the Docker container."""
|
|
24
|
+
return f"""
|
|
25
|
+
import cloudpickle
|
|
26
|
+
import sys
|
|
27
|
+
import os
|
|
28
|
+
import traceback
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
io_dir = Path(sys.argv[1])
|
|
33
|
+
payload_path = io_dir / '{payload_file}'
|
|
34
|
+
result_path = io_dir / '{result_file}'
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
with open(payload_path, 'rb') as f:
|
|
38
|
+
func, args, kwargs = cloudpickle.load(f)
|
|
39
|
+
|
|
40
|
+
result = func(*args, **kwargs)
|
|
41
|
+
|
|
42
|
+
with open(result_path, 'wb') as f:
|
|
43
|
+
cloudpickle.dump(result, f)
|
|
44
|
+
|
|
45
|
+
except Exception as e:
|
|
46
|
+
traceback.print_exc(file=sys.stderr)
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def _hash_path(path_str: str) -> str:
|
|
51
|
+
"""Hashes a file or a directory's contents to create a deterministic signature."""
|
|
52
|
+
h = hashlib.sha256()
|
|
53
|
+
p = Path(path_str)
|
|
54
|
+
if p.is_file():
|
|
55
|
+
with p.open('rb') as f:
|
|
56
|
+
while chunk := f.read(65536):
|
|
57
|
+
h.update(chunk)
|
|
58
|
+
elif p.is_dir():
|
|
59
|
+
for root, dirs, files in os.walk(p, topdown=True):
|
|
60
|
+
dirs.sort()
|
|
61
|
+
files.sort()
|
|
62
|
+
for name in files:
|
|
63
|
+
filepath = Path(root) / name
|
|
64
|
+
relpath = filepath.relative_to(p)
|
|
65
|
+
h.update(str(relpath).encode())
|
|
66
|
+
with filepath.open('rb') as f:
|
|
67
|
+
while chunk := f.read(65536):
|
|
68
|
+
h.update(chunk)
|
|
69
|
+
return h.hexdigest()
|
|
70
|
+
|
|
71
|
+
def _copy_path(src_path: Path, dest_path: Path):
|
|
72
|
+
"""Recursively copies a file or directory to a destination path."""
|
|
73
|
+
if src_path.is_dir():
|
|
74
|
+
shutil.copytree(src_path, dest_path, dirs_exist_ok=True)
|
|
75
|
+
else:
|
|
76
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
shutil.copy(src_path, dest_path)
|
|
78
|
+
|
|
79
|
+
# --- Main Runtime Class ---
|
|
80
|
+
|
|
81
|
+
class Runtime:
|
|
82
|
+
"""
|
|
83
|
+
Handles building a Docker image and executing a function within a container.
|
|
84
|
+
"""
|
|
85
|
+
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):
|
|
86
|
+
self.func = func
|
|
87
|
+
self.python_version = python_version or "3.12"
|
|
88
|
+
self.pip_packages = sorted(pip_packages or [])
|
|
89
|
+
self.apt_packages = sorted(apt_packages or [])
|
|
90
|
+
self.run_commands = sorted(run_commands or [])
|
|
91
|
+
self.copy = copy or {}
|
|
92
|
+
self.name = name
|
|
93
|
+
self.base_url = base_url or "https://service-core-280879789566.me-central1.run.app"
|
|
94
|
+
self.image_prefix = f"cycls/{name}"
|
|
95
|
+
|
|
96
|
+
# Standard paths and filenames used inside the container
|
|
97
|
+
self.io_dir = "/app/io"
|
|
98
|
+
self.runner_filename = "runner.py"
|
|
99
|
+
self.runner_path = f"/app/{self.runner_filename}"
|
|
100
|
+
self.payload_file = "payload.pkl"
|
|
101
|
+
self.result_file = "result.pkl"
|
|
102
|
+
|
|
103
|
+
self.runner_script = _bootstrap_script(self.payload_file, self.result_file)
|
|
104
|
+
self.tag = self._generate_base_tag()
|
|
105
|
+
|
|
106
|
+
self.api_key = api_key
|
|
107
|
+
|
|
108
|
+
def _generate_base_tag(self) -> str:
|
|
109
|
+
"""Creates a unique tag for the base Docker image based on its dependencies."""
|
|
110
|
+
signature_parts = [
|
|
111
|
+
"".join(self.python_version),
|
|
112
|
+
"".join(self.pip_packages),
|
|
113
|
+
"".join(self.apt_packages),
|
|
114
|
+
"".join(self.run_commands),
|
|
115
|
+
self.runner_script
|
|
116
|
+
]
|
|
117
|
+
for src, dst in sorted(self.copy.items()):
|
|
118
|
+
if not Path(src).exists():
|
|
119
|
+
raise FileNotFoundError(f"Path in 'copy' not found: {src}")
|
|
120
|
+
content_hash = _hash_path(src)
|
|
121
|
+
signature_parts.append(f"copy:{src}>{dst}:{content_hash}")
|
|
122
|
+
|
|
123
|
+
signature = "".join(signature_parts)
|
|
124
|
+
image_hash = hashlib.sha256(signature.encode()).hexdigest()
|
|
125
|
+
return f"{self.image_prefix}:{image_hash[:16]}"
|
|
126
|
+
|
|
127
|
+
def _generate_dockerfile(self, port=None) -> str:
|
|
128
|
+
"""Generates a multi-stage Dockerfile string."""
|
|
129
|
+
run_pip_install = f"RUN pip install --no-cache-dir cloudpickle {' '.join(self.pip_packages)}"
|
|
130
|
+
run_apt_install = (
|
|
131
|
+
f"RUN apt-get update && apt-get install -y --no-install-recommends {' '.join(self.apt_packages)}"
|
|
132
|
+
if self.apt_packages else ""
|
|
133
|
+
)
|
|
134
|
+
run_shell_commands = "\n".join([f"RUN {cmd}" for cmd in self.run_commands]) if self.run_commands else ""
|
|
135
|
+
copy_lines = "\n".join([f"COPY {src} {dst}" for src, dst in self.copy.items()])
|
|
136
|
+
expose_line = f"EXPOSE {port}" if port else ""
|
|
137
|
+
|
|
138
|
+
return f"""
|
|
139
|
+
# STAGE 1: Base image with all dependencies
|
|
140
|
+
FROM python:{self.python_version}-slim as base
|
|
141
|
+
ENV PIP_ROOT_USER_ACTION=ignore
|
|
142
|
+
ENV PYTHONUNBUFFERED=1
|
|
143
|
+
RUN mkdir -p {self.io_dir}
|
|
144
|
+
{run_apt_install}
|
|
145
|
+
{run_pip_install}
|
|
146
|
+
{run_shell_commands}
|
|
147
|
+
{copy_lines}
|
|
148
|
+
COPY {self.runner_filename} {self.runner_path}
|
|
149
|
+
ENTRYPOINT ["python", "{self.runner_path}", "{self.io_dir}"]
|
|
150
|
+
|
|
151
|
+
# STAGE 2: Final deployable image with the payload "baked in"
|
|
152
|
+
FROM base
|
|
153
|
+
{expose_line}
|
|
154
|
+
COPY {self.payload_file} {self.io_dir}/
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def _prepare_build_context(self, workdir: Path, include_payload=False, args=None, kwargs=None):
|
|
158
|
+
"""Prepares a complete build context in the given directory."""
|
|
159
|
+
port = kwargs.get('port') if kwargs else None
|
|
160
|
+
|
|
161
|
+
(workdir / "Dockerfile").write_text(self._generate_dockerfile(port=port))
|
|
162
|
+
(workdir / self.runner_filename).write_text(self.runner_script)
|
|
163
|
+
|
|
164
|
+
if include_payload:
|
|
165
|
+
payload_bytes = cloudpickle.dumps((self.func, args or [], kwargs or {}))
|
|
166
|
+
(workdir / self.payload_file).write_bytes(payload_bytes)
|
|
167
|
+
|
|
168
|
+
if self.copy:
|
|
169
|
+
for src in self.copy.keys():
|
|
170
|
+
_copy_path(Path(src), workdir / src)
|
|
171
|
+
|
|
172
|
+
def _build_image_if_needed(self):
|
|
173
|
+
"""Checks if the base Docker image exists locally and builds it if not."""
|
|
174
|
+
try:
|
|
175
|
+
docker_client.images.get(self.tag)
|
|
176
|
+
print(f"✅ Found cached base image: {self.tag}")
|
|
177
|
+
return
|
|
178
|
+
except docker.errors.ImageNotFound:
|
|
179
|
+
print(f"🛠️ Building new base image: {self.tag}")
|
|
180
|
+
|
|
181
|
+
with tempfile.TemporaryDirectory() as tmpdir_str:
|
|
182
|
+
tmpdir = Path(tmpdir_str)
|
|
183
|
+
# Prepare context without payload for the base image
|
|
184
|
+
self._prepare_build_context(tmpdir)
|
|
185
|
+
|
|
186
|
+
print("--- 🐳 Docker Build Logs (Base Image) ---")
|
|
187
|
+
response_generator = docker_client.api.build(
|
|
188
|
+
path=str(tmpdir),
|
|
189
|
+
tag=self.tag,
|
|
190
|
+
forcerm=True,
|
|
191
|
+
decode=True,
|
|
192
|
+
target='base' # Only build the 'base' stage
|
|
193
|
+
)
|
|
194
|
+
try:
|
|
195
|
+
for chunk in response_generator:
|
|
196
|
+
if 'stream' in chunk:
|
|
197
|
+
print(chunk['stream'].strip())
|
|
198
|
+
print("----------------------------------------")
|
|
199
|
+
print(f"✅ Base image built successfully: {self.tag}")
|
|
200
|
+
except docker.errors.BuildError as e:
|
|
201
|
+
print(f"\n❌ Docker build failed. Reason: {e}")
|
|
202
|
+
raise
|
|
203
|
+
|
|
204
|
+
@contextmanager
|
|
205
|
+
def runner(self, *args, **kwargs):
|
|
206
|
+
"""Context manager to set up, run, and tear down the container for local execution."""
|
|
207
|
+
port = kwargs.get('port', None)
|
|
208
|
+
self._build_image_if_needed()
|
|
209
|
+
container = None
|
|
210
|
+
ports_mapping = {f'{port}/tcp': port} if port else None
|
|
211
|
+
|
|
212
|
+
with tempfile.TemporaryDirectory() as tmpdir_str:
|
|
213
|
+
tmpdir = Path(tmpdir_str)
|
|
214
|
+
payload_path = tmpdir / self.payload_file
|
|
215
|
+
result_path = tmpdir / self.result_file
|
|
216
|
+
|
|
217
|
+
with payload_path.open('wb') as f:
|
|
218
|
+
cloudpickle.dump((self.func, args, kwargs), f)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
container = docker_client.containers.create(
|
|
222
|
+
image=self.tag,
|
|
223
|
+
volumes={str(tmpdir): {'bind': self.io_dir, 'mode': 'rw'}},
|
|
224
|
+
ports=ports_mapping
|
|
225
|
+
)
|
|
226
|
+
container.start()
|
|
227
|
+
yield container, result_path
|
|
228
|
+
finally:
|
|
229
|
+
if container:
|
|
230
|
+
print("\n🧹 Cleaning up container...")
|
|
231
|
+
try:
|
|
232
|
+
container.stop(timeout=5)
|
|
233
|
+
container.remove()
|
|
234
|
+
print("✅ Container stopped and removed.")
|
|
235
|
+
except docker.errors.APIError as e:
|
|
236
|
+
print(f"⚠️ Could not clean up container: {e}")
|
|
237
|
+
|
|
238
|
+
def run(self, *args, **kwargs):
|
|
239
|
+
"""Executes the function in a new Docker container and waits for the result."""
|
|
240
|
+
print(f"🚀 Running function '{self.name}' in container...")
|
|
241
|
+
try:
|
|
242
|
+
with self.runner(*args, **kwargs) as (container, result_path):
|
|
243
|
+
print("--- 🪵 Container Logs (streaming) ---")
|
|
244
|
+
for chunk in container.logs(stream=True, follow=True):
|
|
245
|
+
print(chunk.decode('utf-8').strip())
|
|
246
|
+
print("------------------------------------")
|
|
247
|
+
|
|
248
|
+
result_status = container.wait()
|
|
249
|
+
if result_status['StatusCode'] != 0:
|
|
250
|
+
print(f"\n❌ Error: Container exited with code: {result_status['StatusCode']}")
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
if result_path.exists():
|
|
254
|
+
with result_path.open('rb') as f:
|
|
255
|
+
result = cloudpickle.load(f)
|
|
256
|
+
print("✅ Function executed successfully.")
|
|
257
|
+
return result
|
|
258
|
+
else:
|
|
259
|
+
print("\n❌ Error: Result file not found.")
|
|
260
|
+
return None
|
|
261
|
+
except (KeyboardInterrupt, docker.errors.DockerException) as e:
|
|
262
|
+
print(f"\n🛑 Operation stopped: {e}")
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def build(self, *args, **kwargs):
|
|
266
|
+
"""Builds a self-contained, deployable Docker image locally."""
|
|
267
|
+
print("📦 Building self-contained image for deployment...")
|
|
268
|
+
payload_hash = hashlib.sha256(cloudpickle.dumps((self.func, args, kwargs))).hexdigest()[:16]
|
|
269
|
+
final_tag = f"{self.image_prefix}:deploy-{payload_hash}"
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
docker_client.images.get(final_tag)
|
|
273
|
+
print(f"✅ Found cached deployable image: {final_tag}")
|
|
274
|
+
return final_tag
|
|
275
|
+
except docker.errors.ImageNotFound:
|
|
276
|
+
print(f"🛠️ Building new deployable image: {final_tag}")
|
|
277
|
+
|
|
278
|
+
with tempfile.TemporaryDirectory() as tmpdir_str:
|
|
279
|
+
tmpdir = Path(tmpdir_str)
|
|
280
|
+
self._prepare_build_context(tmpdir, include_payload=True, args=args, kwargs=kwargs)
|
|
281
|
+
|
|
282
|
+
print("--- 🐳 Docker Build Logs (Final Image) ---")
|
|
283
|
+
response_generator = docker_client.api.build(
|
|
284
|
+
path=str(tmpdir), tag=final_tag, forcerm=True, decode=True
|
|
285
|
+
)
|
|
286
|
+
try:
|
|
287
|
+
for chunk in response_generator:
|
|
288
|
+
if 'stream' in chunk:
|
|
289
|
+
print(chunk['stream'].strip())
|
|
290
|
+
print("-----------------------------------------")
|
|
291
|
+
print(f"✅ Image built successfully: {final_tag}")
|
|
292
|
+
port = kwargs.get('port') if kwargs else None
|
|
293
|
+
print(f"🤖 Run: docker run --rm -d -p {port}:{port} {final_tag}")
|
|
294
|
+
return final_tag
|
|
295
|
+
except docker.errors.BuildError as e:
|
|
296
|
+
print(f"\n❌ Docker build failed. Reason: {e}")
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
def deploy(self, *args, **kwargs):
|
|
300
|
+
"""Deploys the function by sending it to a remote build server."""
|
|
301
|
+
import requests
|
|
302
|
+
|
|
303
|
+
print(f"🚀 Preparing to deploy function '{self.name}'")
|
|
304
|
+
|
|
305
|
+
# 1. Prepare the build context and compress it into a tarball
|
|
306
|
+
payload_hash = hashlib.sha256(cloudpickle.dumps((self.func, args, kwargs))).hexdigest()[:16]
|
|
307
|
+
archive_name = f"source-{self.tag.split(':')[1]}-{payload_hash}.tar.gz"
|
|
308
|
+
|
|
309
|
+
with tempfile.TemporaryDirectory() as tmpdir_str:
|
|
310
|
+
tmpdir = Path(tmpdir_str)
|
|
311
|
+
self._prepare_build_context(tmpdir, include_payload=True, args=args, kwargs=kwargs)
|
|
312
|
+
|
|
313
|
+
archive_path = Path(tmpdir_str) / archive_name
|
|
314
|
+
with tarfile.open(archive_path, "w:gz") as tar:
|
|
315
|
+
# Add all files from the context to the tar archive
|
|
316
|
+
for f in tmpdir.glob("**/*"):
|
|
317
|
+
if f.is_file():
|
|
318
|
+
tar.add(f, arcname=f.relative_to(tmpdir))
|
|
319
|
+
|
|
320
|
+
# 2. Prepare the request payload
|
|
321
|
+
port = kwargs.get('port', 8080)
|
|
322
|
+
data_payload = {
|
|
323
|
+
"function_name": self.name,
|
|
324
|
+
"port": port,
|
|
325
|
+
# "memory": "1Gi" # You could make this a parameter
|
|
326
|
+
}
|
|
327
|
+
headers = {
|
|
328
|
+
"X-API-Key": self.api_key
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# 3. Upload to the deploy server
|
|
332
|
+
print("📦 Uploading build context to the deploy server...")
|
|
333
|
+
try:
|
|
334
|
+
with open(archive_path, 'rb') as f:
|
|
335
|
+
files = {'source_archive': (archive_name, f, 'application/gzip')}
|
|
336
|
+
|
|
337
|
+
response = requests.post(
|
|
338
|
+
f"{self.base_url}/v1/deploy",
|
|
339
|
+
data=data_payload,
|
|
340
|
+
files=files,
|
|
341
|
+
headers=headers,
|
|
342
|
+
timeout=1800 # Set a long timeout for the entire process
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# 4. Handle the server's response
|
|
346
|
+
response.raise_for_status() # Raise an exception for 4xx/5xx errors
|
|
347
|
+
result = response.json()
|
|
348
|
+
|
|
349
|
+
print(f"✅ Deployment successful!")
|
|
350
|
+
print(f"🔗 Service is available at: {result['url']}")
|
|
351
|
+
return result['url']
|
|
352
|
+
|
|
353
|
+
except requests.exceptions.HTTPError as e:
|
|
354
|
+
print(f"❌ Deployment failed. Server returned error: {e.response.status_code}")
|
|
355
|
+
try:
|
|
356
|
+
# Try to print the detailed error message from the server
|
|
357
|
+
print(f" Reason: {e.response.json()['detail']}")
|
|
358
|
+
except:
|
|
359
|
+
print(f" Reason: {e.response.text}")
|
|
360
|
+
return None
|
|
361
|
+
except requests.exceptions.RequestException as e:
|
|
362
|
+
print(f"❌ Could not connect to the deploy server: {e}")
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
def Deploy(self, *args, **kwargs):
|
|
366
|
+
try:
|
|
367
|
+
from .shared import upload_file_to_cloud, build_and_deploy_to_cloud
|
|
368
|
+
except ImportError:
|
|
369
|
+
print("❌ Shared not found. This is an internal method.")
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
port = kwargs.get('port', 8080)
|
|
373
|
+
|
|
374
|
+
with tempfile.TemporaryDirectory() as tmpdir_str:
|
|
375
|
+
tmpdir = Path(tmpdir_str)
|
|
376
|
+
self._prepare_build_context(tmpdir, include_payload=True, args=args, kwargs=kwargs)
|
|
377
|
+
|
|
378
|
+
archive_path = Path(tmpdir_str) / "source.tar.gz"
|
|
379
|
+
with tarfile.open(archive_path, "w:gz") as tar:
|
|
380
|
+
for f in tmpdir.glob("**/*"):
|
|
381
|
+
if f.is_file():
|
|
382
|
+
tar.add(f, arcname=f.relative_to(tmpdir))
|
|
383
|
+
|
|
384
|
+
archive_name = upload_file_to_cloud(self.name, archive_path)
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
service = build_and_deploy_to_cloud(
|
|
388
|
+
function_name=self.name,
|
|
389
|
+
gcs_object_name=archive_name,
|
|
390
|
+
port=port,
|
|
391
|
+
memory="1Gi"
|
|
392
|
+
)
|
|
393
|
+
except Exception as e:
|
|
394
|
+
print(f"❌ Cloud Deployment Failed: {e}")
|
|
395
|
+
return None
|
cycls/sdk.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .runtime import Runtime
|
|
2
|
+
|
|
3
|
+
def function(python_version="3.12", pip_install=None, apt_install=None, run_commands=None, copy=None, name=None, base_url=None, api_key=None):
|
|
4
|
+
# """
|
|
5
|
+
# A decorator factory that transforms a Python function into a containerized,
|
|
6
|
+
# remotely executable object.
|
|
7
|
+
|
|
8
|
+
# Args:
|
|
9
|
+
# pip (list[str], optional): A list of pip packages to install.
|
|
10
|
+
# apt (list[str], optional): A list of apt packages to install.
|
|
11
|
+
# copy (list[str], optional): A list of local paths to copy to the
|
|
12
|
+
# same path inside the image. For static dependencies.
|
|
13
|
+
# name (str, optional): A name for this function. Defaults to the function's name.
|
|
14
|
+
|
|
15
|
+
# Returns:
|
|
16
|
+
# A decorator that replaces the decorated function with a Runtime instance.
|
|
17
|
+
# """
|
|
18
|
+
def decorator(func):
|
|
19
|
+
Name = name or func.__name__ # should be moved to runtime... or default?
|
|
20
|
+
copy_dict = {i:i for i in copy or []}
|
|
21
|
+
return Runtime(func, Name.replace('_', '-'), python_version, pip_install, apt_install, run_commands, copy_dict, base_url, api_key)
|
|
22
|
+
return decorator
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cycls
|
|
3
|
+
Version: 0.0.2.32
|
|
4
|
+
Summary: Cycls SDK
|
|
5
|
+
Author: Mohammed J. AlRujayi
|
|
6
|
+
Author-email: mj@cycls.com
|
|
7
|
+
Requires-Python: >=3.9,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: cloudpickle (>=3.1.1,<4.0.0)
|
|
16
|
+
Requires-Dist: docker (>=7.1.0,<8.0.0)
|
|
17
|
+
Requires-Dist: fastapi (>=0.111.0,<0.112.0)
|
|
18
|
+
Requires-Dist: httpx (>=0.27.0,<0.28.0)
|
|
19
|
+
Requires-Dist: jwt (>=1.4.0,<2.0.0)
|
|
20
|
+
Requires-Dist: modal (>=1.1.0,<2.0.0)
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
<h3 align="center">
|
|
24
|
+
The Distribution SDK for AI Agents.
|
|
25
|
+
</h3>
|
|
26
|
+
|
|
27
|
+
<h4 align="center">
|
|
28
|
+
<a href="https://cycls.com">Website</a> |
|
|
29
|
+
<a href="https://docs.cycls.com">Docs</a>
|
|
30
|
+
</h4>
|
|
31
|
+
|
|
32
|
+
<h4 align="center">
|
|
33
|
+
<a href="https://pypi.python.org/pypi/cycls"><img src="https://img.shields.io/pypi/v/cycls.svg?label=cycls+pypi&color=blueviolet" alt="cycls Python package on PyPi" /></a>
|
|
34
|
+
<a href="https://blog.cycls.com"><img src="https://img.shields.io/badge/newsletter-blueviolet.svg?logo=substack&label=cycls" alt="Cycls newsletter" /></a>
|
|
35
|
+
<a href="https://x.com/cyclsai">
|
|
36
|
+
<img src="https://img.shields.io/twitter/follow/CyclsAI" alt="Cycls Twitter" />
|
|
37
|
+
</a>
|
|
38
|
+
</h4>
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Cycls 🚲
|
|
42
|
+
|
|
43
|
+
`cycls` is a zero-config framework for building and publishing AI agents. With a single decorator and one command, you can deploy your code as a web application complete with a front-end UI and an OpenAI-compatible API endpoint.
|
|
44
|
+
|
|
45
|
+
### Design Philosophy
|
|
46
|
+
`cycls` is an anti-framework. We treat the boilerplate, config files, and infrastructure that surround modern applications as a bug to be eliminated. A developer's focus is the most valuable resource, and context-switching is its greatest enemy.
|
|
47
|
+
|
|
48
|
+
Our zero-config approach makes your Python script the single source of truth for the entire application. When your code is all you need, you stay focused, iterate faster, and ship with confidence.
|
|
49
|
+
|
|
50
|
+
This philosophy has a powerful side-effect: it makes development genuinely iterative. The self-contained nature of an agent encourages you to 'build in cycles'—starting simple and adding complexity without penalty. This same simplicity also makes `cycls` an ideal target for code generation. Because the entire application can be expressed in one file, LLMs can write, modify, and reason about `cycls` agents far more effectively than with traditional frameworks. It's a seamless interface for both human and machine.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
## Key Features
|
|
54
|
+
|
|
55
|
+
* ✨ **Zero-Config Deployment:** No YAML or Dockerfiles. `cycls` infers your dependencies, and APIs directly from your Python code.
|
|
56
|
+
* 🚀 **One-Command Push to Cloud:** Go from local code to a globally scalable, serverless application with a single `agent.push()`.
|
|
57
|
+
* 💻 **Instant Local Testing:** Run `agent.run()` to spin up a local server with hot-reloading for rapid iteration and debugging.
|
|
58
|
+
* 🤖 **OpenAI-Compatible API:** Automatically serves a streaming `/chat/completions` endpoint.
|
|
59
|
+
* 🌐 **Automatic Web UI:** Get a clean, interactive front-end for your agent out of the box, with no front-end code required.
|
|
60
|
+
* 🔐 **Built-in Authentication:** Secure your agent for production with a simple `auth=True` flag that enables JWT-based authentication.
|
|
61
|
+
* 📦 **Declarative Dependencies:** Define all your `pip`, `apt`, or local file dependencies directly in Python.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install cycls
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## How to Use
|
|
71
|
+
### 1. Local Development: "Hello, World!"
|
|
72
|
+
|
|
73
|
+
Create a file main.py. This simple example creates an agent that streams back the message "hi".
|
|
74
|
+
|
|
75
|
+
```py
|
|
76
|
+
import cycls
|
|
77
|
+
|
|
78
|
+
# Initialize the agent
|
|
79
|
+
agent = cycls.Agent()
|
|
80
|
+
|
|
81
|
+
# Decorate your function to register it as an agent
|
|
82
|
+
@agent()
|
|
83
|
+
async def hello(context):
|
|
84
|
+
yield "hi"
|
|
85
|
+
|
|
86
|
+
agent.run()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Run it from your terminal:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python main.py
|
|
93
|
+
```
|
|
94
|
+
This will start a local server. Open your browser to http://127.0.0.1:8000 to interact with your agent.
|
|
95
|
+
|
|
96
|
+
### 2. Cloud Deployment: An OpenAI-Powered Agent
|
|
97
|
+
This example creates a more advanced agent that calls the OpenAI API. It will be deployed to the cloud with authentication enabled.
|
|
98
|
+
|
|
99
|
+
```py
|
|
100
|
+
# deploy.py
|
|
101
|
+
import cycls
|
|
102
|
+
|
|
103
|
+
# Initialize the agent with dependencies and API keys
|
|
104
|
+
agent = cycls.Agent(
|
|
105
|
+
pip=["openai"],
|
|
106
|
+
keys=["ak-<token_id>", "as-<token_secret>"]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# A helper function to call the LLM
|
|
110
|
+
async def llm(messages):
|
|
111
|
+
# Import inside the function: 'openai' is only needed at runtime in the container.
|
|
112
|
+
import openai
|
|
113
|
+
client = openai.AsyncOpenAI(api_key="sk-...") # Your OpenAI key
|
|
114
|
+
model = "gpt-4o"
|
|
115
|
+
response = await client.chat.completions.create(
|
|
116
|
+
model=model,
|
|
117
|
+
messages=messages,
|
|
118
|
+
temperature=1.0,
|
|
119
|
+
stream=True
|
|
120
|
+
)
|
|
121
|
+
# Yield the content from the streaming response
|
|
122
|
+
async def event_stream():
|
|
123
|
+
async for chunk in response:
|
|
124
|
+
content = chunk.choices[0].delta.content
|
|
125
|
+
if content:
|
|
126
|
+
yield content
|
|
127
|
+
return event_stream()
|
|
128
|
+
|
|
129
|
+
# Register the function as an agent named "cake" and enable auth
|
|
130
|
+
@agent("cake", auth=True)
|
|
131
|
+
async def cake_agent(context):
|
|
132
|
+
# The context object contains the message history
|
|
133
|
+
return await llm(context.messages)
|
|
134
|
+
|
|
135
|
+
# Deploy the agent to the cloud
|
|
136
|
+
agent.push(prod=True)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Run the deployment command from your terminal:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python main.py
|
|
143
|
+
```
|
|
144
|
+
After a few moments, your agent will be live and accessible at a public URL like https://cake.cycls.ai.
|
|
145
|
+
|
|
146
|
+
### License
|
|
147
|
+
This project is licensed under the MIT License.
|
|
148
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
cycls/__init__.py,sha256=9oKTafASGMuJbq_Sk0sfCSQKKwUD8mcdYFovDDzVuW4,50
|
|
2
|
+
cycls/cycls.py,sha256=rLhssEPs4EwDbNsO5cGdKkP888dOaYToMPKVLZWan5U,7479
|
|
3
|
+
cycls/runtime.py,sha256=quBV8G8DkqsK_QzTraiYe7P9x9W5X1Zl8XDsvhUlPus,15939
|
|
4
|
+
cycls/sdk.py,sha256=EcUYi0pGKtCajKzwLg3uH8CRPfc78KBHXgB6ShABxdE,1110
|
|
5
|
+
cycls/theme/assets/index-D0-uI8sw.js,sha256=aUsqm9HZtEJz38o-0MW12ZVeOlSeKigwc_fYJBntiyI,1068551
|
|
6
|
+
cycls/theme/index.html,sha256=epB4cgSjC7xJOXpVuCwt9r7ivoGvLiXSrxsoOgINw58,895
|
|
7
|
+
cycls-0.0.2.32.dist-info/METADATA,sha256=si7x9VRhZUM9ZlrBN3Iusp6iemuHyWoOK-UT6WIZrmA,5666
|
|
8
|
+
cycls-0.0.2.32.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
9
|
+
cycls-0.0.2.32.dist-info/RECORD,,
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: cycls
|
|
3
|
-
Version: 0.0.2.30
|
|
4
|
-
Summary: Cycls SDK
|
|
5
|
-
Author: Mohammed J. AlRujayi
|
|
6
|
-
Author-email: mj@cycls.com
|
|
7
|
-
Requires-Python: >=3.9,<4.0
|
|
8
|
-
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
-
Requires-Dist: fastapi (>=0.111.0,<0.112.0)
|
|
15
|
-
Requires-Dist: httpx (>=0.27.0,<0.28.0)
|
|
16
|
-
Requires-Dist: jwt (>=1.4.0,<2.0.0)
|
|
17
|
-
Requires-Dist: modal (>=1.1.0,<2.0.0)
|
|
18
|
-
Description-Content-Type: text/markdown
|
|
19
|
-
|
|
20
|
-
<p align="center">
|
|
21
|
-
<img src="https://github.com/user-attachments/assets/96bd304d-8116-4bce-8b8f-b08980875ad7" width="800px" alt="Cycls Banner">
|
|
22
|
-
</p>
|
|
23
|
-
|
|
24
|
-
<h3 align="center">
|
|
25
|
-
Generate live apps from code in minutes with built-in memory, <br/>rich hypermedia content, and cross-platform support
|
|
26
|
-
</h3>
|
|
27
|
-
|
|
28
|
-
<h4 align="center">
|
|
29
|
-
<a href="https://cycls.com">Website</a> |
|
|
30
|
-
<a href="https://docs.cycls.com">Docs</a> |
|
|
31
|
-
<a href="https://docs.cycls.com">Blog</a>
|
|
32
|
-
</h4>
|
|
33
|
-
|
|
34
|
-
<h4 align="center">
|
|
35
|
-
<a href="https://pypi.python.org/pypi/cycls"><img src="https://img.shields.io/pypi/v/cycls.svg?label=cycls+pypi&color=blueviolet" alt="cycls Python package on PyPi" /></a>
|
|
36
|
-
<a href="https://blog.cycls.com"><img src="https://img.shields.io/badge/newsletter-blueviolet.svg?logo=substack&label=cycls" alt="Cycls newsletter" /></a>
|
|
37
|
-
<a href="https://x.com/cycls_">
|
|
38
|
-
<img src="https://img.shields.io/twitter/follow/cycls_" alt="Cycls Twitter" />
|
|
39
|
-
</a>
|
|
40
|
-
</h4>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
## Cycls: The AI App Generator
|
|
44
|
-
Cycls[^1] streamlines AI application development by generating apps from high-level descriptions. It eliminates boilerplate, ensures cross-platform compatibility, and manages memory - all from a single codebase.
|
|
45
|
-
|
|
46
|
-
With Cycls, you can quickly prototype ideas and then turn them into production apps, while focusing on AI logic and user interactions rather than wrestling with implementation details.
|
|
47
|
-
|
|
48
|
-
## ✨ Core Features
|
|
49
|
-
- **Fast App Generation**: Create live web apps from code in minutes
|
|
50
|
-
- **Built-in Memory Management**: Integrated state and session management
|
|
51
|
-
- **Rich Hypermedia Content**: Support for various media types (text, images, audio, video, interactive elements)
|
|
52
|
-
- **Framework Agnostic**: Compatible with a wide range of AI frameworks and models
|
|
53
|
-
|
|
54
|
-
## 🚀 Quickstart
|
|
55
|
-
### Installation
|
|
56
|
-
```
|
|
57
|
-
pip install cycls
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
### Basic usage
|
|
61
|
-
```py
|
|
62
|
-
from cycls import Cycls
|
|
63
|
-
|
|
64
|
-
cycls = Cycls()
|
|
65
|
-
|
|
66
|
-
@cycls("@my-app")
|
|
67
|
-
def app():
|
|
68
|
-
return "Hello World!"
|
|
69
|
-
|
|
70
|
-
cycls.push()
|
|
71
|
-
```
|
|
72
|
-
This creates an app named "@my-app" that responds with "Hello World!".
|
|
73
|
-
|
|
74
|
-
The `@cycls("@my-app")` decorator registers your app, and `cycls.push()` streams it to Cycls platform.
|
|
75
|
-
|
|
76
|
-
To see a live example, visit https://cycls.com/@spark.
|
|
77
|
-
|
|
78
|
-
> [!IMPORTANT]
|
|
79
|
-
> Use a unique name for your app (like "@my-app"). This is your app's identifier on Cycls.
|
|
80
|
-
|
|
81
|
-
> [!NOTE]
|
|
82
|
-
> Your apps run on your infrastructure and are streamed in real-time to Cycls.
|
|
83
|
-
|
|
84
|
-
## 📖 Documentation
|
|
85
|
-
For more detailes and instructions, visit our documentation at [docs.cycls.com](https://docs.cycls.com/).
|
|
86
|
-
|
|
87
|
-
## 🗺️ Roadmap
|
|
88
|
-
- **iOS and Android apps**
|
|
89
|
-
- **User management**
|
|
90
|
-
- **JavaScript SDK**
|
|
91
|
-
- **Public API**
|
|
92
|
-
- **Cross-app communication**
|
|
93
|
-
|
|
94
|
-
## 🙌 Support
|
|
95
|
-
Join our Discord community for support and discussions. You can reach us on:
|
|
96
|
-
|
|
97
|
-
- [Join our Discord](https://discord.gg/XbxcTFBf7J)
|
|
98
|
-
- [Join our newsletter](https://blog.cycls.com)
|
|
99
|
-
- [Follow us on Twitter](https://x.com/cycls_)
|
|
100
|
-
- [Email us](mailto:hi@cycls.com)
|
|
101
|
-
|
|
102
|
-
[^1]: The name "Cycls" is a play on "cycles," referring to the continuous exchange between AI prompts (generators) and their responses (generated).
|
|
103
|
-
|
cycls-0.0.2.30.dist-info/RECORD
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
cycls/__init__.py,sha256=D808D5xNP8OdQStdABmWqjGfuDNpVgbJhRo0KRKzo7g,24
|
|
2
|
-
cycls/cycls.py,sha256=KwoWCybzIvfi_oqLv5i2r8oEcxZL229nBHZf2uBFs78,7332
|
|
3
|
-
cycls/theme/assets/index-D0-uI8sw.js,sha256=aUsqm9HZtEJz38o-0MW12ZVeOlSeKigwc_fYJBntiyI,1068551
|
|
4
|
-
cycls/theme/index.html,sha256=epB4cgSjC7xJOXpVuCwt9r7ivoGvLiXSrxsoOgINw58,895
|
|
5
|
-
cycls-0.0.2.30.dist-info/METADATA,sha256=lBYKgRjAR334fJ5ys7IB6FDI4Ck24k_5z7BDB9c_1rg,3711
|
|
6
|
-
cycls-0.0.2.30.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
7
|
-
cycls-0.0.2.30.dist-info/RECORD,,
|