horsies 0.1.0a4__py3-none-any.whl → 0.1.0a5__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 +27 -27
- 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.0a5.dist-info}/METADATA +1 -1
- horsies-0.1.0a5.dist-info/RECORD +42 -0
- horsies-0.1.0a4.dist-info/RECORD +0 -42
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a5.dist-info}/WHEEL +0 -0
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a5.dist-info}/entry_points.txt +0 -0
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a5.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
|
@@ -55,22 +55,22 @@ def get_version() -> str:
|
|
|
55
55
|
try:
|
|
56
56
|
import horsies
|
|
57
57
|
|
|
58
|
-
version = getattr(horsies,
|
|
59
|
-
return str(version) if version else
|
|
58
|
+
version = getattr(horsies, '__version__', None)
|
|
59
|
+
return str(version) if version else '0.1.0'
|
|
60
60
|
except ImportError:
|
|
61
|
-
return
|
|
61
|
+
return '0.1.0'
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def format_banner(version: str | None = None) -> str:
|
|
65
65
|
"""Format the banner string with version."""
|
|
66
66
|
if version is None:
|
|
67
67
|
version = get_version()
|
|
68
|
-
return BANNER.format(version=f
|
|
68
|
+
return BANNER.format(version=f'v{version}')
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def print_banner(
|
|
72
|
-
app:
|
|
73
|
-
role: str =
|
|
72
|
+
app: 'Horsies',
|
|
73
|
+
role: str = 'worker',
|
|
74
74
|
show_tasks: bool = True,
|
|
75
75
|
file: TextIO | None = None,
|
|
76
76
|
) -> None:
|
|
@@ -96,44 +96,44 @@ def print_banner(
|
|
|
96
96
|
lines.append(banner)
|
|
97
97
|
|
|
98
98
|
# Configuration section
|
|
99
|
-
lines.append(
|
|
100
|
-
lines.append(f
|
|
101
|
-
lines.append(f
|
|
102
|
-
lines.append(f
|
|
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}')
|
|
103
103
|
|
|
104
104
|
# Queue info
|
|
105
|
-
if app.config.queue_mode.name ==
|
|
106
|
-
queues_str =
|
|
107
|
-
lines.append(f
|
|
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}')
|
|
108
108
|
else:
|
|
109
|
-
lines.append(f
|
|
109
|
+
lines.append(f' .> queues: default')
|
|
110
110
|
|
|
111
111
|
# Broker info
|
|
112
112
|
broker_url = app.config.broker.database_url
|
|
113
113
|
# Mask password in URL
|
|
114
|
-
if
|
|
115
|
-
pre, post = broker_url.split(
|
|
116
|
-
if
|
|
117
|
-
scheme_user = pre.rsplit(
|
|
118
|
-
broker_url = f
|
|
119
|
-
lines.append(f
|
|
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}')
|
|
120
120
|
|
|
121
121
|
# Concurrency info (if available)
|
|
122
|
-
if hasattr(app.config,
|
|
123
|
-
lines.append(f
|
|
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)')
|
|
124
124
|
|
|
125
|
-
lines.append(
|
|
125
|
+
lines.append('')
|
|
126
126
|
|
|
127
127
|
# Tasks section
|
|
128
128
|
if show_tasks:
|
|
129
129
|
task_names = app.list_tasks()
|
|
130
|
-
lines.append(f
|
|
130
|
+
lines.append(f'[tasks] ({len(task_names)} registered)')
|
|
131
131
|
for task_name in sorted(task_names):
|
|
132
|
-
lines.append(f
|
|
133
|
-
lines.append(
|
|
132
|
+
lines.append(f' . {task_name}')
|
|
133
|
+
lines.append('')
|
|
134
134
|
|
|
135
135
|
# Print everything
|
|
136
|
-
output =
|
|
136
|
+
output = '\n'.join(lines)
|
|
137
137
|
print(output, file=file)
|
|
138
138
|
|
|
139
139
|
|