Repository: frictionlessdata/frictionless-py

Issue:
Migrate SPSS plugin
# Overview

https://github.com/frictionlessdata/tableschema-spss-py

# Tasks

- [x] migrate the code
- [ ] connect the code


Discussion:

Patch:
diff --git a/frictionless/package.py b/frictionless/package.py
index d0d9a141..c65a2b31 100644
--- a/frictionless/package.py
+++ b/frictionless/package.py
@@ -291,6 +291,26 @@ class Package(Metadata):
         """Export package to Pandas dataframes"""
         return self.to_storage(system.create_storage("pandas"))
 
+    @staticmethod
+    def from_spss(*, basepath):
+        """Import package from SPSS directory
+
+        Parameters:
+            basepath (str): SPSS dir path
+        """
+        return Package.from_storage(system.create_storage("spss", basepath=basepath))
+
+    def to_spss(self, *, basepath, force=False):
+        """Export package to SPSS directory
+
+        Parameters:
+            basepath (str): SPSS dir path
+            force (bool): overwrite existent
+        """
+        return self.to_storage(
+            system.create_storage("spss", basepath=basepath), force=force
+        )
+
     def to_dict(self, expand=False):
         """Convert package to a dict
 
diff --git a/frictionless/plugins/bigquery.py b/frictionless/plugins/bigquery.py
index a20ae0a0..2fe13e12 100644
--- a/frictionless/plugins/bigquery.py
+++ b/frictionless/plugins/bigquery.py
@@ -45,12 +45,6 @@ class BigqueryStorage(Storage):
         self.__tables = {}
         self.__fallbacks = {}
 
-    def __repr__(self):
-        template = "Storage <{service}/{project}-{dataset}>"
-        return template.format(
-            service=self.__service, project=self.__project, dataset=self.__dataset
-        )
-
     # Read
 
     def read_table_names(self):
diff --git a/frictionless/plugins/ckan.py b/frictionless/plugins/ckan.py
index e8513a28..33b277d7 100644
--- a/frictionless/plugins/ckan.py
+++ b/frictionless/plugins/ckan.py
@@ -45,11 +45,6 @@ class CkanStorage(Storage):
         self.__bucket_cache = None
         self.__tables = {}
 
-    def __repr__(self):
-        template = "Storage <{base_url}>"
-        text = template.format(base_url=self.__base_url)
-        return text
-
     # Read
 
     @property
diff --git a/frictionless/plugins/elastic.py b/frictionless/plugins/elastic.py
index 592292a2..7d80cc83 100644
--- a/frictionless/plugins/elastic.py
+++ b/frictionless/plugins/elastic.py
@@ -43,12 +43,6 @@ class Storage(object):
         self.__es = es if es is not None else es_core.Elasticsearch()
         self.__no_mapping_types = self.__es.info()["version"]["number"] >= "7"
 
-    def __repr__(self):
-        # Template and format
-        template = "Storage {engine}"
-        text = template.format(engine=self.__es)
-        return text
-
     # Read
 
     def read_table_names(self):
diff --git a/frictionless/plugins/pandas.py b/frictionless/plugins/pandas.py
index b71b293e..a142492e 100644
--- a/frictionless/plugins/pandas.py
+++ b/frictionless/plugins/pandas.py
@@ -34,13 +34,22 @@ class PandasPlugin(Plugin):
 
 
 class PandasStorage(Storage):
-    """Pandas storage implementation"""
+    """Pandas storage implementation
+
+    API      | Usage
+    -------- | --------
+    Public   | `from frictionless.plugins.pandas import PandasStorage`
+
+    Parameters:
+        dataframes? (dict): dictionary of Pandas dataframes
+
+    """
 
     def __init__(self, *, dataframes=None):
         self.__dataframes = dataframes or collections.OrderedDict()
 
-    def __repr__(self):
-        return "Storage <pandas>"
+    def __iter__(self):
+        return iter(sorted(self.__dataframes.keys()))
 
     @property
     def dataframes(self):
@@ -51,7 +60,7 @@ class PandasStorage(Storage):
         if len(self.__dataframes) != 1:
             note = 'The "storage.dataframe" is available for single dataframe storages'
             raise exceptions.FrictionlessException(errors.StorageError(note=note))
-        return self.__dataframes
+        return list(self.__dataframes.values())[0]
 
     # Read
 
@@ -67,14 +76,11 @@ class PandasStorage(Storage):
 
     def read_package(self):
         package = Package()
-        for name in self.__read_resource_names():
+        for name in self:
             resource = self.read_resource(name)
             package.resources.append(resource)
         return package
 
-    def __read_resource_names(self):
-        return list(sorted(self.__dataframes.keys()))
-
     def __read_data_stream(self, name, schema):
         np = helpers.import_from_plugin("numpy", plugin="pandas")
         dataframe = self.__read_pandas_dataframe(name)
@@ -157,7 +163,7 @@ class PandasStorage(Storage):
         return self.write_package(package, force=force)
 
     def write_package(self, package, *, force=False):
-        existent_names = self.__read_resource_names()
+        existent_names = list(self)
 
         # Copy/infer package
         package = Package(package)
@@ -270,7 +276,7 @@ class PandasStorage(Storage):
         return self.delete_package([name], ignore=ignore)
 
     def delete_package(self, names, *, ignore=False):
-        existent_names = self.__read_resource_names()
+        existent_names = list(self)
 
         # Remove dataframes
         for name in names:
diff --git a/frictionless/plugins/spss.py b/frictionless/plugins/spss.py
index 3b21338f..db84ff1e 100644
--- a/frictionless/plugins/spss.py
+++ b/frictionless/plugins/spss.py
@@ -1,7 +1,9 @@
 import re
 import os
-import datetime
+import warnings
+from functools import partial
 from ..resource import Resource
+from ..package import Package
 from ..storage import Storage
 from ..plugin import Plugin
 from ..schema import Schema
@@ -24,86 +26,112 @@ class SpssPlugin(Plugin):
     """
 
     def create_storage(self, name, **options):
-        pass
+        if name == "spss":
+            warnings.warn("SPSS support is in a draft state", UserWarning)
+            return SpssStorage(**options)
 
 
 # Storage
 
 
 class SpssStorage(Storage):
-    """SPSS storage implementation"""
+    """SPSS storage implementation
 
-    def __init__(self, *, basepath):
-        self.__tables = {}
-        self.__basepath = basepath
-        if not os.path.isdir(basepath):
-            note = f'"{basepath}" is not a directory, or doesn\'t exist'
-            raise exceptions.FrictionlessException(errors.StorageError(note=note))
+    API      | Usage
+    -------- | --------
+    Draft    | `from frictionless.plugins.spss import SpssStorage`
 
-    def __repr__(self):
-        template = "Storage <{basepath}>"
-        text = template.format(basepath=self.__basepath)
-        return text
+    Parameters:
+        basepath? (str): A path to a dir for reading/writing SAV files.
+            Defaults to current dir.
 
-    # Read
+    """
+
+    def __init__(self, *, basepath=None):
+        basepath = basepath or os.getcwd()
+        if not os.path.isdir(basepath):
+            note = f'Path "{basepath}" is not a directory, or doesn\'t exist'
+            raise exceptions.FrictionlessException(errors.StorageError(note=note))
+        self.__basepath = basepath
 
-    @property
-    def read_table_names(self):
+    def __iter__(self):
         names = []
-        for name in os.listdir(self.__basepath):
-            if name.endswith(".sav", ".zsav"):
-                name = self.read_table_convert_name(name)
+        for path in os.listdir(self.__basepath):
+            name = self.__read_convert_name(path)
+            if name is not None:
                 names.append(name)
-        return name
+        return iter(names)
 
-    def reat_table_list(self):
-        tables = []
-        for name in self.read_table_names():
-            table = self.read_table(name)
-            tables.append(table)
-        return tables
+    # Read
 
-    def read_table(self, name):
+    def read_resource(self, name):
         sav = helpers.import_from_plugin("savReaderWriter", plugin="spss")
-        table = self.__tables.get(name)
-        if table is None:
-            path = self.read_table_convert_name(name)
-            with sav.SavHeaderReader(path, ioUtf8=True) as header:
-                table = self.read_table_convert_table(header.all())
-        return table
-
-    def read_table_convert_name(self, name):
-        if not name.endswith((".sav", ".zsav")):
-            name = "{}.sav".format(name)
-        path = os.path.join(self.__basepath, name)
-        path = os.path.normpath(path)
-        if not path.startswith(os.path.normpath(self.__basepath)):
-            note = f'Bucket name "{name}" is not valid.'
+        path = self.__write_convert_name(name)
+        if not os.path.isfile(path):
+            note = f'Resource "{name}" does not exist'
             raise exceptions.FrictionlessException(errors.StorageError(note=note))
-        return path
+        with sav.SavHeaderReader(path, ioUtf8=True) as header:
+            spss_schema = header.all()
+            schema = self.__read_convert_schema(spss_schema)
+            data = partial(self.__read_data_stream, name, schema)
+            resource = Resource(name=name, schema=schema, data=data)
+            return resource
+
+    def read_package(self):
+        package = Package()
+        for name in self:
+            resource = self.read_resource(name)
+            package.resources.append(resource)
+        return package
+
+    def __read_data_stream(self, name, schema):
+        sav = helpers.import_from_plugin("savReaderWriter", plugin="spss")
+        path = self.__write_convert_name(name)
+        with sav.SavReader(path, ioUtf8=True, rawMode=False) as reader:
+            for item in reader:
+                cells = []
+                for index, field in enumerate(schema.fields):
+                    value = item[index]
+                    # Fix decimals that should be integers
+                    if field.type == "integer" and value is not None:
+                        value = int(float(value))
+                    # We need to decode bytes to strings
+                    if isinstance(value, bytes):
+                        value = value.decode(reader.fileEncoding)
+                    # Time values need a decimal, add one if missing.
+                    if field.type == "time" and not re.search(r"\.\d*", value):
+                        value = "{}.0".format(value)
+                    cells.append(value)
+                yield cells
+
+    def __read_convert_name(self, path):
+        if path.endswith((".sav", ".zsav")):
+            return os.path.splitext(path)[0]
 
-    def read_table_convert_table(self, name, header):
+    def __read_convert_schema(self, spss_schema):
         schema = Schema()
+
+        # Formats
         date_formats = {
-            "time": self.TIME_FORMAT,
-            "date": self.DATE_FORMAT,
-            "datetime": self.DATETIME_FORMAT,
+            "time": TIME_FORMAT,
+            "date": DATE_FORMAT,
+            "datetime": DATETIME_FORMAT,
         }
 
         # Fields
-        for var in header.varNames:
-            type = self.read_table_convert_field_type(header.formats[var])
-            field = Field(name=var, type=type, title=header.varLabels[var])
-            field["spss:format"] = header.formats[var]
+        for name in spss_schema.varNames:
+            type = self.__read_convert_type(spss_schema.formats[name])
+            field = Field(name=name, type=type)
+            title = spss_schema.varLabels[name]
+            if title:
+                field.title = title
             if type in date_formats.keys():
                 field.format = date_formats[type]
             schema.fields.append(field)
 
-        # Table
-        table = Resource(name=name, schema=schema)
-        return table
+        return schema
 
-    def read_table_convert_field_type(self, type):
+    def __read_convert_type(self, spss_type):
 
         # Mapping
         mapping = [
@@ -122,150 +150,114 @@ class SpssStorage(Storage):
         ]
 
         # Return type
-        for ts_type, pattern in mapping:
-            if pattern.match(format):
-                return ts_type
+        for type, pattern in mapping:
+            if pattern.match(spss_type):
+                return type
 
         # Default
         return "string"
 
-    def read_table_stream_data(self, name):
-        sav = helpers.import_from_plugin("savReaderWriter", plugin="spss")
-        table = self.read_table(name)
-        path = self.read_table_convert_name(name)
-        with sav.SavReader(path, ioUtf8=False, rawMode=False) as reader:
-            for r in reader:
-                cells = []
-                for i, field in enumerate(table.schema.fields):
-                    value = r[i]
-                    # Fix decimals that should be integers
-                    if field.type == "integer" and value is not None:
-                        value = int(float(value))
-                    # We need to decode bytes to strings
-                    if isinstance(value, bytes):
-                        value = value.decode(reader.fileEncoding)
-                    # Time values need a decimal, add one if missing.
-                    if field.type == "time" and not re.search(r"\.\d*", value):
-                        value = "{}.0".format(value)
-                    cells.append(value)
-                yield cells
-
     # Write
 
-    def write_table(self, *tables, force=False):
-        sav = helpers.import_from_plugin("savReaderWriter", plugin="spss")
+    def write_resource(self, resource, *, force=False):
+        package = Package(resources=[resource])
+        return self.write_package(package, force=force)
+
+    def write_package(self, package, *, force=False):
+
+        # Copy/infer package
+        package = Package(package)
+        package.infer()
 
         # Check existence
-        for table in tables:
-            if table.name in self.read_table_names():
+        for resource in package.resources:
+            if resource.name in self:
                 if not force:
-                    note = f'Table "{table.name}" already exists'
+                    note = f'Resource "{resource.name}" already exists'
                     raise exceptions.FrictionlessException(errors.StorageError(note=note))
-                self.write_table_remove(table.name)
+                self.delete_resource(resource.name)
+
+        # Save resources
+        for resource in package.resources:
+            self.__write_row_stream(resource)
+
+    def __write_row_stream(self, resource):
+        sav = helpers.import_from_plugin("savReaderWriter", plugin="spss")
+
+        # Prepare SPSS meta
+        path = self.__write_convert_name(resource.name)
+        spss_schema = self.__write_convert_schema(resource.schema)
+        fallback_types = [
+            "boolean",
+            "object",
+            "geojson",
+            "geopoint",
+            "array",
+            "duration",
+            "yearmonth",
+        ]
+
+        # Write row stream
+        with sav.SavWriter(path, ioUtf8=True, **spss_schema) as writer:
+            for row in resource.read_rows():
+                result = []
+                for field in resource.schema.fields:
+                    cell = row[field.name]
+                    if field.type == "date":
+                        cell = cell.strftime(DATE_FORMAT).encode()
+                        cell = writer.spssDateTime(cell, DATE_FORMAT)
+                    elif field.type == "datetime":
+                        cell = cell.strftime(DATETIME_FORMAT).encode()
+                        cell = writer.spssDateTime(cell, DATETIME_FORMAT)
+                    elif field.type == "time":
+                        cell = cell.strftime(TIME_FORMAT).encode()
+                        cell = writer.spssDateTime(cell, TIME_FORMAT)
+                    elif field.type in fallback_types:
+                        cell, notes = field.write_cell(cell)
+                    result.append(cell)
+                writer.writerow(result)
+
+    def __write_convert_name(self, name):
+        path = os.path.normpath(os.path.join(self.__basepath, f"{name}.sav"))
+        if not path.startswith(os.path.normpath(self.__basepath)):
+            note = f'Resource name "{name}" is not valid.'
+            raise exceptions.FrictionlessException(errors.StorageError(note=note))
+        return path
+
+    def __write_convert_schema(self, schema):
+        spss_schema = {"varNames": [], "varTypes": {}}
+        for field in schema.fields:
+            spss_schema["varNames"].append(field.name)
+            spss_schema["varTypes"][field.name] = self.__write_convert_type(field.type)
+        return spss_schema
 
-        # Define buckets
-        for table in tables:
-            self.__tables[table.name] = table
-            path = self.read_table_convert_name(table.name)
-            # map descriptor to sav header format so we can use the method below.
-            kwargs = self.write_table_convert_table(table)
-            writer = sav.SavWriter(path, ioUtf8=True, **kwargs)
-            writer.close()
+    def __write_convert_type(self, type):
+        if type in ["integer", "number", "date", "datetime", "time", "year"]:
+            return 0
+        return 5
 
-    def write_table_remove(self, *tables, ignore=False):
-        for table in tables:
+    # Delete
+
+    def delete_resource(self, name, *, ignore=False):
+        return self.delete_package([name], ignore=ignore)
+
+    def delete_package(self, names, *, ignore=False):
+        for name in names:
 
             # Check existent
-            if table.name not in self.read_table_names():
+            if name not in self:
                 if not ignore:
-                    note = f'Table "{table.name}" does not exist'
+                    note = f'Resource "{name}" does not exist'
                     raise exceptions.FrictionlessException(errors.StorageError(note=note))
                 continue
 
-            # Remove from tables
-            self.__tables.pop(table.name, None)
-            path = self.write_table_convert_name(table.name)
+            # Delete file
+            path = self.__write_convert_name(name)
             os.remove(path)
 
-    def write_table_convert_name(self, name):
-        if not name.endswith((".sav", ".zsav")):
-            name = "{}.sav".format(name)
-        path = os.path.join(self.__basepath, name)
-        path = os.path.normpath(path)
-        if not path.startswith(os.path.normpath(self.__basepath)):
-            note = f'Bucket name "{name}" is not valid.'
-            raise exceptions.FrictionlessException(errors.StorageError(note=note))
-        return path
-
-    def write_table_convert_table(self, table):
-        schema = table.schema
-
-        def get_format_for_name(name):
-            return schema.get_field(name).descriptor.get("spss:format")
-
-        def get_spss_type_for_name(name):
-            """Return spss number type for `name`.
-
-            First we try to get the spss format (A10, F8.2, etc), and derive the spss type
-            from that.
-
-            If there's not spss format defined, we see if the type is a number and return the
-            appropriate type.
-
-            All else fails, we return a string type of width 1 (the default string format is
-            A1).
-            """
-            spss_format = get_format_for_name(name)
-            if spss_format:
-                string_pattern = re.compile(
-                    r"(?P<printFormat>A(HEX)?)(?P<printWid>\d+)", re.IGNORECASE
-                )
-                is_string = string_pattern.match(spss_format)
-                if is_string:
-                    # Return the 'width' discovered from the passed `format`.
-                    return int(is_string.group("printWid"))
-                else:
-                    return 0
-            else:
-                descriptor_type = schema.get_field(name).type
-                if descriptor_type == "integer" or descriptor_type == "number":
-                    return 0
-
-            note = f'Field "{name}" requires a "spss:format" property.'
-            raise exceptions.FrictionlessException(errors.StorageError(note=note))
 
-        var_names = [field.name for field in schema.fields]
-        var_types = {n: get_spss_type_for_name(n) for n in var_names}
-        formats = {n: get_format_for_name(n) for n in var_names if get_format_for_name(n)}
-        return {"varNames": var_names, "varTypes": var_types, "formats": formats}
+# Internal
 
-    def write_table_row_stream(self, name, row_stream):
-        sav = helpers.import_from_plugin("savReaderWriter", plugin="spss")
-        path = self.read_table_convert_name(name)
-        table = self.read_table(name)
-        kwargs = self.write_table_convert_table(table)
-        schema = table.schema
-        with sav.SavWriter(path, mode=b"ab", ioUtf8=True, **kwargs) as writer:
-            for r in row_stream:
-                row = []
-                for i, field in enumerate(schema.fields):
-                    value = r[i]
-                    if field.type == "date" and isinstance(value, datetime.date):
-                        value = writer.spssDateTime(
-                            value.strftime(self.__mapper.DATE_FORMAT).encode(),
-                            self.__mapper.DATE_FORMAT,
-                        )
-                    elif field.type == "datetime" and isinstance(
-                        value, datetime.datetime
-                    ):
-                        value = writer.spssDateTime(
-                            value.strftime(self.__mapper.DATETIME_FORMAT).encode(),
-                            self.__mapper.DATETIME_FORMAT,
-                        )
-                    elif field.type == "time" and isinstance(value, datetime.time):
-                        value = writer.spssDateTime(
-                            value.strftime(self.__mapper.TIME_FORMAT).encode(),
-                            self.__mapper.TIME_FORMAT,
-                        )
-                    row.append(value)
-                writer.writerow(row)
+DATE_FORMAT = "%Y-%m-%d"
+DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
+TIME_FORMAT = "%H:%M:%S.%f"
diff --git a/frictionless/plugins/sql.py b/frictionless/plugins/sql.py
index e487cd8a..ccc635eb 100644
--- a/frictionless/plugins/sql.py
+++ b/frictionless/plugins/sql.py
@@ -170,7 +170,7 @@ class SqlParser(Parser):
 
 
 class SqlStorage(Storage):
-    """SQL storage representation
+    """SQL storage implementation
 
     API      | Usage
     -------- | --------
@@ -200,10 +200,13 @@ class SqlStorage(Storage):
         self.__metadata = sa.MetaData(bind=self.__connection)
         self.__metadata.reflect()
 
-    def __repr__(self):
-        template = "Storage <{engine}>"
-        text = template.format(engine=self.__connection.engine)
-        return text
+    def __iter__(self):
+        names = []
+        for sql_table in self.__metadata.sorted_tables:
+            name = self.__read_convert_name(sql_table.name)
+            if name is not None:
+                names.append(name)
+        return iter(names)
 
     @property
     def connection(self):
@@ -223,19 +226,11 @@ class SqlStorage(Storage):
 
     def read_package(self):
         package = Package()
-        for name in self.__read_resource_names():
+        for name in self:
             resource = self.read_resource(name)
             package.resources.append(resource)
         return package
 
-    def __read_resource_names(self):
-        names = []
-        for sql_table in self.__metadata.sorted_tables:
-            name = self.__read_convert_name(sql_table.name)
-            if name is not None:
-                names.append(name)
-        return names
-
     def __read_data_stream(self, name):
         sql_table = self.__read_sql_table(name)
         with self.__connection.begin():
@@ -338,7 +333,7 @@ class SqlStorage(Storage):
         return self.write_package(package, force=force)
 
     def write_package(self, package, force=False):
-        existent_names = self.__read_resource_names()
+        existent_names = list(self)
 
         # Copy/infer package
         package = Package(package)
@@ -510,7 +505,7 @@ class SqlStorage(Storage):
         return self.delete_package([name], ignore=ignore)
 
     def delete_package(self, names, *, ignore=False):
-        existent_names = self.__read_resource_names()
+        existent_names = list(self)
 
         # Prepare tables
         sql_tables = []
diff --git a/frictionless/resource.py b/frictionless/resource.py
index 4f9e0a34..1c911ffa 100644
--- a/frictionless/resource.py
+++ b/frictionless/resource.py
@@ -625,6 +625,30 @@ class Resource(Metadata):
         """
         return self.to_storage(system.create_storage("pandas"))
 
+    @staticmethod
+    def from_spss(*, name, basepath):
+        """Import resource from SPSS file
+
+        Parameters:
+            name (str): resource name
+            basepath (str): SPSS dir path
+        """
+        return Resource.from_storage(
+            system.create_storage("spss", basepath=basepath),
+            name=name,
+        )
+
+    def to_spss(self, *, basepath, force=False):
+        """Export resource to SPSS file
+
+        Parameters:
+            basepath (str): SPSS dir path
+            force (bool): overwrite existent
+        """
+        return self.to_storage(
+            system.create_storage("spss", basepath=basepath), force=force
+        )
+
     def to_dict(self, expand=False):
         """Convert resource to dict
 
diff --git a/frictionless/storage.py b/frictionless/storage.py
index ac31bf7f..38ddfd1b 100644
--- a/frictionless/storage.py
+++ b/frictionless/storage.py
@@ -1,27 +1,30 @@
 class Storage:
     def __init__(self, **options):
-        pass
+        raise NotImplementedError()
+
+    def __iter__(self):
+        raise NotImplementedError()
 
     # Read
 
     def read_resource(self, name, **options):
-        pass
+        raise NotImplementedError()
 
     def read_package(self, **options):
-        pass
+        raise NotImplementedError()
 
     # Write
 
     def write_resource(self, resource, *, force=False, **options):
-        pass
+        raise NotImplementedError()
 
     def write_package(self, package, *, force=False, **options):
-        pass
+        raise NotImplementedError()
 
     # Delete
 
     def delete_resource(self, name, *, ignore=False, **options):
-        pass
+        raise NotImplementedError()
 
     def delete_package(self, names, *, ignore=False, **options):
-        pass
+        raise NotImplementedError()


Test patch:
diff --git a/tests/plugins/test_pandas.py b/tests/plugins/test_pandas.py
index 8aa89e49..d083d41d 100644
--- a/tests/plugins/test_pandas.py
+++ b/tests/plugins/test_pandas.py
@@ -32,7 +32,7 @@ def test_storage():
             {"name": "entry_id", "type": "integer", "constraints": {"required": True}},
             {"name": "user_id", "type": "integer", "constraints": {"required": True}},
             {"name": "comment", "type": "string"},
-            {"name": "note", "type": "string"},  # type downgrade
+            {"name": "note", "type": "string"},  # type fallback
         ],
         "primaryKey": ["entry_id", "user_id"],
         # foreign keys removal
diff --git a/tests/plugins/test_spss.py b/tests/plugins/test_spss.py
new file mode 100644
index 00000000..b8a9a749
--- /dev/null
+++ b/tests/plugins/test_spss.py
@@ -0,0 +1,131 @@
+import pytest
+from frictionless import Package, Resource
+from frictionless.plugins.spss import SpssStorage, exceptions
+
+# Storage
+
+
+def test_storage(tmpdir):
+
+    # Export/Import
+    source = Package("data/package-storage.json")
+    storage = source.to_spss(basepath=tmpdir)
+    target = Package.from_spss(basepath=tmpdir)
+
+    # Assert metadata
+
+    assert target.get_resource("article").schema == {
+        "fields": [
+            {"name": "id", "type": "number"},  # type downgrade/constraints removal
+            {"name": "parent", "type": "number"},  # type downgrade
+            {"name": "name", "type": "string"},
+            {"name": "current", "type": "string"},  # type fallback
+            {"name": "rating", "type": "number"},
+        ],
+        # primary key removal
+        # foreign keys removal
+    }
+    assert target.get_resource("comment").schema == {
+        "fields": [
+            {"name": "entry_id", "type": "number"},  # type downgrade/constraints removal
+            {"name": "user_id", "type": "number"},  # type downgrade/constraints removal
+            {"name": "comment", "type": "string"},
+            {"name": "note", "type": "string"},  # type fallback
+        ],
+        # primary key removal
+        # foreign keys removal
+    }
+    assert target.get_resource("location").schema == {
+        "fields": [
+            {"name": "geojson", "type": "string"},  # type fallback
+            {"name": "geopoint", "type": "string"},  # type fallback
+        ]
+    }
+    assert target.get_resource("structure").schema == {
+        "fields": [
+            {"name": "object", "type": "string"},  # type fallback
+            {"name": "array", "type": "string"},  # type fallback
+        ]
+    }
+    assert target.get_resource("temporal").schema == {
+        "fields": [
+            {"name": "date", "type": "number"},  # type downgrade
+            {"name": "date_year", "type": "number"},  # type downgrade/format removal
+            {"name": "datetime", "type": "number"},  # type downgrade
+            {"name": "duration", "type": "string"},  # type fallback
+            {"name": "time", "type": "number"},  # type downgrade
+            {"name": "year", "type": "number"},  # type downgrade
+            {"name": "yearmonth", "type": "string"},  # type fallback
+        ]
+    }
+
+    # Assert data
+    # NOTE: recover commented tests
+
+    #  assert target.get_resource("article").read_rows() == [
+    #  {"id": 1, "parent": None, "name": "Taxes", "current": True, "rating": 9.5},
+    #  {"id": 2, "parent": 1, "name": "中国人", "current": False, "rating": 7},
+    #  ]
+    assert target.get_resource("comment").read_rows() == [
+        {"entry_id": 1, "user_id": 1, "comment": "good", "note": "note1"},
+        {"entry_id": 2, "user_id": 2, "comment": "bad", "note": "note2"},
+    ]
+    #  assert target.get_resource("location").read_rows() == [
+    #  {"geojson": {"type": "Point", "coordinates": [33, 33.33]}, "geopoint": [30, 70]},
+    #  {"geojson": {"type": "Point", "coordinates": [55, 55.55]}, "geopoint": [90, 40]},
+    #  ]
+    #  assert target.get_resource("structure").read_rows() == [
+    #  {"object": {"chars": 560}, "array": ["Mike", "John"]},
+    #  {"object": {"chars": 970}, "array": ["Paul", "Alex"]},
+    #  ]
+    #  assert target.get_resource("temporal").read_rows() == [
+    #  {
+    #  "date": datetime.date(2015, 1, 1),
+    #  "date_year": datetime.date(2015, 1, 1),
+    #  "datetime": datetime.datetime(2015, 1, 1, 3, 0),
+    #  "duration": isodate.parse_duration("P1Y1M"),
+    #  "time": datetime.time(3, 0),
+    #  "year": 2015,
+    #  "yearmonth": [2015, 1],
+    #  },
+    #  {
+    #  "date": datetime.date(2015, 12, 31),
+    #  "date_year": datetime.date(2015, 1, 1),
+    #  "datetime": datetime.datetime(2015, 12, 31, 15, 45, 33),
+    #  "duration": isodate.parse_duration("P2Y2M"),
+    #  "time": datetime.time(15, 45, 33),
+    #  "year": 2015,
+    #  "yearmonth": [2015, 1],
+    #  },
+    #  ]
+
+    # Cleanup storage
+    storage.delete_package(target.resource_names)
+
+
+def test_storage_read_resource_not_existent_error():
+    storage = SpssStorage()
+    with pytest.raises(exceptions.FrictionlessException) as excinfo:
+        storage.read_resource("bad")
+    error = excinfo.value.error
+    assert error.code == "storage-error"
+    assert error.note.count("does not exist")
+
+
+def test_storage_write_resource_existent_error(tmpdir):
+    resource = Resource(path="data/table.csv")
+    storage = resource.to_spss(basepath=tmpdir)
+    with pytest.raises(exceptions.FrictionlessException) as excinfo:
+        storage.write_resource(resource)
+    error = excinfo.value.error
+    assert error.code == "storage-error"
+    assert error.note.count("already exists")
+
+
+def test_storage_delete_resource_not_existent_error():
+    storage = SpssStorage()
+    with pytest.raises(exceptions.FrictionlessException) as excinfo:
+        storage.delete_resource("bad")
+    error = excinfo.value.error
+    assert error.code == "storage-error"
+    assert error.note.count("does not exist")
diff --git a/tests/plugins/test_sql.py b/tests/plugins/test_sql.py
index 1d48e37a..0c1b2832 100644
--- a/tests/plugins/test_sql.py
+++ b/tests/plugins/test_sql.py
@@ -91,7 +91,7 @@ def test_storage_sqlite(database_url):
             {"name": "entry_id", "type": "integer", "constraints": {"required": True}},
             {"name": "user_id", "type": "integer", "constraints": {"required": True}},
             {"name": "comment", "type": "string"},
-            {"name": "note", "type": "string"},  # type downgrade
+            {"name": "note", "type": "string"},  # type fallback
         ],
         "primaryKey": ["entry_id", "user_id"],
         "foreignKeys": [
@@ -195,7 +195,7 @@ def test_storage_postgresql():
             {"name": "entry_id", "type": "integer", "constraints": {"required": True}},
             {"name": "user_id", "type": "integer", "constraints": {"required": True}},
             {"name": "comment", "type": "string"},
-            {"name": "note", "type": "string"},  # type downgrade
+            {"name": "note", "type": "string"},  # type fallback
         ],
         "primaryKey": ["entry_id", "user_id"],
         "foreignKeys": [
@@ -300,7 +300,7 @@ def test_storage_mysql():
             {"name": "entry_id", "type": "integer", "constraints": {"required": True}},
             {"name": "user_id", "type": "integer", "constraints": {"required": True}},
             {"name": "comment", "type": "string"},
-            {"name": "note", "type": "string"},  # type downgrade
+            {"name": "note", "type": "string"},  # type fallback
         ],
         "primaryKey": ["entry_id", "user_id"],
         "foreignKeys": [