horsies 0.1.0a4__py3-none-any.whl → 0.1.0a6__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.
horsies/core/app.py CHANGED
@@ -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:
horsies/core/banner.py CHANGED
@@ -9,6 +9,33 @@ if TYPE_CHECKING:
9
9
  from horsies.core.app import Horsies
10
10
 
11
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
+
12
39
  # Braille art of galloping horse - converted from the golden horse image
13
40
  # Each braille character represents a 2x4 pixel block for higher resolution
14
41
  HORSE_BRAILLE = """
@@ -28,13 +55,13 @@ HORSE_BRAILLE = """
28
55
  ⠚⠒⠒⠒⡶⠚⠒⠒⠒⡰⠓⠒⠒⠒⢲⠓⠒⠒⠒⢻⠿⠀⠀⠚⢿⡷⠐⠒⠒⠚⡆⠒⠒⠒⠚⣖⠒⠒⠒⠚⢶⠒⠒⠒⠚⢶⠂⠐⠒⠛
29
56
  """
30
57
 
31
- # Figlet-style "horsies" text
58
+ # Figlet-style "horsies" text with version and tagline
32
59
  LOGO_TEXT = r"""
33
60
  __ _
34
61
  / /_ ____ __________(_)__ _____
35
- / __ \/ __ \/ ___/ ___/ / _ \/ ___/
36
- / / / / /_/ / / (__ ) / __(__ )
37
- /_/ /_/\____/_/ /____/_/\___/____/
62
+ / __ \/ __ \/ ___/ ___/ / _ \/ ___/ {version}
63
+ / / / / /_/ / / (__ ) / __(__ ) distributed task queue
64
+ /_/ /_/\____/_/ /____/_/\___/____/ and workflow engine
38
65
  """
39
66
 
40
67
  # Full banner combining horse and logo
@@ -51,26 +78,66 @@ BANNER = (
51
78
 
52
79
 
53
80
  def get_version() -> str:
54
- """Get horsies version."""
81
+ """Get horsies version from package metadata."""
55
82
  try:
56
- import horsies
57
-
58
- version = getattr(horsies, "__version__", None)
59
- return str(version) if version else "0.1.0"
60
- except ImportError:
61
- return "0.1.0"
83
+ from importlib.metadata import version
84
+ return version('horsies')
85
+ except Exception:
86
+ return 'dev'
62
87
 
63
88
 
64
89
  def format_banner(version: str | None = None) -> str:
65
90
  """Format the banner string with version."""
66
91
  if version is None:
67
92
  version = get_version()
68
- return BANNER.format(version=f"v{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}')
69
136
 
70
137
 
71
138
  def print_banner(
72
- app: "Horsies",
73
- role: str = "worker",
139
+ app: 'Horsies',
140
+ role: str = 'worker',
74
141
  show_tasks: bool = True,
75
142
  file: TextIO | None = None,
76
143
  ) -> None:
@@ -91,49 +158,86 @@ def print_banner(
91
158
  # Build the banner
92
159
  lines: list[str] = []
93
160
 
94
- # ASCII art header
95
- banner = format_banner(version)
96
- lines.append(banner)
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)
97
169
 
98
- # Configuration section
99
- lines.append("[config]")
100
- lines.append(f" .> app: {app.__class__.__name__}")
101
- lines.append(f" .> role: {role}")
102
- lines.append(f" .> queue_mode: {app.config.queue_mode.name}")
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())
103
175
 
104
176
  # Queue info
105
- if app.config.queue_mode.name == "CUSTOM" and app.config.custom_queues:
106
- queues_str = ", ".join(q.name for q in app.config.custom_queues)
107
- lines.append(f" .> queues: {queues_str}")
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)
108
179
  else:
109
- lines.append(f" .> queues: default")
180
+ queues_str = 'default'
181
+ _write_kv(lines, 'queues', queues_str)
110
182
 
111
- # Broker info
183
+ # Broker info (mask password)
112
184
  broker_url = app.config.broker.database_url
113
- # Mask password in URL
114
- if "@" in broker_url:
115
- pre, post = broker_url.split("@", 1)
116
- if ":" in pre:
117
- scheme_user = pre.rsplit(":", 1)[0]
118
- broker_url = f"{scheme_user}:****@{post}"
119
- lines.append(f" .> broker: {broker_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)
120
191
 
121
- # Concurrency info (if available)
122
- if hasattr(app.config, "cluster_wide_cap") and app.config.cluster_wide_cap:
123
- lines.append(f" .> cap: {app.config.cluster_wide_cap} (cluster-wide)")
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)')
124
195
 
125
- lines.append("")
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))
126
199
 
127
- # Tasks section
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
128
229
  if show_tasks:
129
230
  task_names = app.list_tasks()
130
- lines.append(f"[tasks] ({len(task_names)} registered)")
231
+ tasks_header = _color('tasks', Colors.BRIGHT_CYAN, Colors.BOLD)
232
+ lines.append(f'[{tasks_header}] ({len(task_names)} registered)')
131
233
  for task_name in sorted(task_names):
132
- lines.append(f" . {task_name}")
133
- lines.append("")
234
+ bullet = _color('.', Colors.DIMMED)
235
+ name = _color(task_name, Colors.WHITE)
236
+ lines.append(f' {bullet} {name}')
237
+ lines.append('')
134
238
 
135
239
  # Print everything
136
- output = "\n".join(lines)
240
+ output = '\n'.join(lines)
137
241
  print(output, file=file)
138
242
 
139
243
 
@@ -141,4 +245,14 @@ def print_simple_banner(file: TextIO | None = None) -> None:
141
245
  """Print just the ASCII art banner without config."""
142
246
  if file is None:
143
247
  file = sys.stdout
144
- print(format_banner(), file=file)
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)