commit: e186b078ec03bf5cf109eb2d7c45116f1890ab0a
Author: Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Mon Nov 24 12:48:40 2025 +0000
Commit: Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Mon Nov 24 15:49:35 2025 +0000
URL:
https://gitweb.gentoo.org/proj/pkgcore/pkgcheck.git/commit/?id=e186b078
chore: add callback/multiplexing reporters, cleanup reporter annotations
Additionally, split streaming reporters out as a subclass derivative.
Signed-off-by: Brian Harring <ferringb <AT> gmail.com>
src/pkgcheck/objects.py | 3 +-
src/pkgcheck/reporters.py | 122 +++++++++++++++++++++++++++++++++-------------
tests/test_reporters.py | 38 +++++++++++++++
3 files changed, 129 insertions(+), 34 deletions(-)
diff --git a/src/pkgcheck/objects.py b/src/pkgcheck/objects.py
index 0c2a03d2..8143e819 100644
--- a/src/pkgcheck/objects.py
+++ b/src/pkgcheck/objects.py
@@ -42,6 +42,7 @@ def _find_classes(module, matching_cls, skip=()): # pragma:
no cover
and issubclass(cls, matching_cls)
and cls.__name__[0] != "_"
and cls not in skip
+ and not inspect.isabstract(cls)
):
yield cls
@@ -197,4 +198,4 @@ class _ChecksLazyDict(_LazyDict):
KEYWORDS = _KeywordsLazyDict("KEYWORDS", ("checks", "results.Result"))
CHECKS = _ChecksLazyDict("CHECKS", ("checks", "checks.Check"))
-REPORTERS = _LazyDict("REPORTERS", ("reporters", "reporters.Reporter"))
+REPORTERS = _LazyDict("REPORTERS", ("reporters", "reporters.StreamReporter"))
diff --git a/src/pkgcheck/reporters.py b/src/pkgcheck/reporters.py
index 50345617..6171cf75 100644
--- a/src/pkgcheck/reporters.py
+++ b/src/pkgcheck/reporters.py
@@ -1,6 +1,7 @@
"""Basic result reporters."""
import abc
+import contextlib
import csv
import json
import typing
@@ -18,43 +19,41 @@ T_process_report: typing.TypeAlias = typing.Generator[None,
Result, typing.NoRet
T_report_func: typing.TypeAlias = typing.Callable[[Result], None]
+class ReportFuncShim:
+ """Compatibility shim while migrating endusers away from using .report()"""
+
+ __slots__ = ("report",)
+
+ def __init__(self, report: T_report_func) -> None:
+ self.report = report
+
+ def __call__(self, result: Result) -> None:
+ self.report(result)
+
+
class Reporter(abc.ABC, immutable.Simple):
"""Generic result reporter."""
- __slots__ = ("report", "_current_generator", "out")
+ __slots__ = ("report", "_current_generator")
priority: int # used by the config system
_current_generator: T_process_report | None
- def __init__(self, out: snakeoil_Formatter):
- """Initialize
-
- :type out: L{snakeoil.formatters.Formatter}
- """
- self.out = out
+ def __init__(self) -> None:
self._current_generator = None
@immutable.Simple.__allow_mutation_wrapper__
- def __enter__(self) -> T_report_func:
- self.out.flush()
+ def __enter__(self) -> ReportFuncShim:
self._current_generator = self._consume_reports_generator()
# start the generator
next(self._current_generator)
-
- # make a class so there's no intermediate frame relaying for __call__.
Optimization.
- class reporter:
- __slots__ = ()
- report: T_report_func = staticmethod(self._current_generator.send)
- __call__: T_report_func =
staticmethod(self._current_generator.send)
-
- return reporter()
+ return ReportFuncShim(self._current_generator.send)
@immutable.Simple.__allow_mutation_wrapper__
- def __exit__(self, *exc_info):
+ def __exit__(self, typ, value, traceback):
# shut down the generator so it can do any finalization
self._current_generator.close() # pyright:
ignore[reportOptionalMemberAccess]
self._current_generator = None
- self.out.flush()
@abc.abstractmethod
def _consume_reports_generator(self) -> T_process_report:
@@ -67,7 +66,28 @@ class Reporter(abc.ABC, immutable.Simple):
"""
-class StrReporter(Reporter):
+class StreamReporter(Reporter):
+ __slots__ = ("out",)
+ out: snakeoil_Formatter
+
+ def __init__(self, out: snakeoil_Formatter):
+ """Initialize
+
+ :type out: L{snakeoil.formatters.Formatter}
+ """
+ super().__init__()
+ self.out = out
+
+ def __enter__(self) -> ReportFuncShim:
+ self.out.flush()
+ return super().__enter__()
+
+ def __exit__(self, typ, value, traceback) -> None:
+ super().__exit__(typ, value, traceback)
+ self.out.flush()
+
+
+class StrReporter(StreamReporter):
"""Simple string reporter, pkgcheck-0.1 behaviour.
Example::
@@ -96,7 +116,7 @@ class StrReporter(Reporter):
self.out.stream.flush()
-class FancyReporter(Reporter):
+class FancyReporter(StreamReporter):
"""Colored output grouped by result scope.
Example::
@@ -140,7 +160,7 @@ class FancyReporter(Reporter):
self.out.stream.flush()
-class JsonReporter(Reporter):
+class JsonReporter(StreamReporter):
"""Feed of newline-delimited JSON records.
Note that the format is newline-delimited JSON with each line being related
@@ -175,7 +195,7 @@ class JsonReporter(Reporter):
self.out.stream.flush()
-class XmlReporter(Reporter):
+class XmlReporter(StreamReporter):
"""Feed of newline-delimited XML reports."""
__slots__ = ()
@@ -185,9 +205,9 @@ class XmlReporter(Reporter):
self.out.write("<checks>")
return super().__enter__()
- def __exit__(self, *exc_info):
+ def __exit__(self, typ, value, traceback):
# finalize/close the generator, *then* close the xml.
- ret = super().__exit__(*exc_info)
+ ret = super().__exit__(typ, value, traceback)
self.out.write("</checks>")
return ret
@@ -221,7 +241,7 @@ class XmlReporter(Reporter):
self.out.write(scope_map.get(result.scope, result_template) % d)
-class CsvReporter(Reporter):
+class CsvReporter(StreamReporter):
"""Comma-separated value reporter, convenient for shell processing.
Example::
@@ -253,17 +273,17 @@ class CsvReporter(Reporter):
class _ResultFormatter(Formatter):
"""Custom string formatter that collapses unmatched variables."""
- def get_value(self, key, args, kwds):
+ def get_value(self, key, args, kwargs):
"""Retrieve a given field value, an empty string is returned for
unmatched fields."""
if isinstance(key, str):
try:
- return kwds[key]
+ return kwargs[key]
except KeyError:
return ""
raise base.PkgcheckUserException("FormatReporter: integer indexes are
not supported")
-class FormatReporter(Reporter):
+class FormatReporter(StreamReporter):
"""Custom format string reporter.
This formatter uses custom format string passed using the ``--format``
@@ -296,23 +316,25 @@ class DeserializationError(Exception):
"""Exception occurred while deserializing a data stream."""
-class JsonStream(Reporter):
+class JsonStream(StreamReporter):
"""Generate a stream of result objects serialized in JSON."""
__slots__ = ()
priority = -1001
@staticmethod
- def to_json(obj):
+ def to_json(obj) -> str | dict[str, str]:
"""Serialize results and other objects to JSON."""
if isinstance(obj, Result):
d = {"__class__": obj.__class__.__name__}
d.update(obj._attrs)
return d
+ # TODO: remove this pathway via using JSONDecoder with registered
decoders.
+ # tests for to_json force a cast, so remove that also.
return str(obj)
@staticmethod
- def from_iter(iterable):
+ def from_iter(iterable) -> typing.Generator[Result, None, None]:
"""Deserialize results from a given iterable."""
# avoid circular import issues
from . import objects
@@ -332,7 +354,7 @@ class JsonStream(Reporter):
self.out.write(json.dumps(result, default=self.to_json))
-class FlycheckReporter(Reporter):
+class FlycheckReporter(StreamReporter):
"""Simple line reporter done for easier integration with flycheck [#]_ .
.. [#] https://github.com/flycheck/flycheck
@@ -353,3 +375,37 @@ class FlycheckReporter(Reporter):
else:
lineno = getattr(result, "lineno", 0)
self.out.write(f"{file}:{lineno}:{getattr(result,
'level')}:{message}")
+
+
+class CallbackReporter(Reporter):
+ """Reporter that calls back for every result"""
+
+ __slots__ = ("callbacks",)
+ callbacks: list[T_report_func]
+
+ def __init__(self, *callbacks: T_report_func) -> None:
+ self.callbacks = list(callbacks)
+
+ def _consume_reports_generator(self) -> T_process_report:
+ while True:
+ result = yield
+ for callback in self.callbacks:
+ callback(result)
+
+
+class MultiplexingReporter(Reporter):
+ """Reporter that multiplexes results to multiple Reporters"""
+
+ __slots__ = ("reporters",)
+ reporters: list[Reporter]
+
+ def __init__(self, *args: Reporter) -> None:
+ self.reporters = list(args)
+
+ def _consume_reports_generator(self) -> T_process_report:
+ with contextlib.ExitStack() as context:
+ callbacks = [context.enter_context(reporter) for reporter in
self.reporters]
+ while True:
+ result = yield
+ for report_func in callbacks:
+ report_func(result)
diff --git a/tests/test_reporters.py b/tests/test_reporters.py
index 911a0c92..3fd4bb39 100644
--- a/tests/test_reporters.py
+++ b/tests/test_reporters.py
@@ -217,3 +217,41 @@ class TestFlycheckReporter(BaseReporter):
foo-0.ebuild:7:warning:UnquotedVariable: unquoted variable D
"""
)
+
+
+class TestCallbackReporter:
+ results = BaseReporter.results
+
+ def test_it(self):
+ collected = [], []
+ with reporters.CallbackReporter(*[l.append for l in collected]) as
report:
+ for result in self.results:
+ report(result)
+ assert list(self.results), list(self.results) == collected
+
+
+class TestMultiplexingReporter:
+ results = BaseReporter.results
+
+ def test_it(self):
+ collected = []
+ context_checks = []
+ context_check_results = []
+
+ class context_verifier:
+ def __enter__(self):
+ context_checks.append(True)
+ return reporters.ReportFuncShim(context_check_results.append)
+
+ def __exit__(self, typ, value, traceback):
+ context_checks.append(True)
+
+ with reporters.MultiplexingReporter(
+ context_verifier(), reporters.CallbackReporter(collected.append)
+ ) as report:
+ for result in self.results:
+ report(result)
+
+ assert self.results == tuple(collected)
+ assert [True, True] == context_checks
+ assert self.results == tuple(context_check_results)