Compare commits

..

No commits in common. "master" and "v1.2" have entirely different histories.

18 changed files with 284 additions and 862 deletions

3
.gitignore vendored
View file

@ -1,5 +1,5 @@
*.pyc
__pycache__/
__pycache__
*.idea
@ -7,7 +7,6 @@ build/
dist/
*.egg-info/
.mypy_cache/
.pytest_cache/
*.swp
*.swo

View file

@ -44,13 +44,12 @@ The goal is to keep this library simple:
### Links
- [Source repository](https://mpxd.net/code/jan/klamath)
- [PyPI](https://pypi.org/project/klamath)
- [Github mirror](https://github.com/anewusername/klamath)
## Installation
Requirements:
* python >= 3.11
* python >= 3.7 (written and tested with 3.8)
* numpy
@ -197,7 +196,7 @@ header = klamath.library.FileHeader.read(stream)
struct_positions = klamath.library.scan_structs(stream)
stream.seek(struct_positions[b'my_struct'])
elements_A = klamath.library.read_elements(stream)
elements_A = klamath.library.try_read_struct(stream)
stream.close()

View file

@ -1 +0,0 @@
../LICENSE.md

View file

@ -1 +0,0 @@
../README.md

4
klamath/VERSION.py Normal file
View file

@ -0,0 +1,4 @@
""" VERSION defintion. THIS FILE IS MANUALLY PARSED BY setup.py and REQUIRES A SPECIFIC FORMAT """
__version__ = '''
1.2
'''.strip()

View file

@ -27,14 +27,12 @@ The goal is to keep this library simple:
tools for working with hierarchical design data and supports multiple
file formats.
"""
from . import (
basic as basic,
record as record,
records as records,
elements as elements,
library as library,
)
from . import basic
from . import record
from . import records
from . import elements
from . import library
from .VERSION import __version__
__author__ = 'Jan Petykiewicz'
__version__ = '1.5'

View file

@ -1,26 +1,20 @@
"""
Functionality for encoding/decoding basic datatypes
"""
from typing import IO
from collections.abc import Sequence
from typing import Sequence, BinaryIO, List
import struct
import logging
from datetime import datetime
import numpy
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
import numpy # type: ignore
class KlamathError(Exception):
pass
#
# Parse functions
#
"""
Parse functions
"""
def parse_bitarray(data: bytes) -> int:
if len(data) != 2:
raise KlamathError(f'Incorrect bitarray size ({len(data)}). Data is {data!r}.')
@ -28,31 +22,31 @@ def parse_bitarray(data: bytes) -> int:
return val
def parse_int2(data: bytes) -> NDArray[numpy.int16]:
def parse_int2(data: bytes) -> numpy.ndarray:
data_len = len(data)
if data_len == 0 or (data_len % 2) != 0:
raise KlamathError(f'Incorrect int2 size ({len(data)}). Data is {data!r}.')
return numpy.frombuffer(data, dtype='>i2', count=data_len // 2)
def parse_int4(data: bytes) -> NDArray[numpy.int32]:
def parse_int4(data: bytes) -> numpy.ndarray:
data_len = len(data)
if data_len == 0 or (data_len % 4) != 0:
raise KlamathError(f'Incorrect int4 size ({len(data)}). Data is {data!r}.')
return numpy.frombuffer(data, dtype='>i4', count=data_len // 4)
def decode_real8(nums: NDArray[numpy.uint64]) -> NDArray[numpy.float64]:
def decode_real8(nums: numpy.ndarray) -> numpy.ndarray:
""" Convert GDS REAL8 data to IEEE float64. """
nums = nums.astype(numpy.uint64)
neg = nums & 0x8000_0000_0000_0000
exp = (nums >> 56) & 0x7f
mant = (nums & 0x00ff_ffff_ffff_ffff).astype(numpy.float64)
mant[neg != 0] *= -1
return numpy.ldexp(mant, 4 * (exp.astype(numpy.int64) - 64) - 56)
return numpy.ldexp(mant, (4 * (exp - 64) - 56).astype(numpy.int64))
def parse_real8(data: bytes) -> NDArray[numpy.float64]:
def parse_real8(data: bytes) -> numpy.ndarray:
data_len = len(data)
if data_len == 0 or (data_len % 8) != 0:
raise KlamathError(f'Incorrect real8 size ({len(data)}). Data is {data!r}.')
@ -68,46 +62,41 @@ def parse_ascii(data: bytes) -> bytes:
return data
def parse_datetime(data: bytes) -> list[datetime]:
def parse_datetime(data: bytes) -> List[datetime]:
""" Parse date/time data (12 byte blocks) """
if len(data) == 0 or len(data) % 12 != 0:
raise KlamathError(f'Incorrect datetime size ({len(data)}). Data is {data!r}.')
dts = []
for ii in range(0, len(data), 12):
year, *date_parts = parse_int2(data[ii:ii + 12])
try:
dt = datetime(year + 1900, *date_parts)
except ValueError:
dt = datetime(1900, 1, 1, 0, 0, 0)
logger.info(f'Invalid date {[year] + date_parts}, setting {dt} instead')
dts.append(dt)
year, *date_parts = parse_int2(data[ii:ii+12])
dts.append(datetime(year + 1900, *date_parts))
return dts
#
# Pack functions
#
"""
Pack functions
"""
def pack_bitarray(data: int) -> bytes:
if data > 65535 or data < 0:
raise KlamathError(f'bitarray data out of range: {data}')
return struct.pack('>H', data)
def pack_int2(data: NDArray[numpy.integer] | Sequence[int] | int) -> bytes:
arr = numpy.asarray(data)
def pack_int2(data: Sequence[int]) -> bytes:
arr = numpy.array(data)
if (arr > 32767).any() or (arr < -32768).any():
raise KlamathError(f'int2 data out of range: {arr}')
return arr.astype('>i2').tobytes()
def pack_int4(data: NDArray[numpy.integer] | Sequence[int] | int) -> bytes:
arr = numpy.asarray(data)
def pack_int4(data: Sequence[int]) -> bytes:
arr = numpy.array(data)
if (arr > 2147483647).any() or (arr < -2147483648).any():
raise KlamathError(f'int4 data out of range: {arr}')
return arr.astype('>i4').tobytes()
def encode_real8(fnums: NDArray[numpy.float64]) -> NDArray[numpy.uint64]:
def encode_real8(fnums: numpy.ndarray) -> numpy.ndarray:
""" Convert from float64 to GDS REAL8 representation. """
# Split the ieee float bitfields
ieee = numpy.atleast_1d(fnums.astype(numpy.float64).view(numpy.uint64))
@ -149,7 +138,7 @@ def encode_real8(fnums: NDArray[numpy.float64]) -> NDArray[numpy.uint64]:
gds_exp = exp16 + 64
neg_biased = (gds_exp < 0)
gds_mant[neg_biased] >>= (-gds_exp[neg_biased] * 4).astype(numpy.uint16)
gds_mant[neg_biased] >>= (gds_exp[neg_biased] * 4).astype(numpy.uint16)
gds_exp[neg_biased] = 0
too_big = (gds_exp > 0x7f) & ~(zero | subnorm)
@ -160,12 +149,13 @@ def encode_real8(fnums: NDArray[numpy.float64]) -> NDArray[numpy.uint64]:
real8 = sign | gds_exp_bits | gds_mant
real8[zero] = 0
real8[gds_exp < -14] = 0 # number is too small
return real8.astype(numpy.uint64, copy=False)
return real8
def pack_real8(data: NDArray[numpy.floating] | Sequence[float] | float) -> bytes:
return encode_real8(numpy.asarray(data)).astype('>u8').tobytes()
def pack_real8(data: Sequence[float]) -> bytes:
return encode_real8(numpy.array(data)).astype('>u8').tobytes()
def pack_ascii(data: bytes) -> bytes:
@ -182,7 +172,7 @@ def pack_datetime(data: Sequence[datetime]) -> bytes:
return pack_int2(parts)
def read(stream: IO[bytes], size: int) -> bytes:
def read(stream: BinaryIO, size: int) -> bytes:
""" Read and check for failure """
data = stream.read(size)
if len(data) != size:

View file

@ -2,14 +2,11 @@
Functionality for reading/writing elements (geometry, text labels,
structure references) and associated properties.
"""
import io
from typing import IO, TypeVar
from collections.abc import Mapping
from typing import Dict, Tuple, Optional, BinaryIO, TypeVar, Type, Union
from abc import ABCMeta, abstractmethod
from dataclasses import dataclass
import numpy
from numpy.typing import NDArray
import numpy # type: ignore
from .basic import KlamathError
from .record import Record
@ -31,7 +28,8 @@ T = TypeVar('T', bound='Text')
X = TypeVar('X', bound='Box')
def read_properties(stream: IO[bytes]) -> dict[int, bytes]:
def read_properties(stream: BinaryIO) -> Dict[int, bytes]:
"""
Read element properties.
@ -53,14 +51,12 @@ def read_properties(stream: IO[bytes]) -> dict[int, bytes]:
value = PROPVALUE.read(stream)
if key in properties:
raise KlamathError(f'Duplicate property key: {key!r}')
properties[key] = value
else:
stream.seek(size, io.SEEK_CUR)
properties[key] = value
size, tag = Record.read_header(stream)
return properties
def write_properties(stream: IO[bytes], properties: Mapping[int, bytes]) -> int:
def write_properties(stream: BinaryIO, properties: Dict[int, bytes]) -> int:
"""
Write element properties.
@ -82,7 +78,7 @@ class Element(metaclass=ABCMeta):
"""
@classmethod
@abstractmethod
def read(cls: type[E], stream: IO[bytes]) -> E:
def read(cls: Type[E], stream: BinaryIO) -> E:
"""
Read from a stream to construct this object.
Consumes up to (and including) the ENDEL record.
@ -96,7 +92,7 @@ class Element(metaclass=ABCMeta):
pass
@abstractmethod
def write(self, stream: IO[bytes]) -> int:
def write(self, stream: BinaryIO) -> int:
"""
Write this element to a stream.
Finishes with an ENDEL record.
@ -135,7 +131,7 @@ class Reference(Element):
angle_deg: float
""" Rotation (degrees counterclockwise) """
xy: NDArray[numpy.int32]
xy: numpy.ndarray
"""
(For SREF) Location in the parent structure corresponding to the instance's origin (0, 0).
(For AREF) 3 locations:
@ -148,14 +144,14 @@ class Reference(Element):
basis vectors to match it.
"""
colrow: tuple[int, int] | NDArray[numpy.int16] | None
colrow: Optional[Union[Tuple[int, int], numpy.ndarray]]
""" Number of columns and rows (AREF) or None (SREF) """
properties: Mapping[int, bytes]
properties: Dict[int, bytes]
""" Properties associated with this reference. """
@classmethod
def read(cls: type[R], stream: IO[bytes]) -> R:
def read(cls: Type[R], stream: BinaryIO) -> R:
invert_y = False
mag = 1
angle_deg = 0
@ -178,17 +174,10 @@ class Reference(Element):
size, tag = Record.read_header(stream)
xy = XY.read_data(stream, size).reshape(-1, 2)
properties = read_properties(stream)
return cls(
struct_name=struct_name,
xy=xy,
properties=properties,
colrow=colrow,
invert_y=invert_y,
mag=mag,
angle_deg=angle_deg,
)
return cls(struct_name=struct_name, xy=xy, properties=properties, colrow=colrow,
invert_y=invert_y, mag=mag, angle_deg=angle_deg)
def write(self, stream: IO[bytes]) -> int:
def write(self, stream: BinaryIO) -> int:
b = 0
if self.colrow is None:
b += SREF.write(stream, None)
@ -200,7 +189,7 @@ class Reference(Element):
b += STRANS.write(stream, int(self.invert_y) << 15)
if self.mag != 1:
b += MAG.write(stream, self.mag)
if self.angle_deg != 0:
if self.angle_deg !=0:
b += ANGLE.write(stream, self.angle_deg)
if self.colrow is not None:
@ -215,7 +204,7 @@ class Reference(Element):
if self.colrow is not None:
if self.xy.size != 6:
raise KlamathError(f'colrow is not None, so expected size-6 xy. Got {self.xy}')
else: # noqa: PLR5501
else:
if self.xy.size != 2:
raise KlamathError(f'Expected size-2 xy. Got {self.xy}')
@ -227,24 +216,24 @@ class Boundary(Element):
"""
__slots__ = ('layer', 'xy', 'properties')
layer: tuple[int, int]
layer: Tuple[int, int]
""" (layer, data_type) tuple """
xy: NDArray[numpy.int32]
xy: numpy.ndarray
""" Ordered vertices of the shape. First and last points should be identical. """
properties: Mapping[int, bytes]
properties: Dict[int, bytes]
""" Properties for the element. """
@classmethod
def read(cls: type[B], stream: IO[bytes]) -> B:
def read(cls: Type[B], stream: BinaryIO) -> B:
layer = LAYER.skip_and_read(stream)[0]
dtype = DATATYPE.read(stream)[0]
xy = XY.read(stream).reshape(-1, 2)
properties = read_properties(stream)
return cls(layer=(layer, dtype), xy=xy, properties=properties)
def write(self, stream: IO[bytes]) -> int:
def write(self, stream: BinaryIO) -> int:
b = BOUNDARY.write(stream, None)
b += LAYER.write(stream, self.layer[0])
b += DATATYPE.write(stream, self.layer[1])
@ -264,7 +253,7 @@ class Path(Element):
"""
__slots__ = ('layer', 'xy', 'properties', 'path_type', 'width', 'extension')
layer: tuple[int, int]
layer: Tuple[int, int]
""" (layer, data_type) tuple """
path_type: int
@ -273,17 +262,17 @@ class Path(Element):
width: int
""" Path width """
extension: tuple[int, int]
extension: Tuple[int, int]
""" Extension when using path_type=4. Ignored otherwise. """
xy: NDArray[numpy.int32]
xy: numpy.ndarray
""" Path centerline coordinates """
properties: Mapping[int, bytes]
properties: Dict[int, bytes]
""" Properties for the element. """
@classmethod
def read(cls: type[P], stream: IO[bytes]) -> P:
def read(cls: Type[P], stream: BinaryIO) -> P:
path_type = 0
width = 0
bgn_ext = 0
@ -310,7 +299,7 @@ class Path(Element):
properties=properties, extension=(bgn_ext, end_ext),
path_type=path_type, width=width)
def write(self, stream: IO[bytes]) -> int:
def write(self, stream: BinaryIO) -> int:
b = PATH.write(stream, None)
b += LAYER.write(stream, self.layer[0])
b += DATATYPE.write(stream, self.layer[1])
@ -319,12 +308,12 @@ class Path(Element):
if self.width != 0:
b += WIDTH.write(stream, self.width)
if self.path_type == 4:
if self.path_type < 4:
bgn_ext, end_ext = self.extension
if bgn_ext != 0:
b += BGNEXTN.write(stream, int(bgn_ext))
b += BGNEXTN.write(stream, bgn_ext)
if end_ext != 0:
b += ENDEXTN.write(stream, int(end_ext))
b += ENDEXTN.write(stream, end_ext)
b += XY.write(stream, self.xy)
b += write_properties(stream, self.properties)
b += ENDEL.write(stream, None)
@ -338,24 +327,24 @@ class Box(Element):
"""
__slots__ = ('layer', 'xy', 'properties')
layer: tuple[int, int]
layer: Tuple[int, int]
""" (layer, box_type) tuple """
xy: NDArray[numpy.int32]
xy: numpy.ndarray
""" Box coordinates (5 pairs) """
properties: Mapping[int, bytes]
properties: Dict[int, bytes]
""" Properties for the element. """
@classmethod
def read(cls: type[X], stream: IO[bytes]) -> X:
def read(cls: Type[X], stream: BinaryIO) -> X:
layer = LAYER.skip_and_read(stream)[0]
dtype = BOXTYPE.read(stream)[0]
xy = XY.read(stream).reshape(-1, 2)
properties = read_properties(stream)
return cls(layer=(layer, dtype), xy=xy, properties=properties)
def write(self, stream: IO[bytes]) -> int:
def write(self, stream: BinaryIO) -> int:
b = BOX.write(stream, None)
b += LAYER.write(stream, self.layer[0])
b += BOXTYPE.write(stream, self.layer[1])
@ -372,24 +361,24 @@ class Node(Element):
"""
__slots__ = ('layer', 'xy', 'properties')
layer: tuple[int, int]
layer: Tuple[int, int]
""" (layer, node_type) tuple """
xy: NDArray[numpy.int32]
xy: numpy.ndarray
""" 1-50 pairs of coordinates. """
properties: Mapping[int, bytes]
properties: Dict[int, bytes]
""" Properties for the element. """
@classmethod
def read(cls: type[N], stream: IO[bytes]) -> N:
def read(cls: Type[N], stream: BinaryIO) -> N:
layer = LAYER.skip_and_read(stream)[0]
dtype = NODETYPE.read(stream)[0]
xy = XY.read(stream).reshape(-1, 2)
properties = read_properties(stream)
return cls(layer=(layer, dtype), xy=xy, properties=properties)
def write(self, stream: IO[bytes]) -> int:
def write(self, stream: BinaryIO) -> int:
b = NODE.write(stream, None)
b += LAYER.write(stream, self.layer[0])
b += NODETYPE.write(stream, self.layer[1])
@ -407,7 +396,7 @@ class Text(Element):
__slots__ = ('layer', 'xy', 'properties', 'presentation', 'path_type',
'width', 'invert_y', 'mag', 'angle_deg', 'string')
layer: tuple[int, int]
layer: Tuple[int, int]
""" (layer, node_type) tuple """
presentation: int
@ -432,17 +421,17 @@ class Text(Element):
angle_deg: float
""" Rotation (ccw). Default 0. """
xy: NDArray[numpy.int32]
xy: numpy.ndarray
""" Position (1 pair only) """
string: bytes
""" Text content """
properties: Mapping[int, bytes]
properties: Dict[int, bytes]
""" Properties for the element. """
@classmethod
def read(cls: type[T], stream: IO[bytes]) -> T:
def read(cls: Type[T], stream: BinaryIO) -> T:
path_type = 0
presentation = 0
invert_y = False
@ -478,7 +467,7 @@ class Text(Element):
string=string, presentation=presentation, path_type=path_type,
width=width, invert_y=invert_y, mag=mag, angle_deg=angle_deg)
def write(self, stream: IO[bytes]) -> int:
def write(self, stream: BinaryIO) -> int:
b = TEXT.write(stream, None)
b += LAYER.write(stream, self.layer[0])
b += TEXTTYPE.write(stream, self.layer[1])
@ -492,7 +481,7 @@ class Text(Element):
b += STRANS.write(stream, int(self.invert_y) << 15)
if self.mag != 1:
b += MAG.write(stream, self.mag)
if self.angle_deg != 0:
if self.angle_deg !=0:
b += ANGLE.write(stream, self.angle_deg)
b += XY.write(stream, self.xy)
b += STRING.write(stream, self.string)

View file

@ -1,7 +1,7 @@
"""
File-level read/write functionality.
"""
from typing import IO, Self, TYPE_CHECKING
from typing import List, Dict, Tuple, Optional, BinaryIO, TypeVar, Type, MutableMapping
import io
from datetime import datetime
from dataclasses import dataclass
@ -15,8 +15,8 @@ from .records import BGNSTR, STRNAME, ENDSTR, SNAME, COLROW, ENDEL
from .records import BOX, BOUNDARY, NODE, PATH, TEXT, SREF, AREF
from .elements import Element, Reference, Text, Box, Boundary, Path, Node
if TYPE_CHECKING:
from collections.abc import MutableMapping
FH = TypeVar('FH', bound='FileHeader')
@dataclass
@ -45,7 +45,7 @@ class FileHeader:
""" Last-accessed time """
@classmethod
def read(cls: type[Self], stream: IO[bytes]) -> Self:
def read(cls: Type[FH], stream: BinaryIO) -> FH:
"""
Read and construct a header from the provided stream.
@ -55,7 +55,7 @@ class FileHeader:
Returns:
FileHeader object
"""
_version = HEADER.read(stream)[0] # noqa: F841 # var is unused
version = HEADER.read(stream)[0]
mod_time, acc_time = BGNLIB.read(stream)
name = LIBNAME.skip_and_read(stream)
uu, dbu = UNITS.skip_and_read(stream)
@ -63,7 +63,7 @@ class FileHeader:
return cls(mod_time=mod_time, acc_time=acc_time, name=name,
user_units_per_db_unit=uu, meters_per_db_unit=dbu)
def write(self, stream: IO[bytes]) -> int:
def write(self, stream: BinaryIO) -> int:
"""
Write the header to a stream
@ -80,7 +80,7 @@ class FileHeader:
return b
def scan_structs(stream: IO[bytes]) -> dict[bytes, int]:
def scan_structs(stream: BinaryIO) -> Dict[bytes, int]:
"""
Scan through a GDS file, building a table of
{b'structure_name': byte_offset}.
@ -107,7 +107,7 @@ def scan_structs(stream: IO[bytes]) -> dict[bytes, int]:
return positions
def try_read_struct(stream: IO[bytes]) -> tuple[bytes, list[Element]] | None:
def try_read_struct(stream: BinaryIO) -> Optional[Tuple[bytes, List[Element]]]:
"""
Skip to the next structure and attempt to read it.
@ -125,13 +125,12 @@ def try_read_struct(stream: IO[bytes]) -> tuple[bytes, list[Element]] | None:
return name, elements
def write_struct(
stream: IO[bytes],
name: bytes,
elements: list[Element],
cre_time: datetime = datetime(1900, 1, 1),
mod_time: datetime = datetime(1900, 1, 1),
) -> int:
def write_struct(stream: BinaryIO,
name: bytes,
elements: List[Element],
cre_time: datetime = datetime(1900, 1, 1),
mod_time: datetime = datetime(1900, 1, 1),
) -> int:
"""
Write a structure to the provided stream.
@ -151,7 +150,7 @@ def write_struct(
return b
def read_elements(stream: IO[bytes]) -> list[Element]:
def read_elements(stream: BinaryIO) -> List[Element]:
"""
Read elements from the stream until an ENDSTR
record is encountered. The ENDSTR record is also
@ -163,7 +162,7 @@ def read_elements(stream: IO[bytes]) -> list[Element]:
Returns:
List of element objects.
"""
data: list[Element] = []
data: List[Element] = []
size, tag = Record.read_header(stream)
while tag != ENDSTR.tag:
if tag == BOUNDARY.tag:
@ -176,7 +175,9 @@ def read_elements(stream: IO[bytes]) -> list[Element]:
data.append(Box.read(stream))
elif tag == TEXT.tag:
data.append(Text.read(stream))
elif tag in (SREF.tag, AREF.tag):
elif tag == SREF.tag:
data.append(Reference.read(stream))
elif tag == AREF.tag:
data.append(Reference.read(stream))
else:
# don't care, skip
@ -185,7 +186,7 @@ def read_elements(stream: IO[bytes]) -> list[Element]:
return data
def scan_hierarchy(stream: IO[bytes]) -> dict[bytes, dict[bytes, int]]:
def scan_hierarchy(stream: BinaryIO) -> Dict[bytes, Dict[bytes, int]]:
"""
Scan through a GDS file, building a table of instance counts
`{b'structure_name': {b'ref_name': count}}`.
@ -220,15 +221,10 @@ def scan_hierarchy(stream: IO[bytes]) -> dict[bytes, dict[bytes, int]]:
colrow = COLROW.read_data(stream, size)
ref_count = colrow[0] * colrow[1]
elif tag == ENDEL.tag:
if ref_name is not None:
if ref_count is None:
ref_count = 1
cur_structure[ref_name] += ref_count
ref_name = None
ref_count = None
elif tag in (SREF.tag, AREF.tag):
ref_name = None
ref_count = None
if ref_count is None:
ref_count = 1
assert(ref_name is not None)
cur_structure[ref_name] += ref_count
else:
stream.seek(size, io.SEEK_CUR)
size, tag = Record.read_header(stream)

View file

@ -1,15 +1,14 @@
"""
Generic record-level read/write functionality.
"""
from typing import IO, ClassVar, Self, Generic, TypeVar
from collections.abc import Sequence
from typing import Optional, Sequence, BinaryIO
from typing import TypeVar, List, Tuple, ClassVar, Type
import struct
import io
from datetime import datetime
from abc import ABCMeta, abstractmethod
import numpy
from numpy.typing import NDArray
import numpy # type: ignore
from .basic import KlamathError
from .basic import parse_int2, parse_int4, parse_real8, parse_datetime, parse_bitarray
@ -18,11 +17,9 @@ from .basic import parse_ascii, pack_ascii, read
_RECORD_HEADER_FMT = struct.Struct('>HH')
II = TypeVar('II') # Input type
OO = TypeVar('OO') # Output type
def write_record_header(stream: IO[bytes], data_size: int, tag: int) -> int:
def write_record_header(stream: BinaryIO, data_size: int, tag: int) -> int:
record_size = data_size + 4
if record_size > 0xFFFF:
raise KlamathError(f'Record size is too big: {record_size}')
@ -30,7 +27,7 @@ def write_record_header(stream: IO[bytes], data_size: int, tag: int) -> int:
return stream.write(header)
def read_record_header(stream: IO[bytes]) -> tuple[int, int]:
def read_record_header(stream: BinaryIO) -> Tuple[int, int]:
"""
Read a record's header (size and tag).
Args:
@ -49,46 +46,49 @@ def read_record_header(stream: IO[bytes]) -> tuple[int, int]:
return data_size, tag
def expect_record(stream: IO[bytes], tag: int) -> int:
def expect_record(stream: BinaryIO, tag: int) -> int:
data_size, actual_tag = read_record_header(stream)
if tag != actual_tag:
raise KlamathError(f'Unexpected record! Got tag 0x{actual_tag:04x}, expected 0x{tag:04x}')
return data_size
class Record(Generic[II, OO], metaclass=ABCMeta):
R = TypeVar('R', bound='Record')
class Record(metaclass=ABCMeta):
tag: ClassVar[int] = -1
expected_size: ClassVar[int | None] = None
expected_size: ClassVar[Optional[int]] = None
@classmethod
def check_size(cls: type[Self], size: int) -> None:
def check_size(cls, size: int):
if cls.expected_size is not None and size != cls.expected_size:
raise KlamathError(f'Expected size {cls.expected_size}, got {size}')
@classmethod # noqa: B027 Intentionally non-abstract
def check_data(cls: type[Self], data: II) -> None:
@classmethod
def check_data(cls, data):
pass
@classmethod
@abstractmethod
def read_data(cls: type[Self], stream: IO[bytes], size: int) -> OO:
def read_data(cls, stream: BinaryIO, size: int):
pass
@classmethod
@abstractmethod
def pack_data(cls: type[Self], data: II) -> bytes:
def pack_data(cls, data) -> bytes:
pass
@staticmethod
def read_header(stream: IO[bytes]) -> tuple[int, int]:
def read_header(stream: BinaryIO) -> Tuple[int, int]:
return read_record_header(stream)
@classmethod
def write_header(cls: type[Self], stream: IO[bytes], data_size: int) -> int:
def write_header(cls, stream: BinaryIO, data_size: int) -> int:
return write_record_header(stream, data_size, cls.tag)
@classmethod
def skip_past(cls: type[Self], stream: IO[bytes]) -> bool:
def skip_past(cls, stream: BinaryIO) -> bool:
"""
Skip to the end of the next occurence of this record.
@ -110,7 +110,7 @@ class Record(Generic[II, OO], metaclass=ABCMeta):
return True
@classmethod
def skip_and_read(cls: type[Self], stream: IO[bytes]) -> OO:
def skip_and_read(cls, stream: BinaryIO):
size, tag = Record.read_header(stream)
while tag != cls.tag:
stream.seek(size, io.SEEK_CUR)
@ -119,90 +119,90 @@ class Record(Generic[II, OO], metaclass=ABCMeta):
return data
@classmethod
def read(cls: type[Self], stream: IO[bytes]) -> OO:
def read(cls: Type[R], stream: BinaryIO):
size = expect_record(stream, cls.tag)
data = cls.read_data(stream, size)
return data
@classmethod
def write(cls: type[Self], stream: IO[bytes], data: II) -> int:
def write(cls, stream: BinaryIO, data) -> int:
data_bytes = cls.pack_data(data)
b = cls.write_header(stream, len(data_bytes))
b += stream.write(data_bytes)
return b
class NoDataRecord(Record[None, None]):
expected_size: ClassVar[int | None] = 0
class NoDataRecord(Record):
expected_size: ClassVar[Optional[int]] = 0
@classmethod
def read_data(cls: type[Self], stream: IO[bytes], size: int) -> None:
def read_data(cls, stream: BinaryIO, size: int) -> None:
stream.read(size)
@classmethod
def pack_data(cls: type[Self], data: None) -> bytes:
def pack_data(cls, data: None) -> bytes:
if data is not None:
raise KlamathError('?? Packing {data!r} into NoDataRecord??')
raise KlamathError('?? Packing {data} into NoDataRecord??')
return b''
class BitArrayRecord(Record[int, int]):
expected_size: ClassVar[int | None] = 2
class BitArrayRecord(Record):
expected_size: ClassVar[Optional[int]] = 2
@classmethod
def read_data(cls: type[Self], stream: IO[bytes], size: int) -> int: # noqa: ARG003 size unused
def read_data(cls, stream: BinaryIO, size: int) -> int:
return parse_bitarray(read(stream, 2))
@classmethod
def pack_data(cls: type[Self], data: int) -> bytes:
def pack_data(cls, data: int) -> bytes:
return pack_bitarray(data)
class Int2Record(Record[NDArray[numpy.integer] | Sequence[int] | int, NDArray[numpy.int16]]):
class Int2Record(Record):
@classmethod
def read_data(cls: type[Self], stream: IO[bytes], size: int) -> NDArray[numpy.int16]:
def read_data(cls, stream: BinaryIO, size: int) -> numpy.ndarray:
return parse_int2(read(stream, size))
@classmethod
def pack_data(cls: type[Self], data: NDArray[numpy.integer] | Sequence[int] | int) -> bytes:
def pack_data(cls, data: Sequence[int]) -> bytes:
return pack_int2(data)
class Int4Record(Record[NDArray[numpy.integer] | Sequence[int] | int, NDArray[numpy.int32]]):
class Int4Record(Record):
@classmethod
def read_data(cls: type[Self], stream: IO[bytes], size: int) -> NDArray[numpy.int32]:
def read_data(cls, stream: BinaryIO, size: int) -> numpy.ndarray:
return parse_int4(read(stream, size))
@classmethod
def pack_data(cls: type[Self], data: NDArray[numpy.integer] | Sequence[int] | int) -> bytes:
def pack_data(cls, data: Sequence[int]) -> bytes:
return pack_int4(data)
class Real8Record(Record[Sequence[float] | float, NDArray[numpy.float64]]):
class Real8Record(Record):
@classmethod
def read_data(cls: type[Self], stream: IO[bytes], size: int) -> NDArray[numpy.float64]:
def read_data(cls, stream: BinaryIO, size: int) -> numpy.ndarray:
return parse_real8(read(stream, size))
@classmethod
def pack_data(cls: type[Self], data: Sequence[float] | float) -> bytes:
def pack_data(cls, data: Sequence[int]) -> bytes:
return pack_real8(data)
class ASCIIRecord(Record[bytes, bytes]):
class ASCIIRecord(Record):
@classmethod
def read_data(cls: type[Self], stream: IO[bytes], size: int) -> bytes:
def read_data(cls, stream: BinaryIO, size: int) -> bytes:
return parse_ascii(read(stream, size))
@classmethod
def pack_data(cls: type[Self], data: bytes) -> bytes:
def pack_data(cls, data: bytes) -> bytes:
return pack_ascii(data)
class DateTimeRecord(Record[Sequence[datetime], list[datetime]]):
class DateTimeRecord(Record):
@classmethod
def read_data(cls: type[Self], stream: IO[bytes], size: int) -> list[datetime]:
def read_data(cls, stream: BinaryIO, size: int) -> List[datetime]:
return parse_datetime(read(stream, size))
@classmethod
def pack_data(cls: type[Self], data: Sequence[datetime]) -> bytes:
def pack_data(cls, data: Sequence[datetime]) -> bytes:
return pack_datetime(data)

View file

@ -1,12 +1,8 @@
"""
Record type and tag definitions
"""
from typing import Self
from collections.abc import Sequence, Sized
import numpy
from numpy.typing import NDArray
from typing import Sequence
from .basic import KlamathError
from .record import NoDataRecord, BitArrayRecord, Int2Record, Int4Record, Real8Record
from .record import ASCIIRecord, DateTimeRecord
@ -18,7 +14,7 @@ class HEADER(Int2Record):
class BGNLIB(DateTimeRecord):
tag = 0x0102
expected_size = 2 * 6 * 2
expected_size = 6 * 2
class LIBNAME(ASCIIRecord):
@ -37,7 +33,7 @@ class ENDLIB(NoDataRecord):
class BGNSTR(DateTimeRecord):
tag = 0x0502
expected_size = 2 * 6 * 2
expected_size = 6 * 2
class STRNAME(ASCIIRecord):
@ -115,7 +111,7 @@ class PRESENTATION(BitArrayRecord):
class SPACING(Int2Record):
tag = 0x1802 # Not sure about 02; Unused
tag = 0x1802 #Not sure about 02; Unused
class STRING(ASCIIRecord):
@ -137,29 +133,29 @@ class ANGLE(Real8Record):
class UINTEGER(Int2Record):
tag = 0x1d02 # Unused; not sure about 02
tag = 0x1d02 #Unused; not sure about 02
class USTRING(ASCIIRecord):
tag = 0x1e06 # Unused; not sure about 06
tag = 0x1e06 #Unused; not sure about 06
class REFLIBS(ASCIIRecord):
tag = 0x1f06
@classmethod
def check_size(cls: type[Self], size: int) -> None:
def check_size(cls, size: int):
if size != 0 and size % 44 != 0:
raise KlamathError(f'Expected size to be multiple of 44, got {size}')
raise Exception(f'Expected size to be multiple of 44, got {size}')
class FONTS(ASCIIRecord):
tag = 0x2006
@classmethod
def check_size(cls: type[Self], size: int) -> None:
def check_size(cls, size: int):
if size != 0 and size % 44 != 0:
raise KlamathError(f'Expected size to be multiple of 44, got {size}')
raise Exception(f'Expected size to be multiple of 44, got {size}')
class PATHTYPE(Int2Record):
@ -172,28 +168,26 @@ class GENERATIONS(Int2Record):
expected_size = 2
@classmethod
def check_data(cls: type[Self], data: NDArray[numpy.integer] | Sequence[int] | int) -> None:
if isinstance(data, (int, numpy.integer)):
return
if not isinstance(data, Sized) or len(data) != 1:
raise KlamathError(f'Expected exactly one integer, got {data}')
def check_data(cls, data: Sequence[int]):
if len(data) != 1:
raise Exception(f'Expected exactly one integer, got {data}')
class ATTRTABLE(ASCIIRecord):
tag = 0x2306
@classmethod
def check_size(cls: type[Self], size: int) -> None:
def check_size(cls, size: int):
if size > 44:
raise KlamathError(f'Expected size <= 44, got {size}')
raise Exception(f'Expected size <= 44, got {size}')
class STYPTABLE(ASCIIRecord):
tag = 0x2406 # UNUSED, not sure about 06
tag = 0x2406 #UNUSED, not sure about 06
class STRTYPE(Int2Record):
tag = 0x2502 # UNUSED
tag = 0x2502 #UNUSED
class ELFLAGS(BitArrayRecord):
@ -224,6 +218,7 @@ class PROPATTR(Int2Record):
class PROPVALUE(ASCIIRecord):
tag = 0x2c06
expected_size = 2
class BOX(NoDataRecord):
@ -271,11 +266,9 @@ class FORMAT(Int2Record):
expected_size = 2
@classmethod
def check_data(cls: type[Self], data: NDArray[numpy.integer] | Sequence[int] | int) -> None:
if isinstance(data, (int, numpy.integer)):
return
if not isinstance(data, Sized) or len(data) != 1:
raise KlamathError(f'Expected exactly one integer, got {data}')
def check_data(cls, data: Sequence[int]):
if len(data) != 1:
raise Exception(f'Expected exactly one integer, got {data}')
class MASK(ASCIIRecord):
@ -309,7 +302,7 @@ class SOFTFENCE(NoDataRecord):
class HARDFENCE(NoDataRecord):
tag = 0x3e00
tag = 0x3f00
class SOFTWIRE(NoDataRecord):

View file

@ -1,21 +1,20 @@
import struct
import pytest # type: ignore
import numpy
from datetime import datetime
from numpy.testing import assert_array_equal
import numpy # type: ignore
from numpy.testing import assert_array_equal # type: ignore
from .basic import parse_bitarray, parse_int2, parse_int4, parse_real8, parse_ascii
from .basic import pack_bitarray, pack_int2, pack_int4, pack_real8, pack_ascii
from .basic import decode_real8, encode_real8, parse_datetime
from .basic import decode_real8, encode_real8
from .basic import KlamathError
def test_parse_bitarray() -> None:
assert parse_bitarray(b'59') == 13625
assert parse_bitarray(b'\0\0') == 0
assert parse_bitarray(b'\xff\xff') == 65535
def test_parse_bitarray():
assert(parse_bitarray(b'59') == 13625)
assert(parse_bitarray(b'\0\0') == 0)
assert(parse_bitarray(b'\xff\xff') == 65535)
# 4 bytes (too long)
with pytest.raises(KlamathError):
@ -26,7 +25,7 @@ def test_parse_bitarray() -> None:
parse_bitarray(b'')
def test_parse_int2() -> None:
def test_parse_int2():
assert_array_equal(parse_int2(b'59\xff\xff\0\0'), (13625, -1, 0))
# odd length
@ -38,7 +37,7 @@ def test_parse_int2() -> None:
parse_int2(b'')
def test_parse_int4() -> None:
def test_parse_int4():
assert_array_equal(parse_int4(b'4321'), (875770417,))
# length % 4 != 0
@ -50,17 +49,17 @@ def test_parse_int4() -> None:
parse_int4(b'')
def test_decode_real8() -> None:
def test_decode_real8():
# zeroes
assert decode_real8(numpy.array([0x0])) == 0
assert decode_real8(numpy.array([1 << 63])) == 0 # negative
assert decode_real8(numpy.array([0xff << 56])) == 0 # denormalized
assert(decode_real8(numpy.array([0x0])) == 0)
assert(decode_real8(numpy.array([1<<63])) == 0) # negative
assert(decode_real8(numpy.array([0xff << 56])) == 0) # denormalized
assert decode_real8(numpy.array([0x4110 << 48])) == 1.0
assert decode_real8(numpy.array([0xC120 << 48])) == -2.0
assert(decode_real8(numpy.array([0x4110 << 48])) == 1.0)
assert(decode_real8(numpy.array([0xC120 << 48])) == -2.0)
def test_parse_real8() -> None:
def test_parse_real8():
packed = struct.pack('>3Q', 0x0, 0x4110_0000_0000_0000, 0xC120_0000_0000_0000)
assert_array_equal(parse_real8(packed), (0.0, 1.0, -2.0))
@ -73,59 +72,48 @@ def test_parse_real8() -> None:
parse_real8(b'')
def test_parse_ascii() -> None:
# # empty data Now allowed!
# with pytest.raises(KlamathError):
# parse_ascii(b'')
def test_parse_ascii():
# # empty data Now allowed!
# with pytest.raises(KlamathError):
# parse_ascii(b'')
assert parse_ascii(b'12345') == b'12345'
assert parse_ascii(b'12345\0') == b'12345' # strips trailing null byte
assert(parse_ascii(b'12345') == b'12345')
assert(parse_ascii(b'12345\0') == b'12345') # strips trailing null byte
def test_pack_bitarray() -> None:
def test_pack_bitarray():
packed = pack_bitarray(321)
assert len(packed) == 2
assert packed == struct.pack('>H', 321)
assert(len(packed) == 2)
assert(packed == struct.pack('>H', 321))
def test_pack_int2() -> None:
def test_pack_int2():
packed = pack_int2((3, 2, 1))
assert len(packed) == 3 * 2
assert packed == struct.pack('>3h', 3, 2, 1)
assert pack_int2([-3, 2, -1]) == struct.pack('>3h', -3, 2, -1)
assert(len(packed) == 3*2)
assert(packed == struct.pack('>3h', 3, 2, 1))
assert(pack_int2([-3, 2, -1]) == struct.pack('>3h', -3, 2, -1))
def test_pack_int4() -> None:
def test_pack_int4():
packed = pack_int4((3, 2, 1))
assert len(packed) == 3 * 4
assert packed == struct.pack('>3l', 3, 2, 1)
assert pack_int4([-3, 2, -1]) == struct.pack('>3l', -3, 2, -1)
assert(len(packed) == 3*4)
assert(packed == struct.pack('>3l', 3, 2, 1))
assert(pack_int4([-3, 2, -1]) == struct.pack('>3l', -3, 2, -1))
def test_encode_real8() -> None:
assert encode_real8(numpy.array([0.0])) == 0
def test_encode_real8():
assert(encode_real8(numpy.array([0.0])) == 0)
arr = numpy.array((1.0, -2.0, 1e-9, 1e-3, 1e-12))
assert_array_equal(decode_real8(encode_real8(arr)), arr)
def test_pack_real8() -> None:
def test_pack_real8():
reals = (0, 1, -1, 0.5, 1e-9, 1e-3, 1e-12)
packed = pack_real8(reals)
assert len(packed) == len(reals) * 8
assert(len(packed) == len(reals) * 8)
assert_array_equal(parse_real8(packed), reals)
def test_pack_ascii() -> None:
assert pack_ascii(b'4321') == b'4321'
assert pack_ascii(b'321') == b'321\0'
def test_invalid_date() -> None:
default = [datetime(1900, 1, 1, 0, 0, 0)]
assert parse_datetime(pack_int2((0, 0, 0, 0, 0, 0))) == default
assert parse_datetime(pack_int2((0, 1, 32, 0, 0, 0))) == default
assert parse_datetime(pack_int2((0, 2, 30, 0, 0, 0))) == default
assert parse_datetime(pack_int2((0, 1, 1, 24, 0, 0))) == default
assert parse_datetime(pack_int2((0, 1, 1, 25, 0, 0))) == default
assert parse_datetime(pack_int2((0, 1, 1, 0, 61, 0))) == default
assert parse_datetime(pack_int2((0, 1, 1, 0, 0, 61))) == default
def test_pack_ascii():
assert(pack_ascii(b'4321') == b'4321')
assert(pack_ascii(b'321') == b'321\0')

View file

@ -1,156 +0,0 @@
import io
import numpy
from numpy.testing import assert_array_equal
from klamath.elements import Boundary, Path, Text, Reference, Box, Node
def test_boundary_roundtrip() -> None:
xy = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], dtype=numpy.int32)
b = Boundary(layer=(4, 5), xy=xy, properties={1: b'prop1'})
stream = io.BytesIO()
b.write(stream)
stream.seek(0)
b2 = Boundary.read(stream)
assert b2.layer == b.layer
assert_array_equal(b2.xy, b.xy)
assert b2.properties == b.properties
def test_path_roundtrip() -> None:
xy = numpy.array([[0, 0], [100, 0], [100, 100]], dtype=numpy.int32)
p = Path(layer=(10, 20), xy=xy, properties={2: b'pathprop'},
path_type=4, width=50, extension=(10, 20))
stream = io.BytesIO()
p.write(stream)
stream.seek(0)
p2 = Path.read(stream)
assert p2.layer == p.layer
assert_array_equal(p2.xy, p.xy)
assert p2.properties == p.properties
assert p2.path_type == p.path_type
assert p2.width == p.width
assert p2.extension == p.extension
def test_text_roundtrip() -> None:
xy = numpy.array([[50, 50]], dtype=numpy.int32)
t = Text(layer=(1, 1), xy=xy, string=b"HELLO WORLD", properties={},
presentation=5, path_type=0, width=0, invert_y=True,
mag=2.5, angle_deg=45.0)
stream = io.BytesIO()
t.write(stream)
stream.seek(0)
t2 = Text.read(stream)
assert t2.layer == t.layer
assert_array_equal(t2.xy, t.xy)
assert t2.string == t.string
assert t2.presentation == t.presentation
assert t2.invert_y == t.invert_y
assert t2.mag == t.mag
assert t2.angle_deg == t.angle_deg
def test_reference_sref_roundtrip() -> None:
xy = numpy.array([[100, 200]], dtype=numpy.int32)
r = Reference(struct_name=b"MY_CELL", xy=xy, colrow=None,
properties={5: b'sref'}, invert_y=False, mag=1.0, angle_deg=90.0)
stream = io.BytesIO()
r.write(stream)
stream.seek(0)
r2 = Reference.read(stream)
assert r2.struct_name == r.struct_name
assert_array_equal(r2.xy, r.xy)
assert r2.colrow is None
assert r2.properties == r.properties
assert r2.angle_deg == r.angle_deg
def test_reference_aref_roundtrip() -> None:
xy = numpy.array([[0, 0], [1000, 0], [0, 500]], dtype=numpy.int32)
colrow = (5, 2)
r = Reference(struct_name=b"ARRAY_CELL", xy=xy, colrow=colrow,
properties={}, invert_y=False, mag=1.0, angle_deg=0.0)
stream = io.BytesIO()
r.write(stream)
stream.seek(0)
r2 = Reference.read(stream)
assert r2.struct_name == r.struct_name
assert_array_equal(r2.xy, r.xy)
assert r2.colrow is not None
assert list(r2.colrow) == list(colrow)
assert r2.properties == r.properties
def test_box_roundtrip() -> None:
xy = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], dtype=numpy.int32)
b = Box(layer=(30, 40), xy=xy, properties={})
stream = io.BytesIO()
b.write(stream)
stream.seek(0)
b2 = Box.read(stream)
assert b2.layer == b.layer
assert_array_equal(b2.xy, b.xy)
def test_node_roundtrip() -> None:
xy = numpy.array([[0, 0], [10, 10]], dtype=numpy.int32)
n = Node(layer=(50, 60), xy=xy, properties={})
stream = io.BytesIO()
n.write(stream)
stream.seek(0)
n2 = Node.read(stream)
assert n2.layer == n.layer
assert_array_equal(n2.xy, n.xy)
def test_reference_check() -> None:
import pytest
from klamath.basic import KlamathError
# SREF with too many points
xy = numpy.array([[0, 0], [10, 10]], dtype=numpy.int32)
r = Reference(struct_name=b"CELL", xy=xy, colrow=None, properties={}, invert_y=False, mag=1.0, angle_deg=0.0)
with pytest.raises(KlamathError, match="Expected size-2 xy"):
r.check()
# AREF with too few points
xy = numpy.array([[0, 0]], dtype=numpy.int32)
r = Reference(struct_name=b"CELL", xy=xy, colrow=(2, 2), properties={}, invert_y=False, mag=1.0, angle_deg=0.0)
with pytest.raises(KlamathError, match="colrow is not None, so expected size-6 xy"):
r.check()
def test_read_properties_duplicate() -> None:
import pytest
from klamath.basic import KlamathError
from klamath.records import PROPATTR, PROPVALUE, ENDEL
stream = io.BytesIO()
PROPATTR.write(stream, 1)
PROPVALUE.write(stream, b"val1")
PROPATTR.write(stream, 1) # DUPLICATE
PROPVALUE.write(stream, b"val2")
ENDEL.write(stream, None)
stream.seek(0)
from klamath.elements import read_properties
with pytest.raises(KlamathError, match="Duplicate property key"):
read_properties(stream)
def test_element_read_unexpected_tag() -> None:
import pytest
from klamath.basic import KlamathError
from klamath.records import SREF, SNAME, HEADER, XY, ENDEL
stream = io.BytesIO()
SREF.write(stream, None)
SNAME.write(stream, b"CELL")
HEADER.write(stream, 123) # UNEXPECTED TAG for Reference.read
XY.write(stream, [0, 0])
ENDEL.write(stream, None)
stream.seek(0)
with pytest.raises(KlamathError, match="Unexpected tag"):
Reference.read(stream)

View file

@ -1,102 +0,0 @@
import io
import numpy
from datetime import datetime
from klamath.library import FileHeader, write_struct, try_read_struct, scan_structs, scan_hierarchy, read_elements
from klamath.elements import Boundary
from klamath.records import ENDLIB
def test_file_header_roundtrip() -> None:
h = FileHeader(name=b"MY_LIB", user_units_per_db_unit=0.001, meters_per_db_unit=1e-9,
mod_time=datetime(2023, 1, 1, 0, 0, 0), acc_time=datetime(2023, 1, 1, 0, 0, 0))
stream = io.BytesIO()
h.write(stream)
stream.seek(0)
h2 = FileHeader.read(stream)
assert h2.name == h.name
assert h2.user_units_per_db_unit == h.user_units_per_db_unit
assert h2.meters_per_db_unit == h.meters_per_db_unit
assert h2.mod_time == h.mod_time
def test_write_read_struct() -> None:
xy = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], dtype=numpy.int32)
b = Boundary(layer=(1, 1), xy=xy, properties={})
stream = io.BytesIO()
# Need a header for some operations, but write_struct works standalone
write_struct(stream, name=b"CELL_A", elements=[b])
ENDLIB.write(stream, None)
stream.seek(0)
res = try_read_struct(stream)
assert res is not None
name, elements = res
assert name == b"CELL_A"
assert len(elements) == 1
assert isinstance(elements[0], Boundary)
def test_scan_structs() -> None:
stream = io.BytesIO()
write_struct(stream, name=b"CELL_A", elements=[])
write_struct(stream, name=b"CELL_B", elements=[])
ENDLIB.write(stream, None)
stream.seek(0)
positions = scan_structs(stream)
assert b"CELL_A" in positions
assert b"CELL_B" in positions
# Verify we can seek and read
stream.seek(positions[b"CELL_B"])
elements = read_elements(stream)
assert len(elements) == 0
def test_scan_hierarchy() -> None:
from klamath.elements import Reference
stream = io.BytesIO()
# Struct A has 2 refs to Struct B
ref_b1 = Reference(struct_name=b"B", xy=numpy.array([[0, 0]], dtype=numpy.int32), colrow=None, properties={},
invert_y=False, mag=1.0, angle_deg=0.0)
ref_b2 = Reference(struct_name=b"B", xy=numpy.array([[10, 10]], dtype=numpy.int32), colrow=None, properties={},
invert_y=False, mag=1.0, angle_deg=0.0)
write_struct(stream, name=b"A", elements=[ref_b1, ref_b2])
# Struct B has a 3x2 AREF of Struct C
ref_c = Reference(struct_name=b"C", xy=numpy.array([[0, 0], [10, 0], [0, 10]], dtype=numpy.int32),
colrow=(3, 2), properties={}, invert_y=False, mag=1.0, angle_deg=0.0)
write_struct(stream, name=b"B", elements=[ref_c])
write_struct(stream, name=b"C", elements=[])
ENDLIB.write(stream, None)
stream.seek(0)
hierarchy = scan_hierarchy(stream)
assert hierarchy[b"A"] == {b"B": 2}
assert hierarchy[b"B"] == {b"C": 6}
assert hierarchy[b"C"] == {}
def test_scan_structs_duplicate() -> None:
import pytest
from klamath.basic import KlamathError
stream = io.BytesIO()
write_struct(stream, name=b"CELL_A", elements=[])
write_struct(stream, name=b"CELL_A", elements=[])
ENDLIB.write(stream, None)
stream.seek(0)
with pytest.raises(KlamathError, match="Duplicate structure name"):
scan_structs(stream)
def test_scan_hierarchy_duplicate() -> None:
import pytest
from klamath.basic import KlamathError
stream = io.BytesIO()
write_struct(stream, name=b"CELL_A", elements=[])
write_struct(stream, name=b"CELL_A", elements=[])
ENDLIB.write(stream, None)
stream.seek(0)
with pytest.raises(KlamathError, match="Duplicate structure name"):
scan_hierarchy(stream)

View file

@ -1,134 +0,0 @@
import io
import pytest
import struct
from datetime import datetime
from klamath.basic import KlamathError
from klamath.record import (
write_record_header, read_record_header, expect_record,
BitArrayRecord, Int2Record, ASCIIRecord, DateTimeRecord, NoDataRecord
)
from klamath.records import ENDLIB, HEADER
def test_write_read_record_header() -> None:
stream = io.BytesIO()
tag = 0x1234
data_size = 8
write_record_header(stream, data_size, tag)
stream.seek(0)
read_size, read_tag = read_record_header(stream)
assert read_size == data_size
assert read_tag == tag
assert stream.tell() == 4
def test_write_record_header_too_big() -> None:
stream = io.BytesIO()
with pytest.raises(KlamathError, match="Record size is too big"):
write_record_header(stream, 0x10000, 0x1234)
def test_read_record_header_errors() -> None:
# Too small
stream = io.BytesIO(struct.pack('>HH', 2, 0x1234))
with pytest.raises(KlamathError, match="Record size is too small"):
read_record_header(stream)
# Odd size
stream = io.BytesIO(struct.pack('>HH', 5, 0x1234))
with pytest.raises(KlamathError, match="Record size is odd"):
read_record_header(stream)
def test_expect_record() -> None:
stream = io.BytesIO()
write_record_header(stream, 4, 0x1111)
stream.seek(0)
# Correct tag
size = expect_record(stream, 0x1111)
assert size == 4
# Incorrect tag
stream.seek(0)
with pytest.raises(KlamathError, match="Unexpected record"):
expect_record(stream, 0x2222)
def test_bitarray_record() -> None:
class TestBit(BitArrayRecord):
tag = 0x9999
stream = io.BytesIO()
TestBit.write(stream, 0x8000)
stream.seek(0)
val = TestBit.read(stream)
assert val == 0x8000
def test_int2_record() -> None:
class TestInt2(Int2Record):
tag = 0x8888
stream = io.BytesIO()
TestInt2.write(stream, [1, -2, 3])
stream.seek(0)
val = TestInt2.read(stream)
assert list(val) == [1, -2, 3]
def test_ascii_record() -> None:
class TestASCII(ASCIIRecord):
tag = 0x7777
stream = io.BytesIO()
TestASCII.write(stream, b"HELLO")
stream.seek(0)
val = TestASCII.read(stream)
assert val == b"HELLO"
def test_datetime_record() -> None:
class TestDT(DateTimeRecord):
tag = 0x6666
now = datetime(2023, 10, 27, 12, 30, 45)
stream = io.BytesIO()
TestDT.write(stream, [now, now])
stream.seek(0)
vals = TestDT.read(stream)
assert vals == [now, now]
def test_nodata_record() -> None:
class TestNoData(NoDataRecord):
tag = 0x5555
stream = io.BytesIO()
TestNoData.write(stream, None)
stream.seek(0)
# Verify header: 4 bytes total (size=4, tag=0x5555), data_size=0
header = stream.read(4)
assert header == struct.pack('>HH', 4, 0x5555)
stream.seek(0)
assert TestNoData.read(stream) is None
def test_record_skip_past() -> None:
stream = io.BytesIO()
HEADER.write(stream, 600)
ENDLIB.write(stream, None)
stream.seek(0)
# Skip past HEADER
found = HEADER.skip_past(stream)
assert found is True
assert stream.tell() == 6 # 4 header + 2 data
# Try to skip past something that doesn't exist before ENDLIB
class NONEXISTENT(NoDataRecord):
tag = 0xFFFF
stream.seek(0)
found = NONEXISTENT.skip_past(stream)
assert found is False
# Should be at the end of ENDLIB record header/tag read
assert stream.tell() == 10 # 6 (HEADER) + 4 (ENDLIB)

View file

@ -1,110 +0,0 @@
import io
import pytest
import numpy
from datetime import datetime
from klamath.basic import KlamathError
from klamath import records
def test_record_tags() -> None:
assert records.HEADER.tag == 0x0002
assert records.BGNLIB.tag == 0x0102
assert records.LIBNAME.tag == 0x0206
assert records.UNITS.tag == 0x0305
assert records.ENDLIB.tag == 0x0400
assert records.BGNSTR.tag == 0x0502
assert records.STRNAME.tag == 0x0606
assert records.ENDSTR.tag == 0x0700
assert records.BOUNDARY.tag == 0x0800
assert records.PATH.tag == 0x0900
assert records.SREF.tag == 0x0a00
assert records.AREF.tag == 0x0b00
assert records.TEXT.tag == 0x0c00
assert records.LAYER.tag == 0x0d02
assert records.DATATYPE.tag == 0x0e02
assert records.WIDTH.tag == 0x0f03
assert records.XY.tag == 0x1003
assert records.ENDEL.tag == 0x1100
assert records.SNAME.tag == 0x1206
assert records.COLROW.tag == 0x1302
assert records.NODE.tag == 0x1500
assert records.TEXTTYPE.tag == 0x1602
assert records.PRESENTATION.tag == 0x1701
assert records.STRING.tag == 0x1906
assert records.STRANS.tag == 0x1a01
assert records.MAG.tag == 0x1b05
assert records.ANGLE.tag == 0x1c05
assert records.REFLIBS.tag == 0x1f06
assert records.FONTS.tag == 0x2006
assert records.PATHTYPE.tag == 0x2102
assert records.GENERATIONS.tag == 0x2202
assert records.ATTRTABLE.tag == 0x2306
assert records.ELFLAGS.tag == 0x2601
assert records.NODETYPE.tag == 0x2a02
assert records.PROPATTR.tag == 0x2b02
assert records.PROPVALUE.tag == 0x2c06
assert records.BOX.tag == 0x2d00
assert records.BOXTYPE.tag == 0x2e02
assert records.PLEX.tag == 0x2f03
assert records.BGNEXTN.tag == 0x3003
assert records.ENDEXTN.tag == 0x3103
assert records.TAPENUM.tag == 0x3202
assert records.TAPECODE.tag == 0x3302
assert records.FORMAT.tag == 0x3602
assert records.MASK.tag == 0x3706
assert records.ENDMASKS.tag == 0x3800
assert records.LIBDIRSIZE.tag == 0x3902
assert records.SRFNAME.tag == 0x3a06
assert records.LIBSECUR.tag == 0x3b02
def test_header_validation() -> None:
# Correct size
records.HEADER.check_size(2)
# Incorrect size
with pytest.raises(KlamathError, match="Expected size 2, got 4"):
records.HEADER.check_size(4)
def test_bgnlib_validation() -> None:
now = datetime(2023, 10, 27, 12, 30, 45)
# Correct size (2 datetimes = 24 bytes)
records.BGNLIB.check_size(24)
# Incorrect size
with pytest.raises(KlamathError, match="Expected size 24, got 12"):
records.BGNLIB.check_size(12)
def test_reflibs_fonts_validation() -> None:
# REFLIBS must be multiple of 44
records.REFLIBS.check_size(44)
records.REFLIBS.check_size(88)
records.REFLIBS.check_size(0)
with pytest.raises(KlamathError, match="Expected size to be multiple of 44"):
records.REFLIBS.check_size(10)
def test_generations_format_validation() -> None:
# GENERATIONS expects exactly one integer
records.GENERATIONS.check_data(3)
records.GENERATIONS.check_data([1])
with pytest.raises(KlamathError, match="Expected exactly one integer"):
records.GENERATIONS.check_data([1, 2])
def test_attrtable_validation() -> None:
# ATTRTABLE size <= 44
records.ATTRTABLE.check_size(44)
records.ATTRTABLE.check_size(10)
with pytest.raises(KlamathError, match="Expected size <= 44"):
records.ATTRTABLE.check_size(45)
def test_nodata_records() -> None:
stream = io.BytesIO()
records.ENDLIB.write(stream, None)
stream.seek(0)
assert records.ENDLIB.read(stream) is None
stream = io.BytesIO()
records.BOUNDARY.write(stream, None)
stream.seek(0)
assert records.BOUNDARY.read(stream) is None

View file

@ -1,93 +0,0 @@
[project]
name = "klamath"
description = "GDSII format reader/writer"
readme = "README.md"
license = { file = "LICENSE.md" }
authors = [
{ name="Jan Petykiewicz", email="jan@mpxd.net" },
]
homepage = "https://mpxd.net/code/jan/klamath"
repository = "https://mpxd.net/code/jan/klamath"
keywords = [
"layout",
"gds",
"gdsii",
"gds2",
"Calma",
"stream",
"design",
"CAD",
"EDA",
"electronics",
"photonics",
"IC",
"mask",
"pattern",
"drawing",
"lithography",
"litho",
"geometry",
"geometric",
"polygon",
"vector",
]
classifiers = [
"Programming Language :: Python :: 3",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Intended Audience :: Manufacturing",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
"Topic :: File Formats",
]
requires-python = ">=3.11"
include = [
"LICENSE.md"
]
dynamic = ["version"]
dependencies = [
"numpy>=1.26",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.version]
path = "klamath/__init__.py"
[tool.ruff]
exclude = [
".git",
"dist",
]
line-length = 145
indent-width = 4
lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
lint.select = [
"NPY", "E", "F", "W", "B", "ANN", "UP", "SLOT", "SIM", "LOG",
"C4", "ISC", "PIE", "PT", "RET", "TCH", "PTH", "INT",
"ARG", "PL", "R", "TRY",
"G010", "G101", "G201", "G202",
"Q002", "Q003", "Q004",
]
lint.ignore = [
#"ANN001", # No annotation
"ANN002", # *args
"ANN003", # **kwargs
"ANN401", # Any
"SIM108", # single-line if / else assignment
"RET504", # x=y+z; return x
"PIE790", # unnecessary pass
"ISC003", # non-implicit string concatenation
"C408", # dict(x=y) instead of {'x': y}
"PLR09", # Too many xxx
"PLR2004", # magic number
"PLC0414", # import x as x
"TRY003", # Long exception message
]

63
setup.py Normal file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
from setuptools import setup, find_packages
with open('README.md', 'r') as f:
long_description = f.read()
with open('klamath/VERSION.py', 'rt') as f:
version = f.readlines()[2].strip()
setup(name='klamath',
version=version,
description='GDSII format reader/writer',
long_description=long_description,
long_description_content_type='text/markdown',
author='Jan Petykiewicz',
author_email='jan@mpxd.net',
url='https://mpxd.net/code/jan/klamath',
packages=find_packages(),
package_data={
'klamath': ['py.typed'],
},
install_requires=[
'numpy',
],
classifiers=[
'Programming Language :: Python :: 3',
'Development Status :: 5 - Production/Stable',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'Intended Audience :: Manufacturing',
'Intended Audience :: Science/Research',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)',
],
keywords=[
'layout',
'design',
'CAD',
'EDA',
'electronics',
'photonics',
'IC',
'mask',
'pattern',
'drawing',
'lithography',
'litho',
'geometry',
'geometric',
'polygon',
'gds',
'gdsii',
'gds2',
'stream',
'vector',
'freeform',
'manhattan',
'angle',
'Calma',
],
)