Skip to content

← Config, Logs and Untrusted Input step 12 of 16

Medium Framework

Trace a Log Record Up the Logger Tree

In Log from a Library the Right Way, your library logged through shop.inventory. The handler that the application put on shop still received the records. A handler is the object that writes a record somewhere, such as the terminal or a file. Two walks up the logger tree explain why that worked, and in this exercise you write both.

Loggers form a tree by name. shop is the parent of shop.inventory, and the root logger is the parent of shop. When code calls logger.info(...):

  1. The logger compares the record's level with its effective level. That is the level set on the logger itself or, if it has none, on its nearest ancestor that has one. The logger drops a record below that level.
  2. The record goes to the logger's own handlers, then to its parent's, and so on up to root. This is propagation. A logger whose propagate attribute is False is the last one whose handlers get the record.

Only the first logger checks its level. If you set shop.inventory to DEBUG and shop to WARNING, a debug record from shop.inventory still reaches the handler on shop.

This exercise models the tree with plain dictionaries instead of calling the logging module, so the tests can check each walk on its own.

logger_ancestry("acme.db.pool", {"acme.db": False})  # ["acme.db.pool", "acme.db"]
effective_level({"acme": 20}, "acme.db.pool")         # 20

What to write

def logger_ancestry(name: str, propagate: Mapping[str, bool]) -> list[str]: ...
def effective_level(configured: Mapping[str, int], name: str) -> int: ...

solve returns both results, under "ancestry" and "effective". Keep it as it is. Your code must pass mypy --strict.

Both functions take a logger name. "" and "root" both mean the root logger. Any other name is a dotted name, and every prefix of it counts as a logger. So "acme.db.pool" has this chain, nearest first:

acme.db.pool -> acme.db -> acme -> root

Root comes once, at the end of every chain. That holds even for a name such as "root.worker", whose chain is root.worker, then root. In both mappings, root's key is "root", never "". Ignore entries for loggers outside the chain, and do not change the mappings.

Real logging creates only the loggers that code asks for with getLogger. A missing one would have no level and no handlers, so counting every prefix changes neither walk's result.

Whose handlers get the record

logger_ancestry returns the loggers whose handlers receive the record, in order. Start with name and walk toward root. Stop after the first logger whose propagate flag is False, and include that logger. A logger missing from propagate has the flag True. Root's flag never matters, because root has no parent. Each handler on those loggers can still drop a record that is below the handler's own level.

Which level applies

effective_level walks the full chain, nearest first, and returns the first level that is not NOTSET (0). A logger missing from configured counts as NOTSET. Root works differently:

  • If configured has no "root" entry, return WARNING (30), the level root has when nothing configures it.
  • If root has an entry, return it, even when it is 0.

So a missing root and a root set to 0 give different answers:

configured effective_level(configured, "acme.db")
{} 30
{"root": 0} 0
{"acme": 20, "root": 0} 20

The propagate flags do not affect the level. A logger with propagate set to False still takes its level from its parent, so walk the full chain, not the list from logger_ancestry.

Root's default of WARNING is why an unconfigured library's logger.info(...) prints nothing.

Going further

Three more settings can stop a record, and this exercise leaves them out. They are a logger's disabled flag, logging.disable(), and filters. See the logging reference.