From f13c4b2563f77fb38372ad0bdb746433fb158736 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Fri, 10 Apr 2026 12:12:04 +0100 Subject: [PATCH 1/8] feat(diagnostics): add warning reporting session --- .../src/guppylang_internals/diagnostic.py | 8 + .../src/guppylang_internals/error.py | 173 +++++++++++++++++- guppylang/src/guppylang/__init__.py | 2 + guppylang/src/guppylang/defs.py | 5 +- tests/diagnostics/test_warning_reporting.py | 89 +++++++++ 5 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 tests/diagnostics/test_warning_reporting.py diff --git a/guppylang-internals/src/guppylang_internals/diagnostic.py b/guppylang-internals/src/guppylang_internals/diagnostic.py index 37048ba87..a5c3927dc 100644 --- a/guppylang-internals/src/guppylang_internals/diagnostic.py +++ b/guppylang-internals/src/guppylang_internals/diagnostic.py @@ -174,6 +174,14 @@ class Error(Diagnostic, Protocol): level: ClassVar[Literal[DiagnosticLevel.ERROR]] = DiagnosticLevel.ERROR +@runtime_checkable +@dataclass(frozen=True) +class Warning(Diagnostic, Protocol): + """Compiler diagnostic for non-fatal warnings.""" + + level: ClassVar[Literal[DiagnosticLevel.WARNING]] = DiagnosticLevel.WARNING + + @runtime_checkable @dataclass(frozen=True) class Note(SubDiagnostic, Protocol): diff --git a/guppylang-internals/src/guppylang_internals/error.py b/guppylang-internals/src/guppylang_internals/error.py index 59984e398..9d398c8eb 100644 --- a/guppylang-internals/src/guppylang_internals/error.py +++ b/guppylang-internals/src/guppylang_internals/error.py @@ -1,13 +1,15 @@ import functools import sys +import warnings from collections.abc import Callable, Iterator from contextlib import contextmanager -from dataclasses import dataclass +from contextvars import ContextVar +from dataclasses import dataclass, field from types import TracebackType -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast if TYPE_CHECKING: - from guppylang_internals.diagnostic import Error, Fatal + from guppylang_internals.diagnostic import Diagnostic, Error, Fatal @dataclass @@ -47,9 +49,45 @@ class InternalGuppyError(Exception): """Exception for internal problems during compilation.""" +class GuppyWarning(UserWarning): + """Warning category for non-fatal compiler diagnostics.""" + + ExceptHook = Callable[[type[BaseException], BaseException, TracebackType | None], Any] +class WarningKey(NamedTuple): + """Stable identity for deduplicating warnings within one operation.""" + + filename: str | None + lineno: int | None + column: int | None + message: str + + +@dataclass(frozen=True) +class PendingWarning: + """Buffered warning waiting to be emitted at the end of a top-level operation.""" + + message: str + filename: str | None + lineno: int | None + key: WarningKey + + +@dataclass +class DiagnosticSession: + """Per-operation diagnostic state shared across nested compiler calls.""" + + pending_warnings: list[PendingWarning] = field(default_factory=list) + seen_warnings: set[WarningKey] = field(default_factory=set) + + +_DIAGNOSTIC_SESSION: ContextVar[DiagnosticSession | None] = ContextVar( + "_DIAGNOSTIC_SESSION", default=None +) + + @contextmanager def exception_hook(hook: ExceptHook) -> Iterator[None]: """Sets a custom `excepthook` for the scope of a 'with' block.""" @@ -102,11 +140,136 @@ def saved_exception_hook() -> Iterator[None]: sys.excepthook = old_hook +@contextmanager +def diagnostic_report() -> Iterator[None]: + """Collects compiler warnings and flushes them once per top-level operation.""" + + session = _DIAGNOSTIC_SESSION.get() + # Nested compiler entrypoints reuse the same session so one user operation only + # flushes once, at the outermost boundary. + outermost = session is None + token = None + if outermost: + session = DiagnosticSession() + token = _DIAGNOSTIC_SESSION.set(session) + assert session is not None + + try: + yield + except Exception: + if outermost: + # Failed operations should not emit queued warnings. Clear eagerly so the + # exception path behaves the same whether the warning producer ran before + # or after the eventual failure. + session.pending_warnings.clear() + session.seen_warnings.clear() + raise + else: + if outermost: + # Only the outermost context flushes to Python warnings. Inner contexts + # merely contribute to the shared session. + for pending_warning in session.pending_warnings: + _emit_pending_warning(pending_warning) + finally: + if outermost and token is not None: + # Restore the previous ContextVar value even if warning emission itself + # raises, so subsequent compiler operations start with a clean session. + _DIAGNOSTIC_SESSION.reset(token) + + +def emit_warning(diag: "Diagnostic") -> None: + """Queue or emit a non-fatal compiler warning.""" + + pending_warning = _pending_warning(diag) + session = _DIAGNOSTIC_SESSION.get() + if session is None: + # Warnings emitted outside a diagnostic_report block still surface + # immediately; the session machinery is only needed for batching and + # deduplicating within top-level compiler operations. + _emit_pending_warning(pending_warning) + return + + if pending_warning.key in session.seen_warnings: + # Re-emitting the same warning from nested passes or revisited CFG nodes should + # not duplicate the user-facing Python warning within one operation. + return + + session.seen_warnings.add(pending_warning.key) + session.pending_warnings.append(pending_warning) + + +def _pending_warning(diag: "Diagnostic") -> PendingWarning: + from guppylang_internals.diagnostic import DiagnosticLevel + from guppylang_internals.span import to_span + + if diag.level is not DiagnosticLevel.WARNING: + raise InternalGuppyError("emit_warning expects a warning-level diagnostic") + + filename = None + lineno = None + column = None + if diag.span is not None: + # Python's warning machinery wants file/line information separately rather + # than Guppy's richer span object. + span = to_span(diag.span) + filename = span.start.file + lineno = span.start.line + column = span.start.column + + message = _warning_message(diag) + return PendingWarning( + message=message, + filename=filename, + lineno=lineno, + # Deduplicate on source location plus rendered message so repeated reports from + # the same site collapse, while distinct warnings on one line still survive. + key=WarningKey(filename, lineno, column, message), + ) + + +def _emit_pending_warning(pending_warning: PendingWarning) -> None: + """Emit one queued warning via Python's warning machinery.""" + + if pending_warning.filename is not None and pending_warning.lineno is not None: + warnings.warn_explicit( + pending_warning.message, + GuppyWarning, + pending_warning.filename, + pending_warning.lineno, + ) + else: + warnings.warn( + pending_warning.message, + GuppyWarning, + stacklevel=2, + ) + + +def _warning_message(diag: "Diagnostic") -> str: + lines = [diag.rendered_title] + if diag.rendered_span_label: + lines[0] += f": {diag.rendered_span_label}" + if diag.rendered_message: + lines.append(diag.rendered_message) + lines.extend( + [ + f"{child.level.name.lower().capitalize()}: {child.rendered_message}" + for child in diag.children + if child.rendered_message + ] + ) + return "\n".join(lines) + + FuncT = TypeVar("FuncT", bound=Callable[..., Any]) def pretty_errors(f: FuncT) -> FuncT: - """Decorator to print custom error banners when a `GuppyError` occurs.""" + """Decorator to print custom error banners when a `GuppyError` occurs. + + This is also the standard boundary for warning collection on top-level engine + operations: wrapped calls participate in one `diagnostic_report()` session. + """ def hook( excty: type[BaseException], err: BaseException, traceback: TracebackType | None @@ -127,7 +290,7 @@ def hook( @functools.wraps(f) def pretty_errors_wrapped(*args: Any, **kwargs: Any) -> Any: - with exception_hook(hook): + with diagnostic_report(), exception_hook(hook): return f(*args, **kwargs) return cast("FuncT", pretty_errors_wrapped) diff --git a/guppylang/src/guppylang/__init__.py b/guppylang/src/guppylang/__init__.py index 4494a4e46..4e17024d1 100644 --- a/guppylang/src/guppylang/__init__.py +++ b/guppylang/src/guppylang/__init__.py @@ -1,3 +1,4 @@ +from guppylang_internals.error import GuppyWarning from guppylang_internals.experimental import enable_experimental_features from guppylang.decorator import guppy @@ -8,6 +9,7 @@ __all__ = ( "GuppyModule", + "GuppyWarning", "array", "builtins", "comptime", diff --git a/guppylang/src/guppylang/defs.py b/guppylang/src/guppylang/defs.py index 8b6d5203f..6ecda348d 100644 --- a/guppylang/src/guppylang/defs.py +++ b/guppylang/src/guppylang/defs.py @@ -85,6 +85,9 @@ class GuppyDefinition(TracingDefMixin): def compile(self) -> Package: """Compile a Guppy definition to HUGR.""" + # Single-definition entrypoints rely on the wrapped engine helpers for warning + # collection. `ENGINE.compile_single()` already establishes the top-level + # diagnostic session via `@pretty_errors`. package: Package = ENGINE.compile_single(self.id).package for mod in package.modules: _update_generator_metadata(mod) @@ -92,6 +95,7 @@ def compile(self) -> Package: def check(self) -> None: """Type-check a Guppy definition.""" + # As above, warning collection is handled by the wrapped engine entrypoint. return ENGINE.check_single(self.id) @@ -243,7 +247,6 @@ def compile_entrypoint(self) -> Package: def compile_function(self) -> Package: """Compile a Guppy function definition to HUGR. - Returns: Package: The compiled package object. """ diff --git a/tests/diagnostics/test_warning_reporting.py b/tests/diagnostics/test_warning_reporting.py new file mode 100644 index 000000000..838988d52 --- /dev/null +++ b/tests/diagnostics/test_warning_reporting.py @@ -0,0 +1,89 @@ +import warnings +from dataclasses import dataclass +from typing import ClassVar + +import pytest +from guppylang import GuppyWarning +from guppylang_internals.diagnostic import Note, Warning +from guppylang_internals.error import diagnostic_report, emit_warning +from guppylang_internals.span import Loc, Span + +file = "warning_test.py" + + +@dataclass(frozen=True) +class SyntheticWarning(Warning): + title: ClassVar[str] = "Synthetic warning" + span_label: ClassVar[str] = "Something suspicious happened" + message: ClassVar[str] = "Additional context for the warning" + + +@dataclass(frozen=True) +class SyntheticNote(Note): + message: ClassVar[str] = "Helpful note" + + +def make_warning() -> SyntheticWarning: + warning = SyntheticWarning(Span(Loc(file, 3, 2), Loc(file, 3, 6))) + warning.add_sub_diagnostic(SyntheticNote(None)) + return warning + + +def test_emit_warning_with_source_location(): + """Warnings with spans should preserve filename, line, and message details.""" + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + with diagnostic_report(): + emit_warning(make_warning()) + + assert len(records) == 1 + warning = records[0] + assert warning.category is GuppyWarning + assert warning.filename == file + assert warning.lineno == 3 + assert str(warning.message) == ( + "Synthetic warning: Something suspicious happened\n" + "Additional context for the warning\n" + "Note: Helpful note" + ) + + +def test_nested_reports_flush_on_outer_exit(): + """Nested reporting sessions should flush only when the outermost session exits.""" + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + with diagnostic_report(): + with diagnostic_report(): + emit_warning(make_warning()) + assert records == [] + assert records == [] + + assert len(records) == 1 + assert str(records[0].message).startswith("Synthetic warning") + + +def test_duplicate_warnings_are_deduplicated(): + """The same warning emitted twice in one session should only be reported once.""" + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + with diagnostic_report(): + emit_warning(make_warning()) + emit_warning(make_warning()) + + assert len(records) == 1 + + +def test_warning_is_discarded_if_operation_fails(): + """Buffered warnings should be dropped if the enclosing operation raises.""" + + def fail_with_warning() -> None: + with diagnostic_report(): + emit_warning(make_warning()) + raise RuntimeError("boom") + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + with pytest.raises(RuntimeError, match="boom"): + fail_with_warning() + + assert len(records) == 0 From 406db1ed352c35b89d9cf8aee1a5adcdc28e6187 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Fri, 10 Apr 2026 12:17:24 +0100 Subject: [PATCH 2/8] feat(api): flush compiler warnings once per top-level operation --- guppylang/src/guppylang/defs.py | 22 ++-- tests/integration/test_warning_public_api.py | 111 +++++++++++++++++++ 2 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 tests/integration/test_warning_public_api.py diff --git a/guppylang/src/guppylang/defs.py b/guppylang/src/guppylang/defs.py index 6ecda348d..e5260c691 100644 --- a/guppylang/src/guppylang/defs.py +++ b/guppylang/src/guppylang/defs.py @@ -16,7 +16,7 @@ from guppylang_internals.definition.value import CompiledCallableDef from guppylang_internals.diagnostic import Error, Note from guppylang_internals.engine import DEF_STORE, ENGINE -from guppylang_internals.error import GuppyError, pretty_errors +from guppylang_internals.error import GuppyError, diagnostic_report, pretty_errors from guppylang_internals.span import Span, to_span from guppylang_internals.tracing.object import ( TracingDefMixin, @@ -279,18 +279,26 @@ def _type_members(self) -> list[DefId]: def compile(self) -> Package: """Compile this collection of definitions into a HUGR package.""" - ENGINE.check(self.members) - # Check fills _type_members with additional members only available after - # checking, so we have to call it before compiling (without an engine reset). - pointer = ENGINE.compile(self.members + self._type_members(), reset=False) + # Unlike the single-definition helpers, a library compile spans multiple + # top-level engine calls. Keep one outer diagnostic session here so warnings + # flush once for the whole user operation rather than once per engine call. + with diagnostic_report(): + ENGINE.check(self.members) + # Check fills _type_members with additional members only available after + # checking, so we have to call it before compiling (without an engine + # reset). + pointer = ENGINE.compile(self.members + self._type_members(), reset=False) for mod in pointer.package.modules: _update_generator_metadata(mod) return pointer.package def check(self) -> None: """Type-check all contained definitions.""" - ENGINE.check(self.members) - ENGINE.check(self._type_members(), reset=False) + # Library checks can trigger more than one top-level engine check, so they need + # their own outer diagnostic session. + with diagnostic_report(): + ENGINE.check(self.members) + ENGINE.check(self._type_members(), reset=False) @dataclass(frozen=True) diff --git a/tests/integration/test_warning_public_api.py b/tests/integration/test_warning_public_api.py new file mode 100644 index 000000000..661af95bd --- /dev/null +++ b/tests/integration/test_warning_public_api.py @@ -0,0 +1,111 @@ +import warnings +from dataclasses import dataclass +from types import SimpleNamespace +from typing import ClassVar + +from guppylang import GuppyWarning +from guppylang.defs import GuppyDefinition, GuppyLibrary +from guppylang_internals.definition.common import DefId, Definition +from guppylang_internals.diagnostic import Warning +from guppylang_internals.engine import ENGINE +from guppylang_internals.error import emit_warning +from guppylang_internals.span import Loc, Span + +file = "public_warning_test.py" + + +@dataclass(frozen=True) +class DummyDefinition(Definition): + @property + def description(self) -> str: + return "definition" + + +@dataclass(frozen=True) +class PublicApiWarning(Warning): + title: ClassVar[str] = "Public API warning" + span_label: ClassVar[str] = "Triggered from a public entrypoint" + + +def make_definition() -> GuppyDefinition: + return GuppyDefinition(DummyDefinition(DefId.fresh(), "dummy", None)) + + +def make_warning() -> PublicApiWarning: + return PublicApiWarning(Span(Loc(file, 5, 1), Loc(file, 5, 4))) + + +def test_definition_check_emits_warning(monkeypatch): + definition = make_definition() + + def fake_check(_def_ids, *, reset=True) -> None: + del reset + emit_warning(make_warning()) + + monkeypatch.setattr(ENGINE, "check", fake_check) + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + definition.check() + + assert len(records) == 1 + assert records[0].category is GuppyWarning + assert records[0].filename == file + + +def test_definition_compile_emits_warning(monkeypatch): + definition = make_definition() + + def fake_compile(_def_ids, *, reset=True): + del reset + emit_warning(make_warning()) + pointer = SimpleNamespace(package=SimpleNamespace(modules=[])) + return pointer, [None] + + monkeypatch.setattr(ENGINE, "_compile", fake_compile) + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + definition.compile() + + assert len(records) == 1 + assert records[0].category is GuppyWarning + assert records[0].filename == file + + +def test_library_check_emits_warning_once(monkeypatch): + library = GuppyLibrary([]) + + def fake_check(_def_ids, *, reset=True) -> None: + del reset + emit_warning(make_warning()) + + monkeypatch.setattr(ENGINE, "check", fake_check) + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + library.check() + + assert len(records) == 1 + + +def test_library_compile_emits_warning_once(monkeypatch): + library = GuppyLibrary([]) + + def fake_check(_def_ids, *, reset=True) -> None: + del reset + emit_warning(make_warning()) + + def fake_compile(_def_ids, *, reset=True): + del reset + emit_warning(make_warning()) + return SimpleNamespace(package=SimpleNamespace(modules=[])) + + monkeypatch.setattr(ENGINE, "check", fake_check) + monkeypatch.setattr(ENGINE, "compile", fake_compile) + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + library.compile() + + assert len(records) == 1 From 6d51bb1c28832b622d48fd3f21e3e0f0bf2919bd Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Fri, 10 Apr 2026 12:18:11 +0100 Subject: [PATCH 3/8] test(diagnostics): lock warning framework regressions --- tests/integration/test_warning_public_api.py | 49 +++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_warning_public_api.py b/tests/integration/test_warning_public_api.py index 661af95bd..048adef4d 100644 --- a/tests/integration/test_warning_public_api.py +++ b/tests/integration/test_warning_public_api.py @@ -3,11 +3,13 @@ from types import SimpleNamespace from typing import ClassVar +import pytest from guppylang import GuppyWarning from guppylang.defs import GuppyDefinition, GuppyLibrary from guppylang_internals.definition.common import DefId, Definition -from guppylang_internals.diagnostic import Warning +from guppylang_internals.diagnostic import Error, Warning from guppylang_internals.engine import ENGINE +from guppylang_internals.error import GuppyError from guppylang_internals.error import emit_warning from guppylang_internals.span import Loc, Span @@ -27,6 +29,12 @@ class PublicApiWarning(Warning): span_label: ClassVar[str] = "Triggered from a public entrypoint" +@dataclass(frozen=True) +class PublicApiError(Error): + title: ClassVar[str] = "Public API error" + span_label: ClassVar[str] = "Triggered from a public entrypoint" + + def make_definition() -> GuppyDefinition: return GuppyDefinition(DummyDefinition(DefId.fresh(), "dummy", None)) @@ -35,7 +43,16 @@ def make_warning() -> PublicApiWarning: return PublicApiWarning(Span(Loc(file, 5, 1), Loc(file, 5, 4))) +def make_error() -> PublicApiError: + return PublicApiError(Span(Loc(file, 8, 1), Loc(file, 8, 4))) + + def test_definition_check_emits_warning(monkeypatch): + """`GuppyDefinition.check()` inherits warning flushing from `check_single()`. + + The monkeypatch targets the inner `ENGINE.check()` call to keep the real + `@pretty_errors` wrapper in place while synthesizing a warning producer. + """ definition = make_definition() def fake_check(_def_ids, *, reset=True) -> None: @@ -54,6 +71,11 @@ def fake_check(_def_ids, *, reset=True) -> None: def test_definition_compile_emits_warning(monkeypatch): + """`GuppyDefinition.compile()` inherits warning flushing from `compile_single()`. + + The monkeypatch targets the inner `ENGINE._compile()` call so the test still + exercises the real top-level wrapper around `compile_single()`. + """ definition = make_definition() def fake_compile(_def_ids, *, reset=True): @@ -74,6 +96,11 @@ def fake_compile(_def_ids, *, reset=True): def test_library_check_emits_warning_once(monkeypatch): + """`GuppyLibrary.check()` should not flush separately for engine subcalls. + + Unlike the single-definition helpers, this method needs its own outer + `diagnostic_report()` because it orchestrates multiple top-level engine calls. + """ library = GuppyLibrary([]) def fake_check(_def_ids, *, reset=True) -> None: @@ -90,6 +117,7 @@ def fake_check(_def_ids, *, reset=True) -> None: def test_library_compile_emits_warning_once(monkeypatch): + """`GuppyLibrary.compile()` should coalesce flushes across check and compile.""" library = GuppyLibrary([]) def fake_check(_def_ids, *, reset=True) -> None: @@ -109,3 +137,22 @@ def fake_compile(_def_ids, *, reset=True): library.compile() assert len(records) == 1 + + +def test_definition_check_discards_warning_on_error(monkeypatch): + """Top-level failures should suppress buffered warnings instead of leaking them.""" + definition = make_definition() + + def fake_check(_def_ids, *, reset=True) -> None: + del reset + emit_warning(make_warning()) + raise GuppyError(make_error()) + + monkeypatch.setattr(ENGINE, "check", fake_check) + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + with pytest.raises(GuppyError): + definition.check() + + assert len(records) == 0 From 23b4fef93377de03d3950f589aaf47ce8ea7746d Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Mon, 13 Apr 2026 13:41:44 +0100 Subject: [PATCH 4/8] feat(diagnostics): optional rich warning rendering --- .../src/guppylang_internals/error.py | 36 ++++++++- guppylang/src/guppylang/__init__.py | 3 +- tests/diagnostics/test_warning_reporting.py | 35 +++++++- .../integration/notebooks/rich_warnings.ipynb | 79 +++++++++++++++++++ tests/integration/test_warning_public_api.py | 56 ++++++++++++- 5 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 tests/integration/notebooks/rich_warnings.ipynb diff --git a/guppylang-internals/src/guppylang_internals/error.py b/guppylang-internals/src/guppylang_internals/error.py index 9d398c8eb..8c772f125 100644 --- a/guppylang-internals/src/guppylang_internals/error.py +++ b/guppylang-internals/src/guppylang_internals/error.py @@ -69,6 +69,7 @@ class WarningKey(NamedTuple): class PendingWarning: """Buffered warning waiting to be emitted at the end of a top-level operation.""" + diagnostic: "Diagnostic" message: str filename: str | None lineno: int | None @@ -79,6 +80,7 @@ class PendingWarning: class DiagnosticSession: """Per-operation diagnostic state shared across nested compiler calls.""" + rich_warnings: bool = False pending_warnings: list[PendingWarning] = field(default_factory=list) seen_warnings: set[WarningKey] = field(default_factory=set) @@ -86,6 +88,7 @@ class DiagnosticSession: _DIAGNOSTIC_SESSION: ContextVar[DiagnosticSession | None] = ContextVar( "_DIAGNOSTIC_SESSION", default=None ) +_RICH_WARNINGS: ContextVar[bool] = ContextVar("_RICH_WARNINGS", default=False) @contextmanager @@ -140,6 +143,17 @@ def saved_exception_hook() -> Iterator[None]: sys.excepthook = old_hook +@contextmanager +def rich_warnings() -> Iterator[None]: + """Enable rich stderr rendering for compiler warnings within the current scope.""" + + token = _RICH_WARNINGS.set(True) + try: + yield + finally: + _RICH_WARNINGS.reset(token) + + @contextmanager def diagnostic_report() -> Iterator[None]: """Collects compiler warnings and flushes them once per top-level operation.""" @@ -150,7 +164,7 @@ def diagnostic_report() -> Iterator[None]: outermost = session is None token = None if outermost: - session = DiagnosticSession() + session = DiagnosticSession(rich_warnings=_RICH_WARNINGS.get()) token = _DIAGNOSTIC_SESSION.set(session) assert session is not None @@ -218,6 +232,7 @@ def _pending_warning(diag: "Diagnostic") -> PendingWarning: message = _warning_message(diag) return PendingWarning( + diagnostic=diag, message=message, filename=filename, lineno=lineno, @@ -228,7 +243,7 @@ def _pending_warning(diag: "Diagnostic") -> PendingWarning: def _emit_pending_warning(pending_warning: PendingWarning) -> None: - """Emit one queued warning via Python's warning machinery.""" + """Emit one queued warning via Python's warning machinery and rich stderr output.""" if pending_warning.filename is not None and pending_warning.lineno is not None: warnings.warn_explicit( @@ -244,6 +259,23 @@ def _emit_pending_warning(pending_warning: PendingWarning) -> None: stacklevel=2, ) + session = _DIAGNOSTIC_SESSION.get() + if session is not None and session.rich_warnings: + sys.stderr.write(_render_warning(pending_warning)) + sys.stderr.write("\n") + + +def _render_warning(pending_warning: PendingWarning) -> str: + from guppylang_internals.diagnostic import DiagnosticsRenderer + from guppylang_internals.engine import DEF_STORE + + renderer = DiagnosticsRenderer(DEF_STORE.sources) + try: + renderer.render_diagnostic(pending_warning.diagnostic) + except KeyError: + return pending_warning.message + return "\n".join(renderer.buffer) + def _warning_message(diag: "Diagnostic") -> str: lines = [diag.rendered_title] diff --git a/guppylang/src/guppylang/__init__.py b/guppylang/src/guppylang/__init__.py index 4e17024d1..78a9ae6fe 100644 --- a/guppylang/src/guppylang/__init__.py +++ b/guppylang/src/guppylang/__init__.py @@ -1,4 +1,4 @@ -from guppylang_internals.error import GuppyWarning +from guppylang_internals.error import GuppyWarning, rich_warnings from guppylang_internals.experimental import enable_experimental_features from guppylang.decorator import guppy @@ -19,6 +19,7 @@ "py", "quantum", "qubit", + "rich_warnings", ) # This is updated by our release-please workflow, triggered by this diff --git a/tests/diagnostics/test_warning_reporting.py b/tests/diagnostics/test_warning_reporting.py index 838988d52..ab1dac469 100644 --- a/tests/diagnostics/test_warning_reporting.py +++ b/tests/diagnostics/test_warning_reporting.py @@ -3,8 +3,9 @@ from typing import ClassVar import pytest -from guppylang import GuppyWarning +from guppylang import GuppyWarning, rich_warnings from guppylang_internals.diagnostic import Note, Warning +from guppylang_internals.engine import DEF_STORE from guppylang_internals.error import diagnostic_report, emit_warning from guppylang_internals.span import Loc, Span @@ -29,6 +30,10 @@ def make_warning() -> SyntheticWarning: return warning +def register_source() -> None: + DEF_STORE.sources.add_file(file, "x = 0\nx = 1\nwarn()\n") + + def test_emit_warning_with_source_location(): """Warnings with spans should preserve filename, line, and message details.""" with warnings.catch_warnings(record=True) as records: @@ -87,3 +92,31 @@ def fail_with_warning() -> None: fail_with_warning() assert len(records) == 0 + + +def test_rich_warnings_render_to_stderr(capsys): + """Rich warnings should preserve Python warnings and also render diagnostics.""" + register_source() + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + with rich_warnings(), diagnostic_report(): + emit_warning(make_warning()) + + assert len(records) == 1 + err = capsys.readouterr().err + assert "Warning: Synthetic warning" in err + assert "3 |" in err + assert "Something suspicious happened" in err + + +def test_nested_rich_warnings_do_not_duplicate_stderr(capsys): + """Nested rich-warning scopes should still render exactly once.""" + register_source() + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + with rich_warnings(), rich_warnings(), diagnostic_report(): + emit_warning(make_warning()) + + assert len(records) == 1 + err = capsys.readouterr().err + assert err.count("Warning: Synthetic warning") == 1 diff --git a/tests/integration/notebooks/rich_warnings.ipynb b/tests/integration/notebooks/rich_warnings.ipynb new file mode 100644 index 000000000..e3697a0fb --- /dev/null +++ b/tests/integration/notebooks/rich_warnings.ipynb @@ -0,0 +1,79 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "from dataclasses import dataclass\n", + "from typing import ClassVar\n", + "\n", + "from guppylang import rich_warnings\n", + "from guppylang_internals.diagnostic import Warning\n", + "from guppylang_internals.engine import DEF_STORE\n", + "from guppylang_internals.error import diagnostic_report, emit_warning\n", + "from guppylang_internals.span import Loc, Span\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "warning", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Warning: Notebook warning (at rich_warning_notebook.guppy:1:0)\n", + " | \n", + "1 | suspicious_code()\n", + " | ^^^^^^^^^^^^^^^^^ This warning was rendered from a notebook\n" + ] + } + ], + "source": [ + "import warnings\n", + "\n", + "@dataclass(frozen=True)\n", + "class NotebookWarning(Warning):\n", + " title: ClassVar[str] = \"Notebook warning\"\n", + " span_label: ClassVar[str] = \"This warning was rendered from a notebook\"\n", + "\n", + "\n", + "file = \"rich_warning_notebook.guppy\"\n", + "DEF_STORE.sources.add_file(file, \"suspicious_code()\\n\")\n", + "\n", + "with warnings.catch_warnings(record=True) as records:\n", + " warnings.simplefilter(\"always\")\n", + " with rich_warnings(), diagnostic_report():\n", + " emit_warning(NotebookWarning(Span(Loc(file, 1, 0), Loc(file, 1, 17))))\n", + "\n", + "assert len(records) == 1\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "guppylang (3.13.11)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/integration/test_warning_public_api.py b/tests/integration/test_warning_public_api.py index 048adef4d..4d0398ef4 100644 --- a/tests/integration/test_warning_public_api.py +++ b/tests/integration/test_warning_public_api.py @@ -4,11 +4,12 @@ from typing import ClassVar import pytest -from guppylang import GuppyWarning +from guppylang import rich_warnings from guppylang.defs import GuppyDefinition, GuppyLibrary from guppylang_internals.definition.common import DefId, Definition from guppylang_internals.diagnostic import Error, Warning from guppylang_internals.engine import ENGINE +from guppylang_internals.engine import DEF_STORE from guppylang_internals.error import GuppyError from guppylang_internals.error import emit_warning from guppylang_internals.span import Loc, Span @@ -47,6 +48,10 @@ def make_error() -> PublicApiError: return PublicApiError(Span(Loc(file, 8, 1), Loc(file, 8, 4))) +def register_source() -> None: + DEF_STORE.sources.add_file(file, "line1\nline2\nline3\nline4\nwarn()\nline6\nerr\n") + + def test_definition_check_emits_warning(monkeypatch): """`GuppyDefinition.check()` inherits warning flushing from `check_single()`. @@ -156,3 +161,52 @@ def fake_check(_def_ids, *, reset=True) -> None: definition.check() assert len(records) == 0 + + +def test_definition_check_rich_warning_emits_stderr(monkeypatch, capsys): + """Rich warnings should add rendered stderr output on top of Python warnings.""" + definition = make_definition() + register_source() + + def fake_check(_def_ids, *, reset=True) -> None: + del reset + emit_warning(make_warning()) + + monkeypatch.setattr(ENGINE, "check", fake_check) + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + with rich_warnings(): + definition.check() + + assert len(records) == 1 + err = capsys.readouterr().err + assert "Warning: Public API warning" in err + assert "Triggered from a public entrypoint" in err + + +def test_library_compile_rich_warning_emits_stderr_once(monkeypatch, capsys): + """Rich mode should not duplicate rendered warnings across library subcalls.""" + library = GuppyLibrary([]) + register_source() + + def fake_check(_def_ids, *, reset=True) -> None: + del reset + emit_warning(make_warning()) + + def fake_compile(_def_ids, *, reset=True): + del reset + emit_warning(make_warning()) + return SimpleNamespace(package=SimpleNamespace(modules=[])) + + monkeypatch.setattr(ENGINE, "check", fake_check) + monkeypatch.setattr(ENGINE, "compile", fake_compile) + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + with rich_warnings(): + library.compile() + + assert len(records) == 1 + err = capsys.readouterr().err + assert err.count("Warning: Public API warning") == 1 From 9c04b0e73ca4f8b39c20050d3429fe013c1ea562 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Mon, 13 Apr 2026 14:56:03 +0100 Subject: [PATCH 5/8] refactor(diagnostics): move warning machinery into warning module --- .../src/guppylang_internals/error.py | 199 +---------------- .../src/guppylang_internals/warning.py | 201 ++++++++++++++++++ guppylang/src/guppylang/__init__.py | 2 +- guppylang/src/guppylang/defs.py | 3 +- tests/diagnostics/test_warning_reporting.py | 2 +- tests/error/util.py | 8 +- .../integration/notebooks/rich_warnings.ipynb | 2 +- tests/integration/test_warning_public_api.py | 2 +- 8 files changed, 216 insertions(+), 203 deletions(-) create mode 100644 guppylang-internals/src/guppylang_internals/warning.py diff --git a/guppylang-internals/src/guppylang_internals/error.py b/guppylang-internals/src/guppylang_internals/error.py index 8c772f125..000a35f35 100644 --- a/guppylang-internals/src/guppylang_internals/error.py +++ b/guppylang-internals/src/guppylang_internals/error.py @@ -1,15 +1,13 @@ import functools import sys -import warnings from collections.abc import Callable, Iterator from contextlib import contextmanager -from contextvars import ContextVar -from dataclasses import dataclass, field +from dataclasses import dataclass from types import TracebackType -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast if TYPE_CHECKING: - from guppylang_internals.diagnostic import Diagnostic, Error, Fatal + from guppylang_internals.diagnostic import Error, Fatal @dataclass @@ -49,48 +47,9 @@ class InternalGuppyError(Exception): """Exception for internal problems during compilation.""" -class GuppyWarning(UserWarning): - """Warning category for non-fatal compiler diagnostics.""" - - ExceptHook = Callable[[type[BaseException], BaseException, TracebackType | None], Any] -class WarningKey(NamedTuple): - """Stable identity for deduplicating warnings within one operation.""" - - filename: str | None - lineno: int | None - column: int | None - message: str - - -@dataclass(frozen=True) -class PendingWarning: - """Buffered warning waiting to be emitted at the end of a top-level operation.""" - - diagnostic: "Diagnostic" - message: str - filename: str | None - lineno: int | None - key: WarningKey - - -@dataclass -class DiagnosticSession: - """Per-operation diagnostic state shared across nested compiler calls.""" - - rich_warnings: bool = False - pending_warnings: list[PendingWarning] = field(default_factory=list) - seen_warnings: set[WarningKey] = field(default_factory=set) - - -_DIAGNOSTIC_SESSION: ContextVar[DiagnosticSession | None] = ContextVar( - "_DIAGNOSTIC_SESSION", default=None -) -_RICH_WARNINGS: ContextVar[bool] = ContextVar("_RICH_WARNINGS", default=False) - - @contextmanager def exception_hook(hook: ExceptHook) -> Iterator[None]: """Sets a custom `excepthook` for the scope of a 'with' block.""" @@ -143,156 +102,6 @@ def saved_exception_hook() -> Iterator[None]: sys.excepthook = old_hook -@contextmanager -def rich_warnings() -> Iterator[None]: - """Enable rich stderr rendering for compiler warnings within the current scope.""" - - token = _RICH_WARNINGS.set(True) - try: - yield - finally: - _RICH_WARNINGS.reset(token) - - -@contextmanager -def diagnostic_report() -> Iterator[None]: - """Collects compiler warnings and flushes them once per top-level operation.""" - - session = _DIAGNOSTIC_SESSION.get() - # Nested compiler entrypoints reuse the same session so one user operation only - # flushes once, at the outermost boundary. - outermost = session is None - token = None - if outermost: - session = DiagnosticSession(rich_warnings=_RICH_WARNINGS.get()) - token = _DIAGNOSTIC_SESSION.set(session) - assert session is not None - - try: - yield - except Exception: - if outermost: - # Failed operations should not emit queued warnings. Clear eagerly so the - # exception path behaves the same whether the warning producer ran before - # or after the eventual failure. - session.pending_warnings.clear() - session.seen_warnings.clear() - raise - else: - if outermost: - # Only the outermost context flushes to Python warnings. Inner contexts - # merely contribute to the shared session. - for pending_warning in session.pending_warnings: - _emit_pending_warning(pending_warning) - finally: - if outermost and token is not None: - # Restore the previous ContextVar value even if warning emission itself - # raises, so subsequent compiler operations start with a clean session. - _DIAGNOSTIC_SESSION.reset(token) - - -def emit_warning(diag: "Diagnostic") -> None: - """Queue or emit a non-fatal compiler warning.""" - - pending_warning = _pending_warning(diag) - session = _DIAGNOSTIC_SESSION.get() - if session is None: - # Warnings emitted outside a diagnostic_report block still surface - # immediately; the session machinery is only needed for batching and - # deduplicating within top-level compiler operations. - _emit_pending_warning(pending_warning) - return - - if pending_warning.key in session.seen_warnings: - # Re-emitting the same warning from nested passes or revisited CFG nodes should - # not duplicate the user-facing Python warning within one operation. - return - - session.seen_warnings.add(pending_warning.key) - session.pending_warnings.append(pending_warning) - - -def _pending_warning(diag: "Diagnostic") -> PendingWarning: - from guppylang_internals.diagnostic import DiagnosticLevel - from guppylang_internals.span import to_span - - if diag.level is not DiagnosticLevel.WARNING: - raise InternalGuppyError("emit_warning expects a warning-level diagnostic") - - filename = None - lineno = None - column = None - if diag.span is not None: - # Python's warning machinery wants file/line information separately rather - # than Guppy's richer span object. - span = to_span(diag.span) - filename = span.start.file - lineno = span.start.line - column = span.start.column - - message = _warning_message(diag) - return PendingWarning( - diagnostic=diag, - message=message, - filename=filename, - lineno=lineno, - # Deduplicate on source location plus rendered message so repeated reports from - # the same site collapse, while distinct warnings on one line still survive. - key=WarningKey(filename, lineno, column, message), - ) - - -def _emit_pending_warning(pending_warning: PendingWarning) -> None: - """Emit one queued warning via Python's warning machinery and rich stderr output.""" - - if pending_warning.filename is not None and pending_warning.lineno is not None: - warnings.warn_explicit( - pending_warning.message, - GuppyWarning, - pending_warning.filename, - pending_warning.lineno, - ) - else: - warnings.warn( - pending_warning.message, - GuppyWarning, - stacklevel=2, - ) - - session = _DIAGNOSTIC_SESSION.get() - if session is not None and session.rich_warnings: - sys.stderr.write(_render_warning(pending_warning)) - sys.stderr.write("\n") - - -def _render_warning(pending_warning: PendingWarning) -> str: - from guppylang_internals.diagnostic import DiagnosticsRenderer - from guppylang_internals.engine import DEF_STORE - - renderer = DiagnosticsRenderer(DEF_STORE.sources) - try: - renderer.render_diagnostic(pending_warning.diagnostic) - except KeyError: - return pending_warning.message - return "\n".join(renderer.buffer) - - -def _warning_message(diag: "Diagnostic") -> str: - lines = [diag.rendered_title] - if diag.rendered_span_label: - lines[0] += f": {diag.rendered_span_label}" - if diag.rendered_message: - lines.append(diag.rendered_message) - lines.extend( - [ - f"{child.level.name.lower().capitalize()}: {child.rendered_message}" - for child in diag.children - if child.rendered_message - ] - ) - return "\n".join(lines) - - FuncT = TypeVar("FuncT", bound=Callable[..., Any]) @@ -322,6 +131,8 @@ def hook( @functools.wraps(f) def pretty_errors_wrapped(*args: Any, **kwargs: Any) -> Any: + from guppylang_internals.warning import diagnostic_report + with diagnostic_report(), exception_hook(hook): return f(*args, **kwargs) diff --git a/guppylang-internals/src/guppylang_internals/warning.py b/guppylang-internals/src/guppylang_internals/warning.py new file mode 100644 index 000000000..311b3fd11 --- /dev/null +++ b/guppylang-internals/src/guppylang_internals/warning.py @@ -0,0 +1,201 @@ +import sys +import warnings +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, NamedTuple + +from guppylang_internals.error import InternalGuppyError + +if TYPE_CHECKING: + from guppylang_internals.diagnostic import Diagnostic + + +class GuppyWarning(UserWarning): + """Warning category for non-fatal compiler diagnostics.""" + + +class WarningKey(NamedTuple): + """Stable identity for deduplicating warnings within one operation.""" + + filename: str | None + lineno: int | None + column: int | None + message: str + + +@dataclass(frozen=True) +class PendingWarning: + """Buffered warning waiting to be emitted at the end of a top-level operation.""" + + diagnostic: "Diagnostic" + message: str + filename: str | None + lineno: int | None + key: WarningKey + + +@dataclass +class DiagnosticSession: + """Per-operation diagnostic state shared across nested compiler calls.""" + + rich_warnings: bool = False + pending_warnings: list[PendingWarning] = field(default_factory=list) + seen_warnings: set[WarningKey] = field(default_factory=set) + + +_DIAGNOSTIC_SESSION: ContextVar[DiagnosticSession | None] = ContextVar( + "_DIAGNOSTIC_SESSION", default=None +) +_RICH_WARNINGS: ContextVar[bool] = ContextVar("_RICH_WARNINGS", default=False) + + +@contextmanager +def rich_warnings() -> Iterator[None]: + """Enable rich stderr rendering for compiler warnings within the current scope.""" + + token = _RICH_WARNINGS.set(True) + try: + yield + finally: + _RICH_WARNINGS.reset(token) + + +@contextmanager +def diagnostic_report() -> Iterator[None]: + """Collects compiler warnings and flushes them once per top-level operation.""" + + session = _DIAGNOSTIC_SESSION.get() + # Nested compiler entrypoints reuse the same session so one user operation only + # flushes once, at the outermost boundary. + outermost = session is None + token = None + if outermost: + session = DiagnosticSession(rich_warnings=_RICH_WARNINGS.get()) + token = _DIAGNOSTIC_SESSION.set(session) + assert session is not None + + try: + yield + except Exception: + if outermost: + # Failed operations should not emit queued warnings. Clear eagerly so the + # exception path behaves the same whether the warning producer ran before + # or after the eventual failure. + session.pending_warnings.clear() + session.seen_warnings.clear() + raise + else: + if outermost: + # Only the outermost context flushes to Python warnings. Inner contexts + # merely contribute to the shared session. + for pending_warning in session.pending_warnings: + _emit_pending_warning(pending_warning) + finally: + if outermost and token is not None: + # Restore the previous ContextVar value even if warning emission itself + # raises, so subsequent compiler operations start with a clean session. + _DIAGNOSTIC_SESSION.reset(token) + + +def emit_warning(diag: "Diagnostic") -> None: + """Queue or emit a non-fatal compiler warning.""" + + pending_warning = _pending_warning(diag) + session = _DIAGNOSTIC_SESSION.get() + if session is None: + # Warnings emitted outside a diagnostic_report block still surface + # immediately; the session machinery is only needed for batching and + # deduplicating within top-level compiler operations. + _emit_pending_warning(pending_warning) + return + + if pending_warning.key in session.seen_warnings: + # Re-emitting the same warning from nested passes or revisited CFG nodes should + # not duplicate the user-facing Python warning within one operation. + return + + session.seen_warnings.add(pending_warning.key) + session.pending_warnings.append(pending_warning) + + +def _pending_warning(diag: "Diagnostic") -> PendingWarning: + from guppylang_internals.diagnostic import DiagnosticLevel + from guppylang_internals.span import to_span + + if diag.level is not DiagnosticLevel.WARNING: + raise InternalGuppyError("emit_warning expects a warning-level diagnostic") + + filename = None + lineno = None + column = None + if diag.span is not None: + # Python's warning machinery wants file/line information separately rather + # than Guppy's richer span object. + span = to_span(diag.span) + filename = span.start.file + lineno = span.start.line + column = span.start.column + + message = _warning_message(diag) + return PendingWarning( + diagnostic=diag, + message=message, + filename=filename, + lineno=lineno, + # Deduplicate on source location plus rendered message so repeated reports from + # the same site collapse, while distinct warnings on one line still survive. + key=WarningKey(filename, lineno, column, message), + ) + + +def _emit_pending_warning(pending_warning: PendingWarning) -> None: + """Emit one queued warning via Python's warning machinery and rich stderr output.""" + + if pending_warning.filename is not None and pending_warning.lineno is not None: + warnings.warn_explicit( + pending_warning.message, + GuppyWarning, + pending_warning.filename, + pending_warning.lineno, + ) + else: + warnings.warn( + pending_warning.message, + GuppyWarning, + stacklevel=2, + ) + + session = _DIAGNOSTIC_SESSION.get() + if session is not None and session.rich_warnings: + sys.stderr.write(_render_warning(pending_warning)) + sys.stderr.write("\n") + + +def _render_warning(pending_warning: PendingWarning) -> str: + from guppylang_internals.diagnostic import DiagnosticsRenderer + from guppylang_internals.engine import DEF_STORE + + renderer = DiagnosticsRenderer(DEF_STORE.sources) + try: + renderer.render_diagnostic(pending_warning.diagnostic) + except KeyError: + return pending_warning.message + return "\n".join(renderer.buffer) + + +def _warning_message(diag: "Diagnostic") -> str: + lines = [diag.rendered_title] + if diag.rendered_span_label: + lines[0] += f": {diag.rendered_span_label}" + if diag.rendered_message: + lines.append(diag.rendered_message) + lines.extend( + [ + f"{child.level.name.lower().capitalize()}: {child.rendered_message}" + for child in diag.children + if child.rendered_message + ] + ) + return "\n".join(lines) diff --git a/guppylang/src/guppylang/__init__.py b/guppylang/src/guppylang/__init__.py index 78a9ae6fe..41209f65d 100644 --- a/guppylang/src/guppylang/__init__.py +++ b/guppylang/src/guppylang/__init__.py @@ -1,5 +1,5 @@ -from guppylang_internals.error import GuppyWarning, rich_warnings from guppylang_internals.experimental import enable_experimental_features +from guppylang_internals.warning import GuppyWarning, rich_warnings from guppylang.decorator import guppy from guppylang.module import GuppyModule diff --git a/guppylang/src/guppylang/defs.py b/guppylang/src/guppylang/defs.py index e5260c691..a23ce032d 100644 --- a/guppylang/src/guppylang/defs.py +++ b/guppylang/src/guppylang/defs.py @@ -16,12 +16,13 @@ from guppylang_internals.definition.value import CompiledCallableDef from guppylang_internals.diagnostic import Error, Note from guppylang_internals.engine import DEF_STORE, ENGINE -from guppylang_internals.error import GuppyError, diagnostic_report, pretty_errors +from guppylang_internals.error import GuppyError, pretty_errors from guppylang_internals.span import Span, to_span from guppylang_internals.tracing.object import ( TracingDefMixin, ) from guppylang_internals.tracing.util import hide_trace +from guppylang_internals.warning import diagnostic_report from hugr.envelope import GeneratorDesc from hugr.hugr import Hugr from hugr.metadata import HugrGenerator diff --git a/tests/diagnostics/test_warning_reporting.py b/tests/diagnostics/test_warning_reporting.py index ab1dac469..b2df19f0f 100644 --- a/tests/diagnostics/test_warning_reporting.py +++ b/tests/diagnostics/test_warning_reporting.py @@ -6,8 +6,8 @@ from guppylang import GuppyWarning, rich_warnings from guppylang_internals.diagnostic import Note, Warning from guppylang_internals.engine import DEF_STORE -from guppylang_internals.error import diagnostic_report, emit_warning from guppylang_internals.span import Loc, Span +from guppylang_internals.warning import diagnostic_report, emit_warning file = "warning_test.py" diff --git a/tests/error/util.py b/tests/error/util.py index 598cb7c54..8cffa5115 100644 --- a/tests/error/util.py +++ b/tests/error/util.py @@ -1,15 +1,15 @@ -import importlib.util +import importlib import inspect import pathlib import re import sys import pytest +from guppylang_internals.decorator import custom_type +from guppylang_internals.diagnostic import DiagnosticsRenderer, wrap from hugr import tys from hugr.tys import TypeBound -from guppylang_internals.decorator import custom_type -from guppylang_internals.diagnostic import DiagnosticsRenderer, wrap from tests.util import get_wasm_file # Regular expression to match the `~~~~~^^^~~~` highlights that are printed in @@ -40,7 +40,7 @@ def filter_traceback_not_containing(s: str, disallowed_regex: re.Pattern[str]) - def run_error_test(file, capsys, snapshot): file = pathlib.Path(file) - with pytest.raises(Exception) as exc_info: + with pytest.raises(Exception) as exc_info: # noqa: PT011 importlib.import_module(f"tests.error.{file.parent.name}.{file.stem}") # Remove the importlib frames from the traceback by skipping beginning frames until diff --git a/tests/integration/notebooks/rich_warnings.ipynb b/tests/integration/notebooks/rich_warnings.ipynb index e3697a0fb..5a2b0d777 100644 --- a/tests/integration/notebooks/rich_warnings.ipynb +++ b/tests/integration/notebooks/rich_warnings.ipynb @@ -13,7 +13,7 @@ "from guppylang import rich_warnings\n", "from guppylang_internals.diagnostic import Warning\n", "from guppylang_internals.engine import DEF_STORE\n", - "from guppylang_internals.error import diagnostic_report, emit_warning\n", + "from guppylang_internals.warning import diagnostic_report, emit_warning\n", "from guppylang_internals.span import Loc, Span\n" ] }, diff --git a/tests/integration/test_warning_public_api.py b/tests/integration/test_warning_public_api.py index 4d0398ef4..094bf5ad2 100644 --- a/tests/integration/test_warning_public_api.py +++ b/tests/integration/test_warning_public_api.py @@ -11,8 +11,8 @@ from guppylang_internals.engine import ENGINE from guppylang_internals.engine import DEF_STORE from guppylang_internals.error import GuppyError -from guppylang_internals.error import emit_warning from guppylang_internals.span import Loc, Span +from guppylang_internals.warning import emit_warning file = "public_warning_test.py" From 075fb09de879866ff347bc75864e27ef43125fce Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 14 Apr 2026 13:50:52 +0100 Subject: [PATCH 6/8] test: use warning filtering util --- tests/diagnostics/test_warning_reporting.py | 24 ++++++++++++++------- tests/util.py | 7 ++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/diagnostics/test_warning_reporting.py b/tests/diagnostics/test_warning_reporting.py index b2df19f0f..907ea2bee 100644 --- a/tests/diagnostics/test_warning_reporting.py +++ b/tests/diagnostics/test_warning_reporting.py @@ -9,6 +9,8 @@ from guppylang_internals.span import Loc, Span from guppylang_internals.warning import diagnostic_report, emit_warning +from tests.util import guppy_warning_records + file = "warning_test.py" @@ -41,8 +43,9 @@ def test_emit_warning_with_source_location(): with diagnostic_report(): emit_warning(make_warning()) - assert len(records) == 1 - warning = records[0] + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 1 + warning = guppy_records[0] assert warning.category is GuppyWarning assert warning.filename == file assert warning.lineno == 3 @@ -63,8 +66,9 @@ def test_nested_reports_flush_on_outer_exit(): assert records == [] assert records == [] - assert len(records) == 1 - assert str(records[0].message).startswith("Synthetic warning") + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 1 + assert str(guppy_records[0].message).startswith("Synthetic warning") def test_duplicate_warnings_are_deduplicated(): @@ -75,7 +79,8 @@ def test_duplicate_warnings_are_deduplicated(): emit_warning(make_warning()) emit_warning(make_warning()) - assert len(records) == 1 + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 1 def test_warning_is_discarded_if_operation_fails(): @@ -91,7 +96,8 @@ def fail_with_warning() -> None: with pytest.raises(RuntimeError, match="boom"): fail_with_warning() - assert len(records) == 0 + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 0 def test_rich_warnings_render_to_stderr(capsys): @@ -102,7 +108,8 @@ def test_rich_warnings_render_to_stderr(capsys): with rich_warnings(), diagnostic_report(): emit_warning(make_warning()) - assert len(records) == 1 + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 1 err = capsys.readouterr().err assert "Warning: Synthetic warning" in err assert "3 |" in err @@ -117,6 +124,7 @@ def test_nested_rich_warnings_do_not_duplicate_stderr(capsys): with rich_warnings(), rich_warnings(), diagnostic_report(): emit_warning(make_warning()) - assert len(records) == 1 + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 1 err = capsys.readouterr().err assert err.count("Warning: Synthetic warning") == 1 diff --git a/tests/util.py b/tests/util.py index bef29aeb8..6ec0a0de5 100644 --- a/tests/util.py +++ b/tests/util.py @@ -3,9 +3,12 @@ from pathlib import Path from typing import TYPE_CHECKING +from guppylang import GuppyWarning from guppylang.decorator import custom_guppy_decorator, guppy if TYPE_CHECKING: + from warnings import WarningMessage + from guppylang.defs import GuppyFunctionDefinition from hugr.package import Package, PackagePointer @@ -34,3 +37,7 @@ def get_wasm_file() -> str: def get_h2_wasm_file() -> str: return str(Path(__file__).parent.resolve() / "resources/test.h2.wasm") + + +def guppy_warning_records(records: list[WarningMessage]) -> list[WarningMessage]: + return [warning for warning in records if warning.category is GuppyWarning] From 74aefe09875e304339c26a480d7d077c8696ba8f Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Mon, 13 Apr 2026 15:21:01 +0100 Subject: [PATCH 7/8] test: simplify public api tests --- tests/integration/test_warning_public_api.py | 118 ++++++------------- 1 file changed, 37 insertions(+), 81 deletions(-) diff --git a/tests/integration/test_warning_public_api.py b/tests/integration/test_warning_public_api.py index 094bf5ad2..d72620e26 100644 --- a/tests/integration/test_warning_public_api.py +++ b/tests/integration/test_warning_public_api.py @@ -13,6 +13,7 @@ from guppylang_internals.error import GuppyError from guppylang_internals.span import Loc, Span from guppylang_internals.warning import emit_warning +from tests.util import guppy_warning_records file = "public_warning_test.py" @@ -52,13 +53,8 @@ def register_source() -> None: DEF_STORE.sources.add_file(file, "line1\nline2\nline3\nline4\nwarn()\nline6\nerr\n") -def test_definition_check_emits_warning(monkeypatch): - """`GuppyDefinition.check()` inherits warning flushing from `check_single()`. - - The monkeypatch targets the inner `ENGINE.check()` call to keep the real - `@pretty_errors` wrapper in place while synthesizing a warning producer. - """ - definition = make_definition() +def install_check_warning(monkeypatch) -> None: + """Synthesize a warning from the inner engine `check()` implementation.""" def fake_check(_def_ids, *, reset=True) -> None: del reset @@ -66,22 +62,9 @@ def fake_check(_def_ids, *, reset=True) -> None: monkeypatch.setattr(ENGINE, "check", fake_check) - with warnings.catch_warnings(record=True) as records: - warnings.simplefilter("always") - definition.check() - - assert len(records) == 1 - assert records[0].category is GuppyWarning - assert records[0].filename == file - -def test_definition_compile_emits_warning(monkeypatch): - """`GuppyDefinition.compile()` inherits warning flushing from `compile_single()`. - - The monkeypatch targets the inner `ENGINE._compile()` call so the test still - exercises the real top-level wrapper around `compile_single()`. - """ - definition = make_definition() +def install_compile_warning(monkeypatch) -> None: + """Synthesize a warning from the inner engine `_compile()` implementation.""" def fake_compile(_def_ids, *, reset=True): del reset @@ -91,57 +74,54 @@ def fake_compile(_def_ids, *, reset=True): monkeypatch.setattr(ENGINE, "_compile", fake_compile) - with warnings.catch_warnings(record=True) as records: - warnings.simplefilter("always") - definition.compile() - - assert len(records) == 1 - assert records[0].category is GuppyWarning - assert records[0].filename == file - -def test_library_check_emits_warning_once(monkeypatch): - """`GuppyLibrary.check()` should not flush separately for engine subcalls. - - Unlike the single-definition helpers, this method needs its own outer - `diagnostic_report()` because it orchestrates multiple top-level engine calls. - """ - library = GuppyLibrary([]) - - def fake_check(_def_ids, *, reset=True) -> None: - del reset - emit_warning(make_warning()) - - monkeypatch.setattr(ENGINE, "check", fake_check) +@pytest.mark.parametrize( + ("install_warning", "run_entrypoint"), + [ + ( + install_check_warning, + lambda definition: definition.check(), + ), + ( + install_compile_warning, + lambda definition: definition.compile(), + ), + ], +) +def test_single_definition_entrypoints_emit_warning( + monkeypatch, install_warning, run_entrypoint +): + """Single-definition public entrypoints should flush one warning.""" + definition = make_definition() + install_warning(monkeypatch) with warnings.catch_warnings(record=True) as records: warnings.simplefilter("always") - library.check() + run_entrypoint(definition) - assert len(records) == 1 + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 1 + assert guppy_records[0].filename == file def test_library_compile_emits_warning_once(monkeypatch): - """`GuppyLibrary.compile()` should coalesce flushes across check and compile.""" + """`GuppyLibrary.compile()` should coalesce warnings across its subcalls.""" library = GuppyLibrary([]) - - def fake_check(_def_ids, *, reset=True) -> None: - del reset - emit_warning(make_warning()) + install_check_warning(monkeypatch) def fake_compile(_def_ids, *, reset=True): del reset emit_warning(make_warning()) return SimpleNamespace(package=SimpleNamespace(modules=[])) - monkeypatch.setattr(ENGINE, "check", fake_check) monkeypatch.setattr(ENGINE, "compile", fake_compile) with warnings.catch_warnings(record=True) as records: warnings.simplefilter("always") library.compile() - assert len(records) == 1 + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 1 def test_definition_check_discards_warning_on_error(monkeypatch): @@ -160,46 +140,21 @@ def fake_check(_def_ids, *, reset=True) -> None: with pytest.raises(GuppyError): definition.check() - assert len(records) == 0 - - -def test_definition_check_rich_warning_emits_stderr(monkeypatch, capsys): - """Rich warnings should add rendered stderr output on top of Python warnings.""" - definition = make_definition() - register_source() - - def fake_check(_def_ids, *, reset=True) -> None: - del reset - emit_warning(make_warning()) - - monkeypatch.setattr(ENGINE, "check", fake_check) - - with warnings.catch_warnings(record=True) as records: - warnings.simplefilter("always") - with rich_warnings(): - definition.check() - - assert len(records) == 1 - err = capsys.readouterr().err - assert "Warning: Public API warning" in err - assert "Triggered from a public entrypoint" in err + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 0 def test_library_compile_rich_warning_emits_stderr_once(monkeypatch, capsys): """Rich mode should not duplicate rendered warnings across library subcalls.""" library = GuppyLibrary([]) register_source() - - def fake_check(_def_ids, *, reset=True) -> None: - del reset - emit_warning(make_warning()) + install_check_warning(monkeypatch) def fake_compile(_def_ids, *, reset=True): del reset emit_warning(make_warning()) return SimpleNamespace(package=SimpleNamespace(modules=[])) - monkeypatch.setattr(ENGINE, "check", fake_check) monkeypatch.setattr(ENGINE, "compile", fake_compile) with warnings.catch_warnings(record=True) as records: @@ -207,6 +162,7 @@ def fake_compile(_def_ids, *, reset=True): with rich_warnings(): library.compile() - assert len(records) == 1 + guppy_records = guppy_warning_records(records) + assert len(guppy_records) == 1 err = capsys.readouterr().err assert err.count("Warning: Public API warning") == 1 From 7dcbcc3f4f02bca164a8bd6238d7d93e99912316 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Wed, 15 Apr 2026 11:18:35 +0100 Subject: [PATCH 8/8] refactor: deduplicate contents of PendingWarning --- .../src/guppylang_internals/warning.py | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/warning.py b/guppylang-internals/src/guppylang_internals/warning.py index 311b3fd11..495ce3fee 100644 --- a/guppylang-internals/src/guppylang_internals/warning.py +++ b/guppylang-internals/src/guppylang_internals/warning.py @@ -16,12 +16,16 @@ class GuppyWarning(UserWarning): """Warning category for non-fatal compiler diagnostics.""" -class WarningKey(NamedTuple): +class _WarningKey(NamedTuple): """Stable identity for deduplicating warnings within one operation.""" + # File path passed through to Python's warning machinery, if available. filename: str | None + # 1-based source line passed through to Python's warning machinery, if available. lineno: int | None + # 0-based source column used only for deduplicating distinct warnings on one line. column: int | None + # Concise warning text emitted through Python's warning machinery. message: str @@ -29,11 +33,30 @@ class WarningKey(NamedTuple): class PendingWarning: """Buffered warning waiting to be emitted at the end of a top-level operation.""" + # Original structured diagnostic used for rich rendering. diagnostic: "Diagnostic" - message: str - filename: str | None - lineno: int | None - key: WarningKey + # Stable warning identity and Python-warning payload. + _key: _WarningKey + + @property + def message(self) -> str: + """Concise warning text emitted through Python's warning machinery.""" + return self._key.message + + @property + def filename(self) -> str | None: + """Source file reported to Python's warning machinery, if available.""" + return self._key.filename + + @property + def lineno(self) -> int | None: + """1-based source line reported to Python's warning machinery, if available.""" + return self._key.lineno + + @property + def column(self) -> int | None: + """0-based source column used for deduplication within one operation.""" + return self._key.column @dataclass @@ -42,7 +65,7 @@ class DiagnosticSession: rich_warnings: bool = False pending_warnings: list[PendingWarning] = field(default_factory=list) - seen_warnings: set[WarningKey] = field(default_factory=set) + seen_warnings: set[_WarningKey] = field(default_factory=set) _DIAGNOSTIC_SESSION: ContextVar[DiagnosticSession | None] = ContextVar( @@ -111,12 +134,12 @@ def emit_warning(diag: "Diagnostic") -> None: _emit_pending_warning(pending_warning) return - if pending_warning.key in session.seen_warnings: + if pending_warning._key in session.seen_warnings: # Re-emitting the same warning from nested passes or revisited CFG nodes should # not duplicate the user-facing Python warning within one operation. return - session.seen_warnings.add(pending_warning.key) + session.seen_warnings.add(pending_warning._key) session.pending_warnings.append(pending_warning) @@ -141,12 +164,9 @@ def _pending_warning(diag: "Diagnostic") -> PendingWarning: message = _warning_message(diag) return PendingWarning( diagnostic=diag, - message=message, - filename=filename, - lineno=lineno, # Deduplicate on source location plus rendered message so repeated reports from # the same site collapse, while distinct warnings on one line still survive. - key=WarningKey(filename, lineno, column, message), + _key=_WarningKey(filename, lineno, column, message), )