Skip to content

Commit

Permalink
Fix typehints bug (#55)
Browse files Browse the repository at this point in the history
* switched to dataclasses
* optional field and array dims, type = type_
* Test and docstrings
  • Loading branch information
JoschD committed Oct 27, 2022
1 parent ebd9357 commit e0b5602
Show file tree
Hide file tree
Showing 4 changed files with 195 additions and 95 deletions.
176 changes: 116 additions & 60 deletions sdds/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
Implementation are based on documentation at:
https://ops.aps.anl.gov/manuals/SDDStoolkit/SDDStoolkitsu2.html
"""
from typing import Any, Tuple, List, Iterator, Optional, Dict, Union
from typing import get_type_hints
from typing import Any, Tuple, List, Iterator, Optional, Dict, ClassVar
from dataclasses import dataclass, fields
import logging

LOGGER = logging.getLogger(__name__)


##############################################################################
Expand Down Expand Up @@ -38,6 +41,7 @@ def get_dtype_str(type_: str, endianness: str = 'big', length: int = None):
# Classes
##############################################################################

@dataclass
class Description:
"""
Description (&description) command container.
Expand All @@ -47,140 +51,177 @@ class Description:
contents, is intended to formally specify the type of data stored in a data set. Most
frequently, the contents field is used to record the name of the program that created or most
recently modified the file.
Fields:
text (str): Optional. Informal description intended for humans.
contents (str): Optional. Formal specification of the type of data stored in a data set.
"""
TAG: str = "&description"
text: Optional[str]
contents: Optional[str]

def __init__(self, text: Optional[str] = None, contents: Optional[str] = None) -> None:
self.text = text
self.contents = contents
text: Optional[str] = None
contents: Optional[str] = None
TAG: ClassVar[str] = "&description"

def __repr__(self):
return f"<SDDS Description Container>"


@dataclass
class Include:
"""
Include (&include) command container.
This optional command directs that SDDS header lines be read from the file named by the
filename field. These commands may be nested.
Fields:
filename (str): Name of the file to be read containing header lines.
"""
filename: str

def __init__(self, filename: str) -> None:
self.filename = filename

def __repr__(self):
return f"<SDDS Include Container>"

def __str__(self):
return f"Include: {self.filename:s}"


@dataclass
class Definition:
"""
Abstract class for the common behaviour of the data definition commands.
The name field must be supplied, as must the type field. The type must be one of short, long,
float, double, character, or string.
The optional symbol field allows specification of a symbol to represent the parameter; it may
contain escape sequences, for example, to produce Greek or mathematical characters. The
optional units field allows specification of the units of the parameter. The optional
description field provides for an informal description of the parameter. The optional format
field allows specification of the print format string to be used to print the data (e.g.,
for ASCII in SDDS or other formats).
The Column, Array and Parameter definitions inherit from this class. They can be created just by
passing name and type and optionally more parameters that depend on the actual definition type.
Raises:
AssertionError: If an invalid argument for the definition type is passed.
Fields:
name (str): Name of the data.
type (str): Type of the data.
One of "short", "long", "float", "double", "character", or "string".
symbol (str): Optional. Allows specification of a symbol to represent the parameter;
it may contain escape sequences, for example,
to produce Greek or mathematical characters.
units (str): Optional. Allows specification of the units of the parameter.
description (str): Optional. Provides for an informal description of the parameter.
format (str): Optional. Specification of the print format string to be used to print the data
(e.g. for ASCII in SDDS or other formats). NOT IMPLEMENTED!
"""
name: str
type: str
symbol: Optional[str] = None
units: Optional[str] = None
description: Optional[str] = None
format_string: Optional[str] = None

def __init__(self, name: str, type_: str, **kwargs) -> None:
self.name = name
self.type = type_
type_hints = get_type_hints(self)
for argname in kwargs:
assert hasattr(self, argname),\
f"Unknown name {argname} for data type "\
f"{self.__class__.__name__}"
# The type of the parameter can be resolved from the type hint
type_hint = type_hints[argname]
if hasattr(type_hint, "__args__"): # For the Optional[...] types
type_hint = next(t for t in type_hint.__args__
if not isinstance(t, type(None)))
setattr(self, argname, type_hint(kwargs[argname]))
format: Optional[str] = None

def __post_init__(self):
# Fix types (probably strings from reading files) by using the type-hints
# this only works for native types, not the ones from typing.
for field in fields(self):
value = getattr(self, field.name)
hinted_type = field.type
if hasattr(hinted_type, "__args__"): # For the Optional[...] types
if value is None:
continue

if isinstance(value, str) and value.lower() == "none":
# The key should have been skipped when writing, but to be safe
LOGGER.debug(f"'None' found in {field.name}.")
setattr(self, field.name, None)
continue

# find the proper type from type-hint:
hinted_type = next(t for t in hinted_type.__args__
if not isinstance(t, type(None)))

if isinstance(value, hinted_type):
# all is fine
continue

LOGGER.debug(f"converting {field.name}: "
f"{type(value).__name__} -> {hinted_type.__name__}")
setattr(self, field.name, hinted_type(value))

def get_key_value_string(self) -> str:
""" Return a string with comma separated key=value pairs.
Hint: `ClassVars` (like ``TAG``) are ignored in `fields`.
"""
field_values = {field.name: getattr(self, field.name) for field in fields(self)}
return ", ".join([f"{key}={value}" for key, value in field_values.items() if value is not None])

def __repr__(self):
return f"<SDDS {self.__class__.__name__} '{self.name}'>"

def __str__(self):
return (f"<{self.__class__.__name__} ({getattr(self, 'TAG', 'no tag')})> "
f"{', '.join(f'{k}: {v}' for k, v in self.__dict__.items())}")
return f"<{self.__class__.__name__} ({getattr(self, 'TAG', 'no tag')})> {self.get_key_value_string()}"


@dataclass
class Column(Definition):
"""
Column (&column) command container, a data definition.
This optional command defines a column that will appear in the tabular data section of each
data page.
"""
TAG: str = "&column"
TAG: ClassVar[str] = "&column"


@dataclass
class Parameter(Definition):
"""
Parameter (&parameter) command container, a data definition.
This optional command defines a parameter that will appear along with the tabular data
section of each data page. The optional fixed_value field allows specification of a constant
value for a given parameter. This value will not change from data page to data page,
and is not specified along with non-fixed parameters or tabular data. This feature is for
convenience only; the parameter thus defined is treated like any other.
section of each data page.
Fields:
fixed_value (str): Optional. Allows specification of a constant value for a given parameter.
This value will not change from data page to data page,
and is not specified along with non-fixed parameters or tabular data.
This feature is for convenience only;
the parameter thus defined is treated like any other.
"""
TAG: str = "&parameter"
TAG: ClassVar[str] = "&parameter"
fixed_value: Optional[str] = None


@dataclass
class Array(Definition):
"""
Array (&array) command container, a data definition.
This optional command defines an array that will appear along with the tabular data section
of each data page. The optional group_name field allows specification of a string giving the
name of the array group to which the array belongs; such strings may be defined by the user
to indicate that different arrays are related (e.g., have the same dimensions, or parallel
elements). The optional dimensions field gives the number of dimensions in the array.
of each data page.
Fields:
field_length (int): Optional. Length of the field (Not sure, actually. jdilly2022).
group_name (int): Optional. Allows specification of a string giving the
name of the array group to which the array belongs;
such strings may be defined by the user
to indicate that different arrays are related
(e.g., have the same dimensions, or parallel elements).
dimensions (int): Optional. Gives the number of dimensions in the array.
If not given, defaults to ``1`` upon reading.
"""
TAG: str = "&array"
field_length: int = 0
TAG: ClassVar[str] = "&array"
field_length: Optional[int] = None
group_name: Optional[str] = None
dimensions: int = 1
dimensions: Optional[int] = None


@dataclass
class Data:
"""
Data (&data) command container.
This command is optional unless parameter commands without fixed_value fields,
array commands, or column commands have been given. The mode field is required, and it must
be “binary”, the only supported mode.
"""
TAG: str = "&data"
array commands, or column commands have been given.
def __init__(self, mode: str) -> None:
self.mode = mode
Fields:
mode (str): File/Data mode. Either “binary” or "ascii".
"""
mode: str
TAG: ClassVar[str] = "&data"

def __repr__(self):
return f"<SDDS {self.mode} Data Container>"
Expand All @@ -200,6 +241,21 @@ class SddsFile:
val = sdds_file.values["name"]
# The definitions and values can also be accessed like:
def_, val = sdds_file["name"]
Args:
version (str): Always needs to be "SDDS1"!
description (Description): Optional. Description Tag.
definitions_list (list[Definition]): List of definitions objects,
describing the data.
values_list (list[Any]): List of values for the SDDS data.
Same lengths as definitions.
Fields:
version (str): Always needs to be "SDDS1"!
description (Description): Optional. Description Tag.
definitions (dict[str, Definition]): Definitions of the data, mapped to their name.
values (dict[str, Any]): Values of the data, mapped to their name.
"""
version: str # This should always be "SDDS1"
description: Optional[Description]
Expand Down
14 changes: 8 additions & 6 deletions sdds/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def _read_header(inbytes: IO[bytes]) -> Tuple[str, List[Definition], Optional[De
Column.TAG: Column,
Parameter.TAG: Parameter,
Array.TAG: Array}[word](name=def_dict.pop("name"),
type_=def_dict.pop("type"),
type=def_dict.pop("type"),
**def_dict))
continue
if word == Description.TAG:
Expand Down Expand Up @@ -144,10 +144,9 @@ def _read_bin_array(inbytes: IO[bytes], definition: Array, endianness: str) -> A
dims, total_len = _read_bin_array_len(inbytes, definition.dimensions, endianness)

if definition.type == "string":
len_type: str = "long"\
if not hasattr(definition, "modifier")\
else {"u1": "char", "i2": "short"}\
.get(definition.modifier, "long")
len_type = {"u1": "char", "i2": "short"}.get(
getattr(definition, "modifier", None), "long"
)
str_array = []
for _ in range(total_len):
str_len = int(_read_bin_numeric(inbytes, len_type, 1, endianness))
Expand All @@ -158,7 +157,10 @@ def _read_bin_array(inbytes: IO[bytes], definition: Array, endianness: str) -> A
return data.reshape(dims)


def _read_bin_array_len(inbytes: IO[bytes], num_dims: int, endianness: str) -> Tuple[List[int], int]:
def _read_bin_array_len(inbytes: IO[bytes], num_dims: Optional[int], endianness: str) -> Tuple[List[int], int]:
if num_dims is None:
num_dims = 1

dims = [_read_bin_int(inbytes, endianness) for _ in range(num_dims)]
return dims, int(np.prod(dims))

Expand Down
9 changes: 3 additions & 6 deletions sdds/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""
import pathlib
import struct
from dataclasses import fields
from typing import IO, List, Union, Iterable, Tuple, Any
import numpy as np
from sdds.classes import (SddsFile, Column, Parameter, Definition, Array, Data, Description,
Expand Down Expand Up @@ -44,14 +45,10 @@ def _write_header(sdds_file: SddsFile, outbytes: IO[bytes]) -> List[str]:


def _sdds_def_as_str(definition: Union[Description, Definition, Data]) -> str:
start = definition.TAG + " "
things = ", ".join([f"{key}={definition.__dict__[key]}"
for key in definition.__dict__ if "__" not in key])
end = " &end\n"
return start + things + end
return f"{definition.TAG} {definition.get_key_value_string()} &end\n"


def _write_data(names: List[str], sdds_file: SddsFile, outbytes: IO[bytes])-> None:
def _write_data(names: List[str], sdds_file: SddsFile, outbytes: IO[bytes]) -> None:
# row_count:
outbytes.write(np.array(0, dtype=get_dtype_str("long")).tobytes())
_write_parameters((sdds_file[name] for name in names
Expand Down
Loading

0 comments on commit e0b5602

Please sign in to comment.