Skip to content

feat(logging): shared logger skeleton with Rule extension#46

Open
timtreis wants to merge 10 commits into
scverse:mainfrom
timtreis:feat/shared-logger
Open

feat(logging): shared logger skeleton with Rule extension#46
timtreis wants to merge 10 commits into
scverse:mainfrom
timtreis:feat/shared-logger

Conversation

@timtreis

@timtreis timtreis commented Jun 18, 2026

Copy link
Copy Markdown
Member

Shared logger for scverse packages. One scverse parent logger + single handler (rich if installed, else plain); package loggers are children, so verbosity/rich/rules are controlled centrally.

from scverse_misc.logging import get_logger, config, Rule

log = get_logger("spatialdata")        # real logging.Logger, child of "scverse"
config.verbosity = "info"              # central, all packages
config.add_rule(MyRule())              # 0..N rules

Sole extension point is Rule (keep/rewrite). Ships Elapsed/Deep universal rules (on by default, no-ops until used). get_logger(name, timed=True) opts into scanpy-style time=/deep=/.hint() + a datetime return.

Packages keep their behavior with a local rule

spatialdata-plot — report the public entry point instead of internal helpers:

from contextvars import ContextVar
_ctx: ContextVar[str] = ContextVar("ctx", default="")

class ContextPrefix(Rule):
    def rewrite(self, message, record):
        ctx = _ctx.get()
        return f"{ctx}: {message}" if ctx else message

config.add_rule(ContextPrefix())   # render_* entry points set _ctx

decoupler — always-on timestamp:

from datetime import datetime

class Timestamp(Rule):
    def rewrite(self, message, record):
        return f"{datetime.now():%H:%M:%S} | {message}"

config.add_rule(Timestamp())

design proposal for discussion

timtreis and others added 2 commits June 18, 2026 22:18
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>
@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.20000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 94.63%. Comparing base (b5fd326) to head (943fd7d).

Files with missing lines Patch % Lines
src/scverse_misc/logging.py 99.18% 1 Missing ⚠️
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     
Files with missing lines Coverage Δ
src/scverse_misc/__init__.py 100.00% <100.00%> (ø)
src/scverse_misc/logging.py 99.18% <99.18%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

timtreis and others added 2 commits June 18, 2026 22:47
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>
@timtreis timtreis marked this pull request as ready for review June 18, 2026 20:52
Comment thread src/scverse_misc/logging.py Outdated
Comment on lines +108 to +150
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we integrate that with the global scverse-misc config handling?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, this would make logging depend on the settings dependency group which would be ok IMO.

@timtreis timtreis Jun 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since logging has no additional dependencies, logging/settings would be a single one.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does it even depend on anndata?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, but why is it not part of the datasets dependency group?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/scverse_misc/logging.py Outdated
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you return the TimedLogger, shouldn't it be public?

Comment thread src/scverse_misc/logging.py Outdated
Comment on lines +132 to +136
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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for consistency with verbosity, should this also be a settable property?

Comment thread src/scverse_misc/logging.py Outdated
"""Central logging configuration; the singleton instance is :data:`config`."""

def __init__(self) -> None:
self._parent = logging.getLogger(_ROOT)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parent seems a weird name here, as there's no inheritance. Maybe _logger would be better?

timtreis and others added 6 commits June 19, 2026 13:47
- 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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants