cycls 0.0.2.33__py3-none-any.whl → 0.0.2.35__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 CHANGED
@@ -1,2 +1,2 @@
1
- from .cycls import Agent
2
- from .sdk import function
1
+ from .sdk import Agent
2
+ from .runtime import Runtime
cycls/runtime.py CHANGED
@@ -9,14 +9,6 @@ from pathlib import Path
9
9
  from contextlib import contextmanager
10
10
  import tarfile
11
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
12
  # --- Top-Level Helper Functions ---
21
13
 
22
14
  def _bootstrap_script(payload_file: str, result_file: str) -> str:
@@ -84,7 +76,7 @@ class Runtime:
84
76
  """
85
77
  def __init__(self, func, name, python_version=None, pip_packages=None, apt_packages=None, run_commands=None, copy=None, base_url=None, api_key=None):
86
78
  self.func = func
87
- self.python_version = python_version or "3.12"
79
+ self.python_version = python_version or f"{sys.version_info.major}.{sys.version_info.minor}"
88
80
  self.pip_packages = sorted(pip_packages or [])
89
81
  self.apt_packages = sorted(apt_packages or [])
90
82
  self.run_commands = sorted(run_commands or [])
@@ -104,6 +96,50 @@ class Runtime:
104
96
  self.tag = self._generate_base_tag()
105
97
 
106
98
  self.api_key = api_key
99
+ self._docker_client = None
100
+ self.managed_label = f"cycls.runtime"
101
+
102
+ @property
103
+ def docker_client(self):
104
+ """
105
+ Lazily initializes and returns a Docker client.
106
+ This ensures Docker is only required for methods that actually use it.
107
+ """
108
+ if self._docker_client is None:
109
+ try:
110
+ print("🐳 Initializing Docker client...")
111
+ client = docker.from_env()
112
+ client.ping()
113
+ self._docker_client = client
114
+ except docker.errors.DockerException:
115
+ print("\n❌ Error: Docker is not running or is not installed.")
116
+ print(" This is required for local 'run' and 'build' operations.")
117
+ print(" Please start the Docker daemon and try again.")
118
+ sys.exit(1)
119
+ return self._docker_client
120
+
121
+ def _perform_auto_cleanup(self):
122
+ """Performs a simple, automatic cleanup of old Docker resources."""
123
+ try:
124
+ for container in self.docker_client.containers.list(all=True, filters={"label": self.managed_label}):
125
+ container.remove(force=True)
126
+
127
+ cleaned_images = 0
128
+ for image in self.docker_client.images.list(name=self.image_prefix):
129
+ is_current = self.tag in image.tags
130
+ is_deployable = any(t.startswith(f"{self.image_prefix}:deploy-") for t in image.tags)
131
+
132
+ if not is_current and not is_deployable:
133
+ self.docker_client.images.remove(image.id, force=True)
134
+ cleaned_images += 1
135
+
136
+ if cleaned_images > 0:
137
+ print(f"🧹 Cleaned up {cleaned_images} old image version(s).")
138
+
139
+ self.docker_client.images.prune(filters={'label': self.managed_label})
140
+
141
+ except Exception as e:
142
+ print(f"⚠️ An error occurred during cleanup: {e}")
107
143
 
108
144
  def _generate_base_tag(self) -> str:
109
145
  """Creates a unique tag for the base Docker image based on its dependencies."""
@@ -132,7 +168,11 @@ class Runtime:
132
168
  if self.apt_packages else ""
133
169
  )
134
170
  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()])
171
+
172
+ # fixes absolute path copy, but causes collision
173
+ copy_lines = "\n".join([f"COPY {Path(src).name} {dst}" for src, dst in self.copy.items()])
174
+ # copy_lines = "\n".join([f"COPY {src} {dst}" for src, dst in self.copy.items()])
175
+
136
176
  expose_line = f"EXPOSE {port}" if port else ""
137
177
 
138
178
  return f"""
@@ -166,26 +206,31 @@ COPY {self.payload_file} {self.io_dir}/
166
206
  (workdir / self.payload_file).write_bytes(payload_bytes)
167
207
 
168
208
  if self.copy:
209
+ # for src in self.copy.keys():
210
+ # _copy_path(Path(src), workdir / src)
211
+
212
+ # fixes absolute path copy
169
213
  for src in self.copy.keys():
170
- _copy_path(Path(src), workdir / src)
214
+ src_path = Path(src)
215
+ dest_path = workdir / src_path.name if src_path.is_absolute() else workdir / src
216
+ _copy_path(src_path, dest_path)
171
217
 
172
218
  def _build_image_if_needed(self):
173
219
  """Checks if the base Docker image exists locally and builds it if not."""
174
220
  try:
175
- docker_client.images.get(self.tag)
221
+ self.docker_client.images.get(self.tag)
176
222
  print(f"✅ Found cached base image: {self.tag}")
177
223
  return
178
224
  except docker.errors.ImageNotFound:
179
225
  print(f"🛠️ Building new base image: {self.tag}")
180
226
 
181
- # with tempfile.TemporaryDirectory() as tmpdir_str:
182
- with tempfile.TemporaryDirectory(dir="/tmp") as tmpdir_str:
227
+ with tempfile.TemporaryDirectory() as tmpdir_str:
183
228
  tmpdir = Path(tmpdir_str)
184
229
  # Prepare context without payload for the base image
185
230
  self._prepare_build_context(tmpdir)
186
231
 
187
232
  print("--- 🐳 Docker Build Logs (Base Image) ---")
188
- response_generator = docker_client.api.build(
233
+ response_generator = self.docker_client.api.build(
189
234
  path=str(tmpdir),
190
235
  tag=self.tag,
191
236
  forcerm=True,
@@ -206,12 +251,12 @@ COPY {self.payload_file} {self.io_dir}/
206
251
  def runner(self, *args, **kwargs):
207
252
  """Context manager to set up, run, and tear down the container for local execution."""
208
253
  port = kwargs.get('port', None)
254
+ self._perform_auto_cleanup()
209
255
  self._build_image_if_needed()
210
256
  container = None
211
257
  ports_mapping = {f'{port}/tcp': port} if port else None
212
258
 
213
- # with tempfile.TemporaryDirectory() as tmpdir_str:
214
- with tempfile.TemporaryDirectory(dir="/tmp") as tmpdir_str:
259
+ with tempfile.TemporaryDirectory() as tmpdir_str:
215
260
  tmpdir = Path(tmpdir_str)
216
261
  payload_path = tmpdir / self.payload_file
217
262
  result_path = tmpdir / self.result_file
@@ -220,10 +265,11 @@ COPY {self.payload_file} {self.io_dir}/
220
265
  cloudpickle.dump((self.func, args, kwargs), f)
221
266
 
222
267
  try:
223
- container = docker_client.containers.create(
268
+ container = self.docker_client.containers.create(
224
269
  image=self.tag,
225
270
  volumes={str(tmpdir): {'bind': self.io_dir, 'mode': 'rw'}},
226
- ports=ports_mapping
271
+ ports=ports_mapping,
272
+ labels={self.managed_label: "true"}
227
273
  )
228
274
  container.start()
229
275
  yield container, result_path
@@ -271,19 +317,18 @@ COPY {self.payload_file} {self.io_dir}/
271
317
  final_tag = f"{self.image_prefix}:deploy-{payload_hash}"
272
318
 
273
319
  try:
274
- docker_client.images.get(final_tag)
320
+ self.docker_client.images.get(final_tag)
275
321
  print(f"✅ Found cached deployable image: {final_tag}")
276
322
  return final_tag
277
323
  except docker.errors.ImageNotFound:
278
324
  print(f"🛠️ Building new deployable image: {final_tag}")
279
325
 
280
- # with tempfile.TemporaryDirectory() as tmpdir_str:
281
- with tempfile.TemporaryDirectory(dir="/tmp") as tmpdir_str:
326
+ with tempfile.TemporaryDirectory() as tmpdir_str:
282
327
  tmpdir = Path(tmpdir_str)
283
328
  self._prepare_build_context(tmpdir, include_payload=True, args=args, kwargs=kwargs)
284
329
 
285
330
  print("--- 🐳 Docker Build Logs (Final Image) ---")
286
- response_generator = docker_client.api.build(
331
+ response_generator = self.docker_client.api.build(
287
332
  path=str(tmpdir), tag=final_tag, forcerm=True, decode=True
288
333
  )
289
334
  try:
@@ -309,8 +354,7 @@ COPY {self.payload_file} {self.io_dir}/
309
354
  payload_hash = hashlib.sha256(cloudpickle.dumps((self.func, args, kwargs))).hexdigest()[:16]
310
355
  archive_name = f"source-{self.tag.split(':')[1]}-{payload_hash}.tar.gz"
311
356
 
312
- # with tempfile.TemporaryDirectory() as tmpdir_str:
313
- with tempfile.TemporaryDirectory(dir="/tmp") as tmpdir_str:
357
+ with tempfile.TemporaryDirectory() as tmpdir_str:
314
358
  tmpdir = Path(tmpdir_str)
315
359
  self._prepare_build_context(tmpdir, include_payload=True, args=args, kwargs=kwargs)
316
360
 
@@ -375,8 +419,7 @@ COPY {self.payload_file} {self.io_dir}/
375
419
 
376
420
  port = kwargs.get('port', 8080)
377
421
 
378
- # with tempfile.TemporaryDirectory() as tmpdir_str:
379
- with tempfile.TemporaryDirectory(dir="/tmp") as tmpdir_str:
422
+ with tempfile.TemporaryDirectory() as tmpdir_str:
380
423
  tmpdir = Path(tmpdir_str)
381
424
  self._prepare_build_context(tmpdir, include_payload=True, args=args, kwargs=kwargs)
382
425
 
cycls/sdk.py CHANGED
@@ -1,22 +1,107 @@
1
+ import json, time, modal, inspect, uvicorn
1
2
  from .runtime import Runtime
3
+ from modal.runner import run_app
4
+ from .web import web
5
+ import importlib.resources
2
6
 
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
7
+ theme_path = importlib.resources.files('cycls').joinpath('theme')
8
+ cycls_path = importlib.resources.files('cycls')
9
+
10
+ class Agent:
11
+ def __init__(self, theme=theme_path, org=None, api_token=None, pip=[], apt=[], copy=[], keys=["",""], api_key=None):
12
+ self.org, self.api_token = org, api_token
13
+ self.theme = theme
14
+ self.keys, self.pip, self.apt, self.copy = keys, pip, apt, copy
15
+ self.api_key = api_key
16
+
17
+ self.registered_functions = []
18
+
19
+ def __call__(self, name=None, header="", intro="", domain=None, auth=False):
20
+ def decorator(f):
21
+ self.registered_functions.append({
22
+ "func": f,
23
+ "config": ["public", False, self.org, self.api_token, header, intro, auth],
24
+ # "name": name,
25
+ "name": name or (f.__name__).replace('_', '-'),
26
+ "domain": domain or f"{name}.cycls.ai",
27
+ })
28
+ return f
29
+ return decorator
30
+
31
+ def run(self, port=8080):
32
+ if not self.registered_functions:
33
+ print("Error: No @agent decorated function found.")
34
+ return
35
+
36
+ i = self.registered_functions[0]
37
+ if len(self.registered_functions) > 1:
38
+ print(f"⚠️ Warning: Multiple agents found. Running '{i['name']}'.")
39
+ print(f"🚀 Starting local server at localhost:{port}")
40
+ i["config"][0], i["config"][6] = self.theme, False
41
+ uvicorn.run(web(i["func"], *i["config"]), host="0.0.0.0", port=port)
42
+ return
43
+
44
+ def cycls(self, prod=False, port=8080):
45
+ if not self.registered_functions:
46
+ print("Error: No @agent decorated function found.")
47
+ return
48
+ if (self.api_key is None) and prod:
49
+ print("🛑 Error: Please add your Cycls API key")
50
+ return
51
+
52
+ i = self.registered_functions[0]
53
+ if len(self.registered_functions) > 1:
54
+ print(f"⚠️ Warning: Multiple agents found. Running '{i['name']}'.")
55
+
56
+ i["config"][6] = False
57
+
58
+ copy={str(cycls_path.joinpath('theme')):"public", str(cycls_path)+"/web.py":"app/web.py"}
59
+ copy.update({i:i for i in self.copy})
60
+
61
+ new = Runtime(
62
+ func=lambda port: __import__("uvicorn").run(__import__("web").web(i["func"], *i["config"]), host="0.0.0.0", port=port),
63
+ name=i["name"],
64
+ apt_packages=self.apt,
65
+ pip_packages=["fastapi[standard]", "pyjwt", "cryptography", "uvicorn", *self.pip],
66
+ copy=copy,
67
+ api_key=self.api_key
68
+ )
69
+ new.deploy(port=port) if prod else new.run(port=port)
70
+ return
71
+
72
+ def push(self, prod=False):
73
+ self.client = modal.Client.from_credentials(*self.keys)
74
+ image = (modal.Image.debian_slim()
75
+ .pip_install("fastapi[standard]", "pyjwt", "cryptography", *self.pip)
76
+ .apt_install(*self.apt)
77
+ .add_local_dir(self.theme, "/root/public")
78
+ .add_local_file(str(cycls_path)+"/web.py", "/root/web.py"))
79
+ for item in self.copy:
80
+ image = image.add_local_file(item, f"/root/{item}") if "." in item else image.add_local_dir(item, f'/root/{item}')
81
+ self.app = modal.App("development", image=image)
82
+
83
+ if not self.registered_functions:
84
+ print("Error: No @agent decorated function found.")
85
+ return
86
+
87
+ for i in self.registered_functions:
88
+ i["config"][1] = True if prod else False
89
+ self.app.function(serialized=True, name=i["name"])(
90
+ modal.asgi_app(label=i["name"], custom_domains=[i["domain"]])
91
+ (lambda: __import__("web").web(i["func"], *i["config"]))
92
+ )
93
+ if prod:
94
+ for i in self.registered_functions:
95
+ print(f"✅ Deployed to ⇒ https://{i['domain']}")
96
+ self.app.deploy(client=self.client, name=self.registered_functions[0]["name"])
97
+ return
98
+ else:
99
+ with modal.enable_output():
100
+ run_app(app=self.app, client=self.client)
101
+ print(" Modal development server is running. Press Ctrl+C to stop.")
102
+ with modal.enable_output(), run_app(app=self.app, client=self.client):
103
+ while True: time.sleep(10)
104
+
105
+ # poetry config pypi-token.pypi <your-token>
106
+ # poetry run python agent.py
107
+ # poetry publish --build
@@ -1,8 +1,4 @@
1
- import json, time, modal, inspect, uvicorn
2
- from modal.runner import run_app
3
-
4
- import importlib.resources
5
- theme_path = importlib.resources.files('cycls').joinpath('theme')
1
+ import json, inspect
6
2
 
7
3
  async def openai_encoder(stream): # clean up the meta data / new API?
8
4
  async for message in stream:
@@ -41,7 +37,6 @@ XwIDAQAB
41
37
  """
42
38
 
43
39
  def web(func, front_end_path="", prod=False, org=None, api_token=None, header="", intro="", auth=True): # API auth
44
- print(front_end_path)
45
40
  from fastapi import FastAPI, Request, HTTPException, status, Depends
46
41
  from fastapi.responses import StreamingResponse , HTMLResponse
47
42
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -97,73 +92,4 @@ def web(func, front_end_path="", prod=False, org=None, api_token=None, header=""
97
92
  "pk_live": "pk_live_Y2xlcmsuY3ljbHMuY29tJA", "pk_test": "pk_test_c2VsZWN0LXNsb3RoLTU4LmNsZXJrLmFjY291bnRzLmRldiQ"
98
93
  })
99
94
  app.mount("/", StaticFiles(directory=front_end_path, html=False))
100
- return app
101
-
102
- class Agent:
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
-
108
- self.registered_functions = []
109
-
110
- def __call__(self, name="", header="", intro="", domain=None, auth=False):
111
- def decorator(f):
112
- self.registered_functions.append({
113
- "func": f,
114
- "config": ["public", False, self.org, self.api_token, header, intro, auth],
115
- "name": name,
116
- "domain": domain or f"{name}.cycls.ai",
117
- })
118
- return f
119
- return decorator
120
-
121
- def run(self, port=8000):
122
- if not self.registered_functions:
123
- print("Error: No @agent decorated function found.")
124
- return
125
-
126
- i = self.registered_functions[0]
127
- if len(self.registered_functions) > 1:
128
- print(f"⚠️ Warning: Multiple agents found. Running '{i['name']}'.")
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)
132
- return
133
-
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
-
145
- if not self.registered_functions:
146
- print("Error: No @agent decorated function found.")
147
- return
148
-
149
- for i in self.registered_functions:
150
- i["config"][1] = True if prod else False
151
- self.app.function(serialized=True, name=i["name"])(
152
- modal.asgi_app(label=i["name"], custom_domains=[i["domain"]])
153
- (lambda: web(i["func"], *i["config"]))
154
- )
155
- if prod:
156
- for i in self.registered_functions:
157
- print(f"✅ Deployed to ⇒ https://{i['domain']}")
158
- self.app.deploy(client=self.client, name=self.registered_functions[0]["name"])
159
- return
160
- else:
161
- with modal.enable_output():
162
- run_app(app=self.app, client=self.client)
163
- print(" Modal development server is running. Press Ctrl+C to stop.")
164
- with modal.enable_output(), run_app(app=self.app, client=self.client):
165
- while True: time.sleep(10)
166
-
167
- # poetry config pypi-token.pypi <your-token>
168
- # poetry run python agent.py
169
- # poetry publish --build
95
+ return app
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cycls
3
- Version: 0.0.2.33
3
+ Version: 0.0.2.35
4
4
  Summary: Cycls SDK
5
5
  Author: Mohammed J. AlRujayi
6
6
  Author-email: mj@cycls.com
@@ -0,0 +1,9 @@
1
+ cycls/__init__.py,sha256=ysfA4R_r_INokmpZGK2S8wf2ypLCqwyj0WdfX-hx0bs,51
2
+ cycls/runtime.py,sha256=U0dMUs9dxgjMTbtv9dzK3MQ57g8kun0ICqA30Cx9cBA,18187
3
+ cycls/sdk.py,sha256=gEdxYDtYE6FOWwLS33k0h5wuzwdcB9kvTMrkEBmz2iE,4573
4
+ cycls/theme/assets/index-D0-uI8sw.js,sha256=aUsqm9HZtEJz38o-0MW12ZVeOlSeKigwc_fYJBntiyI,1068551
5
+ cycls/theme/index.html,sha256=epB4cgSjC7xJOXpVuCwt9r7ivoGvLiXSrxsoOgINw58,895
6
+ cycls/web.py,sha256=nSEJMUQoPaz8qjgVmsC1JiDRv9Y1UKVzTH4_pRHp_VE,4260
7
+ cycls-0.0.2.35.dist-info/METADATA,sha256=DztQ_n4XO8rvuwGq-bN7Y028yINUNjcH3XYbjF-OKsI,5666
8
+ cycls-0.0.2.35.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
9
+ cycls-0.0.2.35.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- cycls/__init__.py,sha256=9oKTafASGMuJbq_Sk0sfCSQKKwUD8mcdYFovDDzVuW4,50
2
- cycls/cycls.py,sha256=rLhssEPs4EwDbNsO5cGdKkP888dOaYToMPKVLZWan5U,7479
3
- cycls/runtime.py,sha256=y_ilfi418m-9ZsRvY7H7zrO0FYdhz2X-hnZ9DACaSW4,16289
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.33.dist-info/METADATA,sha256=HKNRnxvSkLwolKtk2-QUgQUGzbpKHSqv_hOx9Gpjnis,5666
8
- cycls-0.0.2.33.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
9
- cycls-0.0.2.33.dist-info/RECORD,,