Skip to content

Commit 00b78d1

Browse files
authored
Merge pull request #122 from MScottBlake/improve-logging
Add recipe name context and color to log entries
2 parents 7050f73 + 2d6a2b5 commit 00b78d1

File tree

8 files changed

+174
-98
lines changed

8 files changed

+174
-98
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ dev = [
1717

1818
[project]
1919
name = "cloud-autopkg-runner"
20-
version = "0.24.2"
20+
version = "0.24.3"
2121
description = "A Python library designed to level-up your AutoPkg automations with a focus on CI/CD performance."
2222
readme = { file = "README.md", content-type = "text/markdown" }
2323
requires-python = ">=3.10"

src/cloud_autopkg_runner/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .recipe import Recipe
2222
from .recipe_finder import RecipeFinder
2323
from .recipe_report import RecipeReport
24+
from . import logging_config
2425

2526
__all__ = [
2627
"AutoPkgPrefs",
@@ -31,3 +32,6 @@
3132
"Settings",
3233
"get_cache_plugin",
3334
]
35+
36+
# Library-level logger
37+
logger = logging_config.get_logger(__name__)

src/cloud_autopkg_runner/__main__.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,11 @@
4545
InvalidJsonContents,
4646
RecipeException,
4747
)
48+
from cloud_autopkg_runner.logging_context import recipe_context
4849
from cloud_autopkg_runner.recipe_report import ConsolidatedReport
4950

51+
logger = logging_config.get_logger(__name__)
52+
5053
T = TypeVar("T")
5154

5255
# Constant that indicates a worker queue is empty and can be stopped
@@ -127,7 +130,6 @@ async def _create_recipe(
127130
recipe_path = await _get_recipe_path(recipe_name, autopkg_prefs)
128131
return Recipe(recipe_path, report_dir, autopkg_prefs)
129132
except (InvalidFileContents, RecipeException):
130-
logger = logging_config.get_logger(__name__)
131133
logger.exception("Failed to create `Recipe` object: %s", recipe_name)
132134
return None
133135

@@ -154,7 +156,6 @@ def _generate_recipe_list(args: Namespace) -> set[str]:
154156
InvalidJsonContents: If the JSON file specified by `args.recipe_list`
155157
contains invalid JSON, indicating a malformed input file.
156158
"""
157-
logger = logging_config.get_logger(__name__)
158159
logger.debug("Generating recipe list...")
159160

160161
output: set[str] = set()
@@ -344,7 +345,6 @@ async def _process_recipe_list(
344345
Returns:
345346
A dictionary of recipe names mapped to their reports.
346347
"""
347-
logger = logging_config.get_logger(__name__)
348348
settings = Settings()
349349

350350
num_workers = min(settings.max_concurrency, _count_iterable(recipe_list))
@@ -388,12 +388,15 @@ async def _recipe_worker(
388388
every recipe name retrieved from the queue, the worker performs the full
389389
execution lifecycle:
390390
391-
1. Resolve the recipe path and construct a `Recipe` instance. Invalid or
391+
1. Set the per-task logging context (`recipe_context`) so all log output is
392+
tagged with the active recipe name.
393+
2. Resolve the recipe path and construct a `Recipe` instance. Invalid or
392394
unreadable recipes are logged and skipped without terminating the worker.
393-
2. Execute the recipe with a timeout using `asyncio.wait_for()`, capturing the
395+
3. Execute the recipe with a timeout using `asyncio.wait_for()`, capturing the
394396
resulting `ConsolidatedReport` on success.
395-
3. Handle and log timeouts and unexpected exceptions without interrupting
397+
4. Handle and log timeouts and unexpected exceptions without interrupting
396398
other workers.
399+
5. Reset the logging context and mark the queue item as processed.
397400
398401
The worker returns a mapping of recipe names to their final `ConsolidatedReport`
399402
objects. Multiple workers may run concurrently; their partial result maps are
@@ -415,7 +418,6 @@ async def _recipe_worker(
415418
Exception: Any unexpected error inside the worker is logged and re-raised
416419
to allow the caller to fail fast, while still ensuring proper cleanup.
417420
"""
418-
logger = logging_config.get_logger(__name__)
419421
results: dict[str, ConsolidatedReport] = {}
420422

421423
while True:
@@ -424,6 +426,7 @@ async def _recipe_worker(
424426
queue.task_done()
425427
break
426428

429+
token = recipe_context.set(recipe_name)
427430
try:
428431
logger.info("Starting recipe %s", recipe_name)
429432

@@ -453,6 +456,7 @@ async def _recipe_worker(
453456
raise
454457
finally:
455458
queue.task_done()
459+
recipe_context.reset(token)
456460

457461
return results
458462

@@ -471,7 +475,6 @@ def _signal_handler(sig: int, _frame: FrameType | None) -> NoReturn:
471475
_frame: The current stack frame object, which is typically unused in
472476
simple signal handlers and thus ignored.
473477
"""
474-
logger = logging_config.get_logger(__name__)
475478
logger.error("Signal %s received. Exiting...", sig)
476479
sys.exit(0)
477480

src/cloud_autopkg_runner/logging_config.py

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,65 @@
55
destinations (console and/or file). It also offers a convenient way to
66
retrieve logger instances for use in other modules.
77
8-
Functions:
9-
initialize_logger: Initializes the logging system with a console handler
10-
and an optional file handler.
11-
get_logger: Retrieves a logger instance with the specified name.
8+
The logging system is enhanced with recipe-level contextual information.
9+
When the caller sets the context variable in cloud_autopkg_runner.logging_context,
10+
every log line automatically includes the recipe name, even when running concurrently.
1211
"""
1312

1413
import logging
1514
import sys
16-
from typing import TextIO
15+
from typing import ClassVar, TextIO
16+
17+
from cloud_autopkg_runner.logging_context import recipe_context
18+
19+
20+
class ColorFormatter(logging.Formatter):
21+
"""Add ANSI coloring to log level names while preserving padding."""
22+
23+
COLORS: ClassVar[dict[str, str]] = {
24+
"DEBUG": "\033[36m", # Cyan
25+
"INFO": "\033[32m", # Green
26+
"WARNING": "\033[33m", # Yellow
27+
"ERROR": "\033[31m", # Red
28+
"CRITICAL": "\033[41m", # Red background
29+
}
30+
RESET: ClassVar[str] = "\033[0m"
31+
32+
def format(self, record: logging.LogRecord) -> str:
33+
"""Render log message with colorized level names."""
34+
# Let the base class format the record first
35+
msg = super().format(record)
36+
37+
color = self.COLORS.get(record.levelname)
38+
if not color:
39+
return msg
40+
41+
# Replace the exact (already padded) level text inside msg
42+
padded = f"{record.levelname:<7}"
43+
colored = f"{color}{padded}{self.RESET}"
44+
45+
return msg.replace(padded, colored, 1)
46+
47+
48+
class RecipeContextFilter(logging.Filter):
49+
"""Inject contextual recipe information into every log record."""
50+
51+
def filter(self, record: logging.LogRecord) -> bool:
52+
"""Add the current recipe context to the log record.
53+
54+
This method is called for each log record emitted. It sets
55+
`record.recipe` to the value stored in the recipe_context
56+
ContextVar, or "-" if the context variable is not set.
57+
58+
Returns:
59+
True always, so that the record is not filtered out.
60+
"""
61+
try:
62+
record.recipe = recipe_context.get()
63+
except LookupError:
64+
record.recipe = __package__
65+
66+
return True
1767

1868

1969
def initialize_logger(verbosity_level: int, log_file: str | None = None) -> None:
@@ -24,6 +74,9 @@ def initialize_logger(verbosity_level: int, log_file: str | None = None) -> None
2474
`verbosity_level` argument, while the file handler (if enabled) logs at
2575
the DEBUG level.
2676
77+
A logging Filter is added that inserts the current recipe context
78+
(if set) into each log record as `%(recipe)s`.
79+
2780
Args:
2881
verbosity_level: An integer representing the verbosity level. Maps to
2982
logging levels as follows:
@@ -32,11 +85,11 @@ def initialize_logger(verbosity_level: int, log_file: str | None = None) -> None
3285
will be written to this file in addition to the console. If None,
3386
no file logging will occur.
3487
"""
35-
logger = logging.getLogger()
88+
logger = logging.getLogger(__name__.split(".")[0])
3689
logger.setLevel(logging.DEBUG)
37-
3890
logger.handlers.clear()
3991

92+
# Map verbosity flags to log levels
4093
log_levels: list[int] = [
4194
logging.ERROR,
4295
logging.WARNING,
@@ -45,24 +98,28 @@ def initialize_logger(verbosity_level: int, log_file: str | None = None) -> None
4598
]
4699
level: int = log_levels[min(verbosity_level, len(log_levels) - 1)]
47100

101+
# Attach recipe context filter
102+
context_filter = RecipeContextFilter()
103+
logger.addFilter(context_filter)
104+
48105
# Console handler
49106
console_handler: logging.StreamHandler[TextIO] = logging.StreamHandler(sys.stdout)
50107
console_handler.setLevel(level)
51-
console_formatter: logging.Formatter = logging.Formatter(
52-
"%(module)-20s %(levelname)-8s %(message)s"
53-
)
108+
console_formatter = ColorFormatter("%(levelname)-7s %(recipe)-25s %(message)s")
54109
console_handler.setFormatter(console_formatter)
110+
console_handler.addFilter(context_filter)
55111
logger.addHandler(console_handler)
56112

57113
# File handler (optional)
58114
if log_file:
59115
file_handler: logging.FileHandler = logging.FileHandler(log_file, mode="w")
60116
file_handler.setLevel(logging.DEBUG)
61117
file_formatter: logging.Formatter = logging.Formatter(
62-
"%(asctime)s %(module)-20s %(levelname)-8s %(message)s",
118+
"%(asctime)s %(levelname)-7s %(recipe)-25s %(message)s",
63119
datefmt="%m-%d %H:%M",
64120
)
65121
file_handler.setFormatter(file_formatter)
122+
file_handler.addFilter(context_filter)
66123
logger.addHandler(file_handler)
67124

68125

@@ -73,6 +130,13 @@ def get_logger(name: str) -> logging.Logger:
73130
simplifies the process of obtaining loggers for use in different
74131
modules of the application.
75132
133+
This wrapper allows for future enhancements, such as adding context
134+
filters or structured logging.
135+
136+
This does not configure handlers or log levels; it simply returns
137+
the logger with the given name. Use `initialize_logger()` at
138+
application startup to configure logging output.
139+
76140
Args:
77141
name: The name of the logger to retrieve (typically `__name__`).
78142
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Provides a context variable for logging recipe-specific information.
2+
3+
This module defines a ContextVar that stores the currently executing
4+
recipe name. Logging filters can read this variable to automatically
5+
tag log messages with the correct recipe context, even in concurrent
6+
async execution.
7+
8+
Usage:
9+
from cloud_autopkg_runner.logging_context import recipe_context
10+
recipe_context.set("MyRecipe.pkg.recipe")
11+
"""
12+
13+
import contextvars
14+
15+
recipe_context = contextvars.ContextVar("recipe", default=__package__)

tests/unit/test___main__.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -234,15 +234,12 @@ async def test_create_recipe_invalid_file_contents(
234234
) -> None:
235235
"""Should return None and log an error on InvalidFileContents."""
236236
with (
237-
patch("cloud_autopkg_runner.logging_config.get_logger") as mock_get_logger,
237+
patch("cloud_autopkg_runner.__main__.logger") as mock_logger,
238238
patch(
239239
"cloud_autopkg_runner.recipe.Recipe",
240240
side_effect=InvalidFileContents("corrupt recipe file"),
241241
),
242242
):
243-
mock_logger = MagicMock()
244-
mock_get_logger.return_value = mock_logger
245-
246243
result = await _create_recipe("bad_recipe", tmp_path, mock_autopkg_prefs)
247244

248245
mock_logger.exception.assert_called_once_with(
@@ -257,15 +254,12 @@ async def test_create_recipe_recipe_exception(
257254
) -> None:
258255
"""Should return None and log an error on RecipeException."""
259256
with (
260-
patch("cloud_autopkg_runner.logging_config.get_logger") as mock_get_logger,
257+
patch("cloud_autopkg_runner.__main__.logger") as mock_logger,
261258
patch(
262259
"cloud_autopkg_runner.recipe.Recipe",
263260
side_effect=RecipeException("missing processor"),
264261
),
265262
):
266-
mock_logger = MagicMock()
267-
mock_get_logger.return_value = mock_logger
268-
269263
result = await _create_recipe("exception_recipe", tmp_path, mock_autopkg_prefs)
270264

271265
mock_logger.exception.assert_called_once_with(
@@ -385,12 +379,8 @@ async def test_recipe_worker_timeout_logged(tmp_path: Path) -> None:
385379
mock_settings.recipe_timeout = 3
386380
mock_settings.report_dir = tmp_path
387381

388-
mock_logger = MagicMock()
389-
390382
with (
391-
patch(
392-
"cloud_autopkg_runner.logging_config.get_logger", return_value=mock_logger
393-
),
383+
patch("cloud_autopkg_runner.__main__.logger") as mock_logger,
394384
patch(
395385
"cloud_autopkg_runner.__main__._create_recipe",
396386
new=AsyncMock(return_value=mock_recipe),

tests/unit/test_logging_config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
def test_console_handler_levels(verbosity_level: int, expected_level: int) -> None:
1919
"""Test that verbosity levels are set correctly."""
2020
logging_config.initialize_logger(verbosity_level=verbosity_level, log_file=None)
21-
logger = logging.getLogger()
21+
logger = logging.getLogger("cloud_autopkg_runner")
2222

2323
# Get the most recent StreamHandler (console handler)
2424
console_handler = next(
@@ -37,7 +37,7 @@ def test_logs_to_console_but_not_file(tmp_path: Path) -> None:
3737

3838
# Initialize logger with no file output
3939
logging_config.initialize_logger(verbosity_level=1, log_file=None)
40-
logger = logging.getLogger()
40+
logger = logging.getLogger("cloud_autopkg_runner")
4141

4242
# Assert no file handler was added (log file should not exist)
4343
file_handler = next(
@@ -54,7 +54,7 @@ def test_logs_to_file(tmp_path: Path) -> None:
5454
log_file = tmp_path / "test.log"
5555

5656
logging_config.initialize_logger(verbosity_level=1, log_file=str(log_file))
57-
logger = logging.getLogger()
57+
logger = logging.getLogger("cloud_autopkg_runner")
5858

5959
logger.info("This should go to both console and file")
6060

0 commit comments

Comments
 (0)