Repository: pylhc/sdds

Issue:
[Bug]: IndexError for empty arrays
### Documentation

- [X] Yes

### Operating System

Any

### Python Version

Any

### Package Version

0.3.0

### Bug Description

If there is an empty array or an array of empty arrays, the writing function will fail with an index error in: 

https://github.com/pylhc/sdds/blob/f74e04dc1f63afa08b9148982a617a6fcad78bdc/sdds/writer.py#L81




### Steps to Reproduce

_No response_

### Relevant output

_No response_

### Possible Fix Implementation

Possible solution could be 

`return [len(value)] + get_dimensions_from_array(value[0]) if len(value) else []`

but needs to be tested if this would read okay. Or if empty arrays should be allowed in the first place?

Discussion:
JoschD: Hello @Dronakurl ,

thank you very much for your contribution! 
I appreciate especially, that you added a test to assure your fix of #49 works as expected :) .
I see you cleaned up the code a lot and fixed some (possible?) issues here and there as well, some changes of which I have to admit I do not immediately understand. 

E.g. the additional `get_key_value_string()` functions (which btw have a typo in the warnings)
and the `__getitem__` in the `__write _data`.

Could you please write an entry into the changelog, mentioning user-affecting changes? 
Again, thank you very much!!

Cheers,

Josch

JoschD: Btw. I think the tests might be failing because of the SECRETS we have used in the workflows and because this is a PR stemming from a fork. I will try to figure out how to handle that best

Dronakurl: Hi, I updated the PR. That type hint is tricky. pyright still showed type errors when assigning a Definition to a Parameter, Array, Column, despite the fact, that I specifically checked whether it was that subclass. So `type: ignore` was the best option.

I also fixed a DeprecationWarning.

```
DeprecationWarning: Conversion of an array with ndim > 0 to a scalar is deprecated, and will error in future. Ensure you extract a single element from your array before performing this operation. (Deprecated NumPy 1.25.)
```

As for the Changelog: There are no significant user facing changes, but I added an entry as requested
fsoubelet: I also forgot: we should bump the version to `0.4.1`. @Dronakurl would you mind doing so, in the package's `__init__.py`?
Dronakurl: Hi, is there something missing with this PR? I am a bit lost, what to do with those checks. Can you please help?
JoschD: Hey @Dronakurl ,

there is nothing to do about the checks for you. We need to move the branch from your repo to this one and then they can run.
The reason I have not done that yet, is that I was still waiting for you to answer the questions I posed in my first comment, that is what is the purpose of 
 - `get_key_value_string()` in `Data` and `Description`, if it's not implemented anyway? 
 -  `__getitem__` in `__write _data`? I do not see how this is used.

Cheers,

Josch
Dronakurl: Hi Josch,
Ah, I missed these questions.

As for `get_key_value_string`:
If this method is not defined in the parent class, the following function
shows diagnostics (from pyright)
```python
def _sdds_def_as_str(definition: Union[Description, Definition, Data]) ->
str:
    return f"{definition.TAG} {definition.get_key_value_string()} &end\n"
```
The `__get_item__` is a remnant of a botched attempt to solve the typing in
this function more elegantly. I deleted it.

Best regards
Dronakurl


Am Do., 1. Feb. 2024 um 11:26 Uhr schrieb JoschD ***@***.***>:

> Hey @Dronakurl <https://github.com/Dronakurl> ,
>
> there is nothing to do about the checks for you. We need to move the
> branch from your repo to this one and then they can run.
> The reason I have not done that yet, is that I was still waiting for you
> to answer the questions I posed in my first comment, that is what is the
> purpose of
>
>    - get_key_value_string() in Data and Description, if it's not
>    implemented anyway?
>    - __getitem__ in __write _data? I do not see how this is used.
>
> Cheers,
>
> Josch
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/pylhc/sdds/pull/59#issuecomment-1920993359>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ASKZU4DOTOKLOTPKERBTTILYRNUTZAVCNFSM6AAAAABAQMOVD2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMYTSMRQHE4TGMZVHE>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>

JoschD: Hey @Dronakurl , I am very sorry that this took so long. I was very busy throughout January and then also on holidays. Your effort in this is highly appreciated and I promise that future PRs will be handled quicker :) 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7f49f5..d03b0ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
 # SDDS-Files Changelog
 
+## Version 0.4.1
+- Writing empty arrays doesn't throw errors
+
 ## Version 0.4
 - Added:
     - Support for reading gzipped compressed file and arbitrary compression formats if the opener abstraction is provided.
diff --git a/doc/conf.py b/doc/conf.py
index ca3b849..0a4fbdb 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -12,7 +12,6 @@
 # All configuration values have a default; values that are commented out
 # serve to show the default.
 import pathlib
-import sys
 
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
@@ -110,14 +109,14 @@ todo_include_todos = True
 html_theme = "sphinx_rtd_theme"
 
 html_theme_options = {
-    'collapse_navigation': False,
-    'display_version': True,
-    'logo_only': True,
-    'navigation_depth': 3,
+    "collapse_navigation": False,
+    "display_version": True,
+    "logo_only": True,
+    "navigation_depth": 3,
 }
 
-html_logo = '_static/img/omc_logo.svg'
-html_static_path = ['_static']
+html_logo = "_static/img/omc_logo.svg"
+html_static_path = ["_static"]
 html_css_files = ["css/custom.css"]
 
 smartquotes_action = "qe"  # renders only quotes and ellipses (...) but not dashes (option: D)
diff --git a/sdds/__init__.py b/sdds/__init__.py
index 7fc940b..d14bcdd 100644
--- a/sdds/__init__.py
+++ b/sdds/__init__.py
@@ -6,7 +6,7 @@ from sdds.writer import write_sdds
 __title__ = "sdds"
 __description__ = "SDDS file handling."
 __url__ = "https://github.com/pylhc/sdds"
-__version__ = "0.4.0"
+__version__ = "0.4.1"
 __author__ = "pylhc"
 __author_email__ = "pylhc@github.com"
 __license__ = "MIT"
@@ -16,4 +16,4 @@ read = read_sdds
 write = write_sdds
 
 
-__all__ = [read, SddsFile, write, __version__]
\ No newline at end of file
+__all__ = ["read", "SddsFile", "write", "__version__"]
diff --git a/sdds/classes.py b/sdds/classes.py
index 806c339..38d323f 100644
--- a/sdds/classes.py
+++ b/sdds/classes.py
@@ -6,9 +6,10 @@ This module holds classes to handle different namelist commands in an SDDS file.
 Implementation are based on documentation at:
 https://ops.aps.anl.gov/manuals/SDDStoolkit/SDDStoolkitsu2.html
 """
-from typing import Any, Tuple, List, Iterator, Optional, Dict, ClassVar
-from dataclasses import dataclass, fields
 import logging
+import warnings
+from dataclasses import dataclass, fields
+from typing import Any, ClassVar, Dict, Iterator, List, Optional, Tuple
 
 LOGGER = logging.getLogger(__name__)
 
@@ -20,27 +21,31 @@ LOGGER = logging.getLogger(__name__)
 ENCODING = "utf-8"
 ENCODING_LEN = 1
 
-ENDIAN = {'little': '<', 'big': '>'}
+ENDIAN = {"little": "<", "big": ">"}
 
-NUMTYPES = {"float": "f", "double": "d", "short": "i2",
-            "long": "i4", "llong": "i8", "char": "i1", "boolean": "i1",
-            "string": "s"}
-NUMTYPES_SIZES = {"float": 4, "double": 8, "short": 2,
-                  "long": 4, "llong": 8, "char": 1, "boolean": 1}
-NUMTYPES_CAST = {"float": float, "double": float, "short": int,
-                 "long": int, "llong": int, "char": str, "boolean": int}
+NUMTYPES = {
+    "float": "f",
+    "double": "d",
+    "short": "i2",
+    "long": "i4",
+    "llong": "i8",
+    "char": "i1",
+    "boolean": "i1",
+    "string": "s",
+}
+NUMTYPES_SIZES = {"float": 4, "double": 8, "short": 2, "long": 4, "llong": 8, "char": 1, "boolean": 1}
+NUMTYPES_CAST = {"float": float, "double": float, "short": int, "long": int, "llong": int, "char": str, "boolean": int}
 
 
-def get_dtype_str(type_: str, endianness: str = 'big', length: int = None):
-    if length is None:
-        length = ''
-    return f"{ENDIAN[endianness]}{length}{NUMTYPES[type_]}"
+def get_dtype_str(type_: str, endianness: str = "big", length: Optional[int] = None):
+    return f"{ENDIAN[endianness]}{length if length is not None else ''}{NUMTYPES[type_]}"
 
 
 ##############################################################################
 #  Classes
 ##############################################################################
 
+
 @dataclass
 class Description:
     """
@@ -51,17 +56,22 @@ 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.
     """
+
     text: Optional[str] = None
     contents: Optional[str] = None
     TAG: ClassVar[str] = "&description"
 
     def __repr__(self):
-        return f"<SDDS Description Container>"
+        return "<SDDS Description Container>"
+
+    def get_key_value_string(self) -> str:
+        warnings.warn("not implemented yet")
+        return ""
 
 
 @dataclass
@@ -75,10 +85,11 @@ class Include:
     Fields:
         filename (str): Name of the file to be read containing header lines.
     """
+
     filename: str
 
     def __repr__(self):
-        return f"<SDDS Include Container>"
+        return "<SDDS Include Container>"
 
     def __str__(self):
         return f"Include: {self.filename:s}"
@@ -106,12 +117,14 @@ class Definition:
                       (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: Optional[str] = None
+    TAG: ClassVar[Optional[str]] = None
 
     def __post_init__(self):
         # Fix types (probably strings from reading files) by using the type-hints
@@ -130,20 +143,19 @@ class Definition:
                     continue
 
                 # find the proper type from type-hint:
-                hinted_type = next(t for t in hinted_type.__args__
-                                   if not isinstance(t, type(None)))
+                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__}")
+            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`.
+        """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])
@@ -152,7 +164,7 @@ class Definition:
         return f"<SDDS {self.__class__.__name__} '{self.name}'>"
 
     def __str__(self):
-        return f"<{self.__class__.__name__} ({getattr(self, 'TAG', 'no tag')})> {self.get_key_value_string()}"
+        return f"<{self.__class__.__name__} ({self.TAG or 'no tag'})> {self.get_key_value_string()}"
 
 
 @dataclass
@@ -163,6 +175,7 @@ class Column(Definition):
     This optional command defines a column that will appear in the tabular data section of each
     data page.
     """
+
     TAG: ClassVar[str] = "&column"
 
 
@@ -181,6 +194,7 @@ class Parameter(Definition):
                            This feature is for convenience only;
                            the parameter thus defined is treated like any other.
     """
+
     TAG: ClassVar[str] = "&parameter"
     fixed_value: Optional[str] = None
 
@@ -203,6 +217,7 @@ class Array(Definition):
         dimensions (int): Optional. Gives the number of dimensions in the array.
                           If not given, defaults to ``1`` upon reading.
     """
+
     TAG: ClassVar[str] = "&array"
     field_length: Optional[int] = None
     group_name: Optional[str] = None
@@ -220,12 +235,17 @@ class Data:
     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>"
 
+    def get_key_value_string(self) -> str:
+        warnings.warn("not implemented yet")
+        return ""
+
 
 class SddsFile:
     """
@@ -257,14 +277,19 @@ class SddsFile:
         values (dict[str, Any]): Values of the data, mapped to their name.
 
     """
+
     version: str  # This should always be "SDDS1"
     description: Optional[Description]
     definitions: Dict[str, Definition]
     values: Dict[str, Any]
 
-    def __init__(self, version: str, description: Optional[Description],
-                 definitions_list: List[Definition],
-                 values_list: List[Any]) -> None:
+    def __init__(
+        self,
+        version: str,
+        description: Optional[Description],
+        definitions_list: List[Definition],
+        values_list: List[Any],
+    ) -> None:
         self.version = version
 
         name_list = [definition.name for definition in definitions_list]
@@ -273,8 +298,7 @@ class SddsFile:
 
         self.description = description
         self.definitions = {definition.name: definition for definition in definitions_list}
-        self.values = {definition.name: value for definition, value
-                       in zip(definitions_list, values_list)}
+        self.values = {definition.name: value for definition, value in zip(definitions_list, values_list)}
 
     def __getitem__(self, name: str) -> Tuple[Definition, Any]:
         return self.definitions[name], self.values[name]
@@ -284,7 +308,7 @@ class SddsFile:
             yield self[def_name]
 
     def __repr__(self):
-        return f"<SDDS-File Object>"
+        return "<SDDS-File Object>"
 
     def __str__(self):
         return f"SDDS-File ({self.version})"  # TODO make something nice
diff --git a/sdds/reader.py b/sdds/reader.py
index 69d1709..d48e496 100644
--- a/sdds/reader.py
+++ b/sdds/reader.py
@@ -6,13 +6,12 @@ This module contains the reading functionality of ``sdds``.
 It provides a high-level function to read SDDS files in different formats, and a series of helpers.
 """
 import gzip
-import io
 import os
 import pathlib
 import struct
 import sys
 from collections.abc import Callable
-from contextlib import AbstractContextManager, contextmanager
+from contextlib import AbstractContextManager
 from functools import partial
 from typing import IO, Any, Dict, Generator, List, Optional, Tuple, Type, Union
 
@@ -31,7 +30,7 @@ from sdds.classes import (ENCODING, NUMTYPES_CAST, NUMTYPES_SIZES, Array,
 if sys.version_info < (3, 9, 0):  # we're running on 3.8, which is our lowest supported
     OpenerType = Callable
 else:
-    OpenerType = Callable[[os.PathLike], AbstractContextManager[io.BufferedIOBase]]
+    OpenerType = Callable[[os.PathLike], AbstractContextManager[IO]]
 
 binary_open = partial(open, mode="rb")  # default opening mode, simple sdds files
 gzip_open = partial(gzip.open, mode="rb")  # for gzip-compressed sdds files
@@ -39,7 +38,12 @@ gzip_open = partial(gzip.open, mode="rb")  # for gzip-compressed sdds files
 
 # ----- Reader Function ----- #
 
-def read_sdds(file_path: Union[pathlib.Path, str], endianness: str = None, opener: OpenerType = binary_open) -> SddsFile:
+
+def read_sdds(
+    file_path: Union[pathlib.Path, str],
+    endianness: Optional[str] = None,
+    opener: OpenerType = binary_open,
+) -> SddsFile:
     """
     Reads an SDDS file from the specified ``file_path``.
 
@@ -56,7 +60,7 @@ def read_sdds(file_path: Union[pathlib.Path, str], endianness: str = None, opene
 
     Returns:
         An `SddsFile` object containing the loaded data.
-    
+
     Examples:
 
         To read a typical file, one can use the default options:
@@ -105,23 +109,24 @@ def read_sdds(file_path: Union[pathlib.Path, str], endianness: str = None, opene
 # Common reading of header and data.
 ##############################################################################
 
-def _read_header(inbytes: IO[bytes]) -> Tuple[str, List[Definition], Optional[Description], Data]:
+
+def _read_header(
+    inbytes: IO[bytes],
+) -> Tuple[str, List[Definition], Optional[Description], Data]:
     word_gen = _gen_words(inbytes)
     version = next(word_gen)  # First token is the SDDS version
-    assert version == "SDDS1",\
-        "This module is compatible with SDDS v1 only... are there really other versions?"
+    assert version == "SDDS1", "This module is compatible with SDDS v1 only... are there really other versions?"
     definitions: List[Definition] = []
     description: Optional[Description] = None
     data: Optional[Data] = None
     for word in word_gen:
         def_dict: Dict[str, str] = _get_def_as_dict(word_gen)
         if word in (Column.TAG, Parameter.TAG, Array.TAG):
-            definitions.append({
-                Column.TAG: Column,
-                Parameter.TAG: Parameter,
-                Array.TAG: Array}[word](name=def_dict.pop("name"),
-                                        type=def_dict.pop("type"),
-                                        **def_dict))
+            definitions.append(
+                {Column.TAG: Column, Parameter.TAG: Parameter, Array.TAG: Array}[word](
+                    name=def_dict.pop("name"), type=def_dict.pop("type"), **def_dict
+                )
+            )
             continue
         if word == Description.TAG:
             if description is not None:
@@ -147,8 +152,7 @@ def _sort_definitions(orig_defs: List[Definition]) -> List[Definition]:
     According to the specification, parameters appear first in data pages then arrays
     and then columns. Inside each group they follow the order of appearance in the header.
     """
-    definitions: List[Definition] = [definition for definition in orig_defs
-                                     if isinstance(definition, Parameter)]
+    definitions: List[Definition] = [definition for definition in orig_defs if isinstance(definition, Parameter)]
     definitions.extend([definition for definition in orig_defs if isinstance(definition, Array)])
     definitions.extend([definition for definition in orig_defs if isinstance(definition, Column)])
     return definitions
@@ -167,12 +171,13 @@ def _read_data(data: Data, definitions: List[Definition], inbytes: IO[bytes], en
 # Binary data reading
 ##############################################################################
 
+
 def _read_data_binary(definitions: List[Definition], inbytes: IO[bytes], endianness: str) -> List[Any]:
     row_count: int = _read_bin_int(inbytes, endianness)  # First int in bin data
     functs_dict: Dict[Type[Definition], Callable] = {
         Parameter: _read_bin_param,
         Column: lambda x, y, z: _read_bin_column(x, y, z, row_count),
-        Array: _read_bin_array
+        Array: _read_bin_array,
     }
     return [functs_dict[definition.__class__](inbytes, definition, endianness) for definition in definitions]
 
@@ -188,9 +193,7 @@ def _read_bin_param(inbytes: IO[bytes], definition: Parameter, endianness: str)
     if definition.type == "string":
         str_len: int = _read_bin_int(inbytes, endianness)
         return _read_string(inbytes, str_len, endianness)
-    return NUMTYPES_CAST[definition.type](
-        _read_bin_numeric(inbytes, definition.type, 1, endianness)
-    )
+    return NUMTYPES_CAST[definition.type](_read_bin_numeric(inbytes, definition.type, 1, endianness)[0])
 
 
 def _read_bin_column(inbytes: IO[bytes], definition: Column, endianness: str, row_count: int):
@@ -202,12 +205,10 @@ 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 = {"u1": "char", "i2": "short"}.get(
-            getattr(definition, "modifier", None), "long"
-        )
+        len_type = {"u1": "char", "i2": "short"}.get(getattr(definition, "modifier", ""), "long")
         str_array = []
         for _ in range(total_len):
-            str_len = int(_read_bin_numeric(inbytes, len_type, 1, endianness))
+            str_len = int(_read_bin_numeric(inbytes, len_type, 1, endianness)[0])
             str_array.append(_read_string(inbytes, str_len, endianness))
         return str_array
 
@@ -224,12 +225,14 @@ def _read_bin_array_len(inbytes: IO[bytes], num_dims: Optional[int], endianness:
 
 
 def _read_bin_numeric(inbytes: IO[bytes], type_: str, count: int, endianness: str) -> Any:
-    return np.frombuffer(inbytes.read(count * NUMTYPES_SIZES[type_]),
-                         dtype=np.dtype(get_dtype_str(type_, endianness)))
+    return np.frombuffer(
+        inbytes.read(count * NUMTYPES_SIZES[type_]),
+        dtype=np.dtype(get_dtype_str(type_, endianness)),
+    )
 
 
 def _read_bin_int(inbytes: IO[bytes], endianness: str) -> int:
-    return int(_read_bin_numeric(inbytes, "long", 1, endianness))
+    return int(_read_bin_numeric(inbytes, "long", 1, endianness)[0])
 
 
 def _read_string(inbytes: IO[bytes], str_len: int, endianness: str) -> str:
@@ -242,6 +245,7 @@ def _read_string(inbytes: IO[bytes], str_len: int, endianness: str) -> str:
 # ASCII data reading
 ##############################################################################
 
+
 def _read_data_ascii(definitions: List[Definition], inbytes: IO[bytes]) -> List[Any]:
     def _ascii_generator(ascii_text):
         for line in ascii_text:
@@ -249,33 +253,26 @@ def _read_data_ascii(definitions: List[Definition], inbytes: IO[bytes]) -> List[
 
     # Convert bytes to ASCII, separate by lines and remove comments
     ascii_text = [chr(r) for r in inbytes.read()]
-    ascii_text = ''.join(ascii_text).split('\n')
-    ascii_text = [line for line in ascii_text if not line.startswith('!')]
+    ascii_text = "".join(ascii_text).split("\n")
+    ascii_text = [line for line in ascii_text if not line.startswith("!")]
 
     # Get the generator for the text
     ascii_gen = _ascii_generator(ascii_text)
 
-    # Dict of function to call for each type of tag: array, parameter
-    functs_dict = {Parameter: _read_ascii_parameter,
-                   Array: _read_ascii_array
-                   }
-
     # Iterate through every parameters and arrays in the file
-    data = []
+    data: List[Any] = []
     for definition in definitions:
-        def_tag = definition.__class__
-
         # Call the function handling the tag we're on
         # Change the current line according to the tag and dimensions
-        value = functs_dict[def_tag](ascii_gen, definition)
-        data.append(value)
+        if isinstance(definition, Parameter):
+            data.append(_read_ascii_parameter(ascii_gen, definition))
+        elif isinstance(definition, Array):
+            data.append(_read_ascii_array(ascii_gen, definition))
 
     return data
 
 
-def _read_ascii_parameter(ascii_gen: Generator[str, None, None],
-                          definition: Parameter) -> Union[str, int, float]:
-
+def _read_ascii_parameter(ascii_gen: Generator[str, None, None], definition: Parameter) -> Union[str, int, float]:
     # Check if we got fixed values, no need to read a line if that's the case
     if definition.fixed_value is not None:
         if definition.type == "string":
@@ -295,34 +292,32 @@ def _read_ascii_parameter(ascii_gen: Generator[str, None, None],
     raise TypeError(f"Type {definition.type} for Parameter unsupported")
 
 
-def _read_ascii_array(ascii_gen: Generator[str, None, None],
-                      definition: Array) -> np.ndarray:
-
+def _read_ascii_array(ascii_gen: Generator[str, None, None], definition: Array) -> np.ndarray:
     # Get the number of elements per dimension
-    dimensions = next(ascii_gen).split()
-    dimensions = np.array(dimensions, dtype="int")
+    dimensions = np.array(next(ascii_gen).split(), dtype="int")
 
     # Get all the data given by the dimensions
-    data = []
+    data: List[str] = []
     while len(data) != np.prod(dimensions):
         # The values on each line are split by a space
-        data += next(ascii_gen).strip().split(' ')
+        data += next(ascii_gen).strip().split(" ")
 
     # Cast every object to the correct type
-    if definition.type != 'string':
+    if definition.type != "string":
         data = list(map(NUMTYPES_CAST[definition.type], data))
 
     # Convert to np.array so that it can be reshaped to reflect the dimensions
-    data = np.array(data)
-    data = data.reshape(dimensions)
+    npdata = np.array(data)
+    npdata = npdata.reshape(dimensions)
 
-    return data
+    return npdata
 
 
 ##############################################################################
 # Helper generators to consume the input bytes
 ##############################################################################
 
+
 def _get_endianness(inbytes: IO[bytes]) -> str:
     """Tries to determine endianness from file-comments.
     If nothing found, uses machine endianness."""
@@ -337,7 +332,7 @@ def _get_endianness(inbytes: IO[bytes]) -> str:
         if line.strip() == "!# little-endian":
             endianness = "little"
             break
-    inbytes.seek(0)   # return back to beginning of file
+    inbytes.seek(0)  # return back to beginning of file
     return endianness
 
 
@@ -364,7 +359,6 @@ def _get_def_as_dict(word_gen: Generator[str, None, None]) -> Dict[str, str]:
         if word.strip() == "&end":
             recomposed: str = " ".join(raw_str)
             parts = [assign for assign in recomposed.split(",") if assign]
-            return {key.strip(): value.strip() for (key, value) in
-                    [assign.split("=") for assign in parts]}
+            return {key.strip(): value.strip() for (key, value) in [assign.split("=") for assign in parts]}
         raw_str.append(word.strip())
     raise ValueError("EOF found while looking for &end tag.")
diff --git a/sdds/writer.py b/sdds/writer.py
index 475ef83..17d1f55 100644
--- a/sdds/writer.py
+++ b/sdds/writer.py
@@ -7,11 +7,11 @@ It provides a high-level function to write SDDS files in different formats, and
 """
 import pathlib
 import struct
-from dataclasses import fields
-from typing import IO, List, Union, Iterable, Tuple, Any
+from typing import IO, Any, Iterable, List, Tuple, Union
+
 import numpy as np
-from sdds.classes import (SddsFile, Column, Parameter, Definition, Array, Data, Description,
-                          ENCODING, get_dtype_str)
+
+from sdds.classes import ENCODING, Array, Column, Data, Definition, Description, Parameter, SddsFile, get_dtype_str
 
 
 def write_sdds(sdds_file: SddsFile, output_path: Union[pathlib.Path, str]) -> None:
@@ -31,10 +31,10 @@ def write_sdds(sdds_file: SddsFile, output_path: Union[pathlib.Path, str]) -> No
 
 
 def _write_header(sdds_file: SddsFile, outbytes: IO[bytes]) -> List[str]:
-    outbytes.writelines(("SDDS1\n".encode(ENCODING),
-                         "!# big-endian\n".encode(ENCODING)))
+    outbytes.writelines(("SDDS1\n".encode(ENCODING), "!# big-endian\n".encode(ENCODING)))
     names = []
     if sdds_file.description is not None:
+        # TODO: For Description and Data, this is not implemented yet
         outbytes.write(_sdds_def_as_str(sdds_file.description).encode(ENCODING))
     for def_name in sdds_file.definitions:
         names.append(def_name)
@@ -51,15 +51,20 @@ def _sdds_def_as_str(definition: Union[Description, Definition, Data]) -> str:
 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
-                       if isinstance(sdds_file.definitions[name], Parameter)),
-                      outbytes)
-    _write_arrays((sdds_file[name] for name in names
-                   if isinstance(sdds_file.definitions[name], Array)),
-                  outbytes)
-    _write_columns((sdds_file[name] for name in names
-                    if isinstance(sdds_file.definitions[name], Column)),
-                   outbytes)
+
+    parameters: List[Tuple[Parameter, Any]] = []
+    arrays: List[Tuple[Array, Any]] = []
+    columns: List[Tuple[Column, Any]] = []
+    for name in names:
+        if isinstance(sdds_file[name][0], Parameter):
+            parameters.append(sdds_file[name])  # type: ignore
+        elif isinstance(sdds_file[name][0], Array):
+            arrays.append(sdds_file[name])  # type: ignore
+        elif isinstance(sdds_file[name][0], Column):
+            columns.append(sdds_file[name])  # type: ignore
+    _write_parameters(parameters, outbytes)
+    _write_arrays(arrays, outbytes)
+    _write_columns(columns, outbytes)
 
 
 def _write_parameters(param_gen: Iterable[Tuple[Parameter, Any]], outbytes: IO[bytes]):
@@ -75,7 +80,10 @@ def _write_arrays(array_gen: Iterable[Tuple[Array, Any]], outbytes: IO[bytes]):
         # Return the number of items per dimension
         # For an array a[n][m], returns [n, m]
         if isinstance(value, np.ndarray) or isinstance(value, list):
-            return [len(value)] + get_dimensions_from_array(value[0])
+            if len(value) == 0:
+                return [0]
+            else:
+                return [len(value)] + get_dimensions_from_array(value[0])
         return []
 
     for array_def, value in array_gen:
@@ -98,4 +106,9 @@ def _write_columns(col_gen: Iterable[Tuple[Column, Any]], outbytes: IO[bytes]):
 
 def _write_string(string: str, outbytes: IO[bytes]):
     outbytes.write(np.array(len(string), dtype=get_dtype_str("long")).tobytes())
-    outbytes.write(struct.pack(get_dtype_str("string", length=len(string)), string.encode(ENCODING)))
+    outbytes.write(
+        struct.pack(
+            get_dtype_str("string", length=len(string)),
+            string.encode(ENCODING),
+        )
+    )
diff --git a/setup.py b/setup.py
index 2924abe..16523bb 100644
--- a/setup.py
+++ b/setup.py
@@ -37,9 +37,7 @@ EXTRA_DEPENDENCIES = {
     "test": ["pytest>=5.2", "pytest-cov>=2.6", "attrs>=19.2.0"],
     "doc": ["sphinx", "sphinx_rtd_theme"],
 }
-EXTRA_DEPENDENCIES.update(
-    {"all": [elem for list_ in EXTRA_DEPENDENCIES.values() for elem in list_]}
-)
+EXTRA_DEPENDENCIES.update({"all": [elem for list_ in EXTRA_DEPENDENCIES.values() for elem in list_]})
 
 
 setuptools.setup(


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index fe2f540..bb76f15 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,5 +1,6 @@
 # ignore numpy warnings, see:
 # https://stackoverflow.com/questions/40845304/runtimewarning-numpy-dtype-size-changed-may-indicate-binary-incompatibility
 import warnings
+
 warnings.filterwarnings("ignore", message="numpy.dtype size changed")
 warnings.filterwarnings("ignore", message="numpy.ufunc size changed")
diff --git a/tests/test_sdds.py b/tests/test_sdds.py
index ca388fe..8d6fd35 100644
--- a/tests/test_sdds.py
+++ b/tests/test_sdds.py
@@ -8,11 +8,19 @@ from typing import Dict
 import numpy as np
 import pytest
 
-from sdds.classes import (NUMTYPES, NUMTYPES_CAST, Array, Column, Data,
-                          Definition, Description, Include, Parameter,
-                          SddsFile, get_dtype_str)
-from sdds.reader import (_gen_words, _get_def_as_dict, _read_data,
-                         _read_header, _sort_definitions, gzip_open, read_sdds)
+from sdds.classes import (
+    NUMTYPES,
+    Array,
+    Column,
+    Data,
+    Definition,
+    Description,
+    Include,
+    Parameter,
+    SddsFile,
+    get_dtype_str,
+)
+from sdds.reader import _gen_words, _get_def_as_dict, _read_data, _read_header, _sort_definitions, gzip_open, read_sdds
 from sdds.writer import _sdds_def_as_str, write_sdds
 
 CURRENT_DIR = pathlib.Path(__file__).parent
@@ -39,10 +47,15 @@ class TestReadWrite:
             assert new_def.type == definition.type
             assert np.all(value == new_val)
 
+    def test_empty_array(self, _sdds_file_pathlib, tmp_file):
+        original = read_sdds(_sdds_file_pathlib)
+        original.values["horPositionsConcentratedAndSorted"] = np.array([])
+        write_sdds(original, tmp_file)
+
 
 class TestReadGzippedFiles:
     def test_sdds_read_gzipped_file_pathlib(self, _sdds_gzipped_file_pathlib, tmp_file):
-        original = read_sdds(_sdds_gzipped_file_pathlib, opener=gzip_open)
+        original = read_sdds(_sdds_gzipped_file_pathlib, opener=gzip_open)  # type: ignore
         write_sdds(original, tmp_file)
         new = read_sdds(tmp_file)
         for definition, value in original:
@@ -52,7 +65,7 @@ class TestReadGzippedFiles:
             assert np.all(value == new_val)
 
     def test_sdds_read_gzipped_file_str(self, _sdds_gzipped_file_str, tmp_file):
-        original = read_sdds(_sdds_gzipped_file_str, opener=gzip_open)
+        original = read_sdds(_sdds_gzipped_file_str, opener=gzip_open)  # type: ignore
         write_sdds(original, tmp_file)
         new = read_sdds(tmp_file)
         for definition, value in original:
@@ -61,6 +74,7 @@ class TestReadGzippedFiles:
             assert new_def.type == definition.type
             assert np.all(value == new_val)
 
+
 class TestEndianness:
     def test_sdds_read_little_endian(self, _sdds_file_little_endian):
         read_sdds(_sdds_file_little_endian)
@@ -70,12 +84,12 @@ class TestEndianness:
 
     def test_sdds_read_big_endian_as_little_endian(self, _sdds_file_pathlib):
         with pytest.raises(ValueError) as e:
-            read_sdds(_sdds_file_pathlib, endianness='little')
+            read_sdds(_sdds_file_pathlib, endianness="little")
         assert "buffer size" in str(e)
 
     def test_sdds_read_little_endian_as_big_endian(self, _sdds_file_little_endian):
         with pytest.raises(struct.error) as e:
-            read_sdds(_sdds_file_little_endian, endianness='big')
+            read_sdds(_sdds_file_little_endian, endianness="big")
         assert "buffer" in str(e)
 
     def test_sdds_write_read_write_little_endian(self, _sdds_file_little_endian, tmp_file):
@@ -118,9 +132,12 @@ class TestReadFunctions:
         &end
         &data mode=binary, &end
         """
-        test_data = {"acqStamp": "double", "nbOfCapTurns": "long",
-                     "horPositionsConcentratedAndSorted": "float",
-                     "verBunchId": "long"}
+        test_data = {
+            "acqStamp": "double",
+            "nbOfCapTurns": "long",
+            "horPositionsConcentratedAndSorted": "float",
+            "verBunchId": "long",
+        }
         version, definitions, _, data = _read_header(io.BytesIO(test_head))
         assert version == "SDDS1"
         assert data.mode == "binary"
@@ -131,25 +148,35 @@ class TestReadFunctions:
         # build
         to_check = {
             "Step": {"type": "long", "class": "Parameter"},
-            "SVNVersion": {"type": "string", "class": "Parameter", "description": '"SVN version number"', "fixed_value": "28096M"},
+            "SVNVersion": {
+                "type": "string",
+                "class": "Parameter",
+                "description": '"SVN version number"',
+                "fixed_value": "28096M",
+            },
             "ElementName": {"type": "string", "class": "Column"},
             "s": {"type": "double", "units": "m", "class": "Column"},
             "ElementType": {"type": "string", "class": "Column"},
             "ElementOccurence": {"type": "long", "class": "Column"},
             "deltaPositiveFound": {"type": "short", "class": "Column"},
-            "deltaPositive": {"type": "double", "symbol": '"$gd$R$bpos$n"', "class": "Column"},
+            "deltaPositive": {
+                "type": "double",
+                "symbol": '"$gd$R$bpos$n"',
+                "class": "Column",
+            },
         }
 
         test_head = (
-                "SDDS1\n"
-                "!# little-endian\n"
-                '&description text="Momentum aperture search", contents="momentum aperture", &end\n' +
-                 _header_from_dict(to_check) +
-                "&data mode=binary, &end"
+            "SDDS1\n"
+            "!# little-endian\n"
+            '&description text="Momentum aperture search", contents="momentum aperture", &end\n'
+            + _header_from_dict(to_check)
+            + "&data mode=binary, &end"
         ).encode("ascii")
 
         # read
-        version, definitions, _, data = _read_header(io.BytesIO(test_head))
+        # returns version, definitions, description, data
+        _, definitions, _, _ = _read_header(io.BytesIO(test_head))
 
         # check
         for entry in definitions:
@@ -160,8 +187,7 @@ class TestReadFunctions:
 
 
 def test_def_as_dict():
-    test_str = (b"test1=value1,  test2= value2, \n"
-                b"test3=value3, &end")
+    test_str = b"test1=value1,  test2= value2, \n" b"test3=value3, &end"
     word_gen = _gen_words(io.BytesIO(test_str))
     def_dict = _get_def_as_dict(word_gen)
     assert def_dict["test1"] == "value1"
@@ -198,7 +224,6 @@ class TestAscii:
                 values_equal = all([a == b for a, b in zip(value, new_val)])
             else:
                 values_equal = np.isclose(value, new_val, atol=0.0001).all()
-                print(values_equal)
 
             assert values_equal
 
@@ -209,7 +234,7 @@ class TestAscii:
         self.template_ascii_read_write_read(_sdds_file_lei2, tmp_file)
 
     def test_sdds_write_ascii(self):
-        sdds_file = b'''SDDS1
+        sdds_file = b"""SDDS1
 &array name=arrayOne, type=float, dimensions=1, &end
 &array name=arrayTwo, type=float, dimensions=2, &end
 &data mode=ascii, &end
@@ -219,15 +244,15 @@ class TestAscii:
 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8
 7 6 5 4 3
 2 1
-'''
+"""
         sdds_io = io.BytesIO(sdds_file)
-        version, definitions, description, data = _read_header(sdds_io)
+        version, definitions, _, data = _read_header(sdds_io)
         data_list = _read_data(data, definitions, sdds_io, endianness=sys.byteorder)
 
-        assert version == 'SDDS1'
+        assert version == "SDDS1"
         assert len(definitions) == 2
-        assert definitions[0].dimensions == 1
-        assert definitions[1].dimensions == 2
+        assert definitions[0].dimensions == 1  # type: ignore
+        assert definitions[1].dimensions == 2  # type: ignore
 
         assert (data_list[0] == np.arange(10, 0, -1)).all()
 
@@ -242,9 +267,12 @@ class TestClasses:
     def test_duplicated_entries(self):
         with pytest.raises(ValueError) as e:
             SddsFile(
-                version="SDDS1", description=None,
-                definitions_list=[Parameter(name="test", type="int"),
-                                  Parameter(name="test", type="str")],
+                version="SDDS1",
+                description=None,
+                definitions_list=[
+                    Parameter(name="test", type="int"),
+                    Parameter(name="test", type="str"),
+                ],
                 values_list=[1, "hello"],
             )
         assert "Duplicated" in str(e)
@@ -268,8 +296,8 @@ class TestClasses:
         assert "&array" in str(array)
 
         data = Data(mode="binary")
-        assert 'binary' in repr(data)
-        assert 'binary' in str(data)
+        assert "binary" in repr(data)
+        assert "binary" in str(data)
 
         include = Include(filename="myfile")
         assert "Include" in repr(include)
@@ -281,10 +309,10 @@ class TestClasses:
         assert "Description" in str(description)
 
     def test_get_dtype(self):
-        assert '>' in get_dtype_str("float", endianness='big')
-        assert '>' in get_dtype_str("float")  # important for reading
-        assert '<' not in get_dtype_str("float")  # important for reading
-        assert '<' in get_dtype_str("float", endianness='little')
+        assert ">" in get_dtype_str("float", endianness="big")
+        assert ">" in get_dtype_str("float")  # important for reading
+        assert "<" not in get_dtype_str("float")  # important for reading
+        assert "<" in get_dtype_str("float", endianness="little")
         assert "16" in get_dtype_str("string", length=16)
 
         for name, format_ in NUMTYPES.items():
@@ -293,9 +321,10 @@ class TestClasses:
 
 # ----- Helpers ----- #
 
+
 def _write_read_header():
     original = Parameter(name="param1", type="str")
-    encoded = _sdds_def_as_str(original)
+    encoded = _sdds_def_as_str(original).encode("utf-8")
     word_gen = _gen_words(io.BytesIO(encoded))
     def_dict = _get_def_as_dict(word_gen)
     assert def_dict["name"] == original.name
@@ -303,17 +332,21 @@ def _write_read_header():
 
 
 def _header_from_dict(d: Dict[str, Dict[str, str]]) -> str:
-    """ Build a quick header from given dict. """
+    """Build a quick header from given dict."""
     d = {k: v.copy() for k, v in d.items()}
-    return ", &end\n".join(  # join lines
-        f"&{v.pop('class').lower()} name={k}, type={v.pop('type')}" +
-        (", " + ", ".join(f"{vk}={vv}" for vk, vv in v.items()) if v else "")
-        for k, v in d.items()
-    ) + ", &end\n"
+    return (
+        ", &end\n".join(  # join lines
+            f"&{v.pop('class').lower()} name={k}, type={v.pop('type')}"
+            + (", " + ", ".join(f"{vk}={vv}" for vk, vv in v.items()) if v else "")
+            for k, v in d.items()
+        )
+        + ", &end\n"
+    )
 
 
 # ----- Fixtures ----- #
 
+
 @pytest.fixture()
 def _sdds_file_pathlib() -> pathlib.Path:
     return CURRENT_DIR / "inputs" / "test_file.sdds"