import struct
from typing import ClassVar
from abc import (
ABC,
abstractmethod,
)
from dataclasses import dataclass
from .util import dump
from .value import Value
__all__ = (
'Domain',
'BoolDomain',
'IntDomain',
'FloatDomain',
'StringDomain',
)
[docs]
class Domain(ABC):
"""
An abstract base class for domains.
"""
ENDIAN: ClassVar[str] = '<'
"""The endian format used by the `struct` module."""
enum: ClassVar[int]
"""The enum value of the domain."""
[docs]
@classmethod
def restore(cls, enum: int, size: int | None = None) -> 'Domain':
"""
Restore a domain from its enum value and size.
The size is required only for `StringDomain`.
"""
match enum:
case 0:
return BoolDomain()
case 1:
return IntDomain()
case 2:
return FloatDomain()
case 3:
assert size is not None
return StringDomain(size)
case _: # pragma: no cover
assert False
[docs]
@classmethod
def parse(cls, format: str) -> 'Domain':
"""
Restore a domain from its format.
Remove the endian prefix, if present, and use the default endian.
"""
format = format.lstrip(Domain.ENDIAN)
match format:
case 'B':
return BoolDomain()
case 'i':
return IntDomain()
case 'f':
return FloatDomain()
case _:
if format.endswith('s'):
return StringDomain(int(format.rstrip('s')))
assert False # pragma: no cover
@property
@abstractmethod
def format(self) -> str: # pragma: no cover
"""
The format of the domain.
This format is used by the `struct` module.
"""
pass
@property
def size(self) -> int:
"""
The size of the domain.
"""
return struct.calcsize(self.format)
[docs]
@abstractmethod
def validate(self, value: Value) -> None: # pragma: no cover
"""
Validate the type of a value.
Raises:
TypeError: if the value has an invalid type
"""
pass
[docs]
@abstractmethod
def encode(self, value: Value) -> bytes: # pragma: no cover
"""
Encode a value into data.
"""
pass
[docs]
@abstractmethod
def decode(self, data: bytes) -> Value: # pragma: no cover
"""
Decode data into a value.
"""
pass
@abstractmethod
def verbose(self) -> str: # pragma: no cover
pass
[docs]
@dataclass(frozen=True, slots=True)
class BoolDomain(Domain):
"""
A domain for `bool` values.
"""
enum = 0
def __repr__(self) -> str: # pragma: no cover
return "BoolDomain()"
@property
def format(self) -> str:
return Domain.ENDIAN + 'B'
[docs]
def validate(self, value: Value) -> None:
if type(value) is not bool:
raise TypeError(f"BoolDomain value must be of type bool ({dump(
value=type(value),
)}).")
[docs]
def encode(self, value: Value) -> bytes:
self.validate(value)
data = struct.pack(self.format, bool(value))
return data
[docs]
def decode(self, data: bytes) -> bool:
(value,) = struct.unpack(self.format, data)
value = bool(value)
return value
def verbose(self) -> str: # pragma: no cover
return "BOOL"
[docs]
@dataclass(frozen=True, slots=True)
class IntDomain(Domain):
"""
A domain for `int` values.
"""
enum = 1
def __repr__(self) -> str: # pragma: no cover
return "IntDomain()"
@property
def format(self) -> str:
return Domain.ENDIAN + 'i'
[docs]
def validate(self, value: Value) -> None:
if type(value) is not int:
raise TypeError(f"IntDomain value must be of type int ({dump(
value=type(value),
)}).")
[docs]
def encode(self, value: Value) -> bytes:
self.validate(value)
data = struct.pack(self.format, value)
return data
[docs]
def decode(self, data: bytes) -> int:
(value,) = struct.unpack(self.format, data)
return value
def verbose(self) -> str: # pragma: no cover
return "INT"
[docs]
@dataclass(frozen=True, slots=True)
class FloatDomain(Domain):
"""
A domain for `float` values.
"""
enum = 2
def __repr__(self) -> str: # pragma: no cover
return "FloatDomain()"
@property
def format(self) -> str:
return Domain.ENDIAN + 'f'
[docs]
def validate(self, value: Value) -> None:
if type(value) is not float:
raise TypeError(f"FloatDomain value must be of type float ({dump(
value=type(value),
)}).")
[docs]
def encode(self, value: Value) -> bytes:
self.validate(value)
data = struct.pack(self.format, value)
return data
[docs]
def decode(self, data: bytes) -> float:
(value,) = struct.unpack(self.format, data)
return value
def verbose(self) -> str: # pragma: no cover
return "FLOAT"
[docs]
@dataclass(frozen=True, slots=True)
class StringDomain(Domain):
"""
A domain for `str` values.
Attributes:
length: the length of a string
Raises:
ValueError: if the string length is invalid
"""
ENCODING: ClassVar[str] = 'utf-8'
"""The encoding used for strings."""
MAX_LENGTH: ClassVar[int] = 255
"""The maximum length of a string."""
enum = 3
length: int
def __repr__(self) -> str: # pragma: no cover
return f"StringDomain({self.length})"
def __post_init__(self) -> None:
if self.length < 1 or self.length > StringDomain.MAX_LENGTH:
raise ValueError(f"Invalid StringDomain length ({dump(
length=self.length,
min=1,
max=StringDomain.MAX_LENGTH,
)}).")
@property
def format(self) -> str:
return Domain.ENDIAN + f'{self.length}s'
[docs]
def validate(self, value: Value) -> None:
"""
Validate the type and length of a value.
Raises:
TypeError: if the value has an invalid type
ValueError: if the value length exceeds the domain length
"""
if type(value) is not str:
raise TypeError(f"StringDomain value must be of type str ({dump(
value=type(value),
)}).")
if len(value) > self.length:
raise ValueError(f"Value length exceeds the domain length ({dump(
value=len(value),
domain=self.length,
)}).")
[docs]
def encode(self, value: Value) -> bytes:
self.validate(value)
assert type(value) is str
data = value.encode(StringDomain.ENCODING)
data = data[:self.length]
data = data.ljust(self.length, b'\x00')
return data
[docs]
def decode(self, data: bytes) -> str:
"""
Decode data into a value.
Raises:
ValueError: if the data length does not match the domain length
"""
if len(data) != self.length:
raise ValueError(f"Data length does not match the domain length ({dump(
data=len(data),
domain=self.length,
)}).")
data = data[:self.length]
value = data.decode(StringDomain.ENCODING)
value = value.rstrip('\x00')
return value
def verbose(self) -> str: # pragma: no cover
return f"STRING({self.length})"