Warnings

A warning can be raised similar to an exception, but it doesn’t interrupt execution at the position where it was raised. The warning travels upwards the call-stack until it’s handled by a WarningCollector similar to a try .. except statement. A warning nobody collects is dropped; a CriticalWarning or an exception nobody collects raises an UnhandledCriticalWarningError or UnhandledExceptionError.

A warning is raised by calling the class-method WarningCollector.Raise. It expects an instance of Warning, CriticalWarning or Exception, and optionally a cause and notes (see Raising a Warning).

To handle a raised warning, a with-statement is used to collect raised warnings. Usually, a list is handed over to a WarningCollector context.

from pyTooling.Warning import Warning, WarningCollector

class ClassA:
  def methA_RaiseWarning(self) -> None:
    WarningCollector.Raise(Warning("Warning from ClassA.methA_RaiseWarning"))
from pyTooling.Warning import WarningCollector

class Caller:
  def operation(self) -> None:
    warnings = []

    a = ClassA()
    with WarningCollector(warnings):
      a.methA_RaiseWarning()

    print("Warnings:")
    for warning in warnings:
      print(f"  {warning}")

Kinds of Warnings

WarningCollector.Raise accepts three kinds of objects. What happens to one depends on whether a WarningCollector is current, and on what its handler returns:

Raised object

No collector

Collector

Handler returns True

Warning

dropped silently

collected

collected, then EscalatedWarningError is raised

CriticalWarning

UnhandledCriticalWarningError is raised

collected

collected, then EscalatedWarningError is raised

Exception

UnhandledExceptionError is raised

collected

collected, then EscalatedWarningError is raised

Each of these exceptions carries the reported object as its cause (__cause__). If nothing is raised, execution continues after the call of Raise().

Warning and CriticalWarning derive from BaseException, not from Exception, so a broad except Exception doesn’t catch one by accident (see Warnings have a hierarchy too).

Raising a Warning

WarningCollector.Raise hands the warning to the current collector of the calling thread. Two optional parameters attach information to the warning:

cause

The exception, which caused the warning. It becomes the warning’s __cause__, as with raise ... from cause.

notes

A note or an iterable of notes, added by add_note(). They are accessible by Warning.Notes.

from pathlib           import Path
from pyTooling.Warning import Warning, WarningCollector

def ReadConfiguration(path: Path) -> str:
  try:
    return path.read_text()
  except OSError as ex:
    WarningCollector.Raise(
      Warning(f"Configuration file '{path}' can't be read."),
      ex,
      notes="The default configuration is used."
    )
    return ""

Generators

A generator’s body runs when the generator is resumed - by next() or a for loop - not when it’s created. So a warning raised inside a generator reaches the collector of the code consuming it:

from typing            import Generator
from pyTooling.Warning import Warning, WarningCollector

def NonEmptyLines(text: str) -> Generator[str, None, None]:
  for line in text.splitlines():
    if line.strip() == "":
      WarningCollector.Raise(Warning("An empty line is skipped."))
      continue

    yield line

lines = NonEmptyLines("a\n\nb")  # nothing runs yet

with WarningCollector() as warnings:
  for line in lines:              # the warning is raised here
    print(line)

Collecting Warnings

A WarningCollector is a context manager. Entering it makes it the current collector of its thread, leaving it makes the previous one current again. The warnings are appended to a list:

  • to the list handed over as first parameter, so the caller owns it,

  • or to an internal list, accessible by Warnings.

The collector supports len(), iteration and indexing as well:

with WarningCollector() as warnings:
  ReadConfiguration(Path("missing.ini"))

print(f"{len(warnings)} warning(s)")
for warning in warnings:
  print(f"WARNING: {warning}")
  for note in warning.Notes:
    print(f"  {note}")

Nesting

Collectors can be nested. Only the innermost collector receives a warning, the outer one doesn’t see it. The inner collector knows the outer one as its Parent. A warning is passed on explicitly, e.g. by AddWarning():

with WarningCollector() as outer:
  with WarningCollector() as inner:
    WarningCollector.Raise(Warning("Only the inner collector receives it."))

  # len(outer) == 0, len(inner) == 1
  for warning in inner:
    outer.AddWarning(warning)

Threads

The current collector is stored per thread (threading.local). A warning raised in another thread doesn’t reach the collector of the thread, which started it (see Threads). Coroutines run by asyncio.run() run in the calling thread, so they report to its collector.

Escalating Warnings

A collector can be given a handler: a callable, which is called with each collected warning. If it returns True, the warning is escalated: WarningCollector.Raise raises an EscalatedWarningError, whose cause is the warning. The warning stays in the collector’s list. An exception raised by the handler itself propagates, too.

So the caller decides per warning, whether an operation continues - e.g. a handler lambda warning: strict turns every warning into an error in a --strict mode. This handler escalates critical warnings only:

from pyTooling.Warning import CriticalWarning, EscalatedWarningError, WarningCollector

def EscalateCriticalWarnings(warning: BaseException) -> bool:
  return isinstance(warning, CriticalWarning)

try:
  with WarningCollector(handler=EscalateCriticalWarnings) as warnings:
    Operation()
except EscalatedWarningError as ex:
  print(f"ERROR: {ex.__cause__}")

Documenting Warnings

A function reporting warnings says so in its doc-string:

  • The description names the warning classes it reports and what happens without a collector, e.g. “Reports a PermissionWarning to a WarningCollector; without a collector, it’s dropped.”

  • The :raises: field lists UnhandledCriticalWarningError for a critical warning and UnhandledExceptionError for a reported exception, because these are raised without a collector.

  • EscalatedWarningError isn’t listed: any function reporting a warning can raise it, but only if its caller installed a handler escalating it - and that caller knows.

  • Each function up the call-hierarchy documents it the same way, up to the first public function, method, property or __init__. It stops at a function, which installs a collector itself. For a generator, the function consuming it documents the warnings.

  • A warning class states in its doc-string, whether it’s critical: in an .. important:: box for a critical warning, in an .. attention:: box for a non-critical one.

def _removeDirectReferences(requirements: Iterable[str], extra: str, source: str) -> list[str]:
  """
  Remove direct references (``name @ URL``) from the requirements of an extra.

  Each removed requirement is reported as a :exc:`DirectReferenceWarning` to a
  :class:`~pyTooling.Warning.WarningCollector`.

  :param requirements:                   Requirements of the extra.
  :param extra:                          Name of the extra.
  :param source:                         Where the requirements were read from, e.g. the requirements file.
  :returns:                              The requirements, which aren't direct references.
  :raises UnhandledCriticalWarningError: If a requirement is a direct reference and no
                                         :class:`~pyTooling.Warning.WarningCollector` collects the warning.
  """

Collecting in Applications

A library reports warnings, the application decides what to do with them: its top level installs a collector around an operation and writes the collected warnings.

setup.py

DescribePythonPackageHostedOnGitHub() reports a critical DirectReferenceWarning for each direct reference it leaves out of an extra (see DescribePythonPackage). A setup.py collects and prints them:

from setuptools import setup

from pyTooling.Packaging import DescribePythonPackageHostedOnGitHub
from pyTooling.Warning   import WarningCollector

with WarningCollector() as warnings:
  setup(**DescribePythonPackageHostedOnGitHub(...))

for warning in warnings:
  print(f"[setup.py] WARNING: {warning}")

Command line application

A TerminalApplication writes each warning with its notes, and exits after critical warnings:

from typing               import ClassVar

from pyTooling.TerminalUI import TerminalApplication
from pyTooling.Warning    import CriticalWarning, WarningCollector

class Application(TerminalApplication):
  HEADLINE: ClassVar[str] = "My Application"

  def Run(self) -> None:
    with WarningCollector() as warnings:
      Operation()

    for warning in warnings:
      if isinstance(warning, CriticalWarning):
        self.WriteCritical(f"{warning}")
      else:
        self.WriteWarning(f"{warning}")

      for note in getattr(warning, "__notes__", ()):
        self.WriteWarningNote(note)

    self.ExitOnPreviousCriticalWarnings()

Threads

A thread started by another one has no collector. A SupervisedWarningCollector is the top-most collector of a thread: on leaving it, it hands the collected warnings and an exception leaving its with-block to a ThreadSupervisor. The supervising thread reads the warnings from Warnings, and ReRaise() raises the exceptions - one as SupervisedThreadError, several as an ExceptionGroup.

from threading         import Thread
from pyTooling.Warning import SupervisedWarningCollector, ThreadSupervisor, Warning, WarningCollector

def Worker(supervisor: ThreadSupervisor, index: int) -> None:
  with SupervisedWarningCollector(supervisor=supervisor):
    WarningCollector.Raise(Warning(f"Warning from worker {index}."))

supervisor = ThreadSupervisor()
threads = [Thread(target=Worker, args=(supervisor, index)) for index in range(3)]
for thread in threads:
  thread.start()
for thread in threads:
  thread.join()

supervisor.ReRaise()
for warning in supervisor.Warnings:
  print(warning)

Designing Warning Classes

  • Derive a class per situation a caller might react to, and let it carry typed data - e.g. PermissionWarning carries the directory, which couldn’t be read. Carry the object where it exists, not its name; the name is reachable from the object. The data are keyword-only parameters after the message, defaulting to None, and read-only properties.

  • Group the situations of one kind under a common base class a caller can collect at once - e.g. BrokenRequirementWarning for an unparsable requirement and for an undeclared extra.

  • Make a warning critical, if a caller who doesn’t see it gets a wrong result, e.g. DirectReferenceWarning: a requirement left out of an extra changes the package. A caller must collect a critical warning.

  • Keep a warning non-critical, if the result stays usable without it, e.g. UnannotatedFieldWarning.

  • Turning a non-critical warning into a critical one is a breaking change: a caller without a collector gets an exception.

  • State the criticality in the class’ doc-string (see Documenting Warnings).

Warning or critical warning?

Ask what happens to a caller who never sees the warning:

  • Its result is still complete and correct, just less detailed or less polished: derive from Warning. A filesystem scan skipping an unreadable directory still returns usable statistics (PermissionWarning); a license that can’t be resolved is still shown as published (UnknownLicenseWarning); a requirement a release states under an extra it doesn’t declare is a defect of that release’s metadata, which no installer installs either (UndeclaredExtraMarkerWarning).

  • Its result silently lacks something it relies on: derive from CriticalWarning. Data must never disappear without a report. In pyTooling.Dependency, a requirement line that can’t be parsed, a requirement naming an extra the release doesn’t declare, or a release whose details can’t be read makes a dependency vanish - and a suddenly missing dependency is a serious issue. So BrokenRequirementWarning, ReleaseDetailsWarning and DirectReferenceWarning are critical.

A critical warning costs nothing while everything is fine: no warning is raised, and nothing has to be handled. Only a caller that can run into the finding has to collect it - and then knows about it.

See also

Warnings have a hierarchy too

→ How a warning hierarchy is designed, compared to an exception hierarchy.

Competing Solutions

Python’s own warnings let execution continue too, but what happens to a warning is configured for the whole process. WarningCollector.Raise hands a warning to the innermost WarningCollector of the current thread, which keeps it in a list, and its handler decides per warning whether it is escalated - so the caller of an operation decides.

warnings

Source: warnings of Python’s standard library.

Disadvantages

  • What happens to a warning is decided by a global list of filters, matched by message, category, module and line - e.g. -W error turns every warning into an exception, for the whole process.

  • catch_warnings changes the module’s global state, which isn’t safe with several threads or coroutines - unless the flag sys.flags.context_aware_warnings is set, which Python 3.14 sets by default only in its free-threaded build.

  • A warning that can’t be ignored has no form: CriticalWarning raises UnhandledCriticalWarningError if no collector receives it.

Standoff

  • Both let execution continue after a warning, and both escalate one to an exception: the filter action error, or a collector’s handler returning True, which raises EscalatedWarningError.

  • catch_warnings(record=True) collects the warnings of a block in a list, as a collector does.

Advantages

  • Every library reports through it, the interpreter’s deprecations too, and -W or PYTHONWARNINGS configure it without changing code.

  • The default action shows a warning once per place it is issued from.

logging

Source: logging of Python’s standard library.

Disadvantages

  • A record goes to the handlers of the logger hierarchy, configured for the application. Returning the warnings of one operation to its caller needs a handler written for it.

  • Records are selected by their level, not by the class of an exception.

Standoff

Advantages

  • Destinations, formats and levels of the output are configured once for an application.