diff options
| -rw-r--r-- | lesana/collection.py | 77 | ||||
| -rw-r--r-- | lesana/types.py | 152 | ||||
| -rw-r--r-- | setup.py | 1 | ||||
| -rw-r--r-- | tests/test_types.py | 157 | 
4 files changed, 343 insertions, 44 deletions
| diff --git a/lesana/collection.py b/lesana/collection.py index b265b4d..4b3e964 100644 --- a/lesana/collection.py +++ b/lesana/collection.py @@ -1,4 +1,3 @@ -import decimal  import logging  import os  import uuid @@ -8,6 +7,7 @@ import xapian  import jinja2  from pkg_resources import resource_string +from . import types  try:      import git @@ -72,7 +72,14 @@ class Entry(object):      @property      def yaml_data(self): -        return ruamel.yaml.dump(self.data, Dumper=ruamel.yaml.RoundTripDumper) +        to_dump = self.data.copy() +        # Decimal fields can't be represented by +        # ruamel.yaml.RoundTripDumper, but transforming them to strings +        # should be enough for all cases that we need. +        for field in self.collection.settings['fields']: +            if field['type'] == 'decimal': +                to_dump[field['name']] = str(to_dump.get(field['name'], '')) +        return ruamel.yaml.dump(to_dump, Dumper=ruamel.yaml.RoundTripDumper)      @property      def idterm(self): @@ -88,51 +95,26 @@ class Entry(object):          for field in self.collection.settings['fields']:              value = self.data.get(field['name'], None)              t = field['type'] +            try: +                self.data[field['name']] = self.collection.types[t].load(value) +            except KeyError: +                errors.append( +                    { +                        'field': field['name'], +                        'error': "No such type {}".format(t), +                    } +                ) +            except types.LesanaValueError as e: +                errors.append( +                    { +                        'field': field['name'], +                        'error': e, +                    } +                ) +              if t != 'list' and not value:                  # empty fields are always fine except for lists                  continue -            if t == 'integer': -                try: -                    int(value) -                except ValueError: -                    valid = False -                    errors.append( -                        { -                            'field': field['name'], -                            'error': -                                'Invalid value for integer field: {}'.format( -                                    value -                            ), -                        } -                    ) -            elif t == 'float': -                try: -                    float(value) -                except ValueError: -                    valid = False -                    errors.append( -                        { -                            'field': field['name'], -                            'error': -                                'Invalid value for float field: {}'.format( -                                    value -                            ), -                        } -                    ) -            elif t == 'decimal': -                try: -                    decimal.Decimal(value) -                except decimal.InvalidOperation: -                    valid = False -                    errors.append( -                        { -                            'field': field['name'], -                            'error': -                                'Invalid value for decimal field: {}'.format( -                                    value -                            ), -                        } -                    )              elif t == 'list':                  if not hasattr(value, '__iter__'):                      valid = False @@ -168,6 +150,7 @@ class Collection(object):      def __init__(self, directory=None, itemdir='items'):          self.basedir = directory or os.getcwd()          self.itemdir = os.path.join(self.basedir, itemdir) +        self.types = self._load_types()          try:              with open(os.path.join(self.basedir, 'settings.yaml')) as fp:                  self.settings = ruamel.yaml.load( @@ -193,6 +176,12 @@ class Collection(object):          self.safe = False          self.entry_class = Entry +    def _load_types(self): +        type_loaders = {} +        for t in types.LesanaType.__subclasses__(): +            type_loaders[t.name] = t() +        return type_loaders +      def _index_file(self, fname, cache):          with open(os.path.join(self.itemdir, fname)) as fp:              if self.safe: diff --git a/lesana/types.py b/lesana/types.py new file mode 100644 index 0000000..4d3910c --- /dev/null +++ b/lesana/types.py @@ -0,0 +1,152 @@ +""" +Type checkers for lesana fields. + +Warning: this part of the code is still in flux and it may change +significantly in a future release. +""" +import datetime +import decimal + +import dateutil.parser + + +class LesanaType: +    """ +    Base class for lesana field types. +    """ +    def load(self, data): +        raise NotImplementedError + +    def empty(self): +        raise NotImplementedError + + +class LesanaString(LesanaType): +    """ +    A string of unicode text +    """ +    name = 'string' + +    def load(self, data): +        if not data: +            return data +        return str(data) + +    def empty(self): +        return "" + + +class LesanaText(LesanaString): +    """ +    A longer block of unicode text +    """ +    name = 'text' + + +class LesanaInt(LesanaType): +    """ +    An integer number +    """ +    name = "integer" + +    def load(self, data): +        if not data: +            return data +        try: +            return int(data) +        except ValueError: +            raise LesanaValueError( +                "Invalid value for integer field: {}".format(data) +            ) + +    def empty(self): +        return 0 + + +class LesanaFloat(LesanaType): +    """ +    A floating point number +    """ +    name = "float" + +    def load(self, data): +        if not data: +            return data +        try: +            return float(data) +        except ValueError: +            raise LesanaValueError( +                "Invalid value for float field: {}".format(data) +            ) + +    def empty(self): +        return 0.0 + + +class LesanaDecimal(LesanaType): +    """ +    A floating point number +    """ +    name = "decimal" + +    def load(self, data): +        if not data: +            return data +        try: +            return decimal.Decimal(data) +        except decimal.InvalidOperation: +            raise LesanaValueError( +                "Invalid value for float field: {}".format(data) +            ) + +    def empty(self): +        return decimal.Decimal(0) + + +class LesanaTimestamp(LesanaType): +    """ +    A datetime +    """ +    name = "timestamp" + +    def load(self, data): +        if not data: +            return data +        if isinstance(data, datetime.datetime) or \ +                isinstance(data, datetime.date): +            return data +        try: +            return dateutil.parser.parse(data) +        except dateutil.parser.ParserError: +            raise LesanaValueError( +                "Invalid value for datetime field: {}".format(data) +            ) + +    def empty(self): +        return None + + +class LesanaBoolean(LesanaType): +    """ +    A boolean value +    """ +    name = 'boolean' + +    def load(self, data): +        if not data: +            return data +        if isinstance(data, bool): +            return data +        else: +            raise LesanaValueError( +                "Invalid value for boolean field: {}".format(data) +            ) + +    def empty(self): +        return None + + +class LesanaValueError(ValueError): +    """ +    Raised in case of validation errors. +    """ @@ -20,6 +20,7 @@ setup(          # 'xapian >= 1.4',          'ruamel.yaml',          'jinja2', +        'dateutil',      ],      python_requires='>=3',      # Metadata diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..cc2ff3b --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,157 @@ +import datetime +import decimal +import unittest + +from lesana import types + + +class testTypes(unittest.TestCase): +    def setUp(self): +        pass + +    def tearDown(self): +        pass + +    def test_base(self): +        checker = types.LesanaType() + +        # The base class does not implement empty nor load +        with self.assertRaises(NotImplementedError): +            checker.empty() + +        with self.assertRaises(NotImplementedError): +            checker.load("") + +    def test_string(self): +        checker = types.LesanaString() + +        s = checker.empty() +        self.assertEqual(s, "") + +        s = checker.load("Hello World!") +        self.assertEqual(s, "Hello World!") + +        s = checker.load(None) +        self.assertEqual(s, None) + +    def test_text(self): +        checker = types.LesanaText() + +        s = checker.empty() +        self.assertEqual(s, "") + +        s = checker.load("Hello World!") +        self.assertEqual(s, "Hello World!") + +        s = checker.load(None) +        self.assertEqual(s, None) + +    def test_int(self): +        checker = types.LesanaInt() + +        v = checker.empty() +        self.assertEqual(v, 0) + +        v = checker.load("10") +        self.assertEqual(v, 10) + +        v = checker.load(10.5) +        self.assertEqual(v, 10) + +        for d in ("ten", "10.5"): +            with self.assertRaises(types.LesanaValueError): +                checker.load(d) + +        v = checker.load(None) +        self.assertEqual(v, None) + +    def test_float(self): +        checker = types.LesanaFloat() + +        v = checker.empty() +        self.assertEqual(v, 0.0) + +        v = checker.load("10") +        self.assertEqual(v, 10) + +        v = checker.load(10.5) +        self.assertEqual(v, 10.5) + +        v = checker.load("10.5") +        self.assertEqual(v, 10.5) + +        for d in ("ten"): +            with self.assertRaises(types.LesanaValueError): +                checker.load(d) + +        v = checker.load(None) +        self.assertEqual(v, None) + +    def test_decimal(self): +        checker = types.LesanaDecimal() + +        v = checker.empty() +        self.assertEqual(v, decimal.Decimal(0)) + +        v = checker.load("10") +        self.assertEqual(v, decimal.Decimal(10)) + +        v = checker.load(10.5) +        self.assertEqual(v, decimal.Decimal(10.5)) + +        v = checker.load("10.5") +        self.assertEqual(v, decimal.Decimal(10.5)) + +        for d in ("ten"): +            with self.assertRaises(types.LesanaValueError): +                checker.load(d) + +        v = checker.load(None) +        self.assertEqual(v, None) + +    def test_timestamp(self): +        checker = types.LesanaTimestamp() + +        v = checker.empty() +        self.assertEqual(v, None) + +        now = datetime.datetime.now() +        v = checker.load(now) +        self.assertEqual(v, now) + +        today = datetime.date.today() +        v = checker.load(today) +        self.assertEqual(v, today) + +        v = checker.load("2020-01-01") +        self.assertEqual(v, datetime.datetime(2020, 1, 1)) + +        v = checker.load("2020-01-01 10:00") +        self.assertEqual(v, datetime.datetime(2020, 1, 1, 10, 0)) + +        for d in ("today", "2020-13-01"): +            with self.assertRaises(types.LesanaValueError): +                checker.load(d) + +        v = checker.load(None) +        self.assertEqual(v, None) + +    def test_boolean(self): +        checker = types.LesanaBoolean() + +        v = checker.empty() +        self.assertEqual(v, None) + +        v = checker.load(True) +        self.assertEqual(v, True) + +        for d in ("maybe", "yes", "no"): +            with self.assertRaises(types.LesanaValueError): +                checker.load(d) + +        v = checker.load(None) +        self.assertEqual(v, None) + + +if __name__ == '__main__': +    unittest.main() | 
