Skip to content

feat!: make Logger a value type with a public ILogger sink - #625

Merged
andiwand merged 1 commit into
mainfrom
feat/logger-value-type
Jul 27, 2026
Merged

feat!: make Logger a value type with a public ILogger sink#625
andiwand merged 1 commit into
mainfrom
feat/logger-value-type

Conversation

@andiwand

@andiwand andiwand commented Jul 27, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Clears the first of the two Breaking API changes (next major) items in
docs/design/README.md, ahead of the v6 tag.

Why

Logger was the one public interface type that was not a value handle. It was an
abstract base handed out as Logger & or std::shared_ptr<Logger>, and the two
spellings had drifted apart across the API:

header took
file.hpp, odr.hpp Logger &logger = Logger::null()
html.hpp, http_server.hpp std::shared_ptr<Logger> logger = Logger::create_null()

What

The sink becomes odr::ILogger and Logger becomes a copyable handle over a
shared_ptr to it, mirroring File, DecodedFile and Document (including the
impl() accessor). Both spellings collapse into a single const Logger &,
defaulted to Logger::null().

ILogger lives in the public header rather than internal::abstract. Routing
diagnostics somewhere is a thing a library user legitimately wants to do, and an
extension point that requires including an internal header is not one.

Parsers that held a borrowed const Logger *m_logger{nullptr} now hold a Logger
by value, so a logger outliving its parser stops being the caller's problem.

Bindings can implement a sink too

Both bindings previously dropped Logger entirely. Now:

class MySink(pyodr.ILogger):
    def will_log(self, level):  return level >= pyodr.LogLevel.warning
    def log(self, time, level, message, location): ...
    def flush(self): ...

pyodr.open(path, logger=pyodr.Logger(MySink()))
try (Logger logger = new Logger(mySink);
     DecodedFile file = Odr.open(path, logger)) { ... }

Entry points that take a logger in C++ gained an optional logger argument.

Two teardown traps the Python trampoline had to work around — both invisible to a
happy-path test, and both hit while writing this:

  • The sink is handed to C++ as a shared_ptr owning a reference to the Python
    object. Without it, Logger(MySink()) leaves C++ holding a dead object and the
    next call dies with Tried to call pure virtual function.
  • flush() dispatches via py::get_override, not PYBIND11_OVERRIDE_PURE. Sinks
    are flushed from destructors, which can run after the Python object is gone, and
    throwing from a destructor aborts the process.

On the Java side JavaLogger attaches/detaches the calling thread (log calls
arrive on whatever thread the library is working on) and clears any exception the
sink throws rather than leaving it pending — a logger must not derail the
operation it is reporting on.

ILogger::log is non-const

std::ostream is the precedent and it argues the same way: operator<<, write,
put and flush are non-const, while good/fail/rdstate are const. Writing
mutates the sink.

The old const was also vacuous — *m_output on a const std::unique_ptr yields
a non-const std::ostream &, so StdioLogger mutated freely from a const method.
All it achieved was forcing implementations to declare their state mutable, as
the test sink in logger_test.cpp had to before this. will_log stays const; it
is a genuine query.

The handle's log()/flush() remain const — const handle, mutable sink, exactly
like shared_ptr. That is what lets every API take const Logger &.

Breaking changes

  • Logger is final; custom sinks implement odr::ILogger and are wrapped with
    Logger(std::shared_ptr<ILogger>).
  • Logger::null() returns a Logger by value instead of Logger &.
  • Logger::create_null() is gone — use Logger::null(). All instances share one
    sink, so it no longer allocates.
  • create_stdio returns Logger, not std::unique_ptr<Logger>.
  • create_tee returns Logger and takes const std::vector<Logger> &.
  • Every API taking Logger & or std::shared_ptr<Logger> now takes const Logger &.

The ODR_* macros are unchanged; call sites just drop the *
(ODR_INFO(*logger, …)ODR_INFO(logger, …)).

TeeLogger no longer flushes its children from its destructor. It was redundant —
each child handle drops its sink on destruction and a buffering sink flushes
itself — and it was the path that called into an already-torn-down Python sink.

Test plan

  • logger_test.cpp went from one smoke test to nine, covering what the value
    semantics newly promise: copies share the sink, the default handle is the null
    logger, level filtering, tee fan-out, empty-tee throw, a null sink rejected, and
    an out-of-library ILogger driven through the handle and through a tee.
  • Full C++ suite (excluding the slow HTML-output regression): 466 passed.
  • Python: 41 passed, incl. a new test_logger.py implementing a sink in Python.
  • JNI: 31 passed, incl. a new LoggerTest implementing a sink in Java and
    passing it to Odr.open.

@andiwand
andiwand force-pushed the feat/logger-value-type branch from 46e03ee to 5ce4a5d Compare July 27, 2026 19:26
@andiwand andiwand changed the title feat!: make Logger a value type feat!: make Logger a value type with a public ILogger sink Jul 27, 2026
`Logger` was the one public interface type that was not a value handle: it
was an abstract base handed out as `Logger &` or `std::shared_ptr<Logger>`,
and the two spellings had drifted apart across the API — `file.hpp` and
`odr.hpp` took `Logger &`, `html.hpp` and `http_server.hpp` took
`std::shared_ptr<Logger>`.

The sink becomes `odr::ILogger`, a public interface, and `Logger` becomes a
copyable handle over a `shared_ptr` to it, mirroring `File`, `DecodedFile`
and `Document`. Both spellings collapse into a single `const Logger &`
parameter, defaulted to `Logger::null()`. `ILogger` stays in the public
header rather than moving to `internal::abstract`: routing diagnostics is
something a user of the library legitimately wants to do, and an extension
point behind an internal header is not one.

Logging is now extensible from the bindings too. Python subclasses
`pyodr.ILogger`; Java implements `app.opendocument.core.ILogger`. Both
gained a `logger` argument on the entry points that take one, so a custom
sink can actually be handed to the library.

`ILogger::log` is non-const, matching `std::ostream`, whose writing
operations are all non-const while its state queries are const. The old
`const` was vacuous anyway — `*m_output` on a const `unique_ptr` yields a
non-const `ostream &` — and it forced every implementation to declare its
state `mutable`.

BREAKING CHANGE:
- `Logger` is `final`; custom sinks implement `odr::ILogger` and are wrapped
  with `Logger(std::shared_ptr<ILogger>)`.
- `Logger::null()` returns a `Logger` by value instead of `Logger &`.
- `Logger::create_null()` is gone — use `Logger::null()` (all instances
  share one sink, so it no longer allocates).
- `create_stdio` returns `Logger`, not `std::unique_ptr<Logger>`.
- `create_tee` returns `Logger` and takes `const std::vector<Logger> &`
  instead of `std::vector<std::shared_ptr<Logger>>`.
- every API taking `Logger &` or `std::shared_ptr<Logger>` now takes
  `const Logger &`.

Parsers that held a borrowed `const Logger *` now hold a `Logger` by
value, so a logger outliving its parser is no longer the caller's problem.
`TeeLogger` no longer flushes its children from its destructor: each child
handle drops its sink on destruction and a buffering sink flushes itself,
so the tee only stood to call into an already-torn-down sink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbnNCsz6rkVKHW2MWL47m5
@andiwand
andiwand force-pushed the feat/logger-value-type branch from 5ce4a5d to 87f7cf3 Compare July 27, 2026 19:34
@andiwand
andiwand enabled auto-merge (squash) July 27, 2026 19:35
@andiwand
andiwand merged commit 2c7c340 into main Jul 27, 2026
18 checks passed
@andiwand
andiwand deleted the feat/logger-value-type branch July 27, 2026 19:47
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.

1 participant