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
« 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
26import allure
27import argparse
28import filecmp
29import io
30import os
31import shutil
32import tempfile
33import unittest
34from typing import *
35from unittest import mock
37from packaging.version import Version
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
53TESTDATA_DIR = os.path.realpath(os.path.join(__file__, '..', '..', '..', 'testdata'))
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'
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)
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
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
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)
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')
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)
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')
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()))
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()))
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'])
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))
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()))
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()))
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()))
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)))
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()))
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())
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))
219@allure.parent_suite("Unit Tests")
220@allure.tag("LVS", "KLayout")
221class KpexCLILVSCacheTest(TempDirTestCase):
222 def setUp(self):
223 super().setUp()
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")
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")
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 )
254 patcher = mock.patch.object(Tool, 'detect_version', return_value=Version('0.30.12'))
255 patcher.start()
256 self.addCleanup(patcher.stop)
258 def make_cache_hit(self):
259 self.cache_entry.store(lvsdb_path=CACHED_LVSDB_PATH,
260 fingerprint=KpexCLI.lvs_input_fingerprint(self.args))
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
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))
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')
282 lvsdb, fake_klayout, _ = self.create_lvsdb()
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))
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()
292 lvsdb, fake_klayout, _ = self.create_lvsdb()
294 self.assertEqual(0, fake_klayout.call_count)
295 self.assertEqual(CELL_NAME, lvsdb.internal_top_cell().name)
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)
302 _, fake_klayout, _ = self.create_lvsdb()
304 self.assertEqual(0, fake_klayout.call_count)
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)
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)
315 _, fake_klayout, info_mock = self.create_lvsdb()
317 self.assert_cache_miss("the cached LVSDB has no fingerprint of its LVS inputs", fake_klayout, info_mock)
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')
323 _, fake_klayout, info_mock = self.create_lvsdb()
325 self.assert_cache_miss("the LVS deck changed (rule_decks/globals.lvs)", fake_klayout, info_mock)
327 def test_cache_miss_after_a_klayout_upgrade(self):
328 self.make_cache_hit()
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)