Coverage for klayout_pex/pex25d/diagnostics.py: 71%
161 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
1#
2# --------------------------------------------------------------------------------
3# SPDX-FileCopyrightText: 2024-2026 Martin Jan Köhler and Harald Pretl
4# Johannes Kepler University, Institute for Integrated Circuits.
5#
6# This file is part of KPEX
7# (see https://github.com/iic-jku/klayout-pex).
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
21# SPDX-License-Identifier: GPL-3.0-or-later
22# --------------------------------------------------------------------------------
23#
25"""
26Validator output: coded, positioned diagnostics, and how they reach the user.
28This can be used to check a custom PEX25D *writer* against the reference validator without depending on kpex internals
29or on the exact wording of a message — so ``--diagnostics json`` and ``--diagnostics pb`` are first-class
30outputs here, not a debugging afterthought.
32Diagnostics are modelled as a plain dataclass rather than as the generated protobuf message, for two reasons:
33 1) rendering must work on a checkout where the ``*_pb2`` modules have not been generated yet, and
34 2) the human renderer wants to group and sort in ways the wire format has no opinion about.
35 The conversion to``kpex.pex25d.Diagnostic`` happens only for ``--diagnostics pb``.
36"""
38from __future__ import annotations
40import contextlib
41import dataclasses
42import enum
43import json
44import sys
45from dataclasses import dataclass, field
46from enum import IntEnum, StrEnum
47from typing import *
49from ..log import (
50 error,
51 warning,
52 info,
53 subproc,
54)
57class ExitCode(IntEnum):
58 """
59 Process exit codes, shared by ``kpex`` and ``pex25d``.
61 CI callers distinguish invalid PEX25D (DIAGNOSTIC_ERRORS) from invocation
62 or I/O problems (USAGE). NOT_IMPLEMENTED identifies unsupported operations;
63 INTERNAL_ERROR identifies unexpected plugin failures.
64 """
66 OK = 0
67 DIAGNOSTIC_ERRORS = 1
68 USAGE = 2
69 NOT_IMPLEMENTED = 3
70 INTERNAL_ERROR = 4
73class Severity(IntEnum):
74 NOTE = 1
75 WARNING = 2
76 ERROR = 3
78 @property
79 def proto_name(self) -> str:
80 return f"SEVERITY_{self.name}"
83class Tier(IntEnum):
84 SYNTAX = 1 # arity, clause order, literal not a grid multiple, unknown record
85 SEMANTIC = 2 # name resolution, acyclic WRAPS, depth ties, ...
86 GEOMETRIC = 3 # ring and box shape, hole containment, overlap (--strict)
88 @property
89 def proto_name(self) -> str:
90 return f"TIER_{self.name}"
93class DiagnosticsFormat(StrEnum):
94 """
95 How validator output is rendered.
97 ``human``
98 Rich text for a terminal. Not stable; never parse it.
99 ``json``
100 One JSON object with a ``diagnostics`` array, mirroring the field names
101 of ``kpex.pex25d.DiagnosticList``. Stable.
102 ``pb``
103 Binary ``kpex.pex25d.DiagnosticList``. Stable.
104 """
106 HUMAN = 'human'
107 JSON = 'json'
108 PB = 'pb'
110 DEFAULT = 'human'
113@dataclass(frozen=True)
114class SourceRef:
115 """Position in a PEX25D source file. Mirrors ``kpex.pex25d.SourceRef``."""
117 file: Optional[str] = None
118 line: Optional[int] = None
119 column: Optional[int] = None
121 def __str__(self) -> str:
122 parts = [self.file or '<input>']
123 if self.line is not None:
124 parts.append(str(self.line))
125 if self.column is not None:
126 parts.append(str(self.column))
127 return ':'.join(parts)
130def source_ref(record: Any) -> Optional[SourceRef]:
131 """The ``SourceRef`` of a protobuf record, or ``None`` if it carries none."""
132 if record is None or not hasattr(record, 'HasField'):
133 return None
134 try:
135 if not record.HasField('source'):
136 return None
137 except ValueError:
138 return None
139 source = record.source
140 return SourceRef(file=source.file or None,
141 line=source.line or None,
142 column=source.column or None)
145@dataclass(frozen=True)
146class Diagnostic:
147 """One validator finding. Mirrors ``kpex.pex25d.Diagnostic``."""
149 code: str
150 severity: Severity
151 tier: Tier
152 message: str
153 source: Optional[SourceRef] = None
154 related: Sequence[SourceRef] = ()
156 def as_json_dict(self) -> Dict[str, Any]:
157 d: Dict[str, Any] = {
158 'code': self.code,
159 'severity': self.severity.proto_name,
160 'tier': self.tier.proto_name,
161 'message': self.message,
162 }
163 if self.source is not None:
164 d['source'] = dataclasses.asdict(self.source)
165 if self.related:
166 d['related'] = [dataclasses.asdict(r) for r in self.related]
167 return d
170@dataclass
171class DiagnosticsReport:
172 """Accumulates diagnostics and decides the process exit code."""
174 diagnostics: List[Diagnostic] = field(default_factory=list)
175 warnings_are_errors: bool = False
177 def add(self, diagnostic: Diagnostic) -> None:
178 self.diagnostics.append(diagnostic)
180 def extend(self, diagnostics: Iterable[Diagnostic]) -> None:
181 self.diagnostics.extend(diagnostics)
183 @property
184 def num_errors(self) -> int:
185 return sum(1 for d in self.diagnostics if d.severity == Severity.ERROR)
187 @property
188 def num_warnings(self) -> int:
189 return sum(1 for d in self.diagnostics if d.severity == Severity.WARNING)
191 @property
192 def exit_code(self) -> ExitCode:
193 if self.num_errors:
194 return ExitCode.DIAGNOSTIC_ERRORS
195 if self.warnings_are_errors and self.num_warnings:
196 return ExitCode.DIAGNOSTIC_ERRORS
197 return ExitCode.OK
199 # ---------------------------------------------------------------- rendering
201 def render(self,
202 format: DiagnosticsFormat,
203 stream: Optional[IO[Any]] = None) -> None:
204 match format:
205 case DiagnosticsFormat.HUMAN:
206 self._render_human()
207 case DiagnosticsFormat.JSON:
208 self._render_json(stream or sys.stdout)
209 case DiagnosticsFormat.PB:
210 self._render_pb(stream)
211 case _:
212 raise ValueError(f"Unknown diagnostics format {format}")
214 def _render_human(self) -> None:
215 by_tier: Dict[Tier, List[Diagnostic]] = {}
216 for d in self.diagnostics:
217 by_tier.setdefault(d.tier, []).append(d)
219 for tier in sorted(by_tier.keys()):
220 for d in by_tier[tier]:
221 where = f"{d.source} " if d.source else ''
222 line = f"{where}{d.code}: {d.message}"
223 match d.severity:
224 case Severity.ERROR:
225 error(line)
226 case Severity.WARNING:
227 warning(line)
228 case _:
229 info(line)
230 for r in d.related:
231 subproc(f" … see also {r}")
233 if not self.diagnostics:
234 info("No diagnostics.")
235 else:
236 info(f"{self.num_errors} error(s), {self.num_warnings} warning(s), "
237 f"{len(self.diagnostics)} diagnostic(s) total")
239 def _render_json(self, stream: IO[Any]) -> None:
240 payload = {'diagnostics': [d.as_json_dict() for d in self.diagnostics]}
241 text = json.dumps(payload, indent=2, ensure_ascii=False)
242 if hasattr(stream, 'buffer') or isinstance(stream, io_text_types()):
243 stream.write(text + '\n')
244 else:
245 stream.write((text + '\n').encode('utf-8'))
246 stream.flush()
248 def _render_pb(self, stream: Optional[IO[Any]]) -> None:
249 from .protobuf import pex25d_diagnostics_pb2
251 pb2 = pex25d_diagnostics_pb2()
252 diagnostic_list = pb2.DiagnosticList()
253 for d in self.diagnostics:
254 pb = diagnostic_list.diagnostics.add()
255 pb.code = d.code
256 pb.severity = pb2.Diagnostic.Severity.Value(d.severity.proto_name)
257 pb.tier = pb2.Diagnostic.Tier.Value(d.tier.proto_name)
258 pb.message = d.message
259 if d.source is not None:
260 _fill_source_ref(pb.source, d.source)
261 for r in d.related:
262 _fill_source_ref(pb.related.add(), r)
264 out = stream if stream is not None else sys.stdout.buffer
265 out = getattr(out, 'buffer', out)
266 out.write(diagnostic_list.SerializeToString())
267 out.flush()
270def _fill_source_ref(pb: Any, ref: SourceRef) -> None:
271 if ref.file is not None:
272 pb.file = ref.file
273 if ref.line is not None:
274 pb.line = ref.line
275 if ref.column is not None:
276 pb.column = ref.column
279def io_text_types() -> Tuple[type, ...]:
280 import io
281 return (io.TextIOBase,)
284@contextlib.contextmanager
285def diagnostics_stream(writes_to_stdout: bool) -> Iterator[IO[Any]]:
286 """
287 Yield the stream diagnostics should be written to.
289 When the verb's *artifact* output goes to stdout, diagnostics must not:
290 a PEX25D file with a JSON diagnostics blob stapled to the front is not a PEX25D file.
291 In that case diagnostics go to stderr, which is also where the rich logger already writes.
292 """
293 yield sys.stderr if writes_to_stdout else sys.stdout