Repository: rsagroup/rsatoolbox

Issue:
hdf5 writing function version hardcoded 
The hdf5 code currently always tags the files as `0.1`:

https://github.com/rsagroup/rsatoolbox/blob/2361bc51698008978e6bdd2eadf768e0b460cb57/src/rsatoolbox/util/file_io.py#L28

Discussion:

Patch:
diff --git a/.bandit b/.bandit
new file mode 100644
index 00000000..6f9debfa
--- /dev/null
+++ b/.bandit
@@ -0,0 +1,2 @@
+[bandit]
+skips: B403, 301
diff --git a/docs/source/rsatoolbox.io.hdf5.rst b/docs/source/rsatoolbox.io.hdf5.rst
new file mode 100644
index 00000000..7e188988
--- /dev/null
+++ b/docs/source/rsatoolbox.io.hdf5.rst
@@ -0,0 +1,7 @@
+rsatoolbox.io.hdf5 module
+============================
+
+.. automodule:: rsatoolbox.io.hdf5
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/rsatoolbox.io.pandas.rst b/docs/source/rsatoolbox.io.pandas.rst
new file mode 100644
index 00000000..87fac902
--- /dev/null
+++ b/docs/source/rsatoolbox.io.pandas.rst
@@ -0,0 +1,7 @@
+rsatoolbox.io.pandas module
+============================
+
+.. automodule:: rsatoolbox.io.pandas
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/rsatoolbox.io.pkl.rst b/docs/source/rsatoolbox.io.pkl.rst
new file mode 100644
index 00000000..db3c22ab
--- /dev/null
+++ b/docs/source/rsatoolbox.io.pkl.rst
@@ -0,0 +1,7 @@
+rsatoolbox.io.pkl module
+============================
+
+.. automodule:: rsatoolbox.io.pkl
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/rsatoolbox.io.rst b/docs/source/rsatoolbox.io.rst
index 15dde6a3..62ccfd96 100644
--- a/docs/source/rsatoolbox.io.rst
+++ b/docs/source/rsatoolbox.io.rst
@@ -7,7 +7,10 @@ Submodules
 .. toctree::
    :maxdepth: 4
 
+   rsatoolbox.io.hdf5
    rsatoolbox.io.meadows
+   rsatoolbox.io.pandas
+   rsatoolbox.io.pkl
 
 Module contents
 ---------------
diff --git a/requirements.txt b/requirements.txt
index 43517c61..118d1e50 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,3 +7,4 @@ matplotlib
 h5py
 tqdm
 joblib
+importlib-metadata>=6.0.0; python_version < "3.8"
\ No newline at end of file
diff --git a/src/rsatoolbox/data/base.py b/src/rsatoolbox/data/base.py
index ba4e53be..d0a25d20 100644
--- a/src/rsatoolbox/data/base.py
+++ b/src/rsatoolbox/data/base.py
@@ -4,8 +4,8 @@ from __future__ import annotations
 from rsatoolbox.util.descriptor_utils import check_descriptor_length_error
 from rsatoolbox.util.descriptor_utils import format_descriptor
 from rsatoolbox.util.descriptor_utils import parse_input_descriptor
-from rsatoolbox.util.file_io import write_dict_hdf5
-from rsatoolbox.util.file_io import write_dict_pkl
+from rsatoolbox.io.hdf5 import write_dict_hdf5
+from rsatoolbox.io.pkl import write_dict_pkl
 from rsatoolbox.util.file_io import remove_file
 
 
diff --git a/src/rsatoolbox/data/dataset.py b/src/rsatoolbox/data/dataset.py
index 8f388053..e9f89606 100644
--- a/src/rsatoolbox/data/dataset.py
+++ b/src/rsatoolbox/data/dataset.py
@@ -20,8 +20,8 @@ from rsatoolbox.util.descriptor_utils import format_descriptor
 from rsatoolbox.util.descriptor_utils import parse_input_descriptor
 from rsatoolbox.util.descriptor_utils import append_obs_descriptors
 from rsatoolbox.util.descriptor_utils import desc_eq
-from rsatoolbox.util.file_io import read_dict_hdf5
-from rsatoolbox.util.file_io import read_dict_pkl
+from rsatoolbox.io.hdf5 import read_dict_hdf5
+from rsatoolbox.io.pkl import read_dict_pkl
 from rsatoolbox.data.base import DatasetBase
 
 
diff --git a/src/rsatoolbox/inference/result.py b/src/rsatoolbox/inference/result.py
index 71e2c2c1..e2a5b632 100755
--- a/src/rsatoolbox/inference/result.py
+++ b/src/rsatoolbox/inference/result.py
@@ -7,10 +7,8 @@ Result object definition
 import numpy as np
 import scipy.stats
 import rsatoolbox.model
-from rsatoolbox.util.file_io import write_dict_hdf5
-from rsatoolbox.util.file_io import write_dict_pkl
-from rsatoolbox.util.file_io import read_dict_hdf5
-from rsatoolbox.util.file_io import read_dict_pkl
+from rsatoolbox.io.hdf5 import read_dict_hdf5, write_dict_hdf5
+from rsatoolbox.io.pkl import read_dict_pkl, write_dict_pkl
 from rsatoolbox.util.file_io import remove_file
 from rsatoolbox.util.inference_util import extract_variances
 from rsatoolbox.util.inference_util import all_tests, pair_tests, nc_tests, zero_tests
diff --git a/src/rsatoolbox/io/hdf5.py b/src/rsatoolbox/io/hdf5.py
new file mode 100755
index 00000000..56816504
--- /dev/null
+++ b/src/rsatoolbox/io/hdf5.py
@@ -0,0 +1,118 @@
+"""
+saving to and reading from HDF5 files
+"""
+from __future__ import annotations
+from typing import Union, Dict, List, IO
+import os
+from collections.abc import Iterable
+try:  # drop:py37 (backport)
+    from importlib.metadata import version
+except ModuleNotFoundError:
+    from importlib_metadata import version
+from h5py import File, Group, Empty
+import numpy as np
+
+
+def write_dict_hdf5(fhandle: Union[str, IO], dictionary: Dict) -> None:
+    """ writes a nested dictionary containing strings & arrays as data into
+    a hdf5 file
+
+    Args:
+        file: a filename or opened writable file
+        dictionary(dict): the dict to be saved
+
+    """
+    if isinstance(fhandle, str):
+        if os.path.exists(fhandle):
+            raise ValueError('File already exists!')
+    file = File(fhandle, 'a')
+    file.attrs['rsatoolbox_version'] = version('rsatoolbox')
+    _write_to_group(file, dictionary)
+
+
+def _write_to_group(group: Group, dictionary: Dict) -> None:
+    """ writes a dictionary to a hdf5 group, which can recurse"""
+    for key in dictionary.keys():
+        value = dictionary[key]
+        if isinstance(value, str):
+            # needs another conversion to string to catch weird subtypes
+            # like numpy.str_
+            group.attrs[key] = str(value)
+        elif isinstance(value, np.ndarray):
+            if str(value.dtype)[:2] == '<U':
+                group[key] = value.astype('S')
+            else:
+                group[key] = value
+        elif isinstance(value, list):
+            _write_list(group, key, value)
+        elif isinstance(value, dict):
+            subgroup = group.create_group(key)
+            _write_to_group(subgroup, value)
+        elif value is None:
+            group[key] = Empty("f")
+        elif isinstance(value, Iterable):
+            if isinstance(value[0], str):
+                group.attrs[key] = value
+        else:
+            group[key] = value
+
+
+def _write_list(group: Group, key: str, value: List) -> None:
+    """
+    writes a list to a hdf5 file. First tries conversion to np.array.
+    If this fails the list is converted to a dict with integer keys.
+
+    Parameters
+    ----------
+    group : hdf5 group
+        where to write.
+    key :  hdf5 key
+    value : list
+        list to be written
+    """
+    try:
+        value = np.array(value)
+        if str(value.dtype)[:2] == '<U':
+            group[key] = value.astype('S')
+        else:
+            group[key] = value
+    except TypeError:
+        l_group = group.create_group(key)
+        for i, v in enumerate(value):
+            l_group[str(i)] = v
+
+
+def read_dict_hdf5(fhandle: Union[str, IO]) -> Dict:
+    """ writes a nested dictionary containing strings & arrays as data into
+    a hdf5 file
+
+    Args:
+        file: a filename or opened readable file
+
+    Returns:
+        dictionary(dict): the loaded dict
+
+    """
+    file = File(fhandle, 'r')
+    return _read_group(file)
+
+
+def _read_group(group: Group) -> Dict:
+    """ reads a group from a hdf5 file into a dict, which allows recursion"""
+    dictionary = {}
+    for key in group.keys():
+        sub_val = group[key]
+        if isinstance(sub_val, Group):
+            dictionary[key] = _read_group(sub_val)
+        elif sub_val.shape is None:
+            dictionary[key] = None
+        else:
+            dictionary[key] = np.array(sub_val)
+            if dictionary[key].dtype.type is np.string_:
+                dictionary[key] = np.array(sub_val).astype('unicode')
+            # if (len(dictionary[key].shape) == 1
+            #     and dictionary[key].shape[0] == 1):
+            #     dictionary[key] = dictionary[key][0]
+    for key in group.attrs.keys():
+        dictionary[key] = group.attrs[key]
+    return dictionary
diff --git a/src/rsatoolbox/io/pkl.py b/src/rsatoolbox/io/pkl.py
new file mode 100755
index 00000000..1d05a34a
--- /dev/null
+++ b/src/rsatoolbox/io/pkl.py
@@ -0,0 +1,43 @@
+"""
+saving to and reading from pickle files
+"""
+from __future__ import annotations
+from typing import Union, Dict, IO
+try:  # drop:py37 (backport)
+    from importlib.metadata import version
+except ModuleNotFoundError:
+    from importlib_metadata import version
+import pickle
+
+
+def write_dict_pkl(fhandle: Union[str, IO], dictionary: Dict) -> None:
+    """ writes a nested dictionary containing strings & arrays as data into
+    a pickle file
+
+    Args:
+        file: a filename or opened writable file
+        dictionary(dict): the dict to be saved
+
+    """
+    if isinstance(fhandle, str):
+        fhandle = open(fhandle, 'wb')
+    dictionary['rsatoolbox_version'] = version('rsatoolbox')
+    pickle.dump(dictionary, fhandle, protocol=-1)
+
+
+def read_dict_pkl(fhandle: Union[str, IO]) -> Dict:
+    """ writes a nested dictionary containing strings & arrays as data into
+    a pickle file
+
+    Args:
+        file: a filename or opened readable file
+
+    Returns:
+        dictionary(dict): the loaded dict
+
+
+    """
+    if isinstance(fhandle, str):
+        fhandle = open(fhandle, 'rb')
+    data = pickle.load(fhandle)
+    return data
diff --git a/src/rsatoolbox/rdm/rdms.py b/src/rsatoolbox/rdm/rdms.py
index 2ff4184a..655ad3d7 100644
--- a/src/rsatoolbox/rdm/rdms.py
+++ b/src/rsatoolbox/rdm/rdms.py
@@ -22,10 +22,8 @@ from rsatoolbox.util.descriptor_utils import append_descriptor
 from rsatoolbox.util.descriptor_utils import dict_to_list
 from rsatoolbox.util.descriptor_utils import desc_eq
 from rsatoolbox.util.data_utils import extract_dict
-from rsatoolbox.util.file_io import write_dict_hdf5
-from rsatoolbox.util.file_io import write_dict_pkl
-from rsatoolbox.util.file_io import read_dict_hdf5
-from rsatoolbox.util.file_io import read_dict_pkl
+from rsatoolbox.io.hdf5 import read_dict_hdf5, write_dict_hdf5
+from rsatoolbox.io.pkl import read_dict_pkl, write_dict_pkl
 from rsatoolbox.util.file_io import remove_file
 
 
diff --git a/src/rsatoolbox/util/file_io.py b/src/rsatoolbox/util/file_io.py
index 43cc0be8..d1ebe094 100755
--- a/src/rsatoolbox/util/file_io.py
+++ b/src/rsatoolbox/util/file_io.py
@@ -1,161 +1,18 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
 """
 saving to and reading from files
 """
-
+from __future__ import annotations
 import os
-from collections.abc import Iterable
 from pathlib import Path
-import pickle
-import h5py
-import numpy as np
-
-
-def write_dict_hdf5(file, dictionary):
-    """ writes a nested dictionary containing strings & arrays as data into
-    a hdf5 file
-
-    Args:
-        file: a filename or opened writable file
-        dictionary(dict): the dict to be saved
-
-    """
-    if isinstance(file, str):
-        if os.path.exists(file):
-            raise ValueError('File already exists!')
-    file = h5py.File(file, 'a')
-    file.attrs['rsatoolbox_version'] = '0.0.1'
-    _write_to_group(file, dictionary)
-
-
-def _write_to_group(group, dictionary):
-    """ writes a dictionary to a hdf5 group, which can recurse"""
-    for key in dictionary.keys():
-        value = dictionary[key]
-        if isinstance(value, str):
-            # needs another conversion to string to catch weird subtypes
-            # like numpy.str_
-            group.attrs[key] = str(value)
-        elif isinstance(value, np.ndarray):
-            if str(value.dtype)[:2] == '<U':
-                group[key] = value.astype('S')
-            else:
-                group[key] = value
-        elif isinstance(value, list):
-            _write_list(group, key, value)
-        elif isinstance(value, dict):
-            subgroup = group.create_group(key)
-            _write_to_group(subgroup, value)
-        elif value is None:
-            group[key] = h5py.Empty("f")
-        elif isinstance(value, Iterable):
-            if isinstance(value[0], str):
-                group.attrs[key] = value
-        else:
-            group[key] = value
-
-
-def _write_list(group, key, value):
-    """
-    writes a list to a hdf5 file. First tries conversion to np.array.
-    If this fails the list is converted to a dict with integer keys.
-
-    Parameters
-    ----------
-    group : hdf5 group
-        where to write.
-    key :  hdf5 key
-    value : list
-        list to be written
-    """
-    try:
-        value = np.array(value)
-        if str(value.dtype)[:2] == '<U':
-            group[key] = value.astype('S')
-        else:
-            group[key] = value
-    except TypeError:
-        l_group = group.create_group(key)
-        for i, v in enumerate(value):
-            l_group[str(i)] = v
-
-
-def read_dict_hdf5(file):
-    """ writes a nested dictionary containing strings & arrays as data into
-    a hdf5 file
-
-    Args:
-        file: a filename or opened readable file
-
-    Returns:
-        dictionary(dict): the loaded dict
-
-    """
-    file = h5py.File(file, 'r')
-    return _read_group(file)
-
-
-def _read_group(group):
-    """ reads a group from a hdf5 file into a dict, which allows recursion"""
-    dictionary = {}
-    for key in group.keys():
-        if isinstance(group[key], h5py.Group):
-            dictionary[key] = _read_group(group[key])
-        elif group[key].shape is None:
-            dictionary[key] = None
-        else:
-            dictionary[key] = np.array(group[key])
-            if dictionary[key].dtype.type is np.string_:
-                dictionary[key] = np.array(group[key]).astype('unicode')
-            # if (len(dictionary[key].shape) == 1
-            #     and dictionary[key].shape[0] == 1):
-            #     dictionary[key] = dictionary[key][0]
-    for key in group.attrs.keys():
-        dictionary[key] = group.attrs[key]
-    return dictionary
-
-
-def write_dict_pkl(file, dictionary):
-    """ writes a nested dictionary containing strings & arrays as data into
-    a pickle file
-
-    Args:
-        file: a filename or opened writable file
-        dictionary(dict): the dict to be saved
-
-    """
-    if isinstance(file, str):
-        file = open(file, 'wb')
-    dictionary['rsatoolbox_version'] = '0.0.1'
-    pickle.dump(dictionary, file, protocol=-1)
+from typing import Union, IO
 
 
-def read_dict_pkl(file):
-    """ writes a nested dictionary containing strings & arrays as data into
-    a pickle file
-
-    Args:
-        file: a filename or opened readable file
-
-    Returns:
-        dictionary(dict): the loaded dict
-
-
-    """
-    if isinstance(file, str):
-        file = open(file, 'rb')
-    data = pickle.load(file)
-    return data
-
-
-def remove_file(file):
+def remove_file(file: Union[str, Path, IO]):
     """ Deletes file from OS if it exists
 
     Args:
         file (str, Path):
             a filename or opened readable file
-
     """
     if isinstance(file, (str, Path)) and os.path.exists(file):
         os.remove(file)


Test patch:
diff --git a/tests/test_io_hdf5.py b/tests/test_io_hdf5.py
new file mode 100644
index 00000000..b5aae55a
--- /dev/null
+++ b/tests/test_io_hdf5.py
@@ -0,0 +1,21 @@
+from unittest import TestCase
+from unittest.mock import patch
+try:  # drop:py37 (backport)
+    from importlib.metadata import version
+except ModuleNotFoundError:
+    from importlib_metadata import version
+
+
+class Hdf5IOTests(TestCase):
+
+    @patch('rsatoolbox.io.hdf5.File')
+    def test_write_dict_hdf5_version(self, h5pyFile):
+        """Check version tag matches current version
+        """
+        from rsatoolbox.io.hdf5 import write_dict_hdf5
+        h5pyFile().attrs = dict()
+        write_dict_hdf5('a file path', dict())
+        self.assertEqual(
+            h5pyFile().attrs.get('rsatoolbox_version'),
+            version('rsatoolbox')
+        )
diff --git a/tests/test_io_pkl.py b/tests/test_io_pkl.py
new file mode 100644
index 00000000..b7298cdd
--- /dev/null
+++ b/tests/test_io_pkl.py
@@ -0,0 +1,20 @@
+from unittest import TestCase
+from unittest.mock import patch, sentinel
+try:  # drop:py37 (backport)
+    from importlib.metadata import version
+except ModuleNotFoundError:
+    from importlib_metadata import version
+
+
+class PickleIOTests(TestCase):
+
+    @patch('rsatoolbox.io.pkl.pickle')
+    def test_write_dict_pkl_version(self, pickle):
+        """Check version tag matches current version
+        """
+        from rsatoolbox.io.pkl import write_dict_pkl
+        write_dict_pkl(sentinel.filepath, dict())
+        self.assertEqual(
+            pickle.dump.call_args[0][0],
+            dict(rsatoolbox_version=version('rsatoolbox')),
+        )