"""
Package-scope Python `logging` setup for spacr.
Central configuration for every spacr subsystem — core pipelines,
I/O, measure, utilities, and the Qt GUI all funnel through the same
rotating file handler at ``~/.spacr/logs/spacr.log``.
Two ways to opt in:
- Automatic — the Qt GUI calls :func:`setup_logging` at launch, so
once you run ``spacr-qt`` the log file is populated for the life
of the session.
- Manual — for headless scripts and notebooks:
.. code-block:: python
from spacr.logging_util import setup_logging, get_logger
setup_logging() # once, at program start
LOG = get_logger(__name__) # in every module that logs
LOG.info("started")
The log level can be overridden by ``SPACR_LOG_LEVEL`` in the env
(``DEBUG``, ``INFO``, ``WARNING``, …). :func:`enable_debug` and
:func:`disable_debug` are convenience toggles for interactive use.
Third-party libraries that spam INFO records during a spacr pipeline
(torch, cellpose, matplotlib, PIL, urllib3, botocore, tensorflow,
asyncio) are pinned to WARNING so the log stays useful. Add more to
:data:`QUIET_LOGGERS` if a new dependency starts spamming.
Public API:
setup_logging(level=INFO, log_file=None) — call once early.
get_logger(name) — module-scoped logger.
enable_debug() — crank spacr.* to DEBUG.
disable_debug() — revert to session level.
log_dir() — folder holding the log.
log_path() — absolute log file path.
"""
from __future__ import annotations
import logging
import logging.handlers
import os
from pathlib import Path
from typing import Iterable, Optional
# ---------------------------------------------------------------------------
# Configurable constants
# ---------------------------------------------------------------------------
[docs]
DEFAULT_LOG_FILENAME = "spacr.log"
[docs]
MAX_BYTES = 5 * 1024 * 1024 # 5 MB per file
[docs]
BACKUP_COUNT = 3 # → up to ~20 MB total
#: Third-party loggers that spam INFO records — capped at WARNING.
[docs]
QUIET_LOGGERS: tuple[str, ...] = (
"PIL",
"matplotlib",
"urllib3",
"asyncio",
"torch",
"torchvision",
"cellpose",
"tensorflow",
"botocore",
"numba",
"h5py",
)
# Module-level bookkeeping — set once by setup_logging().
_INITIALISED: bool = False
_SESSION_LEVEL: int = logging.INFO
_LOG_PATH: Optional[Path] = None
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
[docs]
def log_dir() -> Path:
"""Return the folder where spacr log files live.
:returns: ``~/.spacr/logs`` — created if it does not exist.
"""
root = Path.home() / ".spacr" / "logs"
root.mkdir(parents=True, exist_ok=True)
return root
[docs]
def log_path() -> Path:
"""Return the absolute path of the rotating log file.
Uses whatever was passed to :func:`setup_logging` last, or the
default under :func:`log_dir` when never set.
"""
return _LOG_PATH if _LOG_PATH is not None else (
log_dir() / DEFAULT_LOG_FILENAME
)
# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
[docs]
def setup_logging(level: Optional[int] = None,
log_file: Optional[Path] = None,
stream: bool = False,
quiet: Iterable[str] = QUIET_LOGGERS) -> Path:
"""Install the rotating file handler on the root logger.
Idempotent — subsequent calls only re-apply the level, they don't
stack additional handlers. Honours the ``SPACR_LOG_LEVEL``
environment variable when ``level`` is not given.
:param level: minimum record level for the log file. Defaults to
``SPACR_LOG_LEVEL`` env var (any of ``DEBUG``/``INFO``/…) or
:data:`logging.INFO`.
:param log_file: override for where the file lands. Defaults to
:func:`log_path`.
:param stream: also attach a StreamHandler to stderr — handy for
headless / CI runs where the log file isn't inspected.
:param quiet: iterable of logger names to pin at WARNING. Defaults
to :data:`QUIET_LOGGERS`.
:returns: the resolved log-file path.
"""
global _INITIALISED, _SESSION_LEVEL, _LOG_PATH
if level is None:
level = _env_level()
_SESSION_LEVEL = level
resolved_path = Path(log_file) if log_file else log_path()
resolved_path.parent.mkdir(parents=True, exist_ok=True)
_LOG_PATH = resolved_path
if _INITIALISED:
logging.getLogger().setLevel(level)
logging.getLogger("spacr").setLevel(level)
return resolved_path
root = logging.getLogger()
root.setLevel(logging.DEBUG) # let each handler cap its own view
# spacr.* explicitly follows the requested level so enable_debug
# is the only way records below `level` reach the handlers.
logging.getLogger("spacr").setLevel(level)
file_h = logging.handlers.RotatingFileHandler(
resolved_path,
maxBytes=MAX_BYTES,
backupCount=BACKUP_COUNT,
encoding="utf-8",
)
file_h.setLevel(level)
file_h.setFormatter(logging.Formatter(FILE_FORMAT))
root.addHandler(file_h)
if stream:
stream_h = logging.StreamHandler()
stream_h.setLevel(level)
stream_h.setFormatter(logging.Formatter(STREAM_FORMAT))
root.addHandler(stream_h)
for name in quiet:
logging.getLogger(name).setLevel(logging.WARNING)
_INITIALISED = True
get_logger("spacr").info("logging initialised → %s", resolved_path)
return resolved_path
def _env_level() -> int:
"""Read ``SPACR_LOG_LEVEL`` from env; fall back to INFO."""
raw = os.environ.get("SPACR_LOG_LEVEL", "").upper().strip()
if raw and hasattr(logging, raw):
return getattr(logging, raw)
return logging.INFO
# ---------------------------------------------------------------------------
# Convenience API for modules and interactive sessions
# ---------------------------------------------------------------------------
[docs]
def get_logger(name: str) -> logging.Logger:
"""Return a spacr-scoped :class:`logging.Logger`.
Idiomatic usage from any module:
.. code-block:: python
from spacr.logging_util import get_logger
LOG = get_logger(__name__)
:param name: logger name — typically ``__name__`` so the log
stream shows which module the record came from.
"""
return logging.getLogger(name)
[docs]
def enable_debug() -> None:
"""Crank every ``spacr.*`` logger to DEBUG.
Useful when debugging interactively:
.. code-block:: pycon
>>> from spacr.logging_util import enable_debug
>>> enable_debug()
Third-party loggers listed in :data:`QUIET_LOGGERS` are left at
WARNING to keep the log readable.
"""
logging.getLogger("spacr").setLevel(logging.DEBUG)
for h in logging.getLogger().handlers:
h.setLevel(logging.DEBUG)
[docs]
def disable_debug() -> None:
"""Revert every ``spacr.*`` logger to the level chosen at setup.
Inverse of :func:`enable_debug`.
"""
logging.getLogger("spacr").setLevel(_SESSION_LEVEL)
for h in logging.getLogger().handlers:
h.setLevel(_SESSION_LEVEL)