horsies 0.1.0a1__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/__init__.py +115 -0
- horsies/core/__init__.py +0 -0
- horsies/core/app.py +552 -0
- horsies/core/banner.py +144 -0
- horsies/core/brokers/__init__.py +5 -0
- horsies/core/brokers/listener.py +444 -0
- horsies/core/brokers/postgres.py +864 -0
- horsies/core/cli.py +624 -0
- horsies/core/codec/serde.py +575 -0
- horsies/core/errors.py +535 -0
- horsies/core/logging.py +90 -0
- horsies/core/models/__init__.py +0 -0
- horsies/core/models/app.py +268 -0
- horsies/core/models/broker.py +79 -0
- horsies/core/models/queues.py +23 -0
- horsies/core/models/recovery.py +101 -0
- horsies/core/models/schedule.py +229 -0
- horsies/core/models/task_pg.py +307 -0
- horsies/core/models/tasks.py +332 -0
- horsies/core/models/workflow.py +1988 -0
- horsies/core/models/workflow_pg.py +245 -0
- horsies/core/registry/tasks.py +101 -0
- horsies/core/scheduler/__init__.py +26 -0
- horsies/core/scheduler/calculator.py +267 -0
- horsies/core/scheduler/service.py +569 -0
- horsies/core/scheduler/state.py +260 -0
- horsies/core/task_decorator.py +615 -0
- horsies/core/types/status.py +38 -0
- horsies/core/utils/imports.py +203 -0
- horsies/core/utils/loop_runner.py +44 -0
- horsies/core/worker/current.py +17 -0
- horsies/core/worker/worker.py +1967 -0
- horsies/core/workflows/__init__.py +23 -0
- horsies/core/workflows/engine.py +2344 -0
- horsies/core/workflows/recovery.py +501 -0
- horsies/core/workflows/registry.py +97 -0
- horsies/py.typed +0 -0
- horsies-0.1.0a1.dist-info/METADATA +31 -0
- horsies-0.1.0a1.dist-info/RECORD +42 -0
- horsies-0.1.0a1.dist-info/WHEEL +5 -0
- horsies-0.1.0a1.dist-info/entry_points.txt +2 -0
- horsies-0.1.0a1.dist-info/top_level.txt +1 -0
horsies/core/errors.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Rust-style error display for horsies startup/validation errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import linecache
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import traceback
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ErrorCode(str, Enum):
|
|
16
|
+
"""Error codes for startup/validation errors.
|
|
17
|
+
|
|
18
|
+
Organized by category:
|
|
19
|
+
- E001-E099: Workflow validation errors
|
|
20
|
+
- E100-E199: Task definition errors
|
|
21
|
+
- E200-E299: Config/broker errors
|
|
22
|
+
- E300-E399: Registry errors
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Workflow validation (E001-E099)
|
|
26
|
+
WORKFLOW_NO_NAME = 'E001'
|
|
27
|
+
WORKFLOW_NO_NODES = 'E002'
|
|
28
|
+
WORKFLOW_INVALID_NODE_ID = 'E003'
|
|
29
|
+
WORKFLOW_DUPLICATE_NODE_ID = 'E004'
|
|
30
|
+
WORKFLOW_NO_ROOT_TASKS = 'E005'
|
|
31
|
+
WORKFLOW_INVALID_DEPENDENCY = 'E006'
|
|
32
|
+
WORKFLOW_CYCLE_DETECTED = 'E007'
|
|
33
|
+
WORKFLOW_INVALID_ARGS_FROM = 'E008'
|
|
34
|
+
WORKFLOW_INVALID_CTX_FROM = 'E009'
|
|
35
|
+
WORKFLOW_CTX_PARAM_MISSING = 'E010'
|
|
36
|
+
WORKFLOW_INVALID_OUTPUT = 'E011'
|
|
37
|
+
WORKFLOW_INVALID_SUCCESS_POLICY = 'E012'
|
|
38
|
+
WORKFLOW_INVALID_JOIN = 'E013'
|
|
39
|
+
WORKFLOW_UNRESOLVED_QUEUE = 'E014'
|
|
40
|
+
WORKFLOW_UNRESOLVED_PRIORITY = 'E015'
|
|
41
|
+
WORKFLOW_INVALID_SUBWORKFLOW_RETRY_MODE = 'E017'
|
|
42
|
+
WORKFLOW_SUBWORKFLOW_APP_MISSING = 'E018'
|
|
43
|
+
|
|
44
|
+
# Task definition (E100-E199)
|
|
45
|
+
TASK_NO_RETURN_TYPE = 'E100'
|
|
46
|
+
TASK_INVALID_RETURN_TYPE = 'E101'
|
|
47
|
+
TASK_INVALID_OPTIONS = 'E102'
|
|
48
|
+
TASK_INVALID_QUEUE = 'E103'
|
|
49
|
+
|
|
50
|
+
# Config/broker (E200-E299)
|
|
51
|
+
CONFIG_INVALID_QUEUE_MODE = 'E200'
|
|
52
|
+
CONFIG_INVALID_CLUSTER_CAP = 'E201'
|
|
53
|
+
CONFIG_INVALID_PREFETCH = 'E202'
|
|
54
|
+
BROKER_INVALID_URL = 'E203'
|
|
55
|
+
CONFIG_INVALID_RECOVERY = 'E204'
|
|
56
|
+
CONFIG_INVALID_SCHEDULE = 'E205'
|
|
57
|
+
CLI_INVALID_ARGS = 'E206'
|
|
58
|
+
WORKER_INVALID_LOCATOR = 'E207'
|
|
59
|
+
|
|
60
|
+
# Registry (E300-E399)
|
|
61
|
+
TASK_NOT_REGISTERED = 'E300'
|
|
62
|
+
TASK_DUPLICATE_NAME = 'E301'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ANSI color codes
|
|
66
|
+
class _Colors:
|
|
67
|
+
"""ANSI escape codes for terminal colors."""
|
|
68
|
+
|
|
69
|
+
RESET = '\033[0m'
|
|
70
|
+
BOLD = '\033[1m'
|
|
71
|
+
RED = '\033[91m'
|
|
72
|
+
BLUE = '\033[94m'
|
|
73
|
+
CYAN = '\033[96m'
|
|
74
|
+
GREEN = '\033[92m'
|
|
75
|
+
YELLOW = '\033[93m'
|
|
76
|
+
DIM = '\033[2m'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _should_use_colors() -> bool:
|
|
80
|
+
"""Determine if colors should be used in output."""
|
|
81
|
+
# Check force color env var
|
|
82
|
+
if os.environ.get('HORSIES_FORCE_COLOR', '').lower() in ('1', 'true', 'yes'):
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
# Check NO_COLOR standard (https://no-color.org/)
|
|
86
|
+
if os.environ.get('NO_COLOR') is not None:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
# Check if stdout is a TTY
|
|
90
|
+
return hasattr(sys.stderr, 'isatty') and sys.stderr.isatty()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _should_show_verbose() -> bool:
|
|
94
|
+
"""Determine if verbose output (full traceback) should be shown."""
|
|
95
|
+
return os.environ.get('HORSIES_VERBOSE', '').lower() in ('1', 'true', 'yes')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _should_use_plain_errors() -> bool:
|
|
99
|
+
"""Determine if plain Python errors should be used instead of Rust-style."""
|
|
100
|
+
return os.environ.get('HORSIES_PLAIN_ERRORS', '').lower() in ('1', 'true', 'yes')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class SourceLocation:
|
|
105
|
+
"""Source code location information."""
|
|
106
|
+
|
|
107
|
+
file: str
|
|
108
|
+
line: int
|
|
109
|
+
column: int | None = None
|
|
110
|
+
end_column: int | None = None
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_frame(cls, frame: Any) -> SourceLocation:
|
|
114
|
+
"""Create SourceLocation from a frame object."""
|
|
115
|
+
return cls(
|
|
116
|
+
file=frame.f_code.co_filename,
|
|
117
|
+
line=frame.f_lineno,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_function(cls, fn: Callable[..., Any]) -> SourceLocation | None:
|
|
122
|
+
"""Create SourceLocation from a function object.
|
|
123
|
+
|
|
124
|
+
Returns None if the callable doesn't have source location info
|
|
125
|
+
(e.g., built-in functions, C extensions, or mock objects).
|
|
126
|
+
"""
|
|
127
|
+
code = getattr(fn, '__code__', None)
|
|
128
|
+
if code is None:
|
|
129
|
+
return None
|
|
130
|
+
return cls(
|
|
131
|
+
file=code.co_filename,
|
|
132
|
+
line=code.co_firstlineno,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def get_source_line(self) -> str | None:
|
|
136
|
+
"""Read the source line from the file."""
|
|
137
|
+
try:
|
|
138
|
+
line = linecache.getline(self.file, self.line)
|
|
139
|
+
return line.rstrip('\n') if line else None
|
|
140
|
+
except Exception:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def format_short(self) -> str:
|
|
144
|
+
"""Format as 'file:line' or 'file:line:col'."""
|
|
145
|
+
if self.column is not None:
|
|
146
|
+
return f'{self.file}:{self.line}:{self.column}'
|
|
147
|
+
return f'{self.file}:{self.line}'
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class HorsiesError(Exception):
|
|
152
|
+
"""Base exception for horsies startup/validation errors.
|
|
153
|
+
|
|
154
|
+
Provides Rust-style error formatting with:
|
|
155
|
+
- Error code and category
|
|
156
|
+
- Source location with code snippet
|
|
157
|
+
- Notes and help text
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
message: str
|
|
161
|
+
code: ErrorCode | None = None
|
|
162
|
+
location: SourceLocation | None = None
|
|
163
|
+
notes: list[str] = field(default_factory=lambda: [])
|
|
164
|
+
help_text: str | None = None
|
|
165
|
+
|
|
166
|
+
# For dataclass inheritance
|
|
167
|
+
def __post_init__(self) -> None:
|
|
168
|
+
# Initialize Exception with the message
|
|
169
|
+
super().__init__(self.message)
|
|
170
|
+
|
|
171
|
+
# Auto-detect location from call stack if not provided
|
|
172
|
+
if self.location is None:
|
|
173
|
+
user_frame = _find_user_frame()
|
|
174
|
+
if user_frame is not None:
|
|
175
|
+
self.location = SourceLocation.from_frame(user_frame)
|
|
176
|
+
|
|
177
|
+
def with_note(self, note: str) -> HorsiesError:
|
|
178
|
+
"""Add a note to the error (fluent API)."""
|
|
179
|
+
self.notes.append(note)
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def with_help(self, help_text: str) -> HorsiesError:
|
|
183
|
+
"""Set help text (fluent API)."""
|
|
184
|
+
self.help_text = help_text
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
def with_location(self, location: SourceLocation) -> HorsiesError:
|
|
188
|
+
"""Set source location (fluent API)."""
|
|
189
|
+
self.location = location
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
def with_location_from_frame(self, frame: Any) -> HorsiesError:
|
|
193
|
+
"""Set source location from frame (fluent API)."""
|
|
194
|
+
self.location = SourceLocation.from_frame(frame)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def format_rust_style(self, use_colors: bool | None = None) -> str:
|
|
198
|
+
"""Format the error in Rust style."""
|
|
199
|
+
if use_colors is None:
|
|
200
|
+
use_colors = _should_use_colors()
|
|
201
|
+
|
|
202
|
+
c = _Colors if use_colors else _NoColors
|
|
203
|
+
lines: list[str] = []
|
|
204
|
+
|
|
205
|
+
# Leading blank line for visual separation from log output
|
|
206
|
+
lines.append('')
|
|
207
|
+
|
|
208
|
+
# Error header: error[E001]: message
|
|
209
|
+
code_part = f'[{self.code.value}]' if self.code else ''
|
|
210
|
+
lines.append(f'{c.BOLD}{c.RED}error{code_part}:{c.RESET} {self.message}')
|
|
211
|
+
|
|
212
|
+
# Source location and code snippet
|
|
213
|
+
if self.location:
|
|
214
|
+
source_line = self.location.get_source_line()
|
|
215
|
+
|
|
216
|
+
# Location arrow: --> file:line:col
|
|
217
|
+
lines.append(
|
|
218
|
+
f' {c.BLUE}-->{c.RESET} {c.CYAN}{self.location.format_short()}{c.RESET}'
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if source_line:
|
|
222
|
+
line_num = str(self.location.line)
|
|
223
|
+
padding = ' ' * len(line_num)
|
|
224
|
+
|
|
225
|
+
# Empty line with pipe
|
|
226
|
+
lines.append(f' {c.BLUE}{padding}|{c.RESET}')
|
|
227
|
+
|
|
228
|
+
# Source line with line number
|
|
229
|
+
lines.append(f' {c.BLUE}{line_num}|{c.RESET} {source_line}')
|
|
230
|
+
|
|
231
|
+
# Underline carets
|
|
232
|
+
if self.location.column is not None:
|
|
233
|
+
# Calculate underline position and width
|
|
234
|
+
start_col = self.location.column
|
|
235
|
+
end_col = self.location.end_column or (start_col + 1)
|
|
236
|
+
width = max(1, end_col - start_col)
|
|
237
|
+
underline = ' ' * start_col + '^' * width
|
|
238
|
+
lines.append(
|
|
239
|
+
f' {c.BLUE}{padding}|{c.RESET} {c.RED}{underline}{c.RESET}'
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
# Underline the whole line (trimmed)
|
|
243
|
+
stripped = source_line.lstrip()
|
|
244
|
+
indent = len(source_line) - len(stripped)
|
|
245
|
+
underline = ' ' * indent + '^' * len(stripped)
|
|
246
|
+
lines.append(
|
|
247
|
+
f' {c.BLUE}{padding}|{c.RESET} {c.RED}{underline}{c.RESET}'
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Notes (support multi-line notes with continuation indentation)
|
|
251
|
+
for note in self.notes:
|
|
252
|
+
note_lines = note.split('\n')
|
|
253
|
+
lines.append(
|
|
254
|
+
f' {c.BLUE}={c.RESET} {c.BOLD}{c.BLUE}note{c.RESET}: {note_lines[0]}'
|
|
255
|
+
)
|
|
256
|
+
for note_line in note_lines[1:]:
|
|
257
|
+
lines.append(f' {note_line}')
|
|
258
|
+
|
|
259
|
+
# Help text (with blank line separator for visual clarity)
|
|
260
|
+
if self.help_text:
|
|
261
|
+
lines.append('') # Blank line before help
|
|
262
|
+
help_lines = self.help_text.split('\n')
|
|
263
|
+
lines.append(f' {c.BLUE}={c.RESET} {c.BOLD}{c.GREEN}help{c.RESET}:')
|
|
264
|
+
for help_line in help_lines:
|
|
265
|
+
lines.append(f' {help_line}')
|
|
266
|
+
|
|
267
|
+
return '\n'.join(lines)
|
|
268
|
+
|
|
269
|
+
def __str__(self) -> str:
|
|
270
|
+
"""String representation uses plain text (no ANSI colors).
|
|
271
|
+
|
|
272
|
+
Colors are only used when printing directly to terminal via
|
|
273
|
+
the custom exception hook. This ensures the string is safe for:
|
|
274
|
+
- Logging frameworks
|
|
275
|
+
- JSON serialization
|
|
276
|
+
- Database storage
|
|
277
|
+
- Non-terminal contexts
|
|
278
|
+
"""
|
|
279
|
+
return self.format_rust_style(use_colors=False)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class _NoColors:
|
|
283
|
+
"""No-op color codes for non-TTY output."""
|
|
284
|
+
|
|
285
|
+
RESET = ''
|
|
286
|
+
BOLD = ''
|
|
287
|
+
RED = ''
|
|
288
|
+
BLUE = ''
|
|
289
|
+
CYAN = ''
|
|
290
|
+
GREEN = ''
|
|
291
|
+
YELLOW = ''
|
|
292
|
+
DIM = ''
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# Store original excepthook
|
|
296
|
+
_original_excepthook = sys.excepthook
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _horsies_excepthook(
|
|
300
|
+
exc_type: type[BaseException],
|
|
301
|
+
exc_value: BaseException,
|
|
302
|
+
exc_tb: Any,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Custom exception hook for HorsiesError exceptions."""
|
|
305
|
+
# HORSIES_PLAIN_ERRORS=1 bypasses custom formatting entirely
|
|
306
|
+
if _should_use_plain_errors():
|
|
307
|
+
_original_excepthook(exc_type, exc_value, exc_tb)
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
if isinstance(exc_value, HorsiesError):
|
|
311
|
+
# Print Rust-style formatted error
|
|
312
|
+
print(exc_value.format_rust_style(), file=sys.stderr)
|
|
313
|
+
|
|
314
|
+
# Show full traceback in verbose mode
|
|
315
|
+
if _should_show_verbose():
|
|
316
|
+
print(file=sys.stderr)
|
|
317
|
+
c = _Colors if _should_use_colors() else _NoColors
|
|
318
|
+
print(
|
|
319
|
+
f'{c.DIM}Full traceback (HORSIES_VERBOSE=1):{c.RESET}',
|
|
320
|
+
file=sys.stderr,
|
|
321
|
+
)
|
|
322
|
+
traceback.print_exception(exc_type, exc_value, exc_tb, file=sys.stderr)
|
|
323
|
+
else:
|
|
324
|
+
# Use original handler for non-horsies exceptions
|
|
325
|
+
_original_excepthook(exc_type, exc_value, exc_tb)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def install_error_handler() -> None:
|
|
329
|
+
"""Install the custom exception hook for Rust-style error display."""
|
|
330
|
+
sys.excepthook = _horsies_excepthook
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def uninstall_error_handler() -> None:
|
|
334
|
+
"""Restore the original exception hook."""
|
|
335
|
+
sys.excepthook = _original_excepthook
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# =============================================================================
|
|
339
|
+
# Specific Error Classes
|
|
340
|
+
# =============================================================================
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass
|
|
344
|
+
class WorkflowValidationError(HorsiesError):
|
|
345
|
+
"""Raised when workflow specification is invalid."""
|
|
346
|
+
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass
|
|
351
|
+
class TaskDefinitionError(HorsiesError):
|
|
352
|
+
"""Raised when task definition is invalid."""
|
|
353
|
+
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass
|
|
358
|
+
class ConfigurationError(HorsiesError):
|
|
359
|
+
"""Raised when app/broker configuration is invalid."""
|
|
360
|
+
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass
|
|
365
|
+
class RegistryError(HorsiesError):
|
|
366
|
+
"""Raised when task registry operation fails."""
|
|
367
|
+
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# =============================================================================
|
|
372
|
+
# Phase-Gated Error Collection
|
|
373
|
+
# =============================================================================
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class ValidationReport:
|
|
377
|
+
"""Collects multiple HorsiesError instances within a validation phase.
|
|
378
|
+
|
|
379
|
+
Formats all collected errors together with a summary line,
|
|
380
|
+
similar to rustc's multi-error output.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(self, phase_name: str) -> None:
|
|
384
|
+
self.phase_name: str = phase_name
|
|
385
|
+
self.errors: list[HorsiesError] = []
|
|
386
|
+
|
|
387
|
+
def add(self, error: HorsiesError) -> None:
|
|
388
|
+
"""Append an error to the report."""
|
|
389
|
+
self.errors.append(error)
|
|
390
|
+
|
|
391
|
+
def has_errors(self) -> bool:
|
|
392
|
+
"""Return True if any errors were collected."""
|
|
393
|
+
return len(self.errors) > 0
|
|
394
|
+
|
|
395
|
+
def format_rust_style(self, use_colors: bool | None = None) -> str:
|
|
396
|
+
"""Format all collected errors, then append an aborting summary."""
|
|
397
|
+
if use_colors is None:
|
|
398
|
+
use_colors = _should_use_colors()
|
|
399
|
+
|
|
400
|
+
c = _Colors if use_colors else _NoColors
|
|
401
|
+
parts: list[str] = []
|
|
402
|
+
|
|
403
|
+
for error in self.errors:
|
|
404
|
+
parts.append(error.format_rust_style(use_colors=use_colors))
|
|
405
|
+
|
|
406
|
+
count = len(self.errors)
|
|
407
|
+
parts.append(
|
|
408
|
+
f'\n{c.BOLD}{c.RED}error{c.RESET}: aborting due to {count} previous errors'
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
return '\n'.join(parts)
|
|
412
|
+
|
|
413
|
+
def __str__(self) -> str:
|
|
414
|
+
return self.format_rust_style(use_colors=False)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass
|
|
418
|
+
class MultipleValidationErrors(HorsiesError):
|
|
419
|
+
"""Wraps a ValidationReport containing 2+ errors.
|
|
420
|
+
|
|
421
|
+
Raised when a validation phase collects multiple errors.
|
|
422
|
+
Single errors are raised as their original type for backward
|
|
423
|
+
compatibility with existing except clauses.
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
report: ValidationReport = field(default_factory=lambda: ValidationReport(''))
|
|
427
|
+
|
|
428
|
+
def __post_init__(self) -> None:
|
|
429
|
+
if not self.message:
|
|
430
|
+
count = len(self.report.errors)
|
|
431
|
+
self.message = f'aborting due to {count} previous errors'
|
|
432
|
+
# Skip auto-location detection — location is per-error in the report
|
|
433
|
+
super(HorsiesError, self).__init__(self.message)
|
|
434
|
+
|
|
435
|
+
def format_rust_style(self, use_colors: bool | None = None) -> str:
|
|
436
|
+
"""Delegate formatting to the underlying report."""
|
|
437
|
+
return self.report.format_rust_style(use_colors=use_colors)
|
|
438
|
+
|
|
439
|
+
def __str__(self) -> str:
|
|
440
|
+
return self.format_rust_style(use_colors=False)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def raise_collected(report: ValidationReport) -> None:
|
|
444
|
+
"""Raise collected errors following the backward-compat rule.
|
|
445
|
+
|
|
446
|
+
- 0 errors: no-op (returns normally)
|
|
447
|
+
- 1 error: raises the original error (preserves except clauses)
|
|
448
|
+
- 2+ errors: raises MultipleValidationErrors wrapping the report
|
|
449
|
+
"""
|
|
450
|
+
count = len(report.errors)
|
|
451
|
+
if count == 0:
|
|
452
|
+
return
|
|
453
|
+
if count == 1:
|
|
454
|
+
raise report.errors[0]
|
|
455
|
+
raise MultipleValidationErrors(
|
|
456
|
+
message=f'aborting due to {count} previous errors',
|
|
457
|
+
report=report,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# =============================================================================
|
|
462
|
+
# Helper Functions for Creating Errors
|
|
463
|
+
# =============================================================================
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _find_user_frame() -> Any | None:
|
|
467
|
+
"""Find the first frame outside of horsies internals.
|
|
468
|
+
|
|
469
|
+
Walks up the call stack to find where user code called into horsies.
|
|
470
|
+
"""
|
|
471
|
+
frame = inspect.currentframe()
|
|
472
|
+
if frame is None:
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
# Walk up the stack
|
|
476
|
+
while frame is not None:
|
|
477
|
+
filename = frame.f_code.co_filename
|
|
478
|
+
|
|
479
|
+
# Skip synthetic frames (e.g., <string>, <module>)
|
|
480
|
+
if filename.startswith('<'):
|
|
481
|
+
frame = frame.f_back
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
# Skip horsies internals and standard library
|
|
485
|
+
if '/horsies/' not in filename and '/site-packages/' not in filename:
|
|
486
|
+
return frame
|
|
487
|
+
|
|
488
|
+
frame = frame.f_back
|
|
489
|
+
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def workflow_validation_error(
|
|
494
|
+
message: str,
|
|
495
|
+
*,
|
|
496
|
+
code: ErrorCode | None = None,
|
|
497
|
+
notes: list[str] | None = None,
|
|
498
|
+
help_text: str | None = None,
|
|
499
|
+
location: SourceLocation | None = None,
|
|
500
|
+
) -> WorkflowValidationError:
|
|
501
|
+
"""Create a WorkflowValidationError with optional location auto-detection."""
|
|
502
|
+
if location is None:
|
|
503
|
+
user_frame = _find_user_frame()
|
|
504
|
+
if user_frame is not None:
|
|
505
|
+
location = SourceLocation.from_frame(user_frame)
|
|
506
|
+
|
|
507
|
+
return WorkflowValidationError(
|
|
508
|
+
message=message,
|
|
509
|
+
code=code,
|
|
510
|
+
location=location,
|
|
511
|
+
notes=notes or [],
|
|
512
|
+
help_text=help_text,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def task_definition_error(
|
|
517
|
+
message: str,
|
|
518
|
+
*,
|
|
519
|
+
code: ErrorCode | None = None,
|
|
520
|
+
fn: Callable[..., Any] | None = None,
|
|
521
|
+
notes: list[str] | None = None,
|
|
522
|
+
help_text: str | None = None,
|
|
523
|
+
) -> TaskDefinitionError:
|
|
524
|
+
"""Create a TaskDefinitionError with location from function."""
|
|
525
|
+
location = None
|
|
526
|
+
if fn is not None:
|
|
527
|
+
location = SourceLocation.from_function(fn)
|
|
528
|
+
|
|
529
|
+
return TaskDefinitionError(
|
|
530
|
+
message=message,
|
|
531
|
+
code=code,
|
|
532
|
+
location=location,
|
|
533
|
+
notes=notes or [],
|
|
534
|
+
help_text=help_text,
|
|
535
|
+
)
|
horsies/core/logging.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# app/core/logging.py
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
# Module-level default log level, can be changed by setup_logging()
|
|
7
|
+
_default_level: int = logging.INFO
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ColoredFormatter(logging.Formatter):
|
|
11
|
+
"""Colored formatter for TaskLib logging"""
|
|
12
|
+
|
|
13
|
+
# ANSI color codes
|
|
14
|
+
COLORS = {
|
|
15
|
+
'RESET': '\033[0m',
|
|
16
|
+
'LIGHT_BLUE': '\033[94m',
|
|
17
|
+
'WHITE': '\033[97m',
|
|
18
|
+
'GRAY': '\033[90m',
|
|
19
|
+
'GREEN': '\033[92m',
|
|
20
|
+
'YELLOW': '\033[93m',
|
|
21
|
+
'RED': '\033[91m',
|
|
22
|
+
'BRIGHT_RED': '\033[1;91m',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
LEVEL_COLORS = {
|
|
26
|
+
'DEBUG': COLORS['GRAY'],
|
|
27
|
+
'INFO': COLORS['GREEN'],
|
|
28
|
+
'WARNING': COLORS['YELLOW'],
|
|
29
|
+
'ERROR': COLORS['RED'],
|
|
30
|
+
'CRITICAL': COLORS['BRIGHT_RED'],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
34
|
+
# Format time as HH:MM:SS
|
|
35
|
+
time_str = datetime.fromtimestamp(record.created).strftime('%H:%M:%S')
|
|
36
|
+
|
|
37
|
+
# Get component name from logger name (e.g., 'horsies.broker' -> 'broker')
|
|
38
|
+
component = record.name.split('.')[-1] if '.' in record.name else record.name
|
|
39
|
+
|
|
40
|
+
# Calculate padding for alignment
|
|
41
|
+
component_section = f'[{component}]'
|
|
42
|
+
level_section = f'[{record.levelname}]'
|
|
43
|
+
|
|
44
|
+
# Pad sections to fixed widths for tabular layout
|
|
45
|
+
component_padded = component_section.ljust(
|
|
46
|
+
14
|
|
47
|
+
) # [dispatcher] = 12 chars, so 14 for padding
|
|
48
|
+
level_padded = level_section.ljust(10) # [WARNING] = 9 chars, so 10 for padding
|
|
49
|
+
|
|
50
|
+
# Get level color
|
|
51
|
+
level_color = self.LEVEL_COLORS.get(record.levelname, self.COLORS['WHITE'])
|
|
52
|
+
|
|
53
|
+
# Format: [time] [comp_name] [level] message
|
|
54
|
+
formatted = (
|
|
55
|
+
f"{self.COLORS['LIGHT_BLUE']}[{time_str}]{self.COLORS['RESET']} "
|
|
56
|
+
f"{self.COLORS['WHITE']}{component_padded}{self.COLORS['RESET']}"
|
|
57
|
+
f"{level_color}{level_padded}{self.COLORS['RESET']}"
|
|
58
|
+
f"{self.COLORS['WHITE']}{record.getMessage()}{self.COLORS['RESET']}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Add exception info if present
|
|
62
|
+
if record.exc_info:
|
|
63
|
+
formatted += '\n' + self.formatException(record.exc_info)
|
|
64
|
+
|
|
65
|
+
return formatted
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def set_default_level(level: int) -> None:
|
|
69
|
+
"""Set the default log level for new loggers."""
|
|
70
|
+
global _default_level
|
|
71
|
+
_default_level = level
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_logger(component_name: str) -> logging.Logger:
|
|
75
|
+
"""Get a logger for the specified component."""
|
|
76
|
+
logger_name = f'horsies.{component_name}'
|
|
77
|
+
logger = logging.getLogger(logger_name)
|
|
78
|
+
|
|
79
|
+
# Configure logger if not already configured
|
|
80
|
+
if not logger.handlers:
|
|
81
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
82
|
+
handler.setFormatter(ColoredFormatter())
|
|
83
|
+
handler.setLevel(_default_level)
|
|
84
|
+
logger.addHandler(handler)
|
|
85
|
+
logger.setLevel(_default_level)
|
|
86
|
+
|
|
87
|
+
# Prevent duplicate logs from parent loggers
|
|
88
|
+
logger.propagate = False
|
|
89
|
+
|
|
90
|
+
return logger
|
|
File without changes
|