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

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# 

24 

25"""Version requirements for the external tools, and the checks against them.""" 

26 

27from __future__ import annotations 

28 

29from dataclasses import dataclass 

30from enum import StrEnum 

31import re 

32import subprocess 

33from typing import * 

34 

35from packaging.specifiers import SpecifierSet 

36from packaging.version import InvalidVersion, Version 

37 

38from .env import EnvVar 

39from .log import debug, error, rule, subproc, warning 

40from .pdk_config import PDK 

41 

42 

43class Tool(StrEnum): 

44 FASTCAP = 'FastCap2' 

45 FASTERCAP = 'FasterCap' 

46 KLAYOUT = 'KLayout' 

47 MAGIC = 'MAGIC' 

48 

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}'") 

57 

58 @property 

59 def version_argument(self) -> str: 

60 match self: 

61 case Tool.KLAYOUT: return '-v' 

62 case _: return '--version' 

63 

64 def parse_version(self, text: str) -> Optional[Version]: 

65 """ 

66 The version a tool reports, as a comparable PEP 440 version. 

67 

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) 

83 

84 if candidate is None: 

85 return None 

86 try: 

87 return Version(candidate) 

88 except InvalidVersion: 

89 return None 

90 

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 

102 

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}") 

106 

107 

108class Severity(StrEnum): 

109 ERROR = 'error' 

110 WARNING = 'warning' 

111 

112 

113class VersionCheckMode(StrEnum): 

114 ON = 'on' 

115 """Report a violated constraint at its own severity.""" 

116 

117 OFF = 'off' 

118 """Do not look at tool versions at all.""" 

119 

120 WARN = 'warn' 

121 """Report every violated constraint as a warning, never as an error.""" 

122 

123 DEFAULT = 'on' 

124 

125 

126@dataclass(frozen=True) 

127class ToolVersionConstraint: 

128 """ 

129 One requirement on the version of an external tool. 

130 

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 """ 

135 

136 id: str 

137 """Names this constraint in the log.""" 

138 

139 tool: Tool 

140 

141 specifier: str 

142 """A PEP 440 specifier: '>= 0.30.3', '!= 0.30.5', '< 9.0'.""" 

143 

144 reason: str 

145 """Why the constraint exists. Printed when it is violated.""" 

146 

147 severity: Severity = Severity.ERROR 

148 

149 pdk: Optional[PDK] = None 

150 """The PDK that needs this. None applies to every PDK.""" 

151 

152 @property 

153 def specifier_set(self) -> SpecifierSet: 

154 return SpecifierSet(self.specifier) 

155 

156 def is_satisfied_by(self, version: Version) -> bool: 

157 return self.specifier_set.contains(version, prereleases=True) 

158 

159 def applies_to(self, pdk: Optional[PDK]) -> bool: 

160 return self.pdk is None or self.pdk == pdk 

161 

162 

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) 

202 

203 

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)] 

208 

209 

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))) 

214 

215 

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. 

221 

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 

227 

228 rule('Tool versions') 

229 

230 found_errors = False 

231 rows: List[Tuple[str, str, str]] = [] 

232 

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')) 

239 

240 for constraint in constraints: 

241 debug(f"{constraint.id}: {tool.value} {constraint.specifier}" 

242 f"{constraint.reason}") 

243 

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 

250 

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) 

261 

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()) 

267 

268 return not found_errors