horsies 0.1.0a4__tar.gz → 0.1.0a6__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.
Files changed (49) hide show
  1. {horsies-0.1.0a4 → horsies-0.1.0a6}/PKG-INFO +1 -1
  2. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/app.py +67 -47
  3. horsies-0.1.0a6/horsies/core/banner.py +258 -0
  4. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/brokers/postgres.py +315 -288
  5. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/cli.py +7 -2
  6. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/errors.py +3 -0
  7. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/app.py +87 -64
  8. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/recovery.py +30 -21
  9. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/schedule.py +30 -19
  10. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/tasks.py +1 -0
  11. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/workflow.py +489 -202
  12. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/workflow_pg.py +3 -1
  13. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/scheduler/service.py +5 -1
  14. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/scheduler/state.py +39 -27
  15. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/task_decorator.py +138 -0
  16. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/types/status.py +7 -5
  17. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/utils/imports.py +10 -10
  18. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/worker/worker.py +197 -139
  19. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/workflows/engine.py +487 -352
  20. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/workflows/recovery.py +148 -119
  21. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies.egg-info/PKG-INFO +1 -1
  22. {horsies-0.1.0a4 → horsies-0.1.0a6}/pyproject.toml +1 -1
  23. horsies-0.1.0a4/horsies/core/banner.py +0 -144
  24. {horsies-0.1.0a4 → horsies-0.1.0a6}/README.md +0 -0
  25. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/__init__.py +0 -0
  26. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/__init__.py +0 -0
  27. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/brokers/__init__.py +0 -0
  28. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/brokers/listener.py +0 -0
  29. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/codec/serde.py +0 -0
  30. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/logging.py +0 -0
  31. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/__init__.py +0 -0
  32. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/broker.py +0 -0
  33. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/queues.py +0 -0
  34. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/models/task_pg.py +0 -0
  35. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/registry/tasks.py +0 -0
  36. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/scheduler/__init__.py +0 -0
  37. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/scheduler/calculator.py +0 -0
  38. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/utils/loop_runner.py +0 -0
  39. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/worker/current.py +0 -0
  40. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/workflows/__init__.py +0 -0
  41. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/core/workflows/registry.py +0 -0
  42. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies/py.typed +0 -0
  43. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies.egg-info/SOURCES.txt +0 -0
  44. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies.egg-info/dependency_links.txt +0 -0
  45. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies.egg-info/entry_points.txt +0 -0
  46. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies.egg-info/requires.txt +0 -0
  47. {horsies-0.1.0a4 → horsies-0.1.0a6}/horsies.egg-info/top_level.txt +0 -0
  48. {horsies-0.1.0a4 → horsies-0.1.0a6}/setup.cfg +0 -0
  49. {horsies-0.1.0a4 → horsies-0.1.0a6}/tests/test_issue_fixes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: horsies
3
- Version: 0.1.0a4
3
+ Version: 0.1.0a6
4
4
  Summary: A Python library for distributed task execution
5
5
  Author: Suleyman Ozkeskin
6
6
  License-Expression: MIT
@@ -82,7 +82,9 @@ class Horsies:
82
82
  f'horsies subprocess initialized with {config.queue_mode.name} mode (pid={os.getpid()})'
83
83
  )
84
84
  else:
85
- self.logger.info(f'horsies initialized as {self._role} with {config.queue_mode.name} mode')
85
+ self.logger.info(
86
+ f'horsies initialized as {self._role} with {config.queue_mode.name} mode'
87
+ )
86
88
 
87
89
  def set_role(self, role: str) -> None:
88
90
  """Set the role and log it. Called by CLI after discovery."""
@@ -192,7 +194,7 @@ class Horsies:
192
194
  # Register task with this app, passing source for duplicate detection
193
195
  # Normalize path with realpath to handle symlinks and relative paths
194
196
  source_str = (
195
- f"{os.path.realpath(fn_location.file)}:{fn_location.line}"
197
+ f'{os.path.realpath(fn_location.file)}:{fn_location.line}'
196
198
  if fn_location
197
199
  else None
198
200
  )
@@ -247,15 +249,19 @@ class Horsies:
247
249
  if module_path.endswith('.py') or os.path.sep in module_path:
248
250
  abs_path = os.path.realpath(module_path)
249
251
  if not os.path.exists(abs_path):
250
- errors.append(_no_location(ConfigurationError(
251
- message=f'task module not found: {module_path}',
252
- code=ErrorCode.CLI_INVALID_ARGS,
253
- notes=[f'resolved path: {abs_path}'],
254
- help_text=(
255
- 'remove it from app.discover_tasks([...]) or fix the path; \n'
256
- 'if using globs, run app.expand_module_globs([...]) first'
257
- ),
258
- )))
252
+ errors.append(
253
+ _no_location(
254
+ ConfigurationError(
255
+ message=f'task module not found: {module_path}',
256
+ code=ErrorCode.CLI_INVALID_ARGS,
257
+ notes=[f'resolved path: {abs_path}'],
258
+ help_text=(
259
+ 'remove it from app.discover_tasks([...]) or fix the path; \n'
260
+ 'if using globs, run app.expand_module_globs([...]) first'
261
+ ),
262
+ )
263
+ )
264
+ )
259
265
  continue
260
266
  import_by_path(abs_path)
261
267
  else:
@@ -265,16 +271,20 @@ class Horsies:
265
271
  except HorsiesError as exc:
266
272
  errors.append(exc)
267
273
  except Exception as exc:
268
- errors.append(_no_location(ConfigurationError(
269
- message=f'failed to import module: {module_path}',
270
- code=ErrorCode.CLI_INVALID_ARGS,
271
- notes=[str(exc)],
272
- help_text=(
273
- 'ensure the module is importable; '
274
- 'for file paths include .py and a valid path, '
275
- 'for dotted paths verify PYTHONPATH or run from the project root'
276
- ),
277
- )))
274
+ errors.append(
275
+ _no_location(
276
+ ConfigurationError(
277
+ message=f'failed to import module: {module_path}',
278
+ code=ErrorCode.CLI_INVALID_ARGS,
279
+ notes=[str(exc)],
280
+ help_text=(
281
+ 'ensure the module is importable; '
282
+ 'for file paths include .py and a valid path, '
283
+ 'for dotted paths verify PYTHONPATH or run from the project root'
284
+ ),
285
+ )
286
+ )
287
+ )
278
288
  finally:
279
289
  self.suppress_sends(prev_suppress)
280
290
  return errors
@@ -283,26 +293,32 @@ class Horsies:
283
293
  """Check broker connectivity via SELECT 1."""
284
294
  import asyncio
285
295
 
296
+ from sqlalchemy import text
297
+
298
+ HEALTH_CHECK_SQL = text("""SELECT 1""")
299
+
286
300
  errors: list[HorsiesError] = []
287
301
  try:
288
302
  broker = self.get_broker()
289
303
 
290
304
  async def _test_connection() -> None:
291
- from sqlalchemy import text
292
-
293
305
  async with broker.session_factory() as session:
294
- await session.execute(text('SELECT 1'))
306
+ await session.execute(HEALTH_CHECK_SQL)
295
307
 
296
308
  asyncio.run(_test_connection())
297
309
  except HorsiesError as exc:
298
310
  errors.append(exc)
299
311
  except Exception as exc:
300
- errors.append(_no_location(ConfigurationError(
301
- message='broker connectivity check failed',
302
- code=ErrorCode.BROKER_INVALID_URL,
303
- notes=[str(exc)],
304
- help_text='check database_url in PostgresConfig',
305
- )))
312
+ errors.append(
313
+ _no_location(
314
+ ConfigurationError(
315
+ message='broker connectivity check failed',
316
+ code=ErrorCode.BROKER_INVALID_URL,
317
+ notes=[str(exc)],
318
+ help_text='check database_url in PostgresConfig',
319
+ )
320
+ )
321
+ )
306
322
  return errors
307
323
 
308
324
  def list_tasks(self) -> list[str]:
@@ -508,26 +524,30 @@ class Horsies:
508
524
  if self.config.queue_mode == QueueMode.CUSTOM:
509
525
  valid_queues = self.get_valid_queue_names()
510
526
  if resolved_queue not in valid_queues:
511
- report.add(ConfigurationError(
512
- message='TaskNode queue not in app config',
513
- code=ErrorCode.TASK_INVALID_QUEUE,
527
+ report.add(
528
+ ConfigurationError(
529
+ message='TaskNode queue not in app config',
530
+ code=ErrorCode.TASK_INVALID_QUEUE,
531
+ notes=[
532
+ f"TaskNode '{task.name}' has queue '{resolved_queue}'",
533
+ f'valid queues: {valid_queues}',
534
+ ],
535
+ help_text='use one of the configured queue names or add this queue to app config',
536
+ )
537
+ )
538
+ queue_valid = False
539
+ elif resolved_queue != 'default':
540
+ report.add(
541
+ ConfigurationError(
542
+ message='TaskNode has non-default queue in DEFAULT mode',
543
+ code=ErrorCode.CONFIG_INVALID_QUEUE_MODE,
514
544
  notes=[
515
545
  f"TaskNode '{task.name}' has queue '{resolved_queue}'",
516
- f'valid queues: {valid_queues}',
546
+ "app is in DEFAULT mode (only 'default' queue allowed)",
517
547
  ],
518
- help_text='use one of the configured queue names or add this queue to app config',
519
- ))
520
- queue_valid = False
521
- elif resolved_queue != 'default':
522
- report.add(ConfigurationError(
523
- message='TaskNode has non-default queue in DEFAULT mode',
524
- code=ErrorCode.CONFIG_INVALID_QUEUE_MODE,
525
- notes=[
526
- f"TaskNode '{task.name}' has queue '{resolved_queue}'",
527
- "app is in DEFAULT mode (only 'default' queue allowed)",
528
- ],
529
- help_text='either remove queue override or switch to QueueMode.CUSTOM',
530
- ))
548
+ help_text='either remove queue override or switch to QueueMode.CUSTOM',
549
+ )
550
+ )
531
551
  queue_valid = False
532
552
 
533
553
  if queue_valid:
@@ -0,0 +1,258 @@
1
+ """Startup banner for horsies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING, TextIO
7
+
8
+ if TYPE_CHECKING:
9
+ from horsies.core.app import Horsies
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # ANSI color codes (matching Rust owo-colors output)
14
+ # ---------------------------------------------------------------------------
15
+
16
+ class Colors:
17
+ """ANSI escape codes for terminal colors."""
18
+
19
+ RESET = '\033[0m'
20
+ BOLD = '\033[1m'
21
+ DIMMED = '\033[2m'
22
+
23
+ # Standard colors
24
+ WHITE = '\033[37m'
25
+ BRIGHT_WHITE = '\033[97m'
26
+ CYAN = '\033[36m'
27
+ BRIGHT_CYAN = '\033[96m'
28
+ YELLOW = '\033[33m'
29
+ BRIGHT_YELLOW = '\033[93m'
30
+
31
+
32
+ def _color(text: str, *codes: str) -> str:
33
+ """Apply ANSI color codes to text."""
34
+ if not codes:
35
+ return text
36
+ return ''.join(codes) + text + Colors.RESET
37
+
38
+
39
+ # Braille art of galloping horse - converted from the golden horse image
40
+ # Each braille character represents a 2x4 pixel block for higher resolution
41
+ HORSE_BRAILLE = """
42
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣾⣿⣿⣿⣿⣷⣯⡀⠀⠀⠀⠀⠀⠀
43
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣿⣆⠀⠀⠀⠀
44
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⡟⠹⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀
45
+ ⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⣠⣴⣾⣿⣿⣿⣶⣶⣶⣤⣤⣤⣤⣤⣶⣾⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠈⠉⠛⠻⡿⣿⣿⠂⠀
46
+ ⠀⠀⢀⣀⠀⢀⣀⣠⣶⣿⣿⠟⢛⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⡐⠀⠀⠀⠀⠀⠀⠈⠋⡿⠁⠀⠀
47
+ ⠀⠀⠀⢹⣿⣿⣿⣿⣿⡿⠁⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
48
+ ⠀⠀⠀⠀⠛⠻⠿⠛⠉⠀⠀⠀⠈⢯⡻⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
49
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠻⣷⣿⣿⣿⡟⠀⠙⠻⠿⠿⣿⣿⠃⣿⣿⣿⣿⣿⣿⣿⣿⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
50
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⡿⠁⢀⠀⠀⠀⠀⠀⠂⠀⢿⣿⣿⣿⡍⠈⢁⣙⣿⢦⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
51
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⠏⠀⠀⣼⠀⠀⠀⠀⠀⠀⠀⠀⠙⢿⣿⣷⠀⠀⠀⠀⠁⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
52
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣿⣧⣀⢄⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢻⣿⣧⠀⠀⢀⣼⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
53
+ ⠀⠀⠀⣀⠀⠀⠀⢀⡀⠀⠀⠀⡀⠀⠀⠀⣀⠛⣿⣷⣍⠛⣦⡀⠀⠂⢠⣷⣶⣾⡿⠟⠛⢃⠀⢠⣾⡟⠀⠀⠀⠀⡀⠀⠀⠀⡀⠀⠀⠀
54
+ ⠄⡤⠦⠤⠤⢤⡤⠡⠤⠤⢤⠬⠤⠤⠤⢤⠅⠀⠤⣿⣷⠄⠎⢻⣤⡦⠄⠀⠤⢵⠄⠠⠤⠬⣾⠏⠁⠀⠥⡤⠄⠠⠬⢦⡤⠀⠠⠵⢤⠠
55
+ ⠚⠒⠒⠒⡶⠚⠒⠒⠒⡰⠓⠒⠒⠒⢲⠓⠒⠒⠒⢻⠿⠀⠀⠚⢿⡷⠐⠒⠒⠚⡆⠒⠒⠒⠚⣖⠒⠒⠒⠚⢶⠒⠒⠒⠚⢶⠂⠐⠒⠛
56
+ """
57
+
58
+ # Figlet-style "horsies" text with version and tagline
59
+ LOGO_TEXT = r"""
60
+ __ _
61
+ / /_ ____ __________(_)__ _____
62
+ / __ \/ __ \/ ___/ ___/ / _ \/ ___/ {version}
63
+ / / / / /_/ / / (__ ) / __(__ ) distributed task queue
64
+ /_/ /_/\____/_/ /____/_/\___/____/ and workflow engine
65
+ """
66
+
67
+ # Full banner combining horse and logo
68
+ BANNER = (
69
+ HORSE_BRAILLE
70
+ + r"""
71
+ __ _
72
+ / /_ ____ __________(_)__ _____
73
+ / __ \/ __ \/ ___/ ___/ / _ \/ ___/ {version}
74
+ / / / / /_/ / / (__ ) / __(__ ) distributed task queue
75
+ /_/ /_/\____/_/ /____/_/\___/____/ and workflow engine
76
+ """
77
+ )
78
+
79
+
80
+ def get_version() -> str:
81
+ """Get horsies version from package metadata."""
82
+ try:
83
+ from importlib.metadata import version
84
+ return version('horsies')
85
+ except Exception:
86
+ return 'dev'
87
+
88
+
89
+ def format_banner(version: str | None = None) -> str:
90
+ """Format the banner string with version."""
91
+ if version is None:
92
+ version = get_version()
93
+ return BANNER.format(version=f'v{version}')
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Formatting helpers (matching Rust banner.rs)
98
+ # ---------------------------------------------------------------------------
99
+
100
+ def _format_ms(ms: int) -> str:
101
+ """Format milliseconds into a human-readable string."""
102
+ if ms >= 60_000:
103
+ mins = ms // 60_000
104
+ remainder_s = (ms % 60_000) // 1_000
105
+ if remainder_s > 0:
106
+ return f'{mins}m{remainder_s}s'
107
+ return f'{mins}m'
108
+ elif ms >= 1_000:
109
+ secs = ms // 1_000
110
+ remainder_ms = ms % 1_000
111
+ if remainder_ms > 0:
112
+ return f'{secs}.{remainder_ms // 100}s'
113
+ return f'{secs}s'
114
+ return f'{ms}ms'
115
+
116
+
117
+ def _format_bool(val: bool) -> str:
118
+ """Format a boolean for display."""
119
+ return 'yes' if val else 'no'
120
+
121
+
122
+ def _write_section_header(lines: list[str], name: str) -> None:
123
+ """Write a colored [section] header line."""
124
+ header = _color(name, Colors.BRIGHT_CYAN, Colors.BOLD)
125
+ lines.append(f'[{header}]')
126
+
127
+
128
+ def _write_kv(lines: list[str], key: str, value: str) -> None:
129
+ """Write a colored '.> key: value' line with consistent alignment."""
130
+ prefix = _color('.>', Colors.DIMMED)
131
+ # Pad key before coloring to maintain alignment
132
+ padded_key = f'{key}:'.ljust(22)
133
+ colored_key = _color(padded_key, Colors.WHITE, Colors.BOLD)
134
+ colored_value = _color(value, Colors.BRIGHT_WHITE)
135
+ lines.append(f' {prefix} {colored_key} {colored_value}')
136
+
137
+
138
+ def print_banner(
139
+ app: 'Horsies',
140
+ role: str = 'worker',
141
+ show_tasks: bool = True,
142
+ file: TextIO | None = None,
143
+ ) -> None:
144
+ """
145
+ Print startup banner with configuration and task list.
146
+
147
+ Args:
148
+ app: The Horsies app instance
149
+ role: The role (worker, scheduler, producer)
150
+ show_tasks: Whether to list discovered tasks
151
+ file: Output file (default: sys.stdout)
152
+ """
153
+ if file is None:
154
+ file = sys.stdout
155
+
156
+ version = get_version()
157
+
158
+ # Build the banner
159
+ lines: list[str] = []
160
+
161
+ # ASCII art header - cyan colored
162
+ colored_horse = _color(HORSE_BRAILLE, Colors.CYAN)
163
+ lines.append(colored_horse)
164
+
165
+ # Logo with version - bright yellow bold
166
+ logo = LOGO_TEXT.replace('{version}', f'v{version}')
167
+ colored_logo = _color(logo, Colors.BRIGHT_YELLOW, Colors.BOLD)
168
+ lines.append(colored_logo)
169
+
170
+ # [config] section
171
+ _write_section_header(lines, 'config')
172
+ _write_kv(lines, 'app', app.__class__.__name__)
173
+ _write_kv(lines, 'role', role)
174
+ _write_kv(lines, 'queue_mode', app.config.queue_mode.name.lower())
175
+
176
+ # Queue info
177
+ if app.config.queue_mode.name == 'CUSTOM' and app.config.custom_queues:
178
+ queues_str = ', '.join(q.name for q in app.config.custom_queues)
179
+ else:
180
+ queues_str = 'default'
181
+ _write_kv(lines, 'queues', queues_str)
182
+
183
+ # Broker info (mask password)
184
+ broker_url = app.config.broker.database_url
185
+ if '@' in broker_url:
186
+ pre, post = broker_url.split('@', 1)
187
+ if ':' in pre:
188
+ scheme_user = pre.rsplit(':', 1)[0]
189
+ broker_url = f'{scheme_user}:****@{post}'
190
+ _write_kv(lines, 'broker', broker_url)
191
+
192
+ # Cluster-wide cap
193
+ if hasattr(app.config, 'cluster_wide_cap') and app.config.cluster_wide_cap:
194
+ _write_kv(lines, 'cluster_cap', f'{app.config.cluster_wide_cap} (cluster-wide)')
195
+
196
+ # Prefetch buffer
197
+ if hasattr(app.config, 'prefetch_buffer') and app.config.prefetch_buffer > 0:
198
+ _write_kv(lines, 'prefetch', str(app.config.prefetch_buffer))
199
+
200
+ lines.append('')
201
+
202
+ # [recovery] section
203
+ if hasattr(app.config, 'recovery') and app.config.recovery:
204
+ recovery = app.config.recovery
205
+ _write_section_header(lines, 'recovery')
206
+ _write_kv(
207
+ lines,
208
+ 'requeue_stale_claimed',
209
+ _format_bool(recovery.auto_requeue_stale_claimed),
210
+ )
211
+ _write_kv(
212
+ lines,
213
+ 'fail_stale_running',
214
+ _format_bool(recovery.auto_fail_stale_running),
215
+ )
216
+ _write_kv(
217
+ lines,
218
+ 'check_interval',
219
+ _format_ms(recovery.check_interval_ms),
220
+ )
221
+ _write_kv(
222
+ lines,
223
+ 'heartbeat_interval',
224
+ _format_ms(recovery.runner_heartbeat_interval_ms),
225
+ )
226
+ lines.append('')
227
+
228
+ # [tasks] section
229
+ if show_tasks:
230
+ task_names = app.list_tasks()
231
+ tasks_header = _color('tasks', Colors.BRIGHT_CYAN, Colors.BOLD)
232
+ lines.append(f'[{tasks_header}] ({len(task_names)} registered)')
233
+ for task_name in sorted(task_names):
234
+ bullet = _color('.', Colors.DIMMED)
235
+ name = _color(task_name, Colors.WHITE)
236
+ lines.append(f' {bullet} {name}')
237
+ lines.append('')
238
+
239
+ # Print everything
240
+ output = '\n'.join(lines)
241
+ print(output, file=file)
242
+
243
+
244
+ def print_simple_banner(file: TextIO | None = None) -> None:
245
+ """Print just the ASCII art banner without config."""
246
+ if file is None:
247
+ file = sys.stdout
248
+
249
+ version = get_version()
250
+
251
+ # Horse art - cyan
252
+ colored_horse = _color(HORSE_BRAILLE, Colors.CYAN)
253
+ print(colored_horse, file=file)
254
+
255
+ # Logo with version - bright yellow bold
256
+ logo = LOGO_TEXT.replace('{version}', f'v{version}')
257
+ colored_logo = _color(logo, Colors.BRIGHT_YELLOW, Colors.BOLD)
258
+ print(colored_logo, file=file)