Coverage for tests/klayout/lvs_cache_test.py: 100%

183 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 23:32 +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# 

24from __future__ import annotations 

25 

26import allure 

27import argparse 

28import filecmp 

29import io 

30import os 

31import shutil 

32import tempfile 

33import unittest 

34from typing import * 

35from unittest import mock 

36 

37from packaging.version import Version 

38 

39from klayout_pex.kpex_cli import InputMode, KpexCLI 

40from klayout_pex.klayout.lvs_cache import ( 

41 FINGERPRINT_FORMAT_VERSION, 

42 LVSCacheEntry, 

43 LVSInputFingerprint, 

44 deck_fingerprint, 

45 fingerprint_differences, 

46 lvs_input_fingerprint, 

47 read_fingerprint, 

48 write_fingerprint, 

49) 

50from klayout_pex.tool_version_constraints import Tool 

51 

52 

53TESTDATA_DIR = os.path.realpath(os.path.join(__file__, '..', '..', '..', 'testdata')) 

54 

55# any LVS database will do, as long as it can be told apart from the one a (fake) LVS run writes 

56CACHED_LVSDB_PATH = os.path.join(TESTDATA_DIR, 'klayout', 'lvs', 

57 'rfnmos_w1u_l0u72_broken_rfmos_model_mapping.lvsdb.gz') 

58CELL_NAME = 'rfnmos_w1u_l0u72' 

59 

60 

61def write_file(path: str, content: str): 

62 os.makedirs(os.path.dirname(path), exist_ok=True) 

63 with open(path, 'w') as f: 

64 f.write(content) 

65 

66 

67class FakeKLayoutLVS: 

68 """ 

69 Stands in for subprocess.Popen, emulating a successful KLayout LVS run 

70 """ 

71 def __init__(self): 

72 self.call_count = 0 

73 

74 def __call__(self, args: List[str], **kwargs): 

75 self.call_count += 1 

76 report_path = next(a.removeprefix('report=') for a in args if a.startswith('report=')) 

77 write_file(report_path, '#%lvsdb-klayout\n') 

78 proc = mock.Mock() 

79 proc.stdout = io.StringIO("") 

80 proc.returncode = 0 

81 return proc 

82 

83 

84class TempDirTestCase(unittest.TestCase): 

85 def setUp(self): 

86 self.tmp_dir = tempfile.mkdtemp(prefix="lvs_cache_") 

87 self.addCleanup(shutil.rmtree, self.tmp_dir, ignore_errors=True) 

88 

89 def make_deck(self, dir_name: str = 'deck') -> str: 

90 deck_dir_path = os.path.join(self.tmp_dir, dir_name) 

91 write_file(os.path.join(deck_dir_path, 'sg13g2.lvs'), '# %include rule_decks/globals.lvs\n') 

92 write_file(os.path.join(deck_dir_path, 'rule_decks', 'globals.lvs'), 'RF_TO_BASE_MOS_MODEL = {}\n') 

93 return os.path.join(deck_dir_path, 'sg13g2.lvs') 

94 

95 def fingerprint(self, **overrides) -> LVSInputFingerprint: 

96 kwargs = dict(pdk='ihp-sg13g2', 

97 klayout_version='0.30.12', 

98 lvs_script_path=os.path.join(self.tmp_dir, 'deck', 'sg13g2.lvs'), 

99 script_parameters={'run_mode': 'deep', 'no_simplify': 'true'}, 

100 gds_path=os.path.join(self.tmp_dir, 'cell.gds'), 

101 cell_name='cell', 

102 schematic_path=os.path.join(self.tmp_dir, 'cell.spice')) 

103 kwargs.update(overrides) 

104 return lvs_input_fingerprint(**kwargs) 

105 

106 

107@allure.parent_suite("Unit Tests") 

108@allure.tag("LVS", "KLayout") 

109class LVSInputFingerprintTest(TempDirTestCase): 

110 def setUp(self): 

111 super().setUp() 

112 self.lvs_script_path = self.make_deck() 

113 write_file(os.path.join(self.tmp_dir, 'cell.gds'), 'layout') 

114 write_file(os.path.join(self.tmp_dir, 'cell.spice'), '.subckt cell VDD VSS\n.ends\n') 

115 

116 def test_deck_covers_every_file_by_relative_path(self): 

117 fingerprint = deck_fingerprint(self.lvs_script_path) 

118 self.assertEqual(self.lvs_script_path, fingerprint.script_path) 

119 self.assertEqual(['rule_decks/globals.lvs', 'sg13g2.lvs'], 

120 sorted(fingerprint.sha256_by_relative_path.keys())) 

121 

122 def test_deck_skips_hidden_files(self): 

123 deck_dir_path = os.path.dirname(self.lvs_script_path) 

124 write_file(os.path.join(deck_dir_path, '.DS_Store'), 'Finder') 

125 write_file(os.path.join(deck_dir_path, 'rule_decks', '.DS_Store'), 'Finder') 

126 write_file(os.path.join(deck_dir_path, '.git', 'HEAD'), 'ref: refs/heads/main') 

127 self.assertEqual(['rule_decks/globals.lvs', 'sg13g2.lvs'], 

128 sorted(deck_fingerprint(self.lvs_script_path).sha256_by_relative_path.keys())) 

129 

130 def test_deck_follows_symbolic_links(self): 

131 # e.g. the ihp-sg13cmos5l rule decks, which link to those of ihp-sg13g2 

132 shared_path = os.path.join(self.tmp_dir, 'shared', 'shared.lvs') 

133 write_file(shared_path, 'v1') 

134 link_path = os.path.join(os.path.dirname(self.lvs_script_path), 'rule_decks', 'shared.lvs') 

135 os.symlink(shared_path, link_path) 

136 os.symlink(os.path.join(self.tmp_dir, 'missing.lvs'), 

137 os.path.join(os.path.dirname(self.lvs_script_path), 'rule_decks', 'dangling.lvs')) 

138 before = deck_fingerprint(self.lvs_script_path) 

139 write_file(shared_path, 'v2') 

140 after = deck_fingerprint(self.lvs_script_path) 

141 self.assertNotIn('rule_decks/dangling.lvs', before.sha256_by_relative_path) 

142 self.assertNotEqual(before.sha256_by_relative_path['rule_decks/shared.lvs'], 

143 after.sha256_by_relative_path['rule_decks/shared.lvs']) 

144 

145 def test_same_inputs_at_other_paths_have_no_differences(self): 

146 # e.g. a reinstalled KPEX, a moved output directory 

147 other_script_path = self.make_deck(dir_name='other_deck') 

148 write_file(os.path.join(self.tmp_dir, 'moved', 'cell.gds'), 'layout') 

149 write_file(os.path.join(self.tmp_dir, 'moved', 'cell.spice'), '.subckt cell VDD VSS\n.ends\n') 

150 other = self.fingerprint(lvs_script_path=other_script_path, 

151 gds_path=os.path.join(self.tmp_dir, 'moved', 'cell.gds'), 

152 schematic_path=os.path.join(self.tmp_dir, 'moved', 'cell.spice')) 

153 self.assertEqual([], fingerprint_differences(self.fingerprint(), other)) 

154 

155 def test_changed_deck_file_is_named(self): 

156 cached = self.fingerprint() 

157 write_file(os.path.join(self.tmp_dir, 'deck', 'rule_decks', 'globals.lvs'), 'changed\n') 

158 self.assertEqual(["the LVS deck changed (rule_decks/globals.lvs)"], 

159 fingerprint_differences(cached, self.fingerprint())) 

160 

161 def test_added_and_removed_deck_files_are_named(self): 

162 cached = self.fingerprint() 

163 os.remove(os.path.join(self.tmp_dir, 'deck', 'rule_decks', 'globals.lvs')) 

164 write_file(os.path.join(self.tmp_dir, 'deck', 'rule_decks', 'custom_reader.lvs'), 'new\n') 

165 self.assertEqual(["the LVS deck changed (rule_decks/custom_reader.lvs, rule_decks/globals.lvs)"], 

166 fingerprint_differences(cached, self.fingerprint())) 

167 

168 def test_many_changed_deck_files_are_counted(self): 

169 cached = self.fingerprint() 

170 for i in range(7): 

171 write_file(os.path.join(self.tmp_dir, 'deck', 'rule_decks', f"new_{i}.lvs"), 'new\n') 

172 self.assertEqual(["the LVS deck changed (rule_decks/new_0.lvs, rule_decks/new_1.lvs, " 

173 "rule_decks/new_2.lvs, rule_decks/new_3.lvs, rule_decks/new_4.lvs " 

174 "and 2 more files)"], 

175 fingerprint_differences(cached, self.fingerprint())) 

176 

177 def test_changed_inputs_are_reported(self): 

178 cached = self.fingerprint() 

179 write_file(os.path.join(self.tmp_dir, 'other.gds'), 'other layout') 

180 write_file(os.path.join(self.tmp_dir, 'other.spice'), '.subckt other VDD VSS\n.ends\n') 

181 cases = [ 

182 (dict(pdk='ihp-sg13cmos5l'), "the PDK changed (ihp-sg13g2 → ihp-sg13cmos5l)"), 

183 (dict(klayout_version='0.30.10'), "the KLayout version changed (0.30.12 → 0.30.10)"), 

184 (dict(klayout_version=''), "the KLayout version changed (0.30.12 → unknown)"), 

185 (dict(script_parameters={'run_mode': 'flat', 'no_simplify': 'true'}), 

186 "the LVS script parameters changed (run_mode: deep → flat)"), 

187 (dict(script_parameters={'run_mode': 'deep'}), 

188 "the LVS script parameters changed (no_simplify: true → unset)"), 

189 (dict(gds_path=os.path.join(self.tmp_dir, 'other.gds')), "the input layout changed"), 

190 (dict(cell_name='other'), "the cell changed (cell → other)"), 

191 (dict(schematic_path=os.path.join(self.tmp_dir, 'other.spice')), "the schematic changed"), 

192 ] 

193 for overrides, expected in cases: 

194 with self.subTest(expected): 

195 self.assertEqual([expected], fingerprint_differences(cached, self.fingerprint(**overrides))) 

196 

197 def test_other_format_version_never_matches(self): 

198 cached = self.fingerprint() 

199 cached.format_version = FINGERPRINT_FORMAT_VERSION - 1 

200 self.assertEqual([f"the fingerprint format changed ({FINGERPRINT_FORMAT_VERSION - 1} → " 

201 f"{FINGERPRINT_FORMAT_VERSION})"], 

202 fingerprint_differences(cached, self.fingerprint())) 

203 

204 def test_json_round_trip(self): 

205 path = os.path.join(self.tmp_dir, 'cell.lvs_fingerprint.pb.json') 

206 fingerprint = self.fingerprint() 

207 write_fingerprint(path, fingerprint) 

208 self.assertEqual(fingerprint, read_fingerprint(path)) 

209 with open(path) as f: 

210 self.assertIn('"sha256_by_relative_path"', f.read()) 

211 

212 def test_missing_or_unreadable_fingerprint_is_none(self): 

213 path = os.path.join(self.tmp_dir, 'cell.lvs_fingerprint.pb.json') 

214 self.assertIsNone(read_fingerprint(path)) 

215 write_file(path, '{"written_by_a_newer_kpex": 1}') 

216 self.assertIsNone(read_fingerprint(path)) 

217 

218 

219@allure.parent_suite("Unit Tests") 

220@allure.tag("LVS", "KLayout") 

221class KpexCLILVSCacheTest(TempDirTestCase): 

222 def setUp(self): 

223 super().setUp() 

224 

225 self.output_dir_path = os.path.join(self.tmp_dir, 'output', f"{CELL_NAME}__{CELL_NAME}") 

226 os.makedirs(self.output_dir_path) 

227 self.lvsdb_path = os.path.join(self.output_dir_path, f"{CELL_NAME}.lvsdb.gz") 

228 

229 self.gds_path = os.path.join(self.tmp_dir, f"{CELL_NAME}.gds") 

230 write_file(self.gds_path, 'layout') 

231 schematic_path = os.path.join(self.output_dir_path, f"{CELL_NAME}_dummy_schematic.spice") 

232 write_file(schematic_path, f".subckt {CELL_NAME} VDD VSS\n.ends\n.end\n") 

233 

234 self.args = argparse.Namespace( 

235 input_mode=InputMode.GDS, 

236 output_dir_path=self.output_dir_path, 

237 cache_dir_path=os.path.join(self.tmp_dir, 'output', '.kpex_cache'), 

238 cache_lvs=True, 

239 pdk='ihp-sg13g2', 

240 gds_path=self.gds_path, 

241 effective_gds_path=self.gds_path, 

242 effective_schematic_path=schematic_path, 

243 effective_cell_name=CELL_NAME, 

244 klayout_exe_path='klayout', 

245 lvs_script_path=self.make_deck(), 

246 klayout_lvs_verbose=False, 

247 ) 

248 self.cache_entry = LVSCacheEntry( 

249 dir_path=os.path.join(self.args.cache_dir_path, self.args.pdk, 

250 os.path.splitroot(os.path.abspath(self.gds_path))[-1]), 

251 cell_name=CELL_NAME 

252 ) 

253 

254 patcher = mock.patch.object(Tool, 'detect_version', return_value=Version('0.30.12')) 

255 patcher.start() 

256 self.addCleanup(patcher.stop) 

257 

258 def make_cache_hit(self): 

259 self.cache_entry.store(lvsdb_path=CACHED_LVSDB_PATH, 

260 fingerprint=KpexCLI.lvs_input_fingerprint(self.args)) 

261 

262 def create_lvsdb(self) -> Tuple[Any, FakeKLayoutLVS, mock.Mock]: 

263 fake_klayout = FakeKLayoutLVS() 

264 with mock.patch('klayout_pex.klayout.lvs_runner.subprocess.Popen', fake_klayout), \ 

265 mock.patch('klayout_pex.kpex_cli.info') as info_mock: 

266 lvsdb = KpexCLI().create_lvsdb(self.args) 

267 return lvsdb, fake_klayout, info_mock 

268 

269 def assert_cache_miss(self, reason: str, fake_klayout: FakeKLayoutLVS, info_mock: mock.Mock): 

270 self.assertEqual(1, fake_klayout.call_count) 

271 info_mock.assert_any_call(f"Cache miss: {reason}") 

272 # the new LVS database is cached, together with its fingerprint 

273 self.assertTrue(filecmp.cmp(self.lvsdb_path, self.cache_entry.lvsdb_path, shallow=False)) 

274 self.assertEqual(KpexCLI.lvs_input_fingerprint(self.args), 

275 read_fingerprint(self.cache_entry.fingerprint_path)) 

276 

277 def test_cache_hit_reads_the_cached_lvsdb(self): 

278 self.make_cache_hit() 

279 # left over from an earlier run in the same output directory 

280 write_file(self.lvsdb_path, '#%lvsdb-klayout\n') 

281 

282 lvsdb, fake_klayout, _ = self.create_lvsdb() 

283 

284 self.assertEqual(0, fake_klayout.call_count) 

285 self.assertEqual(CELL_NAME, lvsdb.internal_top_cell().name) 

286 self.assertTrue(filecmp.cmp(self.cache_entry.lvsdb_path, self.lvsdb_path, shallow=False)) 

287 

288 def test_cache_hit_without_earlier_run_in_the_output_directory(self): 

289 # e.g. a CI run with a restored cache directory 

290 self.make_cache_hit() 

291 

292 lvsdb, fake_klayout, _ = self.create_lvsdb() 

293 

294 self.assertEqual(0, fake_klayout.call_count) 

295 self.assertEqual(CELL_NAME, lvsdb.internal_top_cell().name) 

296 

297 def test_cache_hit_with_a_new_modification_time_of_the_layout(self): 

298 # e.g. a fresh checkout 

299 self.make_cache_hit() 

300 os.utime(self.gds_path) 

301 

302 _, fake_klayout, _ = self.create_lvsdb() 

303 

304 self.assertEqual(0, fake_klayout.call_count) 

305 

306 def test_cache_miss_without_cached_lvsdb(self): 

307 _, fake_klayout, info_mock = self.create_lvsdb() 

308 self.assert_cache_miss("there is no cached LVSDB", fake_klayout, info_mock) 

309 

310 def test_cache_miss_for_cached_lvsdb_without_fingerprint(self): 

311 # e.g. cached by an earlier KPEX, like the IHP RF MOS devices without layout geometry 

312 os.makedirs(self.cache_entry.dir_path) 

313 shutil.copy(CACHED_LVSDB_PATH, self.cache_entry.lvsdb_path) 

314 

315 _, fake_klayout, info_mock = self.create_lvsdb() 

316 

317 self.assert_cache_miss("the cached LVSDB has no fingerprint of its LVS inputs", fake_klayout, info_mock) 

318 

319 def test_cache_miss_after_a_deck_change(self): 

320 self.make_cache_hit() 

321 write_file(os.path.join(self.tmp_dir, 'deck', 'rule_decks', 'globals.lvs'), 'changed\n') 

322 

323 _, fake_klayout, info_mock = self.create_lvsdb() 

324 

325 self.assert_cache_miss("the LVS deck changed (rule_decks/globals.lvs)", fake_klayout, info_mock) 

326 

327 def test_cache_miss_after_a_klayout_upgrade(self): 

328 self.make_cache_hit() 

329 

330 with mock.patch.object(Tool, 'detect_version', return_value=Version('0.30.13')): 

331 _, fake_klayout, info_mock = self.create_lvsdb() 

332 self.assert_cache_miss("the KLayout version changed (0.30.12 → 0.30.13)", fake_klayout, info_mock)