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 +67 -47
- horsies/core/banner.py +158 -44
- horsies/core/brokers/postgres.py +315 -288
- horsies/core/cli.py +7 -2
- horsies/core/errors.py +3 -0
- horsies/core/models/app.py +87 -64
- horsies/core/models/recovery.py +30 -21
- horsies/core/models/schedule.py +30 -19
- horsies/core/models/tasks.py +1 -0
- horsies/core/models/workflow.py +489 -202
- horsies/core/models/workflow_pg.py +3 -1
- horsies/core/scheduler/service.py +5 -1
- horsies/core/scheduler/state.py +39 -27
- horsies/core/task_decorator.py +138 -0
- horsies/core/types/status.py +7 -5
- horsies/core/utils/imports.py +10 -10
- horsies/core/worker/worker.py +197 -139
- horsies/core/workflows/engine.py +487 -352
- horsies/core/workflows/recovery.py +148 -119
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a6.dist-info}/METADATA +1 -1
- horsies-0.1.0a6.dist-info/RECORD +42 -0
- horsies-0.1.0a4.dist-info/RECORD +0 -42
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a6.dist-info}/WHEEL +0 -0
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a6.dist-info}/entry_points.txt +0 -0
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a6.dist-info}/top_level.txt +0 -0
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(
|
|
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
|
|
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(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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(
|
|
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(
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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(
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
546
|
+
"app is in DEFAULT mode (only 'default' queue allowed)",
|
|
517
547
|
],
|
|
518
|
-
help_text='
|
|
519
|
-
)
|
|
520
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return
|
|
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
|
|
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:
|
|
73
|
-
role: str =
|
|
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
|
-
|
|
96
|
-
lines.append(
|
|
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
|
-
#
|
|
99
|
-
lines
|
|
100
|
-
lines
|
|
101
|
-
lines
|
|
102
|
-
lines
|
|
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 ==
|
|
106
|
-
queues_str =
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
#
|
|
122
|
-
if hasattr(app.config,
|
|
123
|
-
lines
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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 =
|
|
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
|
-
|
|
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)
|