cycls 0.0.2.69__py3-none-any.whl → 0.0.2.71__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/cli.py ADDED
@@ -0,0 +1,217 @@
1
+ import sys
2
+ import json
3
+ import time
4
+ import threading
5
+ import httpx
6
+
7
+ # ANSI codes
8
+ DIM = "\033[2m"
9
+ BOLD = "\033[1m"
10
+ RESET = "\033[0m"
11
+ CLEAR_LINE = "\r\033[K"
12
+ GREEN = "\033[32m"
13
+ YELLOW = "\033[33m"
14
+ BLUE = "\033[34m"
15
+ RED = "\033[31m"
16
+ CYAN = "\033[36m"
17
+ MAGENTA = "\033[35m"
18
+
19
+ CALLOUT_STYLES = {
20
+ "success": ("✓", GREEN),
21
+ "warning": ("⚠", YELLOW),
22
+ "info": ("ℹ", BLUE),
23
+ "error": ("✗", RED),
24
+ }
25
+
26
+ def format_time(seconds):
27
+ if seconds < 60:
28
+ return f"{seconds:.1f}s"
29
+ return f"{int(seconds // 60)}m {int(seconds % 60)}s"
30
+
31
+ def render_table(headers, rows):
32
+ if not headers:
33
+ return
34
+ widths = [len(str(h)) for h in headers]
35
+ for row in rows:
36
+ for i, cell in enumerate(row):
37
+ if i < len(widths):
38
+ widths[i] = max(widths[i], len(str(cell)))
39
+
40
+ top = "┌" + "┬".join("─" * (w + 2) for w in widths) + "┐"
41
+ sep = "├" + "┼".join("─" * (w + 2) for w in widths) + "┤"
42
+ bot = "└" + "┴".join("─" * (w + 2) for w in widths) + "┘"
43
+
44
+ def fmt_row(cells, bold=False):
45
+ parts = []
46
+ for i, w in enumerate(widths):
47
+ cell = str(cells[i]) if i < len(cells) else ""
48
+ if bold:
49
+ parts.append(f" {BOLD}{cell.ljust(w)}{RESET} ")
50
+ else:
51
+ parts.append(f" {cell.ljust(w)} ")
52
+ return "│" + "│".join(parts) + "│"
53
+
54
+ print(top)
55
+ print(fmt_row(headers, bold=True))
56
+ print(sep)
57
+ for row in rows:
58
+ print(fmt_row(row))
59
+ print(bot)
60
+
61
+ class Spinner:
62
+ def __init__(self):
63
+ self.active = False
64
+ self.thread = None
65
+ self.frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
66
+
67
+ def start(self):
68
+ self.active = True
69
+ def spin():
70
+ i = 0
71
+ while self.active:
72
+ if not self.active:
73
+ break
74
+ print(f"\r{MAGENTA}{self.frames[i % len(self.frames)]}{RESET} ", end="", flush=True)
75
+ for _ in range(8): # Check active more frequently
76
+ if not self.active:
77
+ break
78
+ time.sleep(0.01)
79
+ i += 1
80
+ self.thread = threading.Thread(target=spin, daemon=True)
81
+ self.thread.start()
82
+
83
+ def stop(self):
84
+ self.active = False
85
+ if self.thread:
86
+ self.thread.join(timeout=0.2)
87
+ print(f"{CLEAR_LINE}", end="", flush=True)
88
+
89
+ def chat(url):
90
+ messages = []
91
+ endpoint = f"{url.rstrip('/')}/chat/cycls"
92
+
93
+ print(f"\n{MAGENTA}●{RESET} {BOLD}{url}{RESET}\n")
94
+
95
+ while True:
96
+ try:
97
+ user_input = input(f"{CYAN}❯{RESET} ")
98
+ if not user_input.strip():
99
+ continue
100
+
101
+ messages.append({"role": "user", "content": user_input})
102
+ print()
103
+
104
+ start_time = time.time()
105
+ in_thinking = False
106
+ table_headers = []
107
+ table_rows = []
108
+
109
+ with httpx.stream("POST", endpoint, json={"messages": messages}, timeout=None) as r:
110
+ for line in r.iter_lines():
111
+ if line.startswith("data: ") and line != "data: [DONE]":
112
+
113
+ data = json.loads(line[6:])
114
+ msg_type = data.get("type")
115
+
116
+ # Handle plain string or missing type
117
+ if msg_type is None:
118
+ # Could be OpenAI format or plain text
119
+ if isinstance(data, str):
120
+ print(data, end="", flush=True)
121
+ continue
122
+ elif "choices" in data:
123
+ # OpenAI format
124
+ content = data.get("choices", [{}])[0].get("delta", {}).get("content", "")
125
+ if content:
126
+ print(content, end="", flush=True)
127
+ continue
128
+ elif "text" in data:
129
+ print(data.get("text", ""), end="", flush=True)
130
+ continue
131
+ elif "content" in data:
132
+ print(data.get("content", ""), end="", flush=True)
133
+ continue
134
+ else:
135
+ # Debug: print raw data
136
+ print(f"[debug: {data}]", end="", flush=True)
137
+ continue
138
+
139
+ # Close thinking if switching
140
+ if msg_type != "thinking" and in_thinking:
141
+ print(f"</thinking>{RESET}\n", end="", flush=True)
142
+ in_thinking = False
143
+
144
+ # Flush table if switching
145
+ if msg_type != "table" and table_headers:
146
+ render_table(table_headers, table_rows)
147
+ table_headers = []
148
+ table_rows = []
149
+
150
+ if msg_type == "thinking":
151
+ if not in_thinking:
152
+ print(f"{DIM}<thinking>", end="", flush=True)
153
+ in_thinking = True
154
+ print(data.get("thinking", ""), end="", flush=True)
155
+
156
+ elif msg_type == "text":
157
+ print(data.get("text", ""), end="", flush=True)
158
+
159
+ elif msg_type == "code":
160
+ lang = data.get("language", "")
161
+ print(f"\n```{lang}\n{data.get('code', '')}\n```\n", end="", flush=True)
162
+
163
+ elif msg_type == "status":
164
+ print(f"{DIM}[{data.get('status', '')}]{RESET} ", end="", flush=True)
165
+
166
+ elif msg_type == "table":
167
+ if "headers" in data:
168
+ if table_headers:
169
+ render_table(table_headers, table_rows)
170
+ table_headers = data["headers"]
171
+ table_rows = []
172
+ elif "row" in data:
173
+ table_rows.append(data["row"])
174
+
175
+ elif msg_type == "callout":
176
+ style = data.get("style", "info")
177
+ icon, color = CALLOUT_STYLES.get(style, ("•", RESET))
178
+ title = data.get("title", "")
179
+ text = data.get("callout", "")
180
+ if title:
181
+ print(f"\n{color}{icon} {BOLD}{title}{RESET}")
182
+ print(f"{color} {text}{RESET}\n", end="", flush=True)
183
+ else:
184
+ print(f"\n{color}{icon} {text}{RESET}\n", end="", flush=True)
185
+
186
+ elif msg_type == "image":
187
+ print(f"{DIM}[image: {data.get('src', '')}]{RESET}", end="", flush=True)
188
+
189
+ # Flush remaining
190
+ if table_headers:
191
+ render_table(table_headers, table_rows)
192
+ if in_thinking:
193
+ print(f"</thinking>{RESET}", end="", flush=True)
194
+
195
+ elapsed = time.time() - start_time
196
+ print(f"\n\n{DIM}✦ {format_time(elapsed)}{RESET}\n")
197
+
198
+ except KeyboardInterrupt:
199
+ continue
200
+ except EOFError:
201
+ print(f"{RESET}\n👋")
202
+ break
203
+ except (httpx.ReadError, httpx.ConnectError):
204
+ print(f"{RESET}🔄 Reconnecting...", end="", flush=True)
205
+ time.sleep(1)
206
+ print(CLEAR_LINE, end="")
207
+ if messages:
208
+ messages.pop()
209
+
210
+ def main():
211
+ if len(sys.argv) < 3 or sys.argv[1] != "chat":
212
+ print("Usage: cycls chat <url>")
213
+ sys.exit(1)
214
+ chat(sys.argv[2])
215
+
216
+ if __name__ == "__main__":
217
+ main()
cycls/runtime.py CHANGED
@@ -337,6 +337,7 @@ COPY {self.payload_file} {self.io_dir}/
337
337
  return
338
338
 
339
339
  import inspect
340
+ import subprocess
340
341
 
341
342
  # Get the main script (the outermost .py file in the stack)
342
343
  main_script = None
@@ -362,75 +363,37 @@ COPY {self.payload_file} {self.io_dir}/
362
363
  print()
363
364
 
364
365
  while True:
365
- # Run the container in a subprocess-like manner
366
- print(f"🚀 Running function '{self.name}' in container...")
367
- self._perform_auto_cleanup()
368
- self._build_image_if_needed()
369
-
370
- port = kwargs.get('port', None)
371
- ports_mapping = {f'{port}/tcp': port} if port else None
372
-
373
- with tempfile.TemporaryDirectory() as tmpdir_str:
374
- tmpdir = Path(tmpdir_str)
375
- payload_path = tmpdir / self.payload_file
376
-
377
- with payload_path.open('wb') as f:
378
- cloudpickle.dump((self.func, args, kwargs), f)
366
+ # Run the script in a subprocess so we survive errors
367
+ print(f"🚀 Running {main_script.name}...")
368
+ proc = subprocess.Popen(
369
+ [sys.executable, str(main_script)],
370
+ env={**os.environ, '_CYCLS_WATCH_CHILD': '1'}
371
+ )
379
372
 
380
- container = self.docker_client.containers.create(
381
- image=self.tag,
382
- volumes={str(tmpdir): {'bind': self.io_dir, 'mode': 'rw'}},
383
- ports=ports_mapping,
384
- labels={self.managed_label: "true"}
385
- )
386
- container.start()
387
- print(f"✅ Container running on port {port}")
388
- print("👀 Waiting for file changes... (Ctrl+C to stop)\n")
373
+ try:
374
+ # Watch for changes
375
+ for changes in watchfiles_watch(*watch_paths):
376
+ changed_files = [str(c[1]) for c in changes]
377
+ print(f"\n🔄 Changes detected:")
378
+ for f in changed_files:
379
+ print(f" {f}")
380
+ break
381
+
382
+ print("\n🔄 Restarting...\n")
383
+ proc.terminate()
384
+ try:
385
+ proc.wait(timeout=3)
386
+ except subprocess.TimeoutExpired:
387
+ proc.kill()
389
388
 
389
+ except KeyboardInterrupt:
390
+ print("\n🛑 Stopping...")
391
+ proc.terminate()
390
392
  try:
391
- # Watch for changes in a separate thread
392
- import threading
393
- import time
394
- change_detected = threading.Event()
395
-
396
- def watch_thread():
397
- for changes in watchfiles_watch(*watch_paths):
398
- changed_files = [str(c[1]) for c in changes]
399
- print(f"\n🔄 Changes detected:")
400
- for f in changed_files:
401
- print(f" {f}")
402
- change_detected.set()
403
- break
404
-
405
- watcher = threading.Thread(target=watch_thread, daemon=True)
406
- watcher.start()
407
-
408
- # Stream container logs in separate thread
409
- def log_thread():
410
- for chunk in container.logs(stream=True, follow=True):
411
- if change_detected.is_set():
412
- break
413
- print(chunk.decode('utf-8').strip())
414
-
415
- logger = threading.Thread(target=log_thread, daemon=True)
416
- logger.start()
417
-
418
- # Wait for change or interrupt
419
- while not change_detected.is_set():
420
- time.sleep(0.5)
421
-
422
- print("\n🔄 Restarting...")
423
- container.stop(timeout=2)
424
- container.remove()
425
- # Re-exec the main script
426
- print(f"🔄 Re-executing {main_script.name}...\n")
427
- os.execv(sys.executable, [sys.executable, str(main_script)])
428
-
429
- except KeyboardInterrupt:
430
- print("\n🛑 Stopping...")
431
- container.stop(timeout=2)
432
- container.remove()
433
- return
393
+ proc.wait(timeout=3)
394
+ except subprocess.TimeoutExpired:
395
+ proc.kill()
396
+ return
434
397
 
435
398
  def build(self, *args, **kwargs):
436
399
  """Builds a self-contained, deployable Docker image locally."""
cycls/sdk.py CHANGED
@@ -1,4 +1,4 @@
1
- import time, inspect, uvicorn
1
+ import os, time, inspect, uvicorn
2
2
  from .runtime import Runtime
3
3
  from .web import web, Config
4
4
  from .auth import PK_LIVE, PK_TEST, JWKS_PROD, JWKS_TEST
@@ -74,7 +74,8 @@ class Agent:
74
74
  return f
75
75
  return decorator
76
76
 
77
- def local(self, port=8080):
77
+ def _local(self, port=8080, watch=True):
78
+ """Run directly with uvicorn (no Docker)."""
78
79
  if not self.registered_functions:
79
80
  print("Error: No @agent decorated function found.")
80
81
  return
@@ -85,19 +86,14 @@ class Agent:
85
86
  print(f"🚀 Starting local server at localhost:{port}")
86
87
  agent.config.public_path = self.theme
87
88
  set_prod(agent.config, False)
88
- uvicorn.run(web(agent.func, agent.config), host="0.0.0.0", port=port)
89
+ uvicorn.run(web(agent.func, agent.config), host="0.0.0.0", port=port, reload=watch)
89
90
  return
90
91
 
91
- def deploy(self, prod=False, port=8080, watch=False):
92
+ def _runtime(self, prod=False):
93
+ """Create a Runtime instance for the first registered agent."""
92
94
  if not self.registered_functions:
93
95
  print("Error: No @agent decorated function found.")
94
- return
95
- if (self.key is None) and prod:
96
- print("🛑 Error: Please add your Cycls API key")
97
- return
98
- if prod and watch:
99
- print("⚠️ Warning: watch=True ignored in production mode.")
100
- watch = False
96
+ return None
101
97
 
102
98
  agent = self.registered_functions[0]
103
99
  if len(self.registered_functions) > 1:
@@ -112,7 +108,7 @@ class Agent:
112
108
  files.update({f: f for f in self.copy})
113
109
  files.update({f: f"public/{f}" for f in self.copy_public})
114
110
 
115
- new = Runtime(
111
+ return Runtime(
116
112
  func=lambda port: __import__("web").serve(func, config_dict, name, port),
117
113
  name=name,
118
114
  apt_packages=self.apt,
@@ -121,13 +117,24 @@ class Agent:
121
117
  base_url=self.base_url,
122
118
  api_key=self.key
123
119
  )
124
- if prod:
125
- new.deploy(port=port)
126
- elif watch:
127
- new.watch(port=port)
128
- else:
129
- new.run(port=port)
130
- return
120
+
121
+ def local(self, port=8080, watch=True):
122
+ """Run locally in Docker with file watching by default."""
123
+ # Child process spawned by watcher - run without watch
124
+ if os.environ.get('_CYCLS_WATCH_CHILD'):
125
+ watch = False
126
+ runtime = self._runtime(prod=False)
127
+ if runtime:
128
+ runtime.watch(port=port) if watch else runtime.run(port=port)
129
+
130
+ def deploy(self, port=8080):
131
+ """Deploy to production."""
132
+ if self.key is None:
133
+ print("🛑 Error: Please add your Cycls API key")
134
+ return
135
+ runtime = self._runtime(prod=True)
136
+ if runtime:
137
+ runtime.deploy(port=port)
131
138
 
132
139
  def modal(self, prod=False):
133
140
  import modal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cycls
3
- Version: 0.0.2.69
3
+ Version: 0.0.2.71
4
4
  Summary: Distribute Intelligence
5
5
  Author: Mohammed J. AlRujayi
6
6
  Author-email: mj@cycls.com
@@ -70,7 +70,7 @@ async def chat(context):
70
70
  if chunk.choices[0].delta.content:
71
71
  yield chunk.choices[0].delta.content
72
72
 
73
- agent.deploy(prod=True) # Live at https://my-agent.cycls.ai
73
+ agent.deploy() # Live at https://my-agent.cycls.ai
74
74
  ```
75
75
 
76
76
  ## Installation
@@ -90,11 +90,12 @@ Requires Docker.
90
90
  - **Monetization** - `tier="cycls_pass"` integrates with [Cycls Pass](https://cycls.ai) subscriptions
91
91
  - **Native UI Components** - Render thinking bubbles, tables, code blocks in responses
92
92
 
93
- ## Deploying
93
+ ## Running
94
94
 
95
95
  ```python
96
- agent.deploy(prod=False) # Development: localhost:8080
97
- agent.deploy(prod=True) # Production: https://agent-name.cycls.ai
96
+ agent.local() # Development with hot-reload (localhost:8080)
97
+ agent.local(watch=False) # Development without hot-reload
98
+ agent.deploy() # Production: https://agent-name.cycls.ai
98
99
  ```
99
100
 
100
101
  Get an API key at [cycls.com](https://cycls.com).
@@ -1,12 +1,14 @@
1
1
  cycls/__init__.py,sha256=bVT0dYTXLdSC3ZURgtm-DEOj-VO6RUM6zGsJB0zuj6Y,61
2
2
  cycls/auth.py,sha256=xkndHZyCfnlertMMEKerCJjf23N3fVcTRVTTSXTTuzg,247
3
+ cycls/cli.py,sha256=AKf0z7ZLau3GvBVR_IhB7agmq4nVaHkcuUafNyvv2_A,7978
3
4
  cycls/default-theme/assets/index-B0ZKcm_V.css,sha256=wK9-NhEB8xPcN9Zv69zpOcfGTlFbMwyC9WqTmSKUaKw,6546
4
5
  cycls/default-theme/assets/index-D5EDcI4J.js,sha256=sN4qRcAXa7DBd9JzmVcCoCwH4l8cNCM-U9QGUjBvWSo,1346506
5
6
  cycls/default-theme/index.html,sha256=bM-yW_g0cGrV40Q5yY3ccY0fM4zI1Wuu5I8EtGFJIxs,828
6
7
  cycls/dev-theme/index.html,sha256=QJBHkdNuMMiwQU7o8dN8__8YQeQB45D37D-NCXIWB2Q,11585
7
- cycls/runtime.py,sha256=BaP5sLG3NLPQZwuKwoYueC3R5WeuNg2-YzOQes6paUE,23221
8
- cycls/sdk.py,sha256=iOSFFPSN2iGCTIL2ZBOpb2X5jjUEm7gQbAuanYHF4qg,6974
8
+ cycls/runtime.py,sha256=LgOrQ6Arh-GWSJqckfra-CvjeSekTvGvjHOBxu7JTQQ,21408
9
+ cycls/sdk.py,sha256=6oRKP44TJN9HKdNw9OYzDlZFDUMUhoCMt8TEwyu26dI,7368
9
10
  cycls/web.py,sha256=3M3qaWTNY3dpgd7Vq5aXREp-cIFsHrDqBQ1YkGrOaUk,4659
10
- cycls-0.0.2.69.dist-info/METADATA,sha256=3nG949VOjQ1K-sftwDGsAQheGAt-GCKWGUmDgUBgOP4,7943
11
- cycls-0.0.2.69.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
12
- cycls-0.0.2.69.dist-info/RECORD,,
11
+ cycls-0.0.2.71.dist-info/METADATA,sha256=MG66f3JLtPXNMLV1rCs29BYkBxk2B3aMIG5_azsD9HI,8008
12
+ cycls-0.0.2.71.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
13
+ cycls-0.0.2.71.dist-info/entry_points.txt,sha256=vEhqUxFhhuzCKWtq02LbMnT3wpUqdfgcM3Yh-jjXom8,40
14
+ cycls-0.0.2.71.dist-info/RECORD,,
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ cycls=cycls.cli:main
3
+