commit:     b96561bcb32bc769b4d883efd92f3173606748cf
Author:     Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Sat Nov 22 16:51:07 2025 +0000
Commit:     Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Sat Nov 22 18:19:28 2025 +0000
URL:        
https://gitweb.gentoo.org/proj/pkgcore/pkgcheck.git/commit/?id=b96561bc

refactor: Fix ontology of reporters and annotations.

The base is an ABC, thus make it one.  In parallel the consuming
generator should *not* be assumed to be reusable between contexts,
instead the context should open up that consumer each time.

This code effects that change, while eliminating _start and _finish
since overloading __enter__ and __exit__ do the same thing.  Plus it's
only shitty xml that needs that.

Beyond this, annotations are threaded in so folks writing checks will
be warned if they try reporting a non Result derivative.

Via the cleanup above, .report was removed from Reporter instances.  Instead
a context object is returned with *strictly* the signature that should be
used, and additionally has a hardwired __call__ to the send in addition
to the .report().

I'll convert all checks to use the __call__ method in later commits.
That sort of flow is both clearer, and it also is faster (__call__ doesn't
require __dict__ or __slots__ lookup in the method tables- it's a hardwired
field in PyObject).

Finally, make this all immutable to keep folks from poking at shit they
shouldn't.

Signed-off-by: Brian Harring <ferringb <AT> gmail.com>

 src/pkgcheck/reporters.py             | 113 +++++++++++++++++++++-------------
 src/pkgcheck/scripts/pkgcheck_scan.py |   4 +-
 2 files changed, 71 insertions(+), 46 deletions(-)

diff --git a/src/pkgcheck/reporters.py b/src/pkgcheck/reporters.py
index 87e8d136..0abb675b 100644
--- a/src/pkgcheck/reporters.py
+++ b/src/pkgcheck/reporters.py
@@ -1,49 +1,70 @@
 """Basic result reporters."""
 
+import abc
 import csv
 import json
+import typing
 from collections import defaultdict
 from string import Formatter
 from xml.sax.saxutils import escape as xml_escape
 
-from snakeoil.decorators import coroutine
+from snakeoil.formatters import Formatter as snakeoil_Formatter
+from snakeoil.klass import immutable
 
 from . import base
 from .results import BaseLinesResult, InvalidResult, Result
 
+T_process_report: typing.TypeAlias = typing.Generator[None, Result, 
typing.NoReturn]
+T_report_func: typing.TypeAlias = typing.Callable[[Result], None]
 
-class Reporter:
+
+class Reporter(abc.ABC, immutable.Simple):
     """Generic result reporter."""
 
-    def __init__(self, out):
+    __slots__ = ("report", "_current_generator", "out")
+
+    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
+        self._current_generator = None
+
+    @immutable.Simple.__allow_mutation_wrapper__
+    def __enter__(self) -> T_report_func:
+        self.out.flush()
+        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()
+
+    @immutable.Simple.__allow_mutation_wrapper__
+    def __exit__(self, *exc_info):
+        # 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:
+        """
+        This must be a generator consuming from yield to then do something 
with Results
 
-        # initialize result processing coroutines
-        self.report = self._process_report().send
-
-    def __enter__(self):
-        self._start()
-        return self
-
-    def __exit__(self, *excinfo):
-        self._finish()
-        # flush output buffer
-        self.out.stream.flush()
-
-    @coroutine
-    def _process_report(self):
-        """Render and output a report result.."""
-        raise NotImplementedError(self._process_report)
-
-    def _start(self):
-        """Initialize reporter output."""
-
-    def _finish(self):
-        """Finalize reporter output."""
+        Whilst the pattern may seem odd, this is a generator since Reporters 
have to typically
+        keep state between Results- a generator simplifies this.  Simpler 
code, and faster
+        since it's just resuming a generator frame.
+        """
 
 
 class StrReporter(Reporter):
@@ -56,10 +77,11 @@ class StrReporter(Reporter):
         sys-apps/portage-2.1-r2: no change in 75 days, keywords [ ~x86-fbsd ]
     """
 
+    __slots__ = ()
+
     priority = 0
 
-    @coroutine
-    def _process_report(self):
+    def _consume_reports_generator(self) -> T_process_report:
         # scope to result prefix mapping
         scope_prefix_map = {
             base.version_scope: "{category}/{package}-{version}: ",
@@ -85,10 +107,10 @@ class FancyReporter(Reporter):
           StableRequest: sys-apps/portage-2.1-r2: no change in 75 days, 
keywords [ ~x86 ]
     """
 
+    __slots__ = ()
     priority = 1
 
-    @coroutine
-    def _process_report(self):
+    def _consume_reports_generator(self) -> T_process_report:
         prev_key = None
 
         while True:
@@ -130,10 +152,10 @@ class JsonReporter(Reporter):
         jq -c -s 'reduce.[]as$x({};.*$x)' orig.json > new.json
     """
 
+    __slots__ = ()
     priority = -1000
 
-    @coroutine
-    def _process_report(self):
+    def _consume_reports_generator(self) -> T_process_report:
         # arbitrarily nested defaultdicts
         json_dict = lambda: defaultdict(json_dict)
         # scope to data conversion mapping
@@ -156,16 +178,20 @@ class JsonReporter(Reporter):
 class XmlReporter(Reporter):
     """Feed of newline-delimited XML reports."""
 
+    __slots__ = ()
     priority = -1000
 
-    def _start(self):
+    def __enter__(self):
         self.out.write("<checks>")
+        return super().__enter__()
 
-    def _finish(self):
+    def __exit__(self, *exc_info):
+        # finalize/close the generator, *then* close the xml.
+        ret = super().__exit__(*exc_info)
         self.out.write("</checks>")
+        return ret
 
-    @coroutine
-    def _process_report(self):
+    def _consume_reports_generator(self) -> T_process_report:
         result_template = 
"<result><class>%(class)s</class><msg>%(msg)s</msg></result>"
         cat_template = (
             "<result><category>%(category)s</category>"
@@ -187,7 +213,6 @@ class XmlReporter(Reporter):
             base.package_scope: pkg_template,
             base.version_scope: ver_template,
         }
-
         while True:
             result = yield
             d = {k: getattr(result, k, "") for k in ("category", "package", 
"version")}
@@ -207,10 +232,10 @@ class CsvReporter(Reporter):
         sys-apps,portage,2.1-r2,"no change in 75 days, keywords [ ~x86-fbsd ]"
     """
 
+    __slots__ = ()
     priority = -1001
 
-    @coroutine
-    def _process_report(self):
+    def _consume_reports_generator(self) -> T_process_report:
         writer = csv.writer(self.out, doublequote=False, escapechar="\\", 
lineterminator="")
 
         while True:
@@ -244,14 +269,14 @@ class FormatReporter(Reporter):
     This formatter uses custom format string passed using the ``--format``
     command line argument."""
 
+    __slots__ = ("format_str",)
     priority = -1001
 
     def __init__(self, format_str, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.format_str = format_str
 
-    @coroutine
-    def _process_report(self):
+    def _consume_reports_generator(self) -> T_process_report:
         formatter = _ResultFormatter()
         # provide expansions for result desc, level, and output name properties
         properties = ("desc", "level", "name")
@@ -274,6 +299,7 @@ class DeserializationError(Exception):
 class JsonStream(Reporter):
     """Generate a stream of result objects serialized in JSON."""
 
+    __slots__ = ()
     priority = -1001
 
     @staticmethod
@@ -300,8 +326,7 @@ class JsonStream(Reporter):
         except (KeyError, InvalidResult):
             raise DeserializationError("unknown result")
 
-    @coroutine
-    def _process_report(self):
+    def _consume_reports_generator(self) -> T_process_report:
         while True:
             result = yield
             self.out.write(json.dumps(result, default=self.to_json))
@@ -313,10 +338,10 @@ class FlycheckReporter(Reporter):
     .. [#] https://github.com/flycheck/flycheck
     """
 
+    __slots__ = ()
     priority = -1001
 
-    @coroutine
-    def _process_report(self):
+    def _consume_reports_generator(self) -> T_process_report:
         while True:
             result = yield
             file = f"{getattr(result, 'package', '')}-{getattr(result, 
'version', '')}.ebuild"

diff --git a/src/pkgcheck/scripts/pkgcheck_scan.py 
b/src/pkgcheck/scripts/pkgcheck_scan.py
index d25a83de..f8cf1006 100644
--- a/src/pkgcheck/scripts/pkgcheck_scan.py
+++ b/src/pkgcheck/scripts/pkgcheck_scan.py
@@ -518,10 +518,10 @@ def _determine_restrictions(namespace, attr):
 @scan.bind_main_func
 def _scan(options, out, err):
     with ExitStack() as stack:
-        reporter = stack.enter_context(options.reporter(out))
+        report = stack.enter_context(options.reporter(out))
         for c in options.pop("contexts"):
             stack.enter_context(c)
         pipe = Pipeline(options)
         for result in pipe:
-            reporter.report(result)
+            report(result)
     return int(bool(pipe.errors))

Reply via email to