masque/masque/file/gdsii.py

654 lines
23 KiB
Python
Raw Normal View History

2016-03-15 19:12:39 -07:00
"""
GDSII file format readers and writers using the `klamath` library.
2020-05-17 22:59:54 -07:00
Note that GDSII references follow the same convention as `masque`,
with this order of operations:
1. Mirroring
2. Rotation
3. Scaling
4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets)
Scaling, rotation, and mirroring apply to individual instances, not grid
vectors or offsets.
Notes:
* absolute positioning is not supported
* PLEX is not supported
* ELFLAGS are not supported
* GDS does not support library- or structure-level annotations
* GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
2016-03-15 19:12:39 -07:00
"""
from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable
from types import MappingProxyType
import io
import mmap
import logging
import pathlib
import gzip
import string
from pprint import pformat
2016-03-15 19:12:39 -07:00
import numpy
2023-01-24 12:45:44 -08:00
from numpy.typing import ArrayLike, NDArray
import klamath
from klamath import records
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
2019-04-20 15:25:19 -07:00
from ..shapes import Polygon, Path
from ..repetition import Grid
from ..utils import layer_t, annotations_t
2023-04-07 18:08:42 -07:00
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
2023-01-13 20:33:14 -08:00
2016-03-15 19:12:39 -07:00
logger = logging.getLogger(__name__)
2019-04-20 15:42:42 -07:00
path_cap_map = {
0: Path.Cap.Flush,
1: Path.Cap.Circle,
2: Path.Cap.Square,
4: Path.Cap.SquareCustom,
}
2019-04-20 15:25:19 -07:00
RO_EMPTY_DICT: Mapping[int, bytes] = MappingProxyType({})
2019-04-20 15:25:19 -07:00
def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
2023-01-24 12:45:44 -08:00
return numpy.rint(val).astype(numpy.int32)
def write(
library: Mapping[str, Pattern],
stream: IO[bytes],
meters_per_unit: float,
logical_units_per_unit: float = 1,
library_name: str = 'masque-klamath',
) -> None:
"""
Convert a library to a GDSII stream, mapping data as follows:
Pattern -> GDSII structure
2023-01-21 21:22:11 -08:00
Ref -> GDSII SREF or AREF
Path -> GSDII path
Shape (other than path) -> GDSII boundary/ies
Label -> GDSII text
annnotations -> properties, where possible
For each shape,
layer is chosen to be equal to `shape.layer` if it is an int,
or `shape.layer[0]` if it is a tuple
datatype is chosen to be `shape.layer[1]` if available,
otherwise `0`
2026-03-08 14:51:51 -07:00
GDS does not support shape repetition (only cell repetition). Please call
2023-01-23 22:27:26 -08:00
`library.wrap_repeated_shapes()` before writing to file.
2023-01-23 22:27:26 -08:00
Other functions you may want to call:
- `masque.file.gdsii.check_valid_names(library.keys())` to check for invalid names
2023-01-24 23:25:10 -08:00
- `library.dangling_refs()` to check for references to missing patterns
2023-01-23 22:27:26 -08:00
- `pattern.polygonize()` for any patterns with shapes other
than `masque.shapes.Polygon` or `masque.shapes.Path`
Args:
library: A {name: Pattern} mapping of patterns to write.
meters_per_unit: Written into the GDSII file, meters per (database) length unit.
All distances are assumed to be an integer multiple of this unit, and are stored as such.
logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a
"logical" unit which is different from the "database" unit, for display purposes.
Default `1`.
library_name: Library name written into the GDSII file.
Default 'masque-klamath'.
"""
2023-04-07 18:08:42 -07:00
if not isinstance(library, ILibrary):
2023-01-13 20:33:14 -08:00
if isinstance(library, dict):
2023-04-07 18:08:42 -07:00
library = Library(library)
2023-01-13 20:33:14 -08:00
else:
2023-04-07 18:08:42 -07:00
library = Library(dict(library))
# Create library
header = klamath.library.FileHeader(
name=library_name.encode('ASCII'),
user_units_per_db_unit=logical_units_per_unit,
meters_per_db_unit=meters_per_unit,
)
header.write(stream)
2017-08-29 15:57:37 -07:00
# Now create a structure for each pattern, and add in any Boundary and SREF elements
2023-01-13 20:33:14 -08:00
for name, pat in library.items():
2023-02-23 13:15:32 -08:00
elements: list[klamath.elements.Element] = []
elements += _shapes_to_elements(pat.shapes)
elements += _labels_to_texts(pat.labels)
2023-01-21 21:22:11 -08:00
elements += _mrefs_to_grefs(pat.refs)
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
records.ENDLIB.write(stream, None)
def writefile(
library: Mapping[str, Pattern],
2023-02-23 13:15:32 -08:00
filename: str | pathlib.Path,
*args,
**kwargs,
) -> None:
2016-03-15 19:12:39 -07:00
"""
Wrapper for `write()` that takes a filename or path instead of a stream.
2016-03-15 19:12:39 -07:00
Will automatically compress the file if it has a .gz suffix.
Args:
library: {name: Pattern} pairs to save.
filename: Filename to save to.
*args: passed to `write()`
**kwargs: passed to `write()`
2016-03-15 19:12:39 -07:00
"""
path = pathlib.Path(filename)
with tmpfile(path) as base_stream:
2023-02-23 13:15:32 -08:00
streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz':
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
streams = (stream,) + streams
else:
stream = base_stream
try:
write(library, stream, *args, **kwargs)
finally:
for ss in streams:
ss.close()
2016-03-15 19:12:39 -07:00
def readfile(
2023-02-23 13:15:32 -08:00
filename: str | pathlib.Path,
*args,
**kwargs,
2023-04-07 18:08:42 -07:00
) -> tuple[Library, dict[str, Any]]:
"""
Wrapper for `read()` that takes a filename or path instead of a stream.
Will automatically decompress gzipped files.
Args:
filename: Filename to save to.
*args: passed to `read()`
**kwargs: passed to `read()`
"""
path = pathlib.Path(filename)
if is_gzipped(path):
open_func: Callable = gzip.open
else:
open_func = open
with open_func(path, mode='rb') as stream:
results = read(stream, *args, **kwargs)
return results
def read(
stream: IO[bytes],
raw_mode: bool = True,
2023-04-07 18:08:42 -07:00
) -> tuple[Library, dict[str, Any]]:
2016-03-15 19:12:39 -07:00
"""
2023-01-24 23:25:10 -08:00
# TODO check GDSII file for cycles!
2018-09-16 20:18:04 -07:00
Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are
2016-03-15 19:12:39 -07:00
translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs
2023-01-21 21:22:11 -08:00
are translated into Ref objects.
2016-03-15 19:12:39 -07:00
Additional library info is returned in a dict, containing:
'name': name of the library
'meters_per_unit': number of meters per database unit (all values are in database units)
'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns)
per database unit
Args:
2020-05-17 14:12:38 -07:00
stream: Stream to read from.
raw_mode: If True, constructs shapes in raw mode, bypassing most data validation, Default True.
Returns:
2023-02-23 13:15:32 -08:00
- dict of pattern_name:Patterns generated from GDSII structures
- dict of GDSII library info
2016-03-15 19:12:39 -07:00
"""
library_info = _read_header(stream)
2023-04-07 18:08:42 -07:00
mlib = Library()
found_struct = records.BGNSTR.skip_past(stream)
while found_struct:
name = records.STRNAME.skip_and_read(stream)
pat = read_elements(stream, raw_mode=raw_mode)
mlib[name.decode('ASCII')] = pat
found_struct = records.BGNSTR.skip_past(stream)
2016-03-15 19:12:39 -07:00
return mlib, library_info
2023-02-23 13:15:32 -08:00
def _read_header(stream: IO[bytes]) -> dict[str, Any]:
"""
Read the file header and create the library_info dict.
"""
header = klamath.library.FileHeader.read(stream)
library_info = {'name': header.name.decode('ASCII'),
'meters_per_unit': header.meters_per_db_unit,
'logical_units_per_unit': header.user_units_per_db_unit,
}
return library_info
def read_elements(
stream: IO[bytes],
raw_mode: bool = True,
) -> Pattern:
"""
Read elements from a GDS structure and build a Pattern from them.
Args:
stream: Seekable stream, positioned at a record boundary.
Will be read until an ENDSTR record is consumed.
name: Name of the resulting Pattern
raw_mode: If True, bypass per-shape data validation. Default True.
Returns:
A pattern containing the elements that were read.
"""
pat = Pattern()
elements = klamath.library.read_elements(stream)
for element in elements:
if isinstance(element, klamath.elements.Boundary):
2023-04-12 13:56:50 -07:00
layer, poly = _boundary_to_polygon(element, raw_mode)
pat.shapes[layer].append(poly)
elif isinstance(element, klamath.elements.Path):
2023-04-12 13:56:50 -07:00
layer, path = _gpath_to_mpath(element, raw_mode)
pat.shapes[layer].append(path)
elif isinstance(element, klamath.elements.Text):
2023-04-12 13:56:50 -07:00
pat.label(
layer=element.layer,
2023-04-12 13:56:50 -07:00
offset=element.xy.astype(float),
string=element.string.decode('ASCII'),
annotations=_properties_to_annotations(element.properties),
)
elif isinstance(element, klamath.elements.Reference):
2023-04-12 13:56:50 -07:00
target, ref = _gref_to_mref(element)
pat.refs[target].append(ref)
return pat
2023-02-23 13:15:32 -08:00
def _mlayer2gds(mlayer: layer_t) -> tuple[int, int]:
""" Helper to turn a layer tuple-or-int into a layer and datatype"""
2020-05-12 14:17:35 -07:00
if isinstance(mlayer, int):
layer = mlayer
data_type = 0
elif isinstance(mlayer, tuple):
layer = mlayer[0]
if len(mlayer) > 1:
data_type = mlayer[1]
else:
data_type = 0
else:
2020-09-09 19:41:06 -07:00
raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.')
return layer, data_type
2023-04-12 13:56:50 -07:00
def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]:
"""
2023-01-21 21:22:11 -08:00
Helper function to create a Ref from an SREF or AREF. Sets ref.target to struct_name.
"""
xy = ref.xy.astype(float)
offset = xy[0]
repetition = None
if ref.colrow is not None:
a_count, b_count = ref.colrow
a_vector = (xy[1] - offset) / a_count
b_vector = (xy[2] - offset) / b_count
repetition = Grid(a_vector=a_vector, b_vector=b_vector,
a_count=a_count, b_count=b_count)
2023-04-12 13:56:50 -07:00
target = ref.struct_name.decode('ASCII')
2023-01-22 22:16:09 -08:00
mref = Ref(
offset=offset,
rotation=numpy.deg2rad(ref.angle_deg),
scale=ref.mag,
mirrored=ref.invert_y,
annotations=_properties_to_annotations(ref.properties),
repetition=repetition,
)
2023-04-12 13:56:50 -07:00
return target, mref
2023-04-12 13:56:50 -07:00
def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_t, Path]:
if gpath.path_type in path_cap_map:
cap = path_cap_map[gpath.path_type]
else:
raise PatternError(f'Unrecognized path type: {gpath.path_type}')
mpath = Path(
vertices=gpath.xy.astype(float),
width=gpath.width,
cap=cap,
offset=numpy.zeros(2),
annotations=_properties_to_annotations(gpath.properties),
raw=raw_mode,
)
if cap == Path.Cap.SquareCustom:
mpath.cap_extensions = gpath.extension
2023-04-12 13:56:50 -07:00
return gpath.layer, mpath
2023-04-12 13:56:50 -07:00
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
return boundary.layer, Polygon(
vertices=boundary.xy[:-1].astype(float),
offset=numpy.zeros(2),
annotations=_properties_to_annotations(boundary.properties),
raw=raw_mode,
)
2023-04-12 13:56:50 -07:00
def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
2023-01-22 22:16:09 -08:00
grefs = []
2023-04-12 13:56:50 -07:00
for target, rseq in refs.items():
if target is None:
continue
2023-04-12 13:56:50 -07:00
encoded_name = target.encode('ASCII')
for ref in rseq:
# Note: GDS also mirrors first and rotates second
2023-04-12 13:56:50 -07:00
rep = ref.repetition
angle_deg = numpy.rad2deg(ref.rotation) % 360
2023-04-12 13:56:50 -07:00
properties = _annotations_to_properties(ref.annotations, 512)
2023-04-12 13:56:50 -07:00
if isinstance(rep, Grid):
b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
b_count = rep.b_count if rep.b_count is not None else 1
xy = numpy.asarray(ref.offset) + numpy.array([
2023-04-12 13:56:50 -07:00
[0.0, 0.0],
rep.a_vector * rep.a_count,
b_vector * b_count,
])
aref = klamath.library.Reference(
struct_name=encoded_name,
2023-04-12 13:56:50 -07:00
xy=rint_cast(xy),
colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)),
angle_deg=angle_deg,
invert_y=ref.mirrored,
2023-04-12 13:56:50 -07:00
mag=ref.scale,
properties=properties,
)
grefs.append(aref)
elif rep is None:
sref = klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast([ref.offset]),
colrow=None,
angle_deg=angle_deg,
invert_y=ref.mirrored,
2023-01-21 21:22:11 -08:00
mag=ref.scale,
properties=properties,
)
2023-04-12 13:56:50 -07:00
grefs.append(sref)
else:
new_srefs = [
klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast([ref.offset + dd]),
colrow=None,
angle_deg=angle_deg,
invert_y=ref.mirrored,
2023-04-12 13:56:50 -07:00
mag=ref.scale,
properties=properties,
)
for dd in rep.displacements]
grefs += new_srefs
2023-01-22 22:16:09 -08:00
return grefs
def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t:
if not properties:
return None
return {str(k): [v.decode()] for k, v in properties.items()}
def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Mapping[int, bytes]:
if annotations is None:
return RO_EMPTY_DICT
cum_len = 0
props = {}
for key, vals in annotations.items():
try:
i = int(key)
2024-07-28 20:08:53 -07:00
except ValueError as err:
raise PatternError(f'Annotation key {key} is not convertable to an integer') from err
if not (0 < i <= 126):
raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,126])')
val_strings = ' '.join(str(val) for val in vals)
b = val_strings.encode()
if len(b) > 126:
raise PatternError(f'Annotation value {b!r} is longer than 126 characters!')
cum_len += numpy.ceil(len(b) / 2) * 2 + 2
if cum_len > max_len:
raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}')
props[i] = b
return props
def _shapes_to_elements(
2023-04-12 13:56:50 -07:00
shapes: dict[layer_t, list[Shape]],
polygonize_paths: bool = False,
2023-02-23 13:15:32 -08:00
) -> list[klamath.elements.Element]:
elements: list[klamath.elements.Element] = []
2019-04-20 15:25:19 -07:00
# Add a Boundary element for each shape, and Path elements if necessary
2023-04-12 13:56:50 -07:00
for mlayer, sseq in shapes.items():
layer, data_type = _mlayer2gds(mlayer)
for shape in sseq:
if shape.repetition is not None:
raise PatternError('Shape repetitions are not supported by GDS.'
' Please call library.wrap_repeated_shapes() before writing to file.')
2023-04-12 13:56:50 -07:00
properties = _annotations_to_properties(shape.annotations, 128)
if isinstance(shape, Path) and not polygonize_paths:
xy = rint_cast(shape.vertices + shape.offset)
width = rint_cast(shape.width)
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
2023-04-12 13:56:50 -07:00
extension: tuple[int, int]
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
extension = tuple(shape.cap_extensions) # type: ignore
else:
extension = (0, 0)
2023-04-12 13:56:50 -07:00
path = klamath.elements.Path(
layer=(layer, data_type),
xy=xy,
path_type=path_type,
width=int(width),
extension=extension,
properties=properties,
)
elements.append(path)
elif isinstance(shape, Polygon):
polygon = shape
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
xy_closed[-1] = xy_closed[0]
boundary = klamath.elements.Boundary(
layer=(layer, data_type),
xy=xy_closed,
properties=properties,
)
elements.append(boundary)
2023-04-12 13:56:50 -07:00
else:
for polygon in shape.to_polygons():
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
xy_closed[-1] = xy_closed[0]
boundary = klamath.elements.Boundary(
layer=(layer, data_type),
xy=xy_closed,
properties=properties,
)
elements.append(boundary)
2019-04-20 15:25:19 -07:00
return elements
2023-04-12 13:56:50 -07:00
def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.elements.Text]:
texts = []
2023-04-12 13:56:50 -07:00
for mlayer, lseq in labels.items():
layer, text_type = _mlayer2gds(mlayer)
for label in lseq:
properties = _annotations_to_properties(label.annotations, 128)
xy = rint_cast([label.offset])
text = klamath.elements.Text(
layer=(layer, text_type),
xy=xy,
string=label.string.encode('ASCII'),
properties=properties,
2023-09-17 21:33:22 -07:00
presentation=0, # font number & alignment -- unused by us
angle_deg=0, # rotation -- unused by us
invert_y=False, # inversion -- unused by us
width=0, # stroke width -- unused by us
path_type=0, # text path endcaps, unused
mag=1, # size -- unused by us
2023-04-12 13:56:50 -07:00
)
texts.append(text)
return texts
def load_library(
stream: IO[bytes],
*,
full_load: bool = False,
2023-04-07 18:08:42 -07:00
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
2023-02-23 13:15:32 -08:00
) -> tuple[LazyLibrary, dict[str, Any]]:
"""
Scan a GDSII stream to determine what structures are present, and create
a library from them. This enables deferred reading of structures
on an as-needed basis.
All structures are loaded as secondary
Args:
stream: Seekable stream. Position 0 should be the start of the file.
The caller should leave the stream open while the library
is still in use, since the library will need to access it
in order to read the structure contents.
full_load: If True, force all structures to be read immediately rather
than as-needed. Since data is read sequentially from the file, this
will be faster than using the resulting library's `precache` method.
2023-01-24 23:25:10 -08:00
postprocess: If given, this function is used to post-process each
pattern *upon first load only*.
Returns:
2023-01-13 20:33:14 -08:00
LazyLibrary object, allowing for deferred load of structures.
Additional library info (dict, same format as from `read`).
"""
stream.seek(0)
2023-01-13 20:33:14 -08:00
lib = LazyLibrary()
if full_load:
# Full load approach (immediately load everything)
patterns, library_info = read(stream)
for name, pattern in patterns.items():
2023-02-07 14:25:56 -08:00
if postprocess is not None:
lib[name] = postprocess(lib, name, pattern)
else:
lib[name] = pattern
return lib, library_info
# Normal approach (scan and defer load)
library_info = _read_header(stream)
structs = klamath.library.scan_structs(stream)
for name_bytes, pos in structs.items():
name = name_bytes.decode('ASCII')
2023-01-24 23:25:10 -08:00
def mkstruct(pos: int = pos, name: str = name) -> Pattern:
stream.seek(pos)
2023-01-24 23:25:10 -08:00
pat = read_elements(stream, raw_mode=True)
if postprocess is not None:
pat = postprocess(lib, name, pat)
return pat
lib[name] = mkstruct
return lib, library_info
def load_libraryfile(
2023-02-23 13:15:32 -08:00
filename: str | pathlib.Path,
*,
use_mmap: bool = True,
full_load: bool = False,
2023-04-07 18:08:42 -07:00
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
2023-02-23 13:15:32 -08:00
) -> tuple[LazyLibrary, dict[str, Any]]:
"""
Wrapper for `load_library()` that takes a filename or path instead of a stream.
Will automatically decompress the file if it is gzipped.
NOTE that any streams/mmaps opened will remain open until ALL of the
`PatternGenerator` objects in the library are garbage collected.
Args:
path: filename or path to read from
use_mmap: If `True`, will attempt to memory-map the file instead
of buffering. In the case of gzipped files, the file
is decompressed into a python `bytes` object in memory
and reopened as an `io.BytesIO` stream.
full_load: If `True`, immediately loads all data. See `load_library`.
2023-01-24 23:25:10 -08:00
postprocess: Passed to `load_library`
Returns:
2023-01-13 20:33:14 -08:00
LazyLibrary object, allowing for deferred load of structures.
Additional library info (dict, same format as from `read`).
"""
path = pathlib.Path(filename)
stream: IO[bytes]
if is_gzipped(path):
2024-07-28 20:21:02 -07:00
if use_mmap:
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
2024-07-28 20:30:28 -07:00
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
2023-01-22 22:16:09 -08:00
stream = io.BytesIO(gz_stream.read()) # type: ignore
else:
2024-07-28 20:30:28 -07:00
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
2023-01-22 22:16:09 -08:00
stream = io.BufferedReader(gz_stream) # type: ignore
else: # noqa: PLR5501
2024-07-28 20:21:02 -07:00
if use_mmap:
2024-07-28 20:30:15 -07:00
base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115
2023-01-22 22:16:09 -08:00
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
else:
2024-07-28 20:30:15 -07:00
stream = path.open(mode='rb') # noqa: SIM115
2023-01-24 23:25:10 -08:00
return load_library(stream, full_load=full_load, postprocess=postprocess)
def check_valid_names(
names: Iterable[str],
max_length: int = 32,
) -> None:
"""
Check all provided names to see if they're valid GDSII cell names.
Args:
names: Collection of names to check
max_length: Max allowed length
"""
allowed_chars = set(string.ascii_letters + string.digits + '_?$')
bad_chars = [
name for name in names
if not set(name).issubset(allowed_chars)
]
bad_lengths = [
name for name in names
if len(name) > max_length
]
if bad_chars:
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
if bad_lengths:
logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))
if bad_chars or bad_lengths:
raise LibraryError('Library contains invalid names, see log above')