open-swarm 0.1.1745125888__py3-none-any.whl → 0.1.1745125904__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-swarm
3
- Version: 0.1.1745125888
3
+ Version: 0.1.1745125904
4
4
  Summary: Open Swarm: Orchestrating AI Agent Swarms with Django
5
5
  Project-URL: Homepage, https://github.com/yourusername/open-swarm
6
6
  Project-URL: Documentation, https://github.com/yourusername/open-swarm/blob/main/README.md
@@ -56,17 +56,17 @@ swarm/blueprints/whiskeytango_foxtrot/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCe
56
56
  swarm/blueprints/whiskeytango_foxtrot/apps.py,sha256=V1QKvyb2Vz-EtDNhhNe4tw2W9LYhNDuiaIq_fAU4ilw,334
57
57
  swarm/blueprints/whiskeytango_foxtrot/blueprint_whiskeytango_foxtrot.py,sha256=y5OUPe5-W0Bhm3nO0hBH31ucXuote3PBVgb0WeOQodE,16567
58
58
  swarm/core/agent_utils.py,sha256=exKnbJEm1VRL270x6XqQXHtJhqD8ogY3ZBIGZO_tYUE,552
59
- swarm/core/blueprint_base.py,sha256=tt6NUIpckafJLBr4yM7_jgdTMrtMq8wvONgNQRbmUCw,22090
60
- swarm/core/blueprint_discovery.py,sha256=rNbfe0D98kfWiW5MdushT8405899tfm_hnpVN5jDg_Q,5688
59
+ swarm/core/blueprint_base.py,sha256=ZXfgCqaAVprML0KS3njHNxgldrwOCSGZrcI-fj7HyZs,40178
60
+ swarm/core/blueprint_discovery.py,sha256=UyJuBq_u7KLQC2_I9KGioX-Bcbj2-qjhKke4ensk_Xw,5622
61
61
  swarm/core/blueprint_runner.py,sha256=TIfcIFfW86gCIeYs67ePmurKRPrcGgVYmVFGbpNuojQ,2576
62
62
  swarm/core/blueprint_utils.py,sha256=Ef_pu-RYomqzFjMg6LOSPSdbYFCbYXjEoSvK1OT49Eo,702
63
- swarm/core/blueprint_ux.py,sha256=9sxOCjAfXW4v1VnaTpbOw7ymCxanzXlP5qFaXTFZTK4,2853
63
+ swarm/core/blueprint_ux.py,sha256=Lk4VFsIYyZSiDhwpLTYMaPAtDdLoVIDHaMWg9HCN7bQ,4567
64
64
  swarm/core/build_launchers.py,sha256=2NZRvX0A3jFN1mYZI5vbXkPRDoXgdUBdunruhSUogko,563
65
65
  swarm/core/build_swarm_wrapper.py,sha256=c_9oR3To4M2cZyc1uYSiysHLhUGX5FkCAQk9AG7Va2Q,231
66
66
  swarm/core/common_utils.py,sha256=jeKcN3lMdrpOYWIpErH3L5am13jHjaImpVvk2b0mps4,462
67
- swarm/core/config_loader.py,sha256=ldQGtv4tXeDJzL2GCylDxykZxYBo4ALFY2kS0jZ79Eo,5652
67
+ swarm/core/config_loader.py,sha256=iSIKmB8Oc6MsI7luWZwFAIG_FNPILwnUZowKi6B1wyk,5614
68
68
  swarm/core/config_manager.py,sha256=DdrFHpTnEtZOZZdBZbL4hE3AGdaZFJQ9duaAfi7GhJw,10015
69
- swarm/core/output_utils.py,sha256=lEySKRDJTRGaTGUSULYgq166b4pxk3s8w5LJg5STFVo,7249
69
+ swarm/core/output_utils.py,sha256=9rr1MNZ6Kucm78oAVAJaehlGbvgH2LY41VHzjxN1kZM,8345
70
70
  swarm/core/server_config.py,sha256=v2t7q22qZwMAPdiUZreQaLAy1706k3VbR8Wk0NCQuCQ,3224
71
71
  swarm/core/session_logger.py,sha256=92I0IGwUsRsYEISsO1HBeVWPnbBWBC4UuUzk2KstBuk,1859
72
72
  swarm/core/setup_wizard.py,sha256=yAZ7MOgc8ZGti2kjZ72G6QLFBI0lbhXAa7Wi7SeXDYo,4567
@@ -257,6 +257,7 @@ swarm/templates/rest_mode/components/top_bar.html,sha256=I-J0Bmmcr0c6_KXEEmM6mwT
257
257
  swarm/templates/websocket_partials/final_system_message.html,sha256=hZeOez70ZvYsDrmq965kFzV6wh5dBkKkP2FTiXS0FWo,128
258
258
  swarm/templates/websocket_partials/system_message.html,sha256=0eBzz9dJBmnwDwnh-X_7wDefSatbS1LepaLhXxN-qI4,171
259
259
  swarm/templates/websocket_partials/user_message.html,sha256=-TjdT4-FKFVXeYsPglG3VayDYg1A2beE5gV6AQWu-00,149
260
+ swarm/utils/ansi_box.py,sha256=VatZxyCR83Z-taNE361vMhrm8rD-8htic5gI96V2HPg,1172
260
261
  swarm/utils/color_utils.py,sha256=utIfZ6ptGEdpHxIZiZ4gtfo5lLqZKQL5g0F8mEwMhTo,1184
261
262
  swarm/utils/context_utils.py,sha256=CsoyFALq88pcMYU8Fw1joHrssnjDuisaroEkGiDwX_8,19551
262
263
  swarm/utils/disable_tracing.py,sha256=IWe60GGs3l_pZA_OLV6qJDcAHOM2tQkMzg0Xj_x5hRE,1400
@@ -268,6 +269,8 @@ swarm/utils/message_sequence.py,sha256=7Xa7YwGPgojfkrGcl-SbeR_BWwzXGDYNqAxq8F6Xh
268
269
  swarm/utils/message_utils.py,sha256=oNTD7pfmnnu_Br24pR2VEX9afIZwFLwg2HJBLXs1blY,4074
269
270
  swarm/utils/openai_patch.py,sha256=Qogheuyh2_MTR6gO-uKgf3lu2jQc7cS72Vctc7vyXtI,1330
270
271
  swarm/utils/redact.py,sha256=vtqTztsxyBg-2qHMgAPi1lI5mJgZEb8Fqi1KKFk3bZM,2042
272
+ swarm/ux/ansi_box.py,sha256=6mG8MpqrvDXGYU87eKqtky-ib83yt_qk2wiwf9DU5rs,1558
273
+ swarm/ux/spinner.py,sha256=UouAin377z8sKfvgrIF8klp_bKbwsSCxd2jgelndn5Y,1613
271
274
  swarm/views/__init__.py,sha256=AcLk0R7Y69FhIVgJK2hZs8M_gCR-h_5iqUywz89yuHM,1223
272
275
  swarm/views/api_views.py,sha256=BbDEgI6Ftg-c-mMkE9DvRGZHIZ-WAZSfwqAB7j98WxM,1937
273
276
  swarm/views/chat_views.py,sha256=6UUtEJKrM2k_wi9A6AfhbbvMYunjzpY22M6hOIXASjA,15695
@@ -276,8 +279,8 @@ swarm/views/message_views.py,sha256=sDUnXyqKXC8WwIIMAlWf00s2_a2T9c75Na5FvYMJwBM,
276
279
  swarm/views/model_views.py,sha256=aAbU4AZmrOTaPeKMWtoKK7FPYHdaN3Zbx55JfKzYTRY,2937
277
280
  swarm/views/utils.py,sha256=8Usc0g0L0NPegNAyY20tJBNBy-JLwODf4VmxV0yUtpw,3627
278
281
  swarm/views/web_views.py,sha256=T1CKe-Nyv1C8aDt6QFTGWo_dkH7ojWAvS_QW9mZnZp0,7371
279
- open_swarm-0.1.1745125888.dist-info/METADATA,sha256=kzF5948majacP5Fn-7hO3iAr059U6MxWvH9z-eOCXpc,26977
280
- open_swarm-0.1.1745125888.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
281
- open_swarm-0.1.1745125888.dist-info/entry_points.txt,sha256=fo28d0_zJrytRsh8QqkdlWQT_9lyAwYUx1WuSTDI3HM,177
282
- open_swarm-0.1.1745125888.dist-info/licenses/LICENSE,sha256=BU9bwRlnOt_JDIb6OT55Q4leLZx9RArDLTFnlDIrBEI,1062
283
- open_swarm-0.1.1745125888.dist-info/RECORD,,
282
+ open_swarm-0.1.1745125904.dist-info/METADATA,sha256=q8jtZKzgtpfD5DLbBjdCbEdzi3jwsKfx3Nn-WwJfJV8,26977
283
+ open_swarm-0.1.1745125904.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
284
+ open_swarm-0.1.1745125904.dist-info/entry_points.txt,sha256=fo28d0_zJrytRsh8QqkdlWQT_9lyAwYUx1WuSTDI3HM,177
285
+ open_swarm-0.1.1745125904.dist-info/licenses/LICENSE,sha256=BU9bwRlnOt_JDIb6OT55Q4leLZx9RArDLTFnlDIrBEI,1062
286
+ open_swarm-0.1.1745125904.dist-info/RECORD,,
@@ -1,8 +1,24 @@
1
1
  # --- REMOVE noisy debug/framework prints unless SWARM_DEBUG=1 ---
2
2
  import os
3
+ from swarm.utils.general_utils import is_debug_enabled
3
4
 
4
5
  def _should_debug():
5
- return os.environ.get("SWARM_DEBUG") == "1"
6
+ # Standardize debug detection: SWARM_DEBUG, SWARM_LOGLEVEL, LOGLEVEL, LOG_LEVEL, DEBUG
7
+ import os
8
+ # Highest precedence: explicit SWARM_DEBUG=1 or true
9
+ debug_env = os.environ.get("SWARM_DEBUG")
10
+ if debug_env is not None:
11
+ return debug_env.lower() in ("1", "true", "yes", "on")
12
+ # Next: SWARM_LOGLEVEL or LOGLEVEL or LOG_LEVEL
13
+ for var in ("SWARM_LOGLEVEL", "LOGLEVEL", "LOG_LEVEL"):
14
+ val = os.environ.get(var)
15
+ if val and val.upper() == "DEBUG":
16
+ return True
17
+ # Next: DEBUG=1 or true
18
+ debug_std = os.environ.get("DEBUG")
19
+ if debug_std is not None:
20
+ return debug_std.lower() in ("1", "true", "yes", "on")
21
+ return False
6
22
 
7
23
  def _debug_print(*args, **kwargs):
8
24
  if _should_debug():
@@ -123,6 +139,16 @@ class BlueprintBase(ABC):
123
139
  console = Console()
124
140
  session_logger: 'SessionLogger' = None
125
141
 
142
+ def __init__(self, blueprint_id: str, config=None, config_path=None, **kwargs):
143
+ self.blueprint_id = blueprint_id
144
+ self.config_path = config_path
145
+ self._config = config if config is not None else None
146
+ self._llm_profile_name = None
147
+ self._llm_profile_data = None
148
+ self._markdown_output = None
149
+ self._load_configuration() # Ensure config is loaded during init
150
+ # Add any additional initialization logic here
151
+
126
152
  def display_splash_screen(self, animated: bool = False):
127
153
  """Default splash screen. Subclasses can override for custom CLI/API branding."""
128
154
  console = Console()
@@ -133,37 +159,133 @@ class BlueprintBase(ABC):
133
159
  Loads blueprint configuration. This method is a stub for compatibility with tests that patch it.
134
160
  In production, configuration is loaded via _load_and_process_config.
135
161
  """
136
- # You may override this in subclasses or patch in tests
137
- return getattr(self, '_config', {})
138
-
139
- def __init__(self, blueprint_id: str, config: dict = None, config_path: 'Optional[Path]' = None, enable_terminal_commands: 'Optional[bool]' = None, **kwargs):
162
+ import os
163
+ import json
164
+ from pathlib import Path
165
+ def redact(val):
166
+ if not isinstance(val, str) or len(val) <= 4:
167
+ return "****"
168
+ return val[:2] + "*" * (len(val)-4) + val[-2:]
169
+ def redact_dict(d):
170
+ if isinstance(d, dict):
171
+ return {k: (redact_dict(v) if not (isinstance(v, str) and ("key" in k.lower() or "token" in k.lower() or "secret" in k.lower())) else redact(v)) for k, v in d.items()}
172
+ elif isinstance(d, list):
173
+ return [redact_dict(item) for item in d]
174
+ return d
140
175
  try:
141
- if not blueprint_id:
142
- raise ValueError("blueprint_id cannot be empty or None")
143
- self.blueprint_id = blueprint_id
144
- self.config_path = config_path # for legacy compatibility
145
- self._config = config # Allow test injection
146
- self._llm_profile_name = None
147
- self._llm_profile_data = None
148
- self._markdown_output = None
149
- # Allow per-instance override
150
- if enable_terminal_commands is not None:
151
- self.enable_terminal_commands = enable_terminal_commands
152
- # Else: use class attribute (default False or set by subclass)
153
-
154
- logger.info(f"Initializing blueprint '{self.blueprint_id}' (Type: {self.__class__.__name__})")
155
-
156
- # --- Ensure custom OpenAI client for custom LLM providers ---
157
- import os
176
+ if self._config is None:
177
+ try:
178
+ # --- Get config from the AppConfig instance (Django) ---
179
+ app_config_instance = apps.get_app_config('swarm')
180
+ if not hasattr(app_config_instance, 'config') or not app_config_instance.config:
181
+ raise ValueError("AppConfig for 'swarm' does not have a valid 'config' attribute.")
182
+ self._config = app_config_instance.config
183
+ print("[SWARM_CONFIG_DEBUG] Loaded config from Django AppConfig.")
184
+ except Exception as e:
185
+ if _should_debug():
186
+ logger.warning(f"Falling back to CLI/home config due to error: {e}")
187
+ # 1. CLI argument (not handled here, handled in cli_handler)
188
+ # 2. Current working directory (guard against missing CWD)
189
+ try:
190
+ cwd_config = Path.cwd() / "swarm_config.json"
191
+ print(f"[SWARM_CONFIG_DEBUG] Trying: {cwd_config}")
192
+ except Exception as e:
193
+ cwd_config = None
194
+ if _should_debug():
195
+ logger.warning(f"Unable to determine CWD for config lookup: {e}")
196
+ if cwd_config and cwd_config.exists():
197
+ print(f"[SWARM_CONFIG_DEBUG] Loaded: {cwd_config}")
198
+ with open(cwd_config, 'r') as f:
199
+ self._config = json.load(f)
200
+ # 3. XDG_CONFIG_HOME or ~/.config/swarm/swarm_config.json
201
+ elif os.environ.get("XDG_CONFIG_HOME"):
202
+ xdg_config = Path(os.environ["XDG_CONFIG_HOME"]) / "swarm" / "swarm_config.json"
203
+ print(f"[SWARM_CONFIG_DEBUG] Trying: {xdg_config}")
204
+ if xdg_config.exists():
205
+ print(f"[SWARM_CONFIG_DEBUG] Loaded: {xdg_config}")
206
+ with open(xdg_config, 'r') as f:
207
+ self._config = json.load(f)
208
+ elif (Path.home() / ".config/swarm/swarm_config.json").exists():
209
+ home_config = Path.home() / ".config/swarm/swarm_config.json"
210
+ print(f"[SWARM_CONFIG_DEBUG] Loaded: {home_config}")
211
+ with open(home_config, 'r') as f:
212
+ self._config = json.load(f)
213
+ # 4. Legacy fallback: ~/.swarm/swarm_config.json
214
+ elif (Path.home() / ".swarm/swarm_config.json").exists():
215
+ legacy_config = Path.home() / ".swarm/swarm_config.json"
216
+ print(f"[SWARM_CONFIG_DEBUG] Loaded: {legacy_config}")
217
+ with open(legacy_config, 'r') as f:
218
+ self._config = json.load(f)
219
+ # 5. Fallback: OPENAI_API_KEY envvar
220
+ elif os.environ.get("OPENAI_API_KEY"):
221
+ print("[SWARM_CONFIG_DEBUG] No config file found, using OPENAI_API_KEY from env.")
222
+ self._config = {
223
+ "llm": {"default": {"provider": "openai", "model": "gpt-3.5-turbo", "api_key": os.environ["OPENAI_API_KEY"]}},
224
+ "settings": {"default_llm_profile": "default", "default_markdown_output": True},
225
+ "blueprints": {},
226
+ "llm_profile": "default",
227
+ "mcpServers": {}
228
+ }
229
+ logger.info("No config file found, using default config with OPENAI_API_KEY for CLI mode.")
230
+ else:
231
+ print("[SWARM_CONFIG_DEBUG] No config file found and OPENAI_API_KEY is not set. Using empty config.")
232
+ self._config = {}
233
+ logger.warning("No config file found and OPENAI_API_KEY is not set. Using empty config. CLI blueprints may fail if LLM config is required.")
234
+ if self._config is not None:
235
+ self._config = _substitute_env_vars(self._config)
236
+ # Ensure self._config is always a dict
237
+ if self._config is None:
238
+ self._config = {}
239
+ settings_section = self._config.get("settings", {})
240
+ llm_section = self._config.get("llm", {})
241
+
242
+ # --- After config is loaded, set OpenAI client from config if possible ---
243
+ try:
244
+ llm_profiles = self._config.get("llm", {})
245
+ default_profile = llm_profiles.get("default", {})
246
+ base_url = default_profile.get("base_url")
247
+ api_key = default_profile.get("api_key")
248
+ # Expand env vars if present
249
+ import os
250
+ if base_url and base_url.startswith("${"):
251
+ var = base_url[2:-1]
252
+ base_url = os.environ.get(var, base_url)
253
+ if api_key and api_key.startswith("${"):
254
+ var = api_key[2:-1]
255
+ api_key = os.environ.get(var, api_key)
256
+ if base_url and api_key:
257
+ from openai import AsyncOpenAI
258
+ from agents import set_default_openai_client
259
+ _debug_print(f"[DEBUG] (config) Setting OpenAI client: base_url={base_url}, api_key={'set' if api_key else 'NOT SET'}")
260
+ client = AsyncOpenAI(base_url=base_url, api_key=api_key)
261
+ set_default_openai_client(client)
262
+ except Exception as e:
263
+ _debug_print(f"[DEBUG] Failed to set OpenAI client from config: {e}")
264
+
265
+ # --- Debug: Print and log redacted config ---
266
+ redacted_config = redact_dict(self._config)
267
+ logger.debug(f"Loaded config (redacted): {json.dumps(redacted_config, indent=2)}")
158
268
 
159
- # Remove monkey patching and envvar hacks. Always pass config values directly.
160
- # (Retain only explicit AsyncOpenAI client instantiation in blueprints)
161
- # (No changes needed here for direct client pattern)
269
+ # --- Process LLM profile name and data ---
270
+ default_profile = settings_section.get("default_llm_profile") or "default"
271
+ # Only set self._llm_profile_name if explicitly provided in config
272
+ if "llm_profile" in self._config:
273
+ self._llm_profile_name = self._config["llm_profile"]
274
+ # Do NOT set self._llm_profile_name to default_profile here; let resolution logic handle fallback
275
+ if "profiles" in llm_section:
276
+ self._llm_profile_data = llm_section["profiles"].get(self._llm_profile_name, {})
277
+ else:
278
+ self._llm_profile_data = llm_section.get(self._llm_profile_name, {})
279
+ blueprint_specific_settings = self._config.get("blueprints", {}).get(self.blueprint_id, {})
280
+ global_markdown_setting = settings_section.get("default_markdown_output", True)
281
+ self._markdown_output = blueprint_specific_settings.get("markdown_output", global_markdown_setting)
282
+ logger.debug(f"Markdown output for '{self.blueprint_id}': {self._markdown_output}")
162
283
 
163
- self._load_and_process_config()
164
- except AttributeError as e:
165
- logger.debug(f"[BlueprintBase.__init__] AttributeError: {e}")
166
- traceback.print_exc()
284
+ except ValueError as e:
285
+ logger.error(f"Configuration error for blueprint '{self.blueprint_id}': {e}", exc_info=True)
286
+ raise
287
+ except Exception as e:
288
+ logger.error(f"Unexpected error loading config for blueprint '{self.blueprint_id}': {e}", exc_info=True)
167
289
  raise
168
290
 
169
291
  def _load_and_process_config(self):
@@ -189,7 +311,7 @@ class BlueprintBase(ABC):
189
311
  if not hasattr(app_config_instance, 'config') or not app_config_instance.config:
190
312
  raise ValueError("AppConfig for 'swarm' does not have a valid 'config' attribute.")
191
313
  self._config = app_config_instance.config
192
- logger.debug("Loaded config from Django AppConfig.")
314
+ print("[SWARM_CONFIG_DEBUG] Loaded config from Django AppConfig.")
193
315
  except Exception as e:
194
316
  if _should_debug():
195
317
  logger.warning(f"Falling back to CLI/home config due to error: {e}")
@@ -197,28 +319,37 @@ class BlueprintBase(ABC):
197
319
  # 2. Current working directory (guard against missing CWD)
198
320
  try:
199
321
  cwd_config = Path.cwd() / "swarm_config.json"
322
+ print(f"[SWARM_CONFIG_DEBUG] Trying: {cwd_config}")
200
323
  except Exception as e:
201
324
  cwd_config = None
202
325
  if _should_debug():
203
326
  logger.warning(f"Unable to determine CWD for config lookup: {e}")
204
327
  if cwd_config and cwd_config.exists():
328
+ print(f"[SWARM_CONFIG_DEBUG] Loaded: {cwd_config}")
205
329
  with open(cwd_config, 'r') as f:
206
330
  self._config = json.load(f)
207
331
  # 3. XDG_CONFIG_HOME or ~/.config/swarm/swarm_config.json
208
332
  elif os.environ.get("XDG_CONFIG_HOME"):
209
333
  xdg_config = Path(os.environ["XDG_CONFIG_HOME"]) / "swarm" / "swarm_config.json"
334
+ print(f"[SWARM_CONFIG_DEBUG] Trying: {xdg_config}")
210
335
  if xdg_config.exists():
336
+ print(f"[SWARM_CONFIG_DEBUG] Loaded: {xdg_config}")
211
337
  with open(xdg_config, 'r') as f:
212
338
  self._config = json.load(f)
213
339
  elif (Path.home() / ".config/swarm/swarm_config.json").exists():
214
- with open(Path.home() / ".config/swarm/swarm_config.json", 'r') as f:
340
+ home_config = Path.home() / ".config/swarm/swarm_config.json"
341
+ print(f"[SWARM_CONFIG_DEBUG] Loaded: {home_config}")
342
+ with open(home_config, 'r') as f:
215
343
  self._config = json.load(f)
216
344
  # 4. Legacy fallback: ~/.swarm/swarm_config.json
217
345
  elif (Path.home() / ".swarm/swarm_config.json").exists():
218
- with open(Path.home() / ".swarm/swarm_config.json", 'r') as f:
346
+ legacy_config = Path.home() / ".swarm/swarm_config.json"
347
+ print(f"[SWARM_CONFIG_DEBUG] Loaded: {legacy_config}")
348
+ with open(legacy_config, 'r') as f:
219
349
  self._config = json.load(f)
220
350
  # 5. Fallback: OPENAI_API_KEY envvar
221
351
  elif os.environ.get("OPENAI_API_KEY"):
352
+ print("[SWARM_CONFIG_DEBUG] No config file found, using OPENAI_API_KEY from env.")
222
353
  self._config = {
223
354
  "llm": {"default": {"provider": "openai", "model": "gpt-3.5-turbo", "api_key": os.environ["OPENAI_API_KEY"]}},
224
355
  "settings": {"default_llm_profile": "default", "default_markdown_output": True},
@@ -228,10 +359,11 @@ class BlueprintBase(ABC):
228
359
  }
229
360
  logger.info("No config file found, using default config with OPENAI_API_KEY for CLI mode.")
230
361
  else:
362
+ print("[SWARM_CONFIG_DEBUG] No config file found and OPENAI_API_KEY is not set. Using empty config.")
231
363
  self._config = {}
232
364
  logger.warning("No config file found and OPENAI_API_KEY is not set. Using empty config. CLI blueprints may fail if LLM config is required.")
233
- if self._config is not None:
234
- self._config = _substitute_env_vars(self._config)
365
+ if self._config is not None:
366
+ self._config = _substitute_env_vars(self._config)
235
367
  # Ensure self._config is always a dict
236
368
  if self._config is None:
237
369
  self._config = {}
@@ -267,12 +399,14 @@ class BlueprintBase(ABC):
267
399
 
268
400
  # --- Process LLM profile name and data ---
269
401
  default_profile = settings_section.get("default_llm_profile") or "default"
270
- self._llm_profile_name = self._config.get("llm_profile") or default_profile
402
+ # Only set self._llm_profile_name if explicitly provided in config
403
+ if "llm_profile" in self._config:
404
+ self._llm_profile_name = self._config["llm_profile"]
405
+ # Do NOT set self._llm_profile_name to default_profile here; let resolution logic handle fallback
271
406
  if "profiles" in llm_section:
272
407
  self._llm_profile_data = llm_section["profiles"].get(self._llm_profile_name, {})
273
408
  else:
274
409
  self._llm_profile_data = llm_section.get(self._llm_profile_name, {})
275
-
276
410
  blueprint_specific_settings = self._config.get("blueprints", {}).get(self.blueprint_id, {})
277
411
  global_markdown_setting = settings_section.get("default_markdown_output", True)
278
412
  self._markdown_output = blueprint_specific_settings.get("markdown_output", global_markdown_setting)
@@ -285,6 +419,77 @@ class BlueprintBase(ABC):
285
419
  logger.error(f"Unexpected error loading config for blueprint '{self.blueprint_id}': {e}", exc_info=True)
286
420
  raise
287
421
 
422
+ def _resolve_llm_profile(self):
423
+ """Resolve the LLM profile for this blueprint using the following order:
424
+ 1. If self._llm_profile_name is set, use it.
425
+ 2. If config has 'llm_profile', use it.
426
+ 3. If config['blueprints'][blueprint_id or stripped]['llm_profile'] is set, use it.
427
+ 4. If settings.default_llm in self._config, use it.
428
+ 5. If global swarm_config has blueprints.<BlueprintName>.llm_profile, use it.
429
+ 6. If settings.default_llm in global config, use it.
430
+ 7. If env var DEFAULT_LLM is set, use it.
431
+ 8. Otherwise, use 'default'.
432
+ """
433
+ # Use cached value if already resolved
434
+ if getattr(self, '_resolved_llm_profile', None):
435
+ return self._resolved_llm_profile
436
+ name = getattr(self, 'blueprint_id', None) or getattr(self, '__class__', type(self)).__name__
437
+ profile = None
438
+ import logging
439
+ logger = logging.getLogger(__name__)
440
+ logger.debug(f"[DEBUG _resolve_llm_profile] blueprint_id/name: {name}")
441
+ logger.debug(f"[DEBUG _resolve_llm_profile] self._config: {self._config}")
442
+ # 1. Explicit override
443
+ if getattr(self, '_llm_profile_name', None):
444
+ logger.debug(f"[DEBUG _resolve_llm_profile] Using programmatic override: {self._llm_profile_name}")
445
+ profile = self._llm_profile_name
446
+ # 2. Blueprint config (top-level)
447
+ elif self._config and self._config.get('llm_profile'):
448
+ logger.debug(f"[DEBUG _resolve_llm_profile] Using top-level config llm_profile: {self._config['llm_profile']}")
449
+ profile = self._config['llm_profile']
450
+ # 3. Blueprint config (per-blueprint section)
451
+ elif self._config and self._config.get('blueprints'):
452
+ logger.debug(f"[DEBUG _resolve_llm_profile] Checking per-blueprint config for: {name}")
453
+ bp_cfg = self._config['blueprints'].get(name) or self._config['blueprints'].get(name.replace('Blueprint',''))
454
+ logger.debug(f"[DEBUG _resolve_llm_profile] bp_cfg: {bp_cfg}")
455
+ if isinstance(bp_cfg, dict) and 'llm_profile' in bp_cfg:
456
+ logger.debug(f"[DEBUG _resolve_llm_profile] Using per-blueprint llm_profile: {bp_cfg['llm_profile']}")
457
+ profile = bp_cfg['llm_profile']
458
+ # 4. settings.default_llm in self._config
459
+ elif self._config and self._config.get('settings') and self._config['settings'].get('default_llm'):
460
+ profile = self._config['settings']['default_llm']
461
+ # 5. Global config lookup (blueprints.<BlueprintName>.llm_profile)
462
+ else:
463
+ global_config = None
464
+ try:
465
+ import json, os
466
+ from pathlib import Path
467
+ config_paths = [Path.cwd() / 'swarm_config.json', Path.home() / '.config/swarm/swarm_config.json']
468
+ for path in config_paths:
469
+ if path.exists():
470
+ with open(path) as f:
471
+ global_config = json.load(f)
472
+ break
473
+ except Exception:
474
+ global_config = None
475
+ if global_config and 'blueprints' in global_config:
476
+ bp_cfg = global_config['blueprints'].get(name) or global_config['blueprints'].get(name.replace('Blueprint',''))
477
+ if bp_cfg and 'llm_profile' in bp_cfg:
478
+ profile = bp_cfg['llm_profile']
479
+ # 6. settings.default_llm in global config
480
+ if not profile and global_config and 'settings' in global_config and global_config['settings'].get('default_llm'):
481
+ profile = global_config['settings']['default_llm']
482
+ # 7. Env var DEFAULT_LLM
483
+ if not profile:
484
+ import os
485
+ profile = os.environ.get('DEFAULT_LLM')
486
+ # 8. Otherwise, use 'default'
487
+ if not profile:
488
+ profile = 'default'
489
+ logger.debug(f"[DEBUG _resolve_llm_profile] Final resolved profile: {profile}")
490
+ self._resolved_llm_profile = profile
491
+ return profile
492
+
288
493
  @property
289
494
  def config(self) -> Dict[str, Any]:
290
495
  """Returns the loaded and processed Swarm configuration."""
@@ -299,7 +504,7 @@ class BlueprintBase(ABC):
299
504
  Raises a clear error if provider is missing.
300
505
  """
301
506
  llm_section = self._config.get("llm", {}) if self._config else {}
302
- profile_name = self.llm_profile_name or "default"
507
+ profile_name = self._resolve_llm_profile()
303
508
  profile = llm_section.get(profile_name)
304
509
  if not profile:
305
510
  raise ValueError(f"LLM profile '{profile_name}' not found in config: {llm_section}")
@@ -310,9 +515,13 @@ class BlueprintBase(ABC):
310
515
  @property
311
516
  def llm_profile_name(self) -> str:
312
517
  """Returns the name of the LLM profile being used."""
313
- if self._llm_profile_name is None:
314
- raise RuntimeError("LLM profile name accessed before initialization or after failure.")
315
- return self._llm_profile_name
518
+ return self._resolve_llm_profile()
519
+
520
+ @llm_profile_name.setter
521
+ def llm_profile_name(self, value: str):
522
+ self._llm_profile_name = value
523
+ if hasattr(self, '_resolved_llm_profile'):
524
+ del self._resolved_llm_profile
316
525
 
317
526
  @property
318
527
  def slash_commands(self):
@@ -341,6 +550,22 @@ class BlueprintBase(ABC):
341
550
  return bool(settings["default_markdown_output"])
342
551
  return False
343
552
 
553
+ @property
554
+ def splash(self) -> str:
555
+ """
556
+ Plain text splash/description for API, docs, etc.
557
+ """
558
+ title = self.metadata.get('title', 'Blueprint')
559
+ desc = self.metadata.get('description', '')
560
+ return f"{title}: {desc}"
561
+
562
+ def get_cli_splash(self, color='cyan', emoji='🤖') -> str:
563
+ """
564
+ CLI splash with ANSI/emoji, only for terminal output.
565
+ """
566
+ from swarm.utils.ansi_box import ansi_box
567
+ return ansi_box(self.splash, color=color, emoji=emoji)
568
+
344
569
  def _get_model_instance(self, profile_name: str):
345
570
  """Retrieves or creates an LLM Model instance, respecting LITELLM_MODEL/DEFAULT_LLM if set."""
346
571
  if not hasattr(self, '_model_instance_cache'):
@@ -374,7 +599,7 @@ class BlueprintBase(ABC):
374
599
  from agents.models.openai_responses import OpenAIResponsesModel
375
600
  model_instance = OpenAIResponsesModel(model=model_name, openai_client=client)
376
601
  else:
377
- from agents.models.openai_completions import OpenAIChatCompletionsModel
602
+ from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
378
603
  model_instance = OpenAIChatCompletionsModel(model=model_name, openai_client=client)
379
604
  self._model_instance_cache[profile_name] = model_instance
380
605
  return model_instance
@@ -382,7 +607,7 @@ class BlueprintBase(ABC):
382
607
  def make_agent(self, name, instructions, tools, mcp_servers=None, **kwargs):
383
608
  """Factory for creating an Agent with the correct model instance from framework config."""
384
609
  from agents import Agent # Ensure Agent is always in scope
385
- model_instance = self._get_model_instance(self.config.get("llm_profile", "default"))
610
+ model_instance = self._get_model_instance(self._resolve_llm_profile())
386
611
  return Agent(
387
612
  name=name,
388
613
  model=model_instance,
@@ -446,6 +671,26 @@ class BlueprintBase(ABC):
446
671
  self.session_logger.close()
447
672
  self.session_logger = None
448
673
 
674
+ def print_help(self):
675
+ """
676
+ Print CLI usage/help for this blueprint. Subclasses can override for custom help.
677
+ """
678
+ blueprint_name = getattr(self, 'blueprint_id', self.__class__.__name__)
679
+ print(f"\nUsage: {blueprint_name} [options] <prompt>\n")
680
+ print("Options:")
681
+ print(" -m, --model <model> Model to use for completions")
682
+ print(" -q, --quiet Non-interactive mode (only prints final output)")
683
+ print(" -o, --output <file> Output file")
684
+ print(" --project-doc <file> Include a markdown file as context")
685
+ print(" --full-context Load all project files as context")
686
+ print(" --approval <policy> Set approval policy for agent actions (suggest, auto-edit, full-auto)")
687
+ print(" --version Show version and exit")
688
+ print(" -h, --help Show this help message and exit\n")
689
+ print("Examples:")
690
+ print(f" {blueprint_name} \"Refactor all utils into a single module.\"")
691
+ print(f" {blueprint_name} --full-context \"Summarize all TODOs in the project.\"")
692
+ print(f" {blueprint_name} --approval full-auto \"Upgrade all dependencies and update the changelog.\"")
693
+
449
694
  @abstractmethod
450
695
  async def run(self, messages: List[Dict[str, Any]], **kwargs: Any) -> AsyncGenerator[Dict[str, Any], None]:
451
696
  """
@@ -457,3 +702,68 @@ class BlueprintBase(ABC):
457
702
  pprint.pprint(dict(os.environ))
458
703
  raise NotImplementedError("Subclasses must implement the 'run' method.")
459
704
  yield {}
705
+
706
+ def _load_configuration(self):
707
+ """
708
+ Loads blueprint configuration. This method is a stub for compatibility with tests that patch it.
709
+ In production, configuration is loaded via _load_and_process_config.
710
+ """
711
+ import os
712
+ import json
713
+ from pathlib import Path
714
+ import traceback
715
+ try:
716
+ if self._config is None:
717
+ try:
718
+ if self.config_path is not None:
719
+ self.config_path = Path(self.config_path)
720
+ if self.config_path.exists():
721
+ if is_debug_enabled():
722
+ print(f"[DEBUG LOADER] Reading config from {self.config_path}")
723
+ raw = self.config_path.read_text()
724
+ if is_debug_enabled():
725
+ print(f"[DEBUG LOADER] Raw config contents:\n{raw}")
726
+ self._config = json.loads(raw)
727
+ assert isinstance(self._config, dict), f"Config not a dict: {type(self._config)}"
728
+ assert self._config, "Config loaded but is empty!"
729
+ else:
730
+ logger.warning(f"Config path {self.config_path} does not exist. Using empty config.")
731
+ self._config = {}
732
+ else:
733
+ # Try cwd, then default, then /mnt/models/open-swarm-mcp/swarm_config.json
734
+ cwd_path = Path(os.getcwd()) / "swarm_config.json"
735
+ if cwd_path.exists():
736
+ if is_debug_enabled():
737
+ print(f"[DEBUG LOADER] Reading config from {cwd_path}")
738
+ raw = cwd_path.read_text()
739
+ if is_debug_enabled():
740
+ print(f"[DEBUG LOADER] Raw config contents:\n{raw}")
741
+ self._config = json.loads(raw)
742
+ assert isinstance(self._config, dict), f"Config not a dict: {type(self._config)}"
743
+ assert self._config, "Config loaded but is empty!"
744
+ else:
745
+ # Fallback to /mnt/models/open-swarm-mcp/swarm_config.json
746
+ mnt_path = Path("/mnt/models/open-swarm-mcp/swarm_config.json")
747
+ if mnt_path.exists():
748
+ if is_debug_enabled():
749
+ print(f"[DEBUG LOADER] Reading config from {mnt_path}")
750
+ raw = mnt_path.read_text()
751
+ if is_debug_enabled():
752
+ print(f"[DEBUG LOADER] Raw config contents:\n{raw}")
753
+ self._config = json.loads(raw)
754
+ assert isinstance(self._config, dict), f"Config not a dict: {type(self._config)}"
755
+ assert self._config, "Config loaded but is empty!"
756
+ else:
757
+ self._config = {}
758
+ except Exception as e:
759
+ print(f"[FATAL CONFIG LOAD ERROR] {e}")
760
+ traceback.print_exc()
761
+ self._config = {}
762
+ # Ensure self._config is always a dict
763
+ if self._config is None:
764
+ self._config = {}
765
+ return self._config
766
+
767
+ except Exception as e:
768
+ logger.error(f"Unexpected error loading config for blueprint '{self.blueprint_id}': {e}", exc_info=True)
769
+ raise
@@ -17,10 +17,7 @@ try:
17
17
  except ImportError:
18
18
  # This logger call is now safe
19
19
  logger.error("Failed to import BlueprintBase from swarm.core.blueprint_base. Using placeholder.", exc_info=True)
20
- class BlueprintBase: # Fallback placeholder
21
- metadata: Dict[str, Any] = {}
22
- def __init__(self, *args, **kwargs): pass
23
- async def run(self, *args, **kwargs): pass
20
+ # REMOVED DUMMY BlueprintBase to prevent MRO/import bugs. Always import BlueprintBase from swarm.core.blueprint_base
24
21
 
25
22
 
26
23
  class BlueprintLoadError(Exception):
@@ -15,11 +15,11 @@ class BlueprintUX:
15
15
  box += "┗"+"━"*20
16
16
  return box
17
17
 
18
- # Integrate unique improvements from the feature branch
19
18
  import time
20
19
  import itertools
21
20
 
22
21
  # Style presets
22
+
23
23
  def get_style(style):
24
24
  if style == "serious":
25
25
  return {
@@ -56,7 +56,9 @@ class BlueprintUXImproved:
56
56
  return spinner_states[state_idx % len(spinner_states)]
57
57
 
58
58
  def summary(self, op_type, result_count, params):
59
- return f"{op_type} | Results: {result_count} | Params: {params}"
59
+ # Enhanced: pretty param formatting
60
+ param_str = ', '.join(f'{k}={v!r}' for k, v in (params or {}).items()) if params else 'None'
61
+ return f"{op_type} | Results: {result_count} | Params: {param_str}"
60
62
 
61
63
  def progress(self, current, total=None):
62
64
  if total:
@@ -70,4 +72,38 @@ class BlueprintUXImproved:
70
72
  header = "[Semantic Search Results]"
71
73
  else:
72
74
  header = "[Results]"
73
- return f"{header}\n" + "\n".join(results)
75
+ # Enhanced: visually distinct divider
76
+ divider = "\n" + ("=" * 40) + "\n"
77
+ return f"{header}{divider}" + "\n".join(results)
78
+
79
+ def ansi_emoji_box(self, title, content, summary=None, params=None, result_count=None, op_type=None, style=None, color=None, status=None):
80
+ """
81
+ Unified ANSI/emoji box for search/analysis/fileops results.
82
+ Summarizes: operation, result count, params, and allows for periodic updates.
83
+ Supports color and status for advanced UX.
84
+ """
85
+ style_conf = get_style(style or self.style)
86
+ # Color/status logic
87
+ color_map = {
88
+ "success": "92", # green
89
+ "info": "94", # blue
90
+ "warning": "93", # yellow
91
+ "error": "91", # red
92
+ None: "94", # default blue
93
+ }
94
+ ansi_color = color_map.get(status, color_map[None])
95
+ box = f"\033[{ansi_color}m" + style_conf["border_top"] + "\033[0m\n"
96
+ box += f"\033[{ansi_color}m{style_conf['border_side']} {style_conf['emoji']} {title}"
97
+ if op_type:
98
+ box += f" | {op_type}"
99
+ if result_count is not None:
100
+ box += f" | Results: {result_count}"
101
+ box += "\033[0m\n"
102
+ if params:
103
+ box += f"\033[{ansi_color}m{style_conf['border_side']} Params: {params}\033[0m\n"
104
+ if summary:
105
+ box += f"\033[{ansi_color}m{style_conf['border_side']} {summary}\033[0m\n"
106
+ for line in content.split('\n'):
107
+ box += f"\033[{ansi_color}m{style_conf['border_side']} {line}\033[0m\n"
108
+ box += f"\033[{ansi_color}m" + style_conf["border_bottom"] + "\033[0m"
109
+ return box
@@ -9,15 +9,15 @@ from dotenv import load_dotenv
9
9
  logger = logging.getLogger("swarm.config")
10
10
 
11
11
  def _substitute_env_vars(value: Any) -> Any:
12
- """Recursively substitute environment variables in strings, lists, and dicts."""
12
+ import os
13
13
  if isinstance(value, str):
14
+ # Always expand env vars in any string
14
15
  return os.path.expandvars(value)
15
16
  elif isinstance(value, list):
16
17
  return [_substitute_env_vars(item) for item in value]
17
18
  elif isinstance(value, dict):
18
19
  return {k: _substitute_env_vars(v) for k, v in value.items()}
19
- else:
20
- return value
20
+ return value
21
21
 
22
22
  def load_environment(project_root: Path):
23
23
  """Loads environment variables from a `.env` file located at the project root."""
@@ -7,6 +7,7 @@ import logging
7
7
  import os
8
8
  import sys
9
9
  from typing import List, Dict, Any
10
+ import re
10
11
 
11
12
  # Optional import for markdown rendering
12
13
  try:
@@ -106,7 +107,26 @@ def pretty_print_response(messages: List[Dict[str, Any]], use_markdown: bool = F
106
107
  if msg_content:
107
108
  # --- DEBUG PRINT ---
108
109
  print(f"\n[DEBUG Assistant content found, printing/rendering... Rich={RICH_AVAILABLE}, Markdown={use_markdown}]", flush=True)
109
- if use_markdown and RICH_AVAILABLE:
110
+ # --- CODE FENCE HIGHLIGHTING ---
111
+ if RICH_AVAILABLE and '```' in msg_content:
112
+ import re
113
+ code_fence_pattern = r"```([\w\d]*)\n([\s\S]*?)```"
114
+ matches = re.findall(code_fence_pattern, msg_content)
115
+ if matches:
116
+ from rich.syntax import Syntax
117
+ from rich.console import Console
118
+ console = Console()
119
+ for lang, code in matches:
120
+ syntax = Syntax(code, lang or "python", theme="monokai", line_numbers=False)
121
+ console.print(syntax)
122
+ # Optionally print any non-code parts
123
+ non_code = re.split(code_fence_pattern, msg_content)
124
+ for i, part in enumerate(non_code):
125
+ if i % 3 == 0 and part.strip():
126
+ print(part.strip(), flush=True)
127
+ else:
128
+ print(msg_content, flush=True)
129
+ elif use_markdown and RICH_AVAILABLE:
110
130
  render_markdown(msg_content)
111
131
  else:
112
132
  print(msg_content, flush=True)
@@ -0,0 +1,34 @@
1
+ import shutil
2
+
3
+ ANSI_COLORS = {
4
+ 'cyan': '\033[96m',
5
+ 'green': '\033[92m',
6
+ 'yellow': '\033[93m',
7
+ 'magenta': '\033[95m',
8
+ 'blue': '\033[94m',
9
+ 'red': '\033[91m',
10
+ 'white': '\033[97m',
11
+ 'grey': '\033[90m',
12
+ 'reset': '\033[0m',
13
+ }
14
+
15
+
16
+ def ansi_box(text, color='cyan', emoji='🤖', width=None):
17
+ """
18
+ Draw a fancy ANSI box around the given text, with color and emoji.
19
+ """
20
+ lines = [line.rstrip() for line in text.strip('\n').split('\n')]
21
+ max_len = max(len(line) for line in lines)
22
+ if width is None:
23
+ try:
24
+ width = min(shutil.get_terminal_size((80, 20)).columns, max_len + 6)
25
+ except Exception:
26
+ width = max_len + 6
27
+ box_width = max(width, max_len + 6)
28
+ color_code = ANSI_COLORS.get(color, ANSI_COLORS['cyan'])
29
+ reset = ANSI_COLORS['reset']
30
+ top = f"{color_code}╔{'═' * (box_width-2)}╗{reset}"
31
+ title = f"{color_code}║ {emoji} {' ' * (box_width-6)}║{reset}"
32
+ content = [f"{color_code}║ {line.ljust(box_width-4)} ║{reset}" for line in lines]
33
+ bottom = f"{color_code}╚{'═' * (box_width-2)}╝{reset}"
34
+ return '\n'.join([top, title] + content + [bottom])
swarm/ux/ansi_box.py ADDED
@@ -0,0 +1,43 @@
1
+ import sys
2
+
3
+ def ansi_box(title, content, count=None, params=None, style='default', emoji=None):
4
+ """
5
+ Print a visually distinct ANSI box summarizing search/analysis results.
6
+ - title: e.g. 'Searched filesystem', 'Analyzed code'
7
+ - content: main result string or list of strings
8
+ - count: result count (optional)
9
+ - params: dict of search parameters (optional)
10
+ - style: 'default', 'success', 'warning', etc.
11
+ - emoji: optional emoji prefix
12
+ """
13
+ border = {
14
+ 'default': '━',
15
+ 'success': '━',
16
+ 'warning': '━',
17
+ }.get(style, '━')
18
+ color = {
19
+ 'default': '\033[36m', # Cyan
20
+ 'success': '\033[32m', # Green
21
+ 'warning': '\033[33m', # Yellow
22
+ }.get(style, '\033[36m')
23
+ reset = '\033[0m'
24
+ box_width = 80
25
+ lines = []
26
+ head = f"{emoji + ' ' if emoji else ''}{title}"
27
+ if count is not None:
28
+ head += f" | Results: {count}"
29
+ if params:
30
+ head += f" | Params: {params}"
31
+ lines.append(color + border * box_width + reset)
32
+ lines.append(color + f"{head[:box_width]:^{box_width}}" + reset)
33
+ lines.append(color + border * box_width + reset)
34
+ if isinstance(content, str):
35
+ content = [content]
36
+ for line in content:
37
+ for l in str(line).split('\n'):
38
+ lines.append(f"{l[:box_width]:<{box_width}}")
39
+ lines.append(color + border * box_width + reset)
40
+ print("\n".join(lines))
41
+
42
+ # Example usage:
43
+ # ansi_box('Searched filesystem', 'Found 12 files', count=12, params={'pattern': '*.py'}, style='success', emoji='💾')
swarm/ux/spinner.py ADDED
@@ -0,0 +1,53 @@
1
+ import sys
2
+ import threading
3
+ import time
4
+
5
+ class Spinner:
6
+ """
7
+ Displays spinner states: Generating., Generating.., Generating..., Running...,
8
+ and switches to 'Taking longer than expected' after a timeout.
9
+ """
10
+ def __init__(self, base_message="Generating", long_wait_timeout=8):
11
+ self.base_message = base_message
12
+ self.states = [".", "..", "...", "..", "."]
13
+ self.running = False
14
+ self.thread = None
15
+ self.long_wait_timeout = long_wait_timeout
16
+ self._long_wait = False
17
+
18
+ def start(self):
19
+ self.running = True
20
+ self.thread = threading.Thread(target=self._spin, daemon=True)
21
+ self.thread.start()
22
+
23
+ def stop(self):
24
+ self.running = False
25
+ if self.thread:
26
+ self.thread.join()
27
+ sys.stdout.write("\r" + " " * 80 + "\r")
28
+ sys.stdout.flush()
29
+
30
+ def _spin(self):
31
+ idx = 0
32
+ start_time = time.time()
33
+ while self.running:
34
+ if not self._long_wait and (time.time() - start_time > self.long_wait_timeout):
35
+ self._long_wait = True
36
+ if self._long_wait:
37
+ msg = f"{self.base_message}... Taking longer than expected"
38
+ else:
39
+ msg = f"{self.base_message}{self.states[idx % len(self.states)]}"
40
+ sys.stdout.write(f"\r{msg}")
41
+ sys.stdout.flush()
42
+ time.sleep(0.4)
43
+ idx += 1
44
+
45
+ def set_message(self, message):
46
+ self.base_message = message
47
+ self._long_wait = False
48
+
49
+ # Example usage:
50
+ # spinner = Spinner()
51
+ # spinner.start()
52
+ # ... do work ...
53
+ # spinner.stop()