cycls 0.0.2.83__tar.gz → 0.0.2.84__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cycls
3
- Version: 0.0.2.83
3
+ Version: 0.0.2.84
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
+ Cycls is beautifully lean (~862 lines!) and focused on deployment infrastructure for any Python function, not agent logic itself.
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.agent(pip=["openai"])
60
- async def agent(context):
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
- agent.deploy() # Live at https://agent.cycls.ai
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
- agent.local() # Development with hot-reload (localhost:8080)
101
- agent.local(watch=False) # Development without hot-reload
102
- agent.deploy() # Production: https://agent.cycls.ai
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.agent(pip=["openai"], auth=True, analytics=True)
111
- async def agent(context):
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.agent()
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.agent()
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.agent(
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 my_agent(context):
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.agent(copy=["./utils.py"])
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-agent.cycls.ai/public/logo.png`.
258
+ Access them at `https://your-app.cycls.ai/public/logo.png`.
259
259
 
260
260
  ---
261
261
 
@@ -24,6 +24,8 @@ The open-source SDK for distributing AI agents.
24
24
 
25
25
  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.
26
26
 
27
+ Cycls is beautifully lean (~862 lines!) and focused on deployment infrastructure for any Python function, not agent logic itself.
28
+
27
29
  ## Distribute Intelligence
28
30
 
29
31
  Write a function. Deploy it as an API, a web interface, or both. Add authentication, analytics, and monetization with flags.
@@ -33,8 +35,8 @@ import cycls
33
35
 
34
36
  cycls.api_key = "YOUR_CYCLS_API_KEY"
35
37
 
36
- @cycls.agent(pip=["openai"])
37
- async def agent(context):
38
+ @cycls.app(pip=["openai"])
39
+ async def app(context):
38
40
  from openai import AsyncOpenAI
39
41
  client = AsyncOpenAI()
40
42
 
@@ -51,7 +53,7 @@ async def agent(context):
51
53
  elif event.type == "response.output_text.delta":
52
54
  yield event.delta
53
55
 
54
- agent.deploy() # Live at https://agent.cycls.ai
56
+ app.deploy() # Live at https://agent.cycls.ai
55
57
  ```
56
58
 
57
59
  ## Installation
@@ -74,9 +76,9 @@ Requires Docker.
74
76
  ## Running
75
77
 
76
78
  ```python
77
- agent.local() # Development with hot-reload (localhost:8080)
78
- agent.local(watch=False) # Development without hot-reload
79
- agent.deploy() # Production: https://agent.cycls.ai
79
+ app.local() # Development with hot-reload (localhost:8080)
80
+ app.local(watch=False) # Development without hot-reload
81
+ app.deploy() # Production: https://agent.cycls.ai
80
82
  ```
81
83
 
82
84
  Get an API key at [cycls.com](https://cycls.com).
@@ -84,8 +86,8 @@ Get an API key at [cycls.com](https://cycls.com).
84
86
  ## Authentication & Analytics
85
87
 
86
88
  ```python
87
- @cycls.agent(pip=["openai"], auth=True, analytics=True)
88
- async def agent(context):
89
+ @cycls.app(pip=["openai"], auth=True, analytics=True)
90
+ async def app(context):
89
91
  # context.user available when auth=True
90
92
  user = context.user # User(id, email, name, plans)
91
93
  yield f"Hello {user.name}!"
@@ -102,7 +104,7 @@ async def agent(context):
102
104
  Yield structured objects for rich streaming responses:
103
105
 
104
106
  ```python
105
- @cycls.agent()
107
+ @cycls.app()
106
108
  async def demo(context):
107
109
  yield {"type": "thinking", "thinking": "Analyzing the request..."}
108
110
  yield "Here's what I found:\n\n"
@@ -144,7 +146,7 @@ This works seamlessly with OpenAI's reasoning models - just map reasoning summar
144
146
  ## Context Object
145
147
 
146
148
  ```python
147
- @cycls.agent()
149
+ @cycls.app()
148
150
  async def chat(context):
149
151
  context.messages # [{"role": "user", "content": "..."}]
150
152
  context.messages.raw # Full data including UI component parts
@@ -177,13 +179,13 @@ See [docs/streaming-protocol.md](docs/streaming-protocol.md) for frontend integr
177
179
  Define your entire runtime in the decorator:
178
180
 
179
181
  ```python
180
- @cycls.agent(
182
+ @cycls.app(
181
183
  pip=["openai", "pandas", "numpy"],
182
184
  apt=["ffmpeg", "libmagic1"],
183
185
  copy=["./utils.py", "./models/", "/absolute/path/to/config.json"],
184
186
  copy_public=["./assets/logo.png", "./static/"],
185
187
  )
186
- async def my_agent(context):
188
+ async def my_app(context):
187
189
  ...
188
190
  ```
189
191
 
@@ -218,7 +220,7 @@ copy=[
218
220
  Then import them in your function:
219
221
 
220
222
  ```python
221
- @cycls.agent(copy=["./utils.py"])
223
+ @cycls.app(copy=["./utils.py"])
222
224
  async def chat(context):
223
225
  from utils import helper_function # Your bundled module
224
226
  ...
@@ -232,7 +234,7 @@ Files and directories served at the `/public` endpoint. Perfect for images, down
232
234
  copy_public=["./assets/logo.png", "./downloads/"]
233
235
  ```
234
236
 
235
- Access them at `https://your-agent.cycls.ai/public/logo.png`.
237
+ Access them at `https://your-app.cycls.ai/public/logo.png`.
236
238
 
237
239
  ---
238
240
 
@@ -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}'")
@@ -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", "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
@@ -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 Runtime:
77
- """Executes functions in Docker containers. Uses file-based pickle for communication."""
83
+ class Function:
84
+ """Executes functions in Docker containers."""
78
85
 
79
- def __init__(self, func, name, python_version=None, pip_packages=None, apt_packages=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.apt_packages = sorted(apt_packages or [])
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(pip_packages or [])
98
+ user_packages = set(pip or [])
92
99
  if self.base_image == BASE_IMAGE:
93
- self.pip_packages = sorted(user_packages - BASE_PACKAGES)
100
+ self.pip = sorted(user_packages - BASE_PACKAGES)
94
101
  else:
95
- self.pip_packages = sorted(user_packages | {"cloudpickle"})
102
+ self.pip = sorted(user_packages | {"cloudpickle"})
96
103
 
97
- self.image_prefix = f"cycls/{name}"
98
- self.managed_label = "cycls.runtime"
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
- """Creates a unique tag based on image configuration."""
139
- parts = [self.base_image, self.python_version, "".join(self.pip_packages),
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.apt_packages:
158
- lines.append(f"RUN apt-get update && apt-get install -y --no-install-recommends {' '.join(self.apt_packages)}")
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.pip_packages:
161
- lines.append(f"RUN uv pip install --system --no-cache {' '.join(self.pip_packages)}")
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
- """Stop and remove the container."""
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 # Service mode, no result
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
@@ -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):
@@ -1,9 +1,9 @@
1
1
  [tool.poetry]
2
2
  name = "cycls"
3
- version = "0.0.2.83"
3
+ version = "0.0.2.84"
4
4
 
5
5
  packages = [{ include = "cycls" }]
6
- include = ["cycls/theme/**/*"]
6
+ include = ["cycls/themes/**/*"]
7
7
  description = "Distribute Intelligence"
8
8
  authors = ["Mohammed J. AlRujayi <mj@cycls.com>"]
9
9
  readme = "README.md"
@@ -17,7 +17,7 @@ docker = "^7.1.0"
17
17
  cloudpickle = "^3.1.1"
18
18
 
19
19
  [tool.poetry.scripts]
20
- cycls = "cycls.chat:main"
20
+ cycls = "cycls.cli:main"
21
21
 
22
22
  [tool.poetry.group.test.dependencies]
23
23
  pytest = "^8.0.0"
@@ -27,13 +27,6 @@ requests = "^2.31.0"
27
27
  [tool.poetry.group.dev.dependencies]
28
28
  grpcio-tools = "^1.76.0"
29
29
 
30
- [tool.poetry.extras]
31
- modal = ["modal"]
32
-
33
- [tool.poetry.dependencies.modal]
34
- version = "^1.1.0"
35
- optional = true
36
-
37
30
  [build-system]
38
31
  requires = ["poetry-core"]
39
32
  build-backend = "poetry.core.masonry.api"
@@ -1,20 +0,0 @@
1
- import sys
2
- from types import ModuleType
3
- from .sdk import function, agent
4
- from .runtime import Runtime
5
-
6
- class _Module(ModuleType):
7
- def __getattr__(self, name):
8
- from . import sdk
9
- if name in ("api_key", "base_url"):
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)
19
-
20
- sys.modules[__name__].__class__ = _Module
@@ -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
File without changes