Repository: INM-6/python-odmltables

Issue:
Error when converting empty (apart from header) csv file
'row_id' is undefined here when the csv file only contains a header:

https://github.com/INM-6/python-odmltables/blob/f9787990e4865f222fb072e8c5c84f96a2bada6c/odmltables/odml_table.py#L492

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 8b19d30..6981149 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -14,17 +14,17 @@ addons:
 
 python:
   - 3.6
-
+      
 env:
   matrix:
     # This environment tests for python 3 packages with conda
     - DISTRIB="conda" PYTHON_VERSION="3.6"
       NUMPY_VERSION="1.13.3" XLWT_VERSION="1.3.0" XLRD_VERSION="1.1.0"
       COVERAGE="true"
-#    # This environment tests for python 3.9 packages with conda
-#    - DISTRIB="conda_min" PYTHON_VERSION="3.9"
-#      NUMPY_VERSION="1.13.3" XLWT_VERSION="1.3.0" XLRD_VERSION="1.1.0"
-#      COVERAGE="true"
+    # This environment tests for python 2 packages with conda
+    - DISTRIB="conda_min" PYTHON_VERSION="2.7"
+      NUMPY_VERSION="1.13.3" XLWT_VERSION="1.3.0" XLRD_VERSION="1.1.0"
+      COVERAGE="true"
     # basic Ubuntu build environment
     - DISTRIB="ubuntu" PYTHON_VERSION="3.6" INSTALL_ATLAS="true"
       NUMPY_VERSION="1.13.3" XLWT_VERSION="1.3.0" XLRD_VERSION="1.1.0"
diff --git a/doc/conf.py b/doc/conf.py
index e231947..06d6b66 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -44,7 +44,7 @@ for requirement_file in requirement_files:
     for line_id in range(len(lines)):
         lines[line_id] = '* ' + lines[line_id]
         lines[line_id] = lines[line_id].replace(';', ' in case of ')
-    new_filename = f'{os.path.splitext(os.path.basename(requirement_file))[0]}.rst'
+    new_filename = '{}.rst'.format(os.path.splitext(os.path.basename(requirement_file))[0])
     with open(new_filename, 'w+') as f:
         f.writelines(lines)
 
diff --git a/odmltables/compare_section_table.py b/odmltables/compare_section_table.py
index ef5a9bb..dcdd7e2 100644
--- a/odmltables/compare_section_table.py
+++ b/odmltables/compare_section_table.py
@@ -70,11 +70,13 @@ class CompareSectionTable():
                         if len(prop.values) > 1:
                             value += ', ...'
                         if prop.uncertainty:
-                            uncertainty = f'+-{prop.uncertainty})'
-                            value = f'({value}'
+                            uncertainty = '+-{})'.format(prop.uncertainty)
+                            value = '({}'.format(value)
                         if prop.unit:
                             unit = prop.unit
-                        table[sec_ind][properties.index(prop.name)] = f'{value}{uncertainty}{unit}'
+                        table[sec_ind][properties.index(prop.name)] = '{}{}{}'.format(value,
+                                                                                      uncertainty,
+                                                                                      unit)
                     if len(prop.values) > 1:
                         # table[sec_ind][properties.index(prop.name)] += ', ...'
                         warnings.warn('Property %s contains %i values. Only showing first one in '
diff --git a/odmltables/gui/compsectionpages.py b/odmltables/gui/compsectionpages.py
index 75a0bcf..f34b1db 100644
--- a/odmltables/gui/compsectionpages.py
+++ b/odmltables/gui/compsectionpages.py
@@ -424,7 +424,7 @@ class SaveTablePage(QIWizardPage):
         elif platform.startswith('win'):
             subprocess.Popen(["start", self.outputfilename])
         else:
-            raise ValueError(f'Unknown operating platform "{platform}".')
+            raise ValueError('Unknown operating platform "{}".'.format(platform))
 
     def _saveXlsTable(self):
         table = odmltables.compare_section_xls_table.CompareSectionXlsTable()
diff --git a/odmltables/gui/converterpages.py b/odmltables/gui/converterpages.py
index 6af81bb..bfca700 100644
--- a/odmltables/gui/converterpages.py
+++ b/odmltables/gui/converterpages.py
@@ -1076,7 +1076,7 @@ class SaveFilePage(QIWizardPage):
         elif platform.startswith('win'):
             subprocess.Popen(["start", self.outputfilename])
         else:
-            raise ValueError(f'Unknown operating platform "{platform}".')
+            raise ValueError('Unknown operating platform "{}".'.format(platform))
 
     def saveconfig(self):
         if ((self.configlist.currentItem() == None) or
diff --git a/odmltables/gui/filterpages.py b/odmltables/gui/filterpages.py
index e2ee537..809a116 100644
--- a/odmltables/gui/filterpages.py
+++ b/odmltables/gui/filterpages.py
@@ -819,11 +819,11 @@ class FilterPage(QIWizardPage):
             [eval(value) for value in list(filter['kwargs'].values())]
         except:
             Qtw.QMessageBox.warning(self, 'Non-interpretable value',
-                                f'Can not interpret list of values {filter["kwargs"].values()}. There is a non-valid '
+                                'Can not interpret list of values "{}". There is a non-valid '
                                 'python object contained. To generate a string put your '
                                 'text into quotation marks. To define a list '
-                                'use square brackets.')
-            raise ValueError(f'Can not interpret values "{filter["kwargs"].values()}"')
+                                'use square brackets.'.format(str(filter['kwargs'].values())))
+            raise ValueError('Can not interpret values "{}"'.format(str(filter['kwargs'].values())))
 
         try:
             self.filtered_table.filter(mode=filter['mode'],
@@ -1111,7 +1111,7 @@ class SaveFilePage(QIWizardPage):
         elif platform.startswith('win'):
             subprocess.Popen(["start", self.outputfilename])
         else:
-            raise ValueError(f'Unknown operating platform "{platform}".')
+            raise ValueError('Unknown operating platform "{}".'.format(platform))
 
     def saveconfig(self):
         if ((self.configlist.currentItem() == None) or
diff --git a/odmltables/gui/generatetemplatepages.py b/odmltables/gui/generatetemplatepages.py
index 5aba2b0..8631f4f 100644
--- a/odmltables/gui/generatetemplatepages.py
+++ b/odmltables/gui/generatetemplatepages.py
@@ -338,7 +338,7 @@ class SaveFilePage(QIWizardPage):
         elif platform.startswith('win'):
             subprocess.Popen(["start", self.outputfilename])
         else:
-            raise ValueError(f'Unknown operating platform "{platform}".')
+            raise ValueError('Unknown operating platform "{}".'.format(platform))
 
     def validatePage(self):
         if self.issaved == False:
diff --git a/odmltables/gui/mainwindow.py b/odmltables/gui/mainwindow.py
index 5f0b792..94ae01a 100644
--- a/odmltables/gui/mainwindow.py
+++ b/odmltables/gui/mainwindow.py
@@ -114,7 +114,7 @@ class MainWindow(QtWidgets.QMainWindow):
         # title_font.setFamily("Verdana")
         title_font.setBold(True)
         title_font.setPointSize(14)
-        label = QtWidgets.QLabel(f"odMLtables version {VERSION}")
+        label = QtWidgets.QLabel("odMLtables version {}".format(VERSION))
         label.setFont(title_font)
         pal = QtGui.QPalette(label.palette())
         pal.setColor(QtGui.QPalette.WindowText, QtGui.QColor(QtCore.Qt.black))
diff --git a/odmltables/gui/mergepages.py b/odmltables/gui/mergepages.py
index 91183db..0f30b1a 100644
--- a/odmltables/gui/mergepages.py
+++ b/odmltables/gui/mergepages.py
@@ -251,7 +251,7 @@ class LoadFilePage(QIWizardPage):
         elif platform.startswith('win'):
             subprocess.Popen(["start", self.outputfilename])
         else:
-            raise ValueError(f'Unknown operating platform "{platform}".')
+            raise ValueError('Unknown operating platform "{}".'.format(platform))
 
     def validatePage(self):
         if self.issaved == False:
diff --git a/odmltables/odml_table.py b/odmltables/odml_table.py
index 7ac58aa..349b92a 100644
--- a/odmltables/odml_table.py
+++ b/odmltables/odml_table.py
@@ -259,7 +259,7 @@ class OdmlTable(object):
             # check, if all of the needed information are in the table
             if any([(m not in self._header) for m in must_haves]):
                 err_msg = ("your table has to contain all of the following " +
-                           f" attributes: {must_haves}")
+                           " attributes: {0}").format(must_haves)
                 raise ValueError(err_msg)
 
             previous_dic = {"Path": None,
@@ -298,7 +298,8 @@ class OdmlTable(object):
                 if new_dic['Path'] == '':
                     for key in self._SECTION_INF:
                         new_dic[key] = previous_dic[key]
-                    new_dic['Path'] = f"{previous_dic['Path'].split(':')[0]}:{new_dic['PropertyName']}"
+                    new_dic['Path'] = '{}:{}'.format(previous_dic['Path'].split(':')[0],
+                                                     new_dic['PropertyName'])
                 else:
                     # update path and remove section and property names
                     new_dic['Path'] = new_dic['Path'] + ':' + new_dic['PropertyName']
@@ -399,6 +400,8 @@ class OdmlTable(object):
         # create a inverted header_titles dictionary for an inverted lookup
         inv_header_titles = {v: k for (k, v) in list(self._header_titles.items())}
 
+        row_id = None
+
         with open(load_from, 'r') as csvfile:
             csvreader = csv.reader(csvfile)
 
@@ -424,7 +427,8 @@ class OdmlTable(object):
 
             # check, if all of the needed information are in the table
             if any([(m not in self._header) for m in must_haves]):
-                err_msg = (f"Your table has to contain all of the following attributes: {must_haves}")
+                err_msg = ("your table has to contain all of the following " +
+                           " attributes: {0}").format(must_haves)
                 raise ValueError(err_msg)
 
             current_dic = {"Path": "",
@@ -464,14 +468,16 @@ class OdmlTable(object):
 
                 # SAME SECTION: empty path -> reuse old path info
                 if new_dic['Path'].split(':')[0] == '':
-                    new_dic['Path'] = f"{current_dic['Path'].split(':')[0]}:{new_dic['Path'].split(':')[1]}"
+                    new_dic['Path'] = '{}:{}'.format(current_dic['Path'].split(':')[0],
+                                                     new_dic['Path'].split(':')[1])
                     for sec_inf in self._SECTION_INF:
                         if sec_inf in current_dic:
                             new_dic[sec_inf] = current_dic[sec_inf]
 
                 # SAME PROPERTY: empty property name -> reuse old prop info
                 if new_dic['Path'].split(':')[1] == '':
-                    new_dic['Path'] = f"{new_dic['Path'].split(':')[0]}:{current_dic['Path'].split(':')[1]}"
+                    new_dic['Path'] = '{}:{}'.format(new_dic['Path'].split(':')[0],
+                                                     current_dic['Path'].split(':')[1])
                     for sec_inf in self._PROPERTY_INF:
                         if sec_inf in current_dic:
                             new_dic[sec_inf] = current_dic[sec_inf]
@@ -489,10 +495,11 @@ class OdmlTable(object):
                     current_dic = new_dic
 
             # copy final property
-            if row_id == 0:
-                self._odmldict.append(copy.deepcopy(new_dic))
-            else:
-                self._odmldict.append(copy.deepcopy(current_dic))
+            if not (row_id is None):
+                if row_id == 0:
+                    self._odmldict.append(copy.deepcopy(new_dic))
+                else:
+                    self._odmldict.append(copy.deepcopy(current_dic))
 
             # value conversion for all properties
             for current_dic in self._odmldict:
@@ -537,8 +544,8 @@ class OdmlTable(object):
             if k in self._header_titles:
                 self._header_titles[k] = kwargs[k]
             else:
-                errmsg = f"{k} is not in the header_title-dictionary. " \
-                         f"Valid keywords are {', '.join(self._header_titles.keys())}."
+                errmsg = "{0} is not in the header_title-dictionary. Valid keywords are {1}." \
+                         "".format(k, ', '.join(self._header_titles.keys()))
                 raise ValueError(errmsg)
 
     def change_header(self, *args, **kwargs):
@@ -600,7 +607,8 @@ class OdmlTable(object):
 
         # check if first element is in range
         if kwargs[keys_sorted[0]] <= 0:
-            errmsg = f"Your smallest argument is {kwargs[keys_sorted[0]]}, but the columns start at 1"
+            errmsg = ("Your smallest argument is {}, but the columns start" +
+                      " at 1").format(kwargs[keys_sorted[0]])
             raise ValueError(errmsg)
             # TODO: better Exception
 
@@ -612,22 +620,24 @@ class OdmlTable(object):
         if keys_sorted[0] in self._header_titles:
             header[kwargs[keys_sorted[0]] - 1] = keys_sorted[0]
         else:
-            raise KeyError(f"{keys_sorted[0]} not in header_titles. "
-                           f"Available header titles are: {', '.join(self._header_titles.keys())}.")
+            raise KeyError(" {} not in header_titles. Available header titles are: {}."
+                           "".format(keys_sorted[0], ', '.join(self._header_titles.keys())))
 
         # check if there are two keys with the same value
         for index, key in enumerate(keys_sorted[1:]):
             if kwargs[keys_sorted[index]] == kwargs[keys_sorted[index - 1]]:
-                errmsg = f"The keys {keys_sorted[index - 1]} and {keys_sorted[index]} both have the " \
-                         f"value {kwargs[keys_sorted[index]]}"
+                errmsg = "The keys {0} and {1} both have the value {2}" \
+                    .format(keys_sorted[index - 1],
+                            keys_sorted[index],
+                            kwargs[keys_sorted[index]])
                 raise KeyError(errmsg)
                 # TODO: better exception
             else:
                 if key in self._header_titles:
                     header[kwargs[key] - 1] = key
                 else:
-                    raise KeyError(f"{key} not in header_titles. "
-                                   f"Available header titles are: {', '.join(self._header_titles.keys())}.")
+                    raise KeyError("{} not in header_titles. Available header titles are: {}."
+                                   "".format(key, ', '.join(self._header_titles.keys())))
 
         self._header = header
 
@@ -640,8 +650,9 @@ class OdmlTable(object):
             for property_dict in self._odmldict:
                 if property_dict['odmlDatatype'] and \
                         property_dict['odmlDatatype'] not in self.odtypes.valid_dtypes:
-                    raise TypeError(f'Non valid dtype "{property_dict["odmlDatatype"]}" in odmldict.'
-                                    f' Valid types are {self.odtypes.valid_dtypes}')
+                    raise TypeError('Non valid dtype "{0}" in odmldict. Valid types are {1}'
+                                    ''.format(property_dict['odmlDatatype'],
+                                              self.odtypes.valid_dtypes))
 
     def _filter(self, filter_func):
         """
diff --git a/odmltables/odml_xls_table.py b/odmltables/odml_xls_table.py
index d7d064f..f8d1853 100644
--- a/odmltables/odml_xls_table.py
+++ b/odmltables/odml_xls_table.py
@@ -115,7 +115,9 @@ class OdmlXlsTable(OdmlTable):
             try:
                 self._highlight_defaults = bool(mode)
             except:
-                raise TypeError(f'Mode "{mode}" can not be converted to boolean.')
+                raise TypeError('Mode "{}" can not be'
+                                'converted to boolean.'
+                                ''.format(str(mode)))
 
     def mark_columns(self, *args):
         """
@@ -156,8 +158,8 @@ class OdmlXlsTable(OdmlTable):
 
         valid_changing_points = ['sections', 'properties', 'values', None]
         if not self._changing_point in valid_changing_points:
-            raise Exception(f"Invalid argument for changing_point: Your changing_point must be one "
-                            f"of {valid_changing_points}")
+            raise Exception("Invalid argument for changing_point: Your changing_point must"
+                            " be one of {}".format(str(valid_changing_points)))
 
         styles = {"document_info": xlwt.easyxf(
             self.document_info_style.get_style_string()),
@@ -231,7 +233,7 @@ class OdmlXlsTable(OdmlTable):
         elif self._pattern is "alternating":
             row_style_default = np.array([0] * len(self._header), dtype=bool)
         else:
-            raise Exception(f"{self._pattern} is not a valid pattern")
+            raise Exception("{} is not a valid pattern".format(self._pattern))
         column_style_default = np.array([1 if h in self._marked_cols else 0 for h in self._header],
                                         dtype=bool)
 
@@ -281,7 +283,7 @@ class OdmlXlsTable(OdmlTable):
                     row_dic['Value'] = ['']
 
                 for v in row_dic['Value']:
-                    stylestring = [f"row{r:d}col{c:d}"
+                    stylestring = ["row{:d}col{:d}".format(r, c)
                                    for r, c in zip(self.row_style, self.column_style)]
                     # highlight empty values
                     if self._highlight_defaults and (row_dic['Value'] == []
diff --git a/odmltables/xls_style.py b/odmltables/xls_style.py
index 3b632fe..62497ed 100644
--- a/odmltables/xls_style.py
+++ b/odmltables/xls_style.py
@@ -28,6 +28,8 @@ class XlsStyle():
         returns a style_string that can be used to create a cell-style with
         xlwt.easyxf
         """
-        s = (f"font: {self.fontstyle} , color {self.fontcolor}; pattern: pattern solid, fore_colour {self.backcolor};")
+        s = ("font: {0} , color {1}; pattern: pattern solid," +
+             " fore_colour {2};").format(self.fontstyle, self.fontcolor,
+                                         self.backcolor)
 
         return s
diff --git a/setup.py b/setup.py
index df0f085..e8b8297 100644
--- a/setup.py
+++ b/setup.py
@@ -27,9 +27,6 @@ if sys.version_info.major < 3:
                   'installing the odmltables gui, eg. using "conda install -c anaconda '
                   '\'pyqt>=5\'"')
 
-if sys.version_info.major < 3 or (sys.version_info.major == 3 and sys.version_info.minor < 6):
-    raise EnvironmentError('odMLtables requires Python version 3.6 or later.')
-
 VERSION = open('./odmltables/VERSION.txt', 'r').read().strip()
 
 setup(
@@ -46,7 +43,7 @@ setup(
     long_description=long_description,
     license="BSD",
     url='https://github.com/INM-6/python-odmltables',
-    download_url="https://github.com/INM-6/python-odmltables/archive/{}.tar.gz".format(VERSION),
+    download_url="https://github.com/INM-6/python-odmltables/archive/{0}.tar.gz".format(VERSION),
     classifiers=[
         'Development Status :: 5 - Production/Stable',
         'Intended Audience :: Science/Research',


Test patch:
diff --git a/odmltables/tests/test_odml_csv_table.py b/odmltables/tests/test_odml_csv_table.py
index aee45c0..9d07022 100644
--- a/odmltables/tests/test_odml_csv_table.py
+++ b/odmltables/tests/test_odml_csv_table.py
@@ -76,6 +76,22 @@ class TestOdmlCsvTable(unittest.TestCase):
         self.assertEqual(len(table._odmldict), 1)
         self.assertDictEqual(table._odmldict[0], table2._odmldict[0])
 
+    def test_saveload_empty_header(self):
+        doc = odml.Document()
+
+        table = OdmlCsvTable()
+        table.load_from_odmldoc(doc)
+        table.change_header('full')
+        table.write2file(self.filename)
+
+        table2 = OdmlTable()
+        table2.load_from_csv_table(self.filename)
+
+        # comparing values which are written to xls by default
+        self.assertEqual(table._odmldict, [])
+        self.assertDictEqual({'author': None, 'date': None, 'repository': None, 'version': None},
+                             table._docdict)
+
 
 class TestShowallOdmlCsvTable(unittest.TestCase):
     """
diff --git a/odmltables/tests/test_odml_xls_table.py b/odmltables/tests/test_odml_xls_table.py
index 2998882..b386a1d 100644
--- a/odmltables/tests/test_odml_xls_table.py
+++ b/odmltables/tests/test_odml_xls_table.py
@@ -232,6 +232,22 @@ class TestOdmlXlsTable(unittest.TestCase):
         self.assertEqual(len(table._odmldict), 1)
         self.assertDictEqual(table._odmldict[0], table2._odmldict[0])
 
+    def test_saveload_empty_header(self):
+        doc = odml.Document()
+
+        table = OdmlXlsTable()
+        table.load_from_odmldoc(doc)
+        table.change_header('full')
+        table.write2file(self.filename)
+
+        table2 = OdmlTable()
+        table2.load_from_xls_table(self.filename)
+
+        # comparing values which are written to xls by default
+        self.assertEqual(table._odmldict, [])
+        self.assertDictEqual({'author': None, 'date': None, 'repository': None, 'version': None},
+                             table._docdict)
+
 
 class TestShowallOdmlXlsTable(unittest.TestCase):
     """