Coverage for klayout_pex/klayout/lvs_cache.py: 97%
109 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#
24"""
25Cached LVS databases, which are reused only while the inputs of the LVS run are unchanged
26(see protos/kpex/klayout/lvs_input_fingerprint.proto)
27"""
29from __future__ import annotations
31from dataclasses import dataclass
32import hashlib
33import os
34from pathlib import PurePath
35import shutil
36import tempfile
37from typing import *
39import google.protobuf.json_format
41from klayout_pex_protobuf.kpex.klayout.lvs_input_fingerprint_pb2 import (
42 FileFingerprint,
43 LVSDeckFingerprint,
44 LVSInputFingerprint
45)
48# NOTE: increment whenever the fingerprint is computed differently,
49# so that the fingerprints written by an older KPEX never match
50FINGERPRINT_FORMAT_VERSION = 1
52# NOTE: an upgrade may change every file of a deck, only name a few of them
53MAX_NAMED_DECK_FILES = 5
56def file_sha256(path: str) -> str:
57 h = hashlib.sha256()
58 with open(path, 'rb') as f:
59 for chunk in iter(lambda: f.read(1 << 20), b''):
60 h.update(chunk)
61 return h.hexdigest()
64def file_fingerprint(path: str) -> FileFingerprint:
65 return FileFingerprint(path=os.path.abspath(path),
66 sha256=file_sha256(path))
69def deck_fingerprint(lvs_script_path: str) -> LVSDeckFingerprint:
70 """
71 Every file in the directory of the LVS script and below, as the script includes further files
72 (%include, require, load) in ways that are not practical to trace.
74 Hidden files (e.g. .DS_Store) are skipped, as are dangling symbolic links.
75 """
76 script_path = os.path.abspath(lvs_script_path)
77 deck_dir_path = os.path.dirname(script_path)
78 fingerprint = LVSDeckFingerprint(script_path=script_path)
79 for dir_path, dir_names, file_names in os.walk(deck_dir_path, followlinks=True):
80 dir_names[:] = [n for n in dir_names if not n.startswith('.')]
81 for file_name in file_names:
82 path = os.path.join(dir_path, file_name)
83 if file_name.startswith('.') or not os.path.isfile(path):
84 continue
85 relative_path = PurePath(os.path.relpath(path, deck_dir_path)).as_posix()
86 fingerprint.sha256_by_relative_path[relative_path] = file_sha256(path)
87 return fingerprint
90def lvs_input_fingerprint(pdk: str,
91 klayout_version: str,
92 lvs_script_path: str,
93 script_parameters: Dict[str, str],
94 gds_path: str,
95 cell_name: str,
96 schematic_path: str) -> LVSInputFingerprint:
97 fingerprint = LVSInputFingerprint(format_version=FINGERPRINT_FORMAT_VERSION,
98 pdk=pdk,
99 klayout_version=klayout_version,
100 cell_name=cell_name)
101 fingerprint.deck.CopyFrom(deck_fingerprint(lvs_script_path))
102 fingerprint.script_parameters.update(script_parameters)
103 fingerprint.layout.CopyFrom(file_fingerprint(gds_path))
104 fingerprint.schematic.CopyFrom(file_fingerprint(schematic_path))
105 return fingerprint
108def fingerprint_differences(cached: LVSInputFingerprint,
109 current: LVSInputFingerprint) -> List[str]:
110 """
111 What changed between the inputs of the cached LVS run and those of the current one,
112 empty if the cached LVS database can be reused. Paths are not compared.
113 """
114 if cached.format_version != current.format_version:
115 return [f"the fingerprint format changed ({cached.format_version} → {current.format_version})"]
117 differences: List[str] = []
119 def compare(what: str, cached_value: str, current_value: str):
120 if cached_value != current_value:
121 differences.append(f"{what} changed ({cached_value or 'unknown'} → {current_value or 'unknown'})")
123 compare('the PDK', cached.pdk, current.pdk)
124 compare('the KLayout version', cached.klayout_version, current.klayout_version)
126 cached_files = dict(cached.deck.sha256_by_relative_path)
127 current_files = dict(current.deck.sha256_by_relative_path)
128 changed_files = sorted(p for p in cached_files.keys() | current_files.keys()
129 if cached_files.get(p) != current_files.get(p))
130 if changed_files:
131 named = ', '.join(changed_files[:MAX_NAMED_DECK_FILES])
132 more = len(changed_files) - MAX_NAMED_DECK_FILES
133 differences.append(f"the LVS deck changed ({named}{f' and {more} more files' if more > 0 else ''})")
135 cached_parameters = dict(cached.script_parameters)
136 current_parameters = dict(current.script_parameters)
137 changed_parameters = [f"{n}: {cached_parameters.get(n, 'unset')} → {current_parameters.get(n, 'unset')}"
138 for n in sorted(cached_parameters.keys() | current_parameters.keys())
139 if cached_parameters.get(n) != current_parameters.get(n)]
140 if changed_parameters:
141 differences.append(f"the LVS script parameters changed ({', '.join(changed_parameters)})")
143 if cached.layout.sha256 != current.layout.sha256:
144 differences.append("the input layout changed")
145 compare('the cell', cached.cell_name, current.cell_name)
146 if cached.schematic.sha256 != current.schematic.sha256:
147 differences.append("the schematic changed")
149 return differences
152def read_fingerprint(path: str) -> Optional[LVSInputFingerprint]:
153 """
154 None if there is none, or it can't be read (e.g. written by an incompatible KPEX version)
155 """
156 try:
157 with open(path, 'r', encoding='utf-8') as f:
158 return google.protobuf.json_format.Parse(f.read(), LVSInputFingerprint())
159 except (OSError, google.protobuf.json_format.ParseError):
160 return None
163def write_fingerprint(path: str, fingerprint: LVSInputFingerprint):
164 json_str = google.protobuf.json_format.MessageToJson(fingerprint,
165 preserving_proto_field_name=True,
166 sort_keys=True,
167 ensure_ascii=False)
168 with open(path, 'w', encoding='utf-8') as f:
169 f.write(json_str)
172@dataclass(frozen=True)
173class LVSCacheEntry:
174 """
175 The cached LVS database of a cell, and the fingerprint of the LVS inputs it was created from
176 """
177 dir_path: str
178 cell_name: str
180 @property
181 def lvsdb_path(self) -> str:
182 return os.path.join(self.dir_path, f"{self.cell_name}.lvsdb.gz")
184 @property
185 def fingerprint_path(self) -> str:
186 return os.path.join(self.dir_path, f"{self.cell_name}.lvs_fingerprint.pb.json")
188 def cache_miss_reasons(self, fingerprint: LVSInputFingerprint) -> List[str]:
189 """
190 Why the cached LVS database can't be reused, empty if it can
191 """
192 if not os.path.isfile(self.lvsdb_path):
193 return ["there is no cached LVSDB"]
194 cached_fingerprint = read_fingerprint(self.fingerprint_path)
195 if cached_fingerprint is None:
196 return ["the cached LVSDB has no fingerprint of its LVS inputs"]
197 return fingerprint_differences(cached_fingerprint, fingerprint)
199 def store(self, lvsdb_path: str, fingerprint: LVSInputFingerprint):
200 os.makedirs(self.dir_path, exist_ok=True)
201 # NOTE: the fingerprint is written last, so an interrupted store leaves an entry without one,
202 # which is never reused
203 if os.path.exists(self.fingerprint_path):
204 os.remove(self.fingerprint_path)
205 # NOTE: each file is written beside its place and renamed into it, so that a concurrent run
206 # reading the entry (e.g. parallel tests sharing the cache) never reads a partly written file
207 self._replace(self.lvsdb_path, lambda tmp_path: shutil.copyfile(lvsdb_path, tmp_path))
208 self._replace(self.fingerprint_path, lambda tmp_path: write_fingerprint(tmp_path, fingerprint))
210 def _replace(self, path: str, write: Callable[[str], Any]):
211 fd, tmp_path = tempfile.mkstemp(dir=self.dir_path, prefix=f".{os.path.basename(path)}.", suffix='.tmp')
212 os.close(fd)
213 try:
214 write(tmp_path)
215 os.replace(tmp_path, path)
216 except BaseException:
217 os.remove(tmp_path)
218 raise