horsies 0.1.0a4__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.
Files changed (42) hide show
  1. horsies/__init__.py +117 -0
  2. horsies/core/__init__.py +0 -0
  3. horsies/core/app.py +552 -0
  4. horsies/core/banner.py +144 -0
  5. horsies/core/brokers/__init__.py +5 -0
  6. horsies/core/brokers/listener.py +444 -0
  7. horsies/core/brokers/postgres.py +993 -0
  8. horsies/core/cli.py +624 -0
  9. horsies/core/codec/serde.py +596 -0
  10. horsies/core/errors.py +535 -0
  11. horsies/core/logging.py +90 -0
  12. horsies/core/models/__init__.py +0 -0
  13. horsies/core/models/app.py +268 -0
  14. horsies/core/models/broker.py +79 -0
  15. horsies/core/models/queues.py +23 -0
  16. horsies/core/models/recovery.py +101 -0
  17. horsies/core/models/schedule.py +229 -0
  18. horsies/core/models/task_pg.py +307 -0
  19. horsies/core/models/tasks.py +358 -0
  20. horsies/core/models/workflow.py +1990 -0
  21. horsies/core/models/workflow_pg.py +245 -0
  22. horsies/core/registry/tasks.py +101 -0
  23. horsies/core/scheduler/__init__.py +26 -0
  24. horsies/core/scheduler/calculator.py +267 -0
  25. horsies/core/scheduler/service.py +569 -0
  26. horsies/core/scheduler/state.py +260 -0
  27. horsies/core/task_decorator.py +656 -0
  28. horsies/core/types/status.py +38 -0
  29. horsies/core/utils/imports.py +203 -0
  30. horsies/core/utils/loop_runner.py +44 -0
  31. horsies/core/worker/current.py +17 -0
  32. horsies/core/worker/worker.py +1967 -0
  33. horsies/core/workflows/__init__.py +23 -0
  34. horsies/core/workflows/engine.py +2344 -0
  35. horsies/core/workflows/recovery.py +501 -0
  36. horsies/core/workflows/registry.py +97 -0
  37. horsies/py.typed +0 -0
  38. horsies-0.1.0a4.dist-info/METADATA +35 -0
  39. horsies-0.1.0a4.dist-info/RECORD +42 -0
  40. horsies-0.1.0a4.dist-info/WHEEL +5 -0
  41. horsies-0.1.0a4.dist-info/entry_points.txt +2 -0
  42. horsies-0.1.0a4.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
+ )
@@ -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