Source code for nav.config.logger

"""Logger instances and setup for the nav_offset pipeline.

Provides two PdsLogger instances:

- ``MAIN_LOGGER`` (``"nav_offset"``) -- top-level program events, written to stdout
  and to a timestamped logfile under ``NAV_RESULTS_ROOT/logs/nav_offset/``.
- ``IMAGE_LOGGER`` (``"nav_image"``) -- per-image processing events; both its stdout
  handler and its per-image logfile handler are attached as local handlers inside each
  ``logger.open()`` context so they are active only while that image is being processed.

Call ``setup_logging()`` from ``main()`` after the nav-results root and CLI
arguments have been resolved. It is safe to call more than once: existing
``MAIN_LOGGER`` handlers are removed before new ones are attached.
"""

import argparse
import logging
from typing import TYPE_CHECKING, cast

import pdslogger
from filecache import FCPath

if TYPE_CHECKING:
    from .config import Config

MAIN_LOGGER = pdslogger.PdsLogger('nav_offset', lognames=False, digits=3)
IMAGE_LOGGER = pdslogger.PdsLogger('nav_image', lognames=False, digits=3)

_FALLBACK_LEVEL = 'INFO'
_ALLOWED_LOG_LEVELS = frozenset({'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'})


def _resolve_level(attr_name: str, arguments: argparse.Namespace | None, config: 'Config') -> str:
    """Return the log level for ``attr_name``, checking args then config then fallback.

    Parameters:
        attr_name: Name of the config key and argparse attribute (underscore form).
        arguments: Parsed CLI arguments, or None to skip the args check.
        config: Navigation configuration.

    Returns:
        A log-level string (e.g. ``"INFO"``).

    Raises:
        TypeError: If a non-``None`` value from arguments or config is not a string.
        ValueError: If the string is empty after stripping or is not a known level name.
    """
    level: object | None
    if arguments is not None:
        level = getattr(arguments, attr_name, None)
    else:
        level = None
    if level is None:
        level = getattr(config.general, attr_name, None)
    if level is None:
        return _FALLBACK_LEVEL
    if not isinstance(level, str):
        raise TypeError(f'log level {attr_name!r} must be str or None, got {type(level).__name__}')
    normalized = level.strip().upper()
    if normalized == '':
        raise ValueError(f'log level {attr_name!r} is empty or whitespace only')
    if normalized not in _ALLOWED_LOG_LEVELS:
        raise ValueError(
            f'log level {attr_name!r} must be one of '
            f'{sorted(_ALLOWED_LOG_LEVELS)}, got {normalized!r}'
        )
    return normalized


[docs] def setup_logging( arguments: argparse.Namespace, config: 'Config', nav_results_root_str: str, ) -> None: """Configure MAIN_LOGGER with stdout and a timestamped file handler. Reads each log level from ``arguments`` first, then from ``config.general``, then falls back to ``"INFO"``. The main logfile is written as a timestamped file under ``{nav_results_root}/logs/nav_offset/``. IMAGE_LOGGER handlers are **not** configured here; both its console and per-image file handlers are attached as local handlers inside each ``IMAGE_LOGGER.open()`` context via ``image_log_handlers()``. However, the image log levels from ``--log-level-image-console`` and ``--log-level-image-file`` are validated here so that invalid values are caught at startup (before the batch loop) alongside the main-level validation. Parameters: arguments: Parsed CLI arguments; may carry ``log_level_main_console``, ``log_level_main_file``, ``log_level_image_console``, and ``log_level_image_file`` attributes. config: Navigation configuration providing ``general.*`` fallback values. nav_results_root_str: Local filesystem path to the navigation results root. Raises: TypeError: If a configured log level is not a string or ``None``. ValueError: If a configured log level string is empty or not a standard name. """ main_console_level = _resolve_level('log_level_main_console', arguments, config) main_file_level = _resolve_level('log_level_main_file', arguments, config) # Validate image log levels now so --log-level-image-console and # --log-level-image-file errors surface at startup via the same try/except # block in main() that guards this call, not on the first image in the batch loop. _resolve_level('log_level_image_console', arguments, config) _resolve_level('log_level_image_file', arguments, config) # Replace any existing handlers so repeated setup does not duplicate log lines. for existing in list(MAIN_LOGGER.handlers): MAIN_LOGGER.removeHandler(existing) existing.close() MAIN_LOGGER.add_handler(pdslogger.stream_handler(level=main_console_level)) log_dir = FCPath(nav_results_root_str) / 'logs' / 'nav_offset' log_base = log_dir / 'nav_offset.log' MAIN_LOGGER.add_handler( pdslogger.file_handler(log_base, level=main_file_level, rotation='ymdhms') )
[docs] def image_log_handlers( image_log_path: FCPath, arguments: argparse.Namespace | None, config: 'Config', ) -> list[logging.Handler]: """Create local handlers for a single image: a stdout handler and a file handler. Both levels are resolved from ``arguments``, then ``config.general``, then ``"INFO"``. The returned handlers should be passed to ``IMAGE_LOGGER.open()`` so they are active only while that image is being processed and are automatically removed when that context exits. Parameters: image_log_path: Destination path for this image's log file. arguments: Parsed CLI arguments, or None to fall back entirely to config. config: Navigation configuration. Returns: A list containing a stdout stream handler and a file handler for the image log. Raises: TypeError: If a configured log level is not a string or ``None``. ValueError: If a configured log level string is empty or not a standard name. """ image_console_level = _resolve_level('log_level_image_console', arguments, config) image_file_level = _resolve_level('log_level_image_file', arguments, config) return [ pdslogger.stream_handler(level=image_console_level), cast(logging.FileHandler, pdslogger.file_handler(image_log_path, level=image_file_level)), ]