Coverage for klayout_pex/tool_version_constraints.py: 82%
114 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 19:08 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 19:08 +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"""Version requirements for the external tools, and the checks against them."""
27from __future__ import annotations
29from dataclasses import dataclass
30from enum import StrEnum
31import re
32import subprocess
33from typing import *
35from packaging.specifiers import SpecifierSet
36from packaging.version import InvalidVersion, Version
38from .env import EnvVar
39from .log import debug, error, rule, subproc, warning
40from .pdk_config import PDK
43class Tool(StrEnum):
44 FASTCAP = 'FastCap2'
45 FASTERCAP = 'FasterCap'
46 KLAYOUT = 'KLayout'
47 MAGIC = 'MAGIC'
49 @property
50 def env_var(self) -> EnvVar:
51 match self:
52 case Tool.FASTCAP: return EnvVar.FASTCAP_EXE
53 case Tool.FASTERCAP: return EnvVar.FASTERCAP_EXE
54 case Tool.KLAYOUT: return EnvVar.KLAYOUT_EXE
55 case Tool.MAGIC: return EnvVar.MAGIC_EXE
56 case _: raise NotImplementedError(f"Unexpected tool '{self.name}'")
58 @property
59 def version_argument(self) -> str:
60 match self:
61 case Tool.KLAYOUT: return '-v'
62 case _: return '--version'
64 def parse_version(self, text: str) -> Optional[Version]:
65 """
66 The version a tool reports, as a comparable PEP 440 version.
68 MAGIC spells its patch level 'revision' and prints it in the banner of
69 every run ('Magic 8.3 revision 681'), so that becomes 8.3.681 and the
70 banner an extraction already captured can be used as-is.
71 """
72 match self:
73 case Tool.MAGIC:
74 m = re.search(r'(?:Magic\s+)?(\d+)\.(\d+)(?:\s+revision\s+|\.)(\d+)',
75 text, flags=re.IGNORECASE)
76 candidate = None if m is None else '.'.join(m.groups())
77 case _:
78 # A packaging release ('0.30.4-1' from the KLayout .deb) is not
79 # part of the tool's own version and is left out.
80 m = re.search(rf'(?:{re.escape(self.value)}\s+)?v?(\d+\.\d+(?:\.\d+)?)',
81 text, flags=re.IGNORECASE)
82 candidate = None if m is None else m.group(1)
84 if candidate is None:
85 return None
86 try:
87 return Version(candidate)
88 except InvalidVersion:
89 return None
91 def detect_version(self, exe_path: str) -> Optional[Version]:
92 """
93 Ask the tool for its version. None when it could not be determined,
94 which is never fatal: an unknown version only means unchecked.
95 """
96 try:
97 proc = subprocess.run([exe_path, self.version_argument],
98 capture_output=True, text=True, timeout=30)
99 except (OSError, subprocess.SubprocessError) as e:
100 debug(f"Could not run '{exe_path} {self.version_argument}': {e}")
101 return None
103 # Some tools print their banner and then complain about the argument,
104 # so the output is parsed whatever the exit code was.
105 return self.parse_version(f"{proc.stdout}\n{proc.stderr}")
108class Severity(StrEnum):
109 ERROR = 'error'
110 WARNING = 'warning'
113class VersionCheckMode(StrEnum):
114 ON = 'on'
115 """Report a violated constraint at its own severity."""
117 OFF = 'off'
118 """Do not look at tool versions at all."""
120 WARN = 'warn'
121 """Report every violated constraint as a warning, never as an error."""
123 DEFAULT = 'on'
126@dataclass(frozen=True)
127class ToolVersionConstraint:
128 """
129 One requirement on the version of an external tool.
131 Constraints are declared rather than checked inline so that they compose:
132 the effective requirement for a tool is the conjunction of everything that
133 applies to it, which is what gets reported.
134 """
136 id: str
137 """Names this constraint in the log."""
139 tool: Tool
141 specifier: str
142 """A PEP 440 specifier: '>= 0.30.3', '!= 0.30.5', '< 9.0'."""
144 reason: str
145 """Why the constraint exists. Printed when it is violated."""
147 severity: Severity = Severity.ERROR
149 pdk: Optional[PDK] = None
150 """The PDK that needs this. None applies to every PDK."""
152 @property
153 def specifier_set(self) -> SpecifierSet:
154 return SpecifierSet(self.specifier)
156 def is_satisfied_by(self, version: Version) -> bool:
157 return self.specifier_set.contains(version, prereleases=True)
159 def applies_to(self, pdk: Optional[PDK]) -> bool:
160 return self.pdk is None or self.pdk == pdk
163TOOL_VERSION_CONSTRAINTS: Tuple[ToolVersionConstraint, ...] = (
164 ToolVersionConstraint(
165 id='KLAYOUT_NEIGHBORHOOD_VISITOR',
166 tool=Tool.KLAYOUT,
167 specifier='>= 0.30.1',
168 reason="the KPEX/2.5D engine needs PolygonNeighborhoodVisitor, "
169 "EdgeNeighborhoodVisitor and the *WithProperties geometry classes",
170 ),
171 ToolVersionConstraint(
172 id='KLAYOUT_PEX_MODULE',
173 tool=Tool.KLAYOUT,
174 specifier='>= 0.30.2',
175 reason="resistance extraction needs the klayout.pex module",
176 ),
177 ToolVersionConstraint(
178 id='KLAYOUT_RESISTANCE_FIXES',
179 tool=Tool.KLAYOUT,
180 specifier='>= 0.30.3',
181 reason="earlier releases have bugs in resistance extraction",
182 ),
183 ToolVersionConstraint(
184 id='FASTERCAP_BASELINE',
185 tool=Tool.FASTERCAP,
186 specifier='>= 6.0.9',
187 reason="the version the integration tests are run against",
188 severity=Severity.WARNING,
189 ),
190 ToolVersionConstraint(
191 id='MAGIC_SIDEWALL_DEFINITION',
192 tool=Tool.MAGIC,
193 specifier='>= 8.3.679',
194 reason="MAGIC used to count each sidewall edge against the full "
195 "'defaultsidewall' value of the tech file, which double-counts "
196 "it; 8.3.679 redefined the tech file value instead of changing "
197 "every PDK, so an older MAGIC reports twice the sidewall "
198 "capacitance, see "
199 "https://github.com/martinjankoehler/magic/issues/6#issuecomment-5371056429",
200 ),
201)
204def applicable_constraints(tool: Tool,
205 pdk: Optional[PDK] = None) -> List[ToolVersionConstraint]:
206 return [c for c in TOOL_VERSION_CONSTRAINTS
207 if c.tool == tool and c.applies_to(pdk)]
210def effective_version_range(tool: Tool,
211 pdk: Optional[PDK] = None) -> SpecifierSet:
212 """The conjunction of every constraint that applies, as one specifier set."""
213 return SpecifierSet(','.join(c.specifier for c in applicable_constraints(tool, pdk)))
216def check_tool_versions(exe_path_by_tool: Dict[Tool, str],
217 pdk: Optional[PDK] = None,
218 mode: VersionCheckMode = VersionCheckMode.ON) -> bool:
219 """
220 Report the version of every tool a run will use against its constraints.
222 Returns False when a constraint was violated that the mode treats as an
223 error, so that the caller can refuse the run.
224 """
225 if mode == VersionCheckMode.OFF or not exe_path_by_tool:
226 return True
228 rule('Tool versions')
230 found_errors = False
231 rows: List[Tuple[str, str, str]] = []
233 for tool, exe_path in exe_path_by_tool.items():
234 constraints = applicable_constraints(tool, pdk)
235 version = tool.detect_version(exe_path)
236 rows.append((tool.value,
237 'unknown' if version is None else str(version),
238 str(effective_version_range(tool, pdk)) or 'any'))
240 for constraint in constraints:
241 debug(f"{constraint.id}: {tool.value} {constraint.specifier} — "
242 f"{constraint.reason}")
244 if version is None:
245 if constraints:
246 warning(f"Can't determine the {tool.value} version from "
247 f"'{exe_path} {tool.version_argument}', "
248 f"so its version requirements stay unchecked")
249 continue
251 for constraint in constraints:
252 if constraint.is_satisfied_by(version):
253 continue
254 message = (f"{tool.value} {version} does not satisfy "
255 f"{constraint.specifier} ({constraint.id}): {constraint.reason}")
256 if constraint.severity == Severity.ERROR and mode == VersionCheckMode.ON:
257 error(message)
258 found_errors = True
259 else:
260 warning(message)
262 header = ('Tool', 'Found', 'Effective range')
263 widths = [max(len(cell) for cell in column)
264 for column in zip(header, *rows)]
265 for row in (header, *rows):
266 subproc(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip())
268 return not found_errors