Coverage for klayout_pex/pex25d/artifact.py: 75%
97 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"""
26Naming, format inference and stdio handling for PEX25D artifacts.
28There are two orthogonal properties of every PEX25D artifact on disk:
30``kind``
31 Which message it holds — the unresolved ``PEX25DFile`` as written by a
32 reader, or the resolved ``PEX25DScene`` as consumed by a solver adapter.
34``format``
35 How that message is encoded — the normative PEX25D *text* format, binary
36 protobuf, or protobuf text format.
38Both are inferred from the file name, and either can be overridden
39(``--kind`` / ``--format``). The naming convention is::
41 foo.pex25d text format, PEX25DFile
42 foo.pex25d.pb binary protobuf, PEX25DFile
43 foo.pex25d.textpb protobuf text format, PEX25DFile
44 foo.pex25d.scene.pb binary protobuf, PEX25DScene
45 foo.pex25d.scene.textpb protobuf text format, PEX25DScene
47i.e. the ``.scene`` infix selects the kind and the final extension selects the
48format. There is deliberately no text-format spelling of a scene: the text
49format is the *unresolved* format, and resolution is not reversible.
50"""
52from __future__ import annotations
54import contextlib
55import gzip
56import io
57import os
58import sys
59from dataclasses import dataclass
60from enum import StrEnum
61from typing import *
64# A single '-' as a path means stdin (for inputs) or stdout (for outputs).
65STDIO_PATH = '-'
68class ArtifactKind(StrEnum):
69 """Which PEX25D message an artifact holds."""
71 AUTO = 'auto' # infer from the path
72 FILE = 'file' # kpex.pex25d.PEX25DFile — literal, unresolved
73 SCENE = 'scene' # kpex.pex25d.PEX25DScene — resolved, adapter-ready
76class ArtifactFormat(StrEnum):
77 """How a PEX25D message is encoded."""
79 AUTO = 'auto' # infer from the path
80 TEXT = 'text' # the normative PEX25D text format (PEX25DFile only)
81 PB = 'pb' # binary protobuf
82 TEXTPB = 'textpb' # protobuf text format
85# Suffix table, longest first so that '.pex25d.scene.pb' is matched before
86# '.pex25d.pb' would be. Gzip is handled separately, by stripping a trailing
87# '.gz' before the lookup — the repo already writes '.gds.gz' and layout-sized
88# PEX25D text compresses extremely well.
89_SUFFIXES: List[Tuple[str, ArtifactKind, ArtifactFormat]] = [
90 ('.pex25d.scene.textpb', ArtifactKind.SCENE, ArtifactFormat.TEXTPB),
91 ('.pex25d.scene.pb', ArtifactKind.SCENE, ArtifactFormat.PB),
92 ('.pex25d.textpb', ArtifactKind.FILE, ArtifactFormat.TEXTPB),
93 ('.pex25d.pb', ArtifactKind.FILE, ArtifactFormat.PB),
94 ('.pex25d', ArtifactKind.FILE, ArtifactFormat.TEXT),
95]
97# Canonical spelling for each (kind, format) pair, used when a verb has to
98# derive an output name from an input name.
99CANONICAL_SUFFIX: Dict[Tuple[ArtifactKind, ArtifactFormat], str] = {
100 (kind, fmt): suffix for suffix, kind, fmt in reversed(_SUFFIXES)
101}
104class ArtifactNamingError(ValueError):
105 """The kind or format of an artifact could not be determined."""
108@dataclass(frozen=True)
109class ArtifactSpec:
110 """A fully determined artifact: where it lives, what it holds, how it is encoded."""
112 path: str
113 kind: ArtifactKind
114 format: ArtifactFormat
115 gzipped: bool = False
117 @property
118 def is_stdio(self) -> bool:
119 return self.path == STDIO_PATH
121 @property
122 def is_binary(self) -> bool:
123 return self.format == ArtifactFormat.PB or self.gzipped
125 def __str__(self) -> str:
126 where = '<stdin>/<stdout>' if self.is_stdio else self.path
127 return f"{where} ({self.kind.value}, {self.format.value}{', gzipped' if self.gzipped else ''})"
130def infer_artifact_spec(path: str,
131 kind: ArtifactKind = ArtifactKind.AUTO,
132 format: ArtifactFormat = ArtifactFormat.AUTO,
133 default_kind: ArtifactKind = ArtifactKind.FILE,
134 default_format: ArtifactFormat = ArtifactFormat.TEXT) -> ArtifactSpec:
135 """
136 Determine an artifact's kind and encoding from its path, honouring explicit
137 overrides.
139 Inference is by suffix (see the table above). For ``-`` (stdio) there is no
140 suffix to inspect, so the explicit values are used, falling back to
141 ``default_kind`` / ``default_format`` — which is why every verb that accepts
142 ``-`` states its defaults in ``--help``.
144 :raises ArtifactNamingError: if the path carries no recognized suffix and no
145 override was given, or if the resulting combination cannot exist.
146 """
147 gzipped = False
148 inferred_kind = default_kind
149 inferred_format = default_format
151 if path != STDIO_PATH:
152 name = os.path.basename(path)
153 if name.endswith('.gz'):
154 gzipped = True
155 name = name[:-len('.gz')]
157 for suffix, suffix_kind, suffix_format in _SUFFIXES:
158 if name.endswith(suffix):
159 inferred_kind = suffix_kind
160 inferred_format = suffix_format
161 break
162 else:
163 if kind == ArtifactKind.AUTO or format == ArtifactFormat.AUTO:
164 raise ArtifactNamingError(
165 f"Can't tell what kind of PEX25D artifact '{path}' is meant to be. "
166 f"Use one of the conventional suffixes "
167 f"({', '.join(suffix for suffix, _, _ in reversed(_SUFFIXES))}), "
168 f"or state the encoding explicitly — see --help for the "
169 f"--format / --kind options this command offers."
170 )
172 effective_kind = inferred_kind if kind == ArtifactKind.AUTO else kind
173 effective_format = inferred_format if format == ArtifactFormat.AUTO else format
175 if effective_kind == ArtifactKind.SCENE and effective_format == ArtifactFormat.TEXT:
176 raise ArtifactNamingError(
177 "The PEX25D text format has no spelling for a resolved scene — it is the "
178 "unresolved format, and resolution is not reversible. Write the scene as "
179 "'pb' or 'textpb', or write the unresolved file as 'text'."
180 )
182 return ArtifactSpec(path=path,
183 kind=effective_kind,
184 format=effective_format,
185 gzipped=gzipped)
188@contextlib.contextmanager
189def open_artifact_read(spec: ArtifactSpec) -> Iterator[BinaryIO]:
190 """
191 Open an artifact for reading, as bytes.
193 Bytes rather than text even for the text formats: the PEX25D reader scans
194 decimal digits straight into scaled integers and wants to control decoding
195 itself (the format is UTF-8 by definition, so there is nothing to negotiate).
196 """
197 if spec.is_stdio:
198 # sys.__stdin__ rather than sys.stdin: when an artifact is written to
199 # stdout the process redirects sys.stdout to stderr so that logging can
200 # never corrupt the stream, and the artifact I/O must bypass that.
201 stream: BinaryIO = sys.__stdin__.buffer
202 if spec.gzipped:
203 with gzip.GzipFile(fileobj=stream, mode='rb') as f:
204 yield cast(BinaryIO, f)
205 else:
206 yield stream
207 return
209 opener = gzip.open if spec.gzipped else open
210 with opener(spec.path, 'rb') as f:
211 yield cast(BinaryIO, f)
214@contextlib.contextmanager
215def open_artifact_write(spec: ArtifactSpec) -> Iterator[BinaryIO]:
216 """
217 Open an artifact for writing, as bytes.
219 Note that when the destination is stdout, *nothing else may be written
220 there* — diagnostics and progress go to stderr. See
221 :func:`klayout_pex.pex25d.diagnostics.diagnostics_stream`.
222 """
223 if spec.is_stdio:
224 # The real stdout, not the redirected sys.stdout — see open_artifact_read.
225 stream: BinaryIO = sys.__stdout__.buffer
226 if spec.gzipped:
227 with gzip.GzipFile(fileobj=stream, mode='wb') as f:
228 yield cast(BinaryIO, f)
229 else:
230 yield stream
231 stream.flush()
232 return
234 parent = os.path.dirname(os.path.abspath(spec.path))
235 os.makedirs(parent, exist_ok=True)
237 opener = gzip.open if spec.gzipped else open
238 with opener(spec.path, 'wb') as f:
239 yield cast(BinaryIO, f)
242def derive_path(source_path: str,
243 kind: ArtifactKind,
244 format: ArtifactFormat) -> str:
245 """
246 Derive a sibling artifact path from an existing one, e.g. ``foo.pex25d`` →
247 ``foo.pex25d.scene.pb``. Used only where a verb offers a default output
248 location; a verb never writes to a derived path without saying so.
249 """
250 if source_path == STDIO_PATH:
251 return STDIO_PATH
253 name = os.path.basename(source_path)
254 if name.endswith('.gz'):
255 name = name[:-len('.gz')]
256 for suffix, _, _ in _SUFFIXES:
257 if name.endswith(suffix):
258 name = name[:-len(suffix)]
259 break
260 else:
261 name = os.path.splitext(name)[0]
263 return os.path.join(os.path.dirname(source_path),
264 name + CANONICAL_SUFFIX[(kind, format)])