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 |
|---|---|---|---|
dropped silently |
collected |
collected, then |
|
|
collected |
collected, then |
|
|
collected |
collected, then |
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:
causeThe exception, which caused the warning. It becomes the warning’s
__cause__, as withraise ... from cause.notesA note or an iterable of notes, added by
add_note(). They are accessible byWarning.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
PermissionWarningto aWarningCollector; without a collector, it’s dropped.”The
:raises:field listsUnhandledCriticalWarningErrorfor a critical warning andUnhandledExceptionErrorfor a reported exception, because these are raised without a collector.EscalatedWarningErrorisn’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.
PermissionWarningcarries 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 toNone, and read-only properties.Group the situations of one kind under a common base class a caller can collect at once - e.g.
BrokenRequirementWarningfor 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. InpyTooling.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. SoBrokenRequirementWarning,ReleaseDetailsWarningandDirectReferenceWarningare 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 errorturns every warning into an exception, for the whole process.catch_warningschanges the module’s global state, which isn’t safe with several threads or coroutines - unless the flagsys.flags.context_aware_warningsis set, which Python 3.14 sets by default only in its free-threaded build.A warning that can’t be ignored has no form:
CriticalWarningraisesUnhandledCriticalWarningErrorif 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 returningTrue, which raisesEscalatedWarningError.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
-WorPYTHONWARNINGSconfigure 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
logging.captureWarnings()sends the warnings ofwarningsto the loggerpy.warnings.
Advantages
Destinations, formats and levels of the output are configured once for an application.