feat(logging): shared logger skeleton with Rule extension#46
Conversation
One `scverse` parent logger + single handler (rich auto-detected, else plain), package loggers as children, central control via `config`. Sole extension point is `Rule` (keep/rewrite); ships `Elapsed`/`Deep` universal rules on by default. Opt-in `get_logger(name, timed=True)` adds scanpy-style `time=`/`deep=`/`.hint()` and a datetime return. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #46 +/- ##
==========================================
+ Coverage 93.60% 94.63% +1.02%
==========================================
Files 11 12 +1
Lines 532 652 +120
==========================================
+ Hits 498 617 +119
- Misses 34 35 +1
🚀 New features to boost your workflow:
|
The logging module shipped its checks inside `if __name__ == "__main__"`, which CI never runs, so the 255 new lines had 0% coverage; the same block plus the module body also failed ruff (missing annotations/docstrings, E702) and would fail strict mypy. - annotate the public surface (`_emit`, level methods, `verbosity`, `get_logger` via @overload so `timed=True` narrows to `_TimedLogger`); add the missing `Rule` docstrings; type `_rules: list[Rule]`; `__getattr__ -> Any` for transparent delegation - trim `_emit` to the documented `time=`/`deep=` surface (the old `**kwargs` passthrough never passed strict mypy) - remove the `__main__` self-check, promote it to tests/test_logging.py (18 tests; logging.py coverage 0% -> 99%) Verified locally: ruff check + format, mypy --strict, pytest all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
logging.py imports rich.logging lazily in the rich-handler branch. rich is an optional runtime dep, but mypy needs it to resolve RichHandler (else import-not-found + a no-any-return on the Handler return). Matches the existing pattern of listing typecheck-only deps (pytest, sphinx) here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| class _Config: | ||
| """Central logging configuration; the singleton instance is :data:`config`.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self._parent = logging.getLogger(_ROOT) | ||
| self._parent.setLevel(logging.WARNING) | ||
| self._parent.propagate = False # one handler here; don't double-log via root | ||
| self._rules: list[Rule] = [Elapsed(), Deep()] # universal defaults; order matters | ||
| self._install(_make_handler(_rich_available())) | ||
|
|
||
| def _install(self, handler: logging.Handler) -> None: | ||
| for r in self._rules: | ||
| handler.addFilter(r) | ||
| self._parent.addHandler(handler) | ||
|
|
||
| @property | ||
| def verbosity(self) -> str | int: | ||
| """Central level for all scverse loggers. Set with a name (``"info"``) or int.""" | ||
| return logging.getLevelName(self._parent.level) | ||
|
|
||
| @verbosity.setter | ||
| def verbosity(self, level: str | int) -> None: | ||
| self._parent.setLevel(level.upper() if isinstance(level, str) else level) | ||
|
|
||
| def use_rich(self, enabled: bool = True) -> None: | ||
| """Force the rich (``True``) or plain (``False``) handler.""" | ||
| for h in list(self._parent.handlers): | ||
| self._parent.removeHandler(h) | ||
| self._install(_make_handler(enabled)) | ||
|
|
||
| def add_rule(self, rule: Rule) -> None: | ||
| self._rules.append(rule) | ||
| for h in self._parent.handlers: | ||
| h.addFilter(rule) | ||
|
|
||
| def remove_rule(self, rule: Rule) -> None: | ||
| if rule in self._rules: | ||
| self._rules.remove(rule) | ||
| for h in self._parent.handlers: | ||
| h.removeFilter(rule) | ||
|
|
||
|
|
||
| config = _Config() |
There was a problem hiding this comment.
Can we integrate that with the global scverse-misc config handling?
There was a problem hiding this comment.
Partially (except the rule logic), yeah. But I think then we should promote pydantic as a core dep, no? Otherwise we need a fallback for that too here
There was a problem hiding this comment.
yeah, this would make logging depend on the settings dependency group which would be ok IMO.
There was a problem hiding this comment.
I feel like we're about to create a complicated net of various dependency groups that depend on dependency groups. Could we maybe be somewhat opinionated here? 👀 Like do we really want to add a new dependency group for every feature we add? While that's at least only slightly annoying, making them inter-dependent feels a lot
There was a problem hiding this comment.
since logging has no additional dependencies, logging/settings would be a single one.
There was a problem hiding this comment.
Given that scverse-misc now hard-depends on anndata and anndata (from 0.13 on) depends on scverse-misc[settings], we might as well promote pydantic to a hard-dependency.
There was a problem hiding this comment.
ok, but why is it not part of the datasets dependency group?
There was a problem hiding this comment.
Excellent question : ) I didn't get around to reviewing that PR before it was merged, but my assumption would be that if a package is downloading anndata files, it will be using them and will depend on anndata anyway, so scverse-misc shouldn't need anndata anywhere in its dependencies. Same with spatialdata.
| def get_logger(name: str, *, timed: Literal[False] = False) -> logging.Logger: ... | ||
| @overload | ||
| def get_logger(name: str, *, timed: Literal[True]) -> _TimedLogger: ... | ||
| def get_logger(name: str, *, timed: bool = False) -> logging.Logger | _TimedLogger: |
There was a problem hiding this comment.
If you return the TimedLogger, shouldn't it be public?
| def use_rich(self, enabled: bool = True) -> None: | ||
| """Force the rich (``True``) or plain (``False``) handler.""" | ||
| for h in list(self._parent.handlers): | ||
| self._parent.removeHandler(h) | ||
| self._install(_make_handler(enabled)) |
There was a problem hiding this comment.
for consistency with verbosity, should this also be a settable property?
| """Central logging configuration; the singleton instance is :data:`config`.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self._parent = logging.getLogger(_ROOT) |
There was a problem hiding this comment.
parent seems a weird name here, as there's no inheritance. Maybe _logger would be better?
- make TimedLogger public (returned from get_logger, meant to be subclassed) - config.rich settable property for consistency with config.verbosity - rename _Config._parent -> _root (it manages the scverse root logger) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Promote pydantic-settings + python-dotenv to hard dependencies (anndata always pulls scverse-misc[settings] in anyway), and rebuild the logging config on the shared Settings base instead of a bespoke _Config. - verbosity/rich become validated Settings fields: env-var loading (SCVERSE_MISC_*), override/reset, and level validation come for free. - rules stay bespoke (add_rule/remove_rule) — they aren't settings. - _root/_rules derive from the live logger/handler, so there's no pydantic private-attr timing to manage and the test suite is unchanged. - keep the (now empty) settings extra for anndata <0.13 back-compat. - drop the dead suppress(ImportError) around Settings in __init__. Addresses review feedback on scverse#46 (integrate logger with global config handling; promote pydantic; rich settable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Shared logger for scverse packages. One
scverseparent logger + single handler (rich if installed, else plain); package loggers are children, so verbosity/rich/rules are controlled centrally.Sole extension point is
Rule(keep/rewrite). ShipsElapsed/Deepuniversal rules (on by default, no-ops until used).get_logger(name, timed=True)opts into scanpy-styletime=/deep=/.hint()+ a datetime return.Packages keep their behavior with a local rule
spatialdata-plot— report the public entry point instead of internal helpers:decoupler— always-on timestamp:design proposal for discussion