Repository: cldf/csvw

Issue:
Wrong range for nonNegative- and positiveInteger
See
https://github.com/cldf/csvw/blob/71abb843f078aa6b11c5f9df775baf8412992b03/src/csvw/datatypes.py#L755-L770

Discussion:

Patch:
diff --git a/CHANGES b/CHANGES
index d673e5f..2045c52 100644
--- a/CHANGES
+++ b/CHANGES
@@ -4,6 +4,8 @@ Changelog
 Unreleased
 ----------
 
+- Fixed the ranges of positiveInteger and nonNegativeInteger
+
 
 Version 3.1
 -----------
diff --git a/src/csvw/datatypes.py b/src/csvw/datatypes.py
index 775d52e..4730ed3 100644
--- a/src/csvw/datatypes.py
+++ b/src/csvw/datatypes.py
@@ -758,7 +758,7 @@ class nonNegativeInteger(integer):
     Maps to `int`.
     """
     name = 'nonNegativeInteger'
-    range = (1, math.inf)
+    range = (0, math.inf)
 
 
 @register
@@ -767,7 +767,7 @@ class positiveInteger(integer):
     Maps to `int`.
     """
     name = 'positiveInteger'
-    range = (0, math.inf)
+    range = (1, math.inf)
 
 
 @register


Test patch:
diff --git a/tests/test_datatypes.py b/tests/test_datatypes.py
index ccb4da5..1088d8f 100644
--- a/tests/test_datatypes.py
+++ b/tests/test_datatypes.py
@@ -10,35 +10,64 @@ from csvw.datatypes import NumberPattern
 
 
 @pytest.mark.parametrize(
-    'datatype,val,obj',
+    'datatype,val,obj,roundtrip',
     [
-        ({'base': 'string', 'format': '[0-9]+[a-z]+'}, '1a', '1a'),
-        ('anyURI', '/a/b?d=5', None),
-        ('integer', '5', 5),
-        ('integer', '-5', -5),
-        ('date', '2012-12-01', None),
-        ('datetime', '2012-12-01T12:12:12', None),
-        ({'base': 'datetime', 'format': 'd.M.yyyy HH:mm'}, '22.3.2015 22:05', None),
-        ({'base': 'datetime', 'format': 'd.M.yyyy HH:mm:ss.SSS'}, '22.3.2015 22:05:55.012', None),
-        ({'base': 'datetime', 'format': 'd.M.yyyy HH:mm X'}, '22.3.2015 22:05 +03', None),
-        ({'base': 'datetime', 'format': 'd.M.yyyy HH:mm XXX'}, '22.3.2015 22:05 +03:30', None),
-        ({'base': 'date', 'format': "d.M.yyyy"}, '22.3.2015', None),
-        ({'base': "date", 'format': 'M/d/yyyy'}, '10/18/2010', None),
-        ({'base': 'duration'}, 'P1Y1D', None),
-        ({'base': 'duration'}, 'PT2H30M', None),
-        ({'base': 'time', 'format': 'HH:mm X'}, '23:05 +0430', None),
-        ('time', '11:11:11', None),
-        ('binary', 'aGVsbG8gd29ybGQ=', b'hello world'),
-        ('hexBinary', 'ABCDEF12', None),
-        ({'base': 'decimal', 'format': '#,##0.##'}, '1,234.57', None),
-        ({'base': 'decimal', 'format': {'pattern': '#,##0.##', 'groupChar': ' '}}, '1 234.57', None),
-        ('json', '{"a": 5}', {'a': 5}),
-        ('float', '3.5', 3.5),
-        ('number', '3.123456789', 3.123456789),
-        ({'base': 'boolean', 'format': 'J|N'}, 'J', True),
-    ]
+        ({'base': 'string', 'format': '[0-9]+[a-z]+'}, '1a', '1a', True),
+        ('anyURI', '/a/b?d=5', None, True),
+        ('integer', '5', 5, True),
+        ('integer', '-5', -5, True),
+        ('date', '2012-12-01', None, True),
+        ('datetime', '2012-12-01T12:12:12', None, True),
+        ({'base': 'datetime', 'format': 'd.M.yyyy HH:mm'}, '22.3.2015 22:05', None, True),
+        ({'base': 'datetime', 'format': 'd.M.yyyy HH:mm:ss.SSS'}, '22.3.2015 22:05:55.012', None, True),
+        ({'base': 'datetime', 'format': 'd.M.yyyy HH:mm X'}, '22.3.2015 22:05 +03', None, True),
+        ({'base': 'datetime', 'format': 'd.M.yyyy HH:mm XXX'}, '22.3.2015 22:05 +03:30', None, True),
+        ({'base': 'date', 'format': "d.M.yyyy"}, '22.3.2015', None, True),
+        ({'base': "date", 'format': 'M/d/yyyy'}, '10/18/2010', None, True),
+        ({'base': 'duration'}, 'P1Y1D', None, True),
+        ({'base': 'duration'}, 'PT2H30M', None, True),
+        ({'base': 'time', 'format': 'HH:mm X'}, '23:05 +0430', None, True),
+        ('time', '11:11:11', None, True),
+        ('binary', 'aGVsbG8gd29ybGQ=', b'hello world', True),
+        ('hexBinary', 'ABCDEF12', None, True),
+        ({'base': 'decimal', 'format': '#,##0.##'}, '1,234.57', None, True),
+        ({'base': 'decimal', 'format': {'pattern': '#,##0.##', 'groupChar': ' '}}, '1 234.57', None, True),
+        ('json', '{"a": 5}', {'a': 5}, True),
+        ('float', '3.5', 3.5, True),
+        ('number', '3.123456789', 3.123456789, True),
+        ({'base': 'boolean', 'format': 'J|N'}, 'J', True, True),
+        ({'base': 'nonNegativeInteger'}, '0', 0, True),
+        ({'base': 'positiveInteger'}, '1', 1, True),
+        ({'base': "json", "format": '{"type":"object","required":["a"]}'}, '{"a": 1}', {'a': 1}, True),
+        ({'base': "json", "format": '{"type":"object"}'}, '{"x": 1}', dict(x=1), True),
+        ({'base': "json", "format": 'x'}, '{"x": 1}', dict(x=1), True),
+        (
+                {'base': 'decimal', 'format': {'groupChar': '.', 'decimalChar': ','}},
+                '1.234,567',
+                decimal.Decimal('1234.567'),
+                True),
+        (
+                {'base': 'decimal', 'format': {'groupChar': '.', 'decimalChar': ','}},
+                '20%',
+                decimal.Decimal('0.2'),
+                False),
+        # From https://www.w3.org/TR/2015/REC-tabular-data-model-20151217/#parsing-cells
+        # For example, the string value "-25%" must be interpreted as -0.25 and the string value
+        # "1E6" as 1000000.
+        (
+                {'base': 'decimal', 'format': {'groupChar': '.', 'decimalChar': ','}},
+                '-25%',
+                decimal.Decimal('-0.25'),
+                False),
+        (
+                {'base': 'decimal', 'format': {'groupChar': '.', 'decimalChar': ','}},
+                '20‰',
+                decimal.Decimal('0.02'),
+                False),
+        ({'base': 'string', 'length': 5, '@id': 'x', 'dc:type': ''}, 'abcde', 'abcde', True),
+]
 )
-def test_roundtrip(datatype, val, obj):
+def test_roundtrip(datatype, val, obj, roundtrip):
     t = Datatype.fromvalue(datatype)
     o = t.parse(val)
     if obj:
@@ -46,37 +75,39 @@ def test_roundtrip(datatype, val, obj):
             assert o == pytest.approx(obj)
         else:
             assert o == obj
-    assert t.formatted(o) == val
-
-
-def test_double():
-    t = Datatype.fromvalue({'base': 'double', 'minimum': 10})
-    v = t.parse('3.1')
-    with pytest.raises(ValueError):
-        t.validate(v)
+    if roundtrip:
+        assert t.formatted(o) == val
 
 
-def test_string():
-    t = Datatype.fromvalue({'base': 'string', 'format': '[0-9]+[a-z]+'})
-    with pytest.raises(ValueError):
-        t.read('abc')
-    with pytest.raises(ValueError):
-        t.read('1a.')
-
+@pytest.mark.parametrize(
+    'datatype,val',
+    [
+        ({'base': 'nonNegativeInteger'}, '-1'),
+        ({'base': 'positiveInteger'}, '0'),
+        ({'base': 'double', 'minimum': 10}, '3.1'),
+        ({'base': 'string', 'format': '[0-9]+[a-z]+'}, 'abc'),
+        ({'base': 'string', 'format': '[0-9]+[a-z]+'}, '1a.'),
+        ("NMTOKEN", 'bold,brash'),
+        ({'base': "json", "format": '{"type":"object","required":["a"]}'}, '{"x": 1}'),
+        ({'base': 'boolean'}, 'J'),
+        ('float', ' '),
+        ({'base': 'string', 'length': 5, '@id': 'x', 'dc:type': ''}, 'abc'),
+        ({'base': 'binary'}, 'sp\u00e4m'),
+        ({'base': 'binary'}, 'aGVsbG8gd29ybGQ'),
+        ({'base': 'hexBinary'}, 'sp\u00e4m'),
+        ({'base': 'hexBinary'}, 'spam'),
+    ]
+)
+def test_invalid(datatype, val):
+    t = Datatype.fromvalue(datatype)
     with pytest.raises(ValueError):
-        Datatype.fromvalue('NMTOKEN').read("bold,brash")
+        t.read(val)
 
 
 def test_json():
-    t = Datatype.fromvalue({'base': "json", "format": '{"type":"object"}'})
-    t = Datatype.fromvalue({'base': "json", "format": '{"type":"object","required":["a"]}'})
-    assert t.read('{"a": 1}') == dict(a=1)
-    with pytest.raises(ValueError):
-        t.read('{"x": 1}')
-    t = Datatype.fromvalue({'base': "json", "format": '{"type":"object"}'})
-    t.read('{"x": 1}')
-    t = Datatype.fromvalue({'base': "json", "format": 'x'})
-    t.read('{"x": 1}')
+    """
+    If the format annotation is JSON but **not** a valid JSON Schema, emit a warning.
+    """
     with warnings.catch_warnings():
         warnings.simplefilter('ignore')
         t = Datatype.fromvalue({'base': "json", "format": '{"type":"obj"}'})
@@ -84,6 +115,9 @@ def test_json():
 
 
 def test_anyURI():
+    """
+    Writing anyURI will perform some normalization.
+    """
     t = Datatype.fromvalue('anyURI')
     assert t.formatted('Http://example.org') == 'http://example.org'
     assert t.formatted(urlparse('Http://example.org')) == 'http://example.org'
@@ -98,23 +132,12 @@ def test_number():
     with pytest.raises(ValueError):
         t.validate(12)
     
-    t = Datatype.fromvalue({'base': 'nonNegativeInteger'})
-    with pytest.raises(ValueError):
-        v = t.parse('-3')
-
     t = Datatype.fromvalue(
         {'base': 'decimal', 'format': {'groupChar': '.', 'decimalChar': ','}})
     with warnings.catch_warnings():
         warnings.simplefilter('ignore')
         assert t.parse('INF') == decimal.Decimal('Infinity')
         assert t.formatted(decimal.Decimal('NaN')) == 'NaN'
-    assert t.parse('1.234,567') == decimal.Decimal('1234.567')
-    assert t.parse('20%') == decimal.Decimal('0.2')
-    # From https://www.w3.org/TR/2015/REC-tabular-data-model-20151217/#parsing-cells
-    # For example, the string value "-25%" must be interpreted as -0.25 and the string value "1E6"
-    # as 1000000.
-    assert t.parse('-25%') == decimal.Decimal('-0.25')
-    assert t.parse('20‰') == decimal.Decimal('0.02')
     assert t.formatted(decimal.Decimal('1234.567')) == '1.234,567'
     with pytest.raises(ValueError):
         t.parse(' ')
@@ -126,17 +149,6 @@ def test_number():
         {'base': 'decimal', 'format': {'pattern': '0.00;0.00-', 'decimalChar': ','}})
     assert t.formatted(decimal.Decimal('-3.1415')) == '3,14-'
 
-    t = Datatype.fromvalue('float')
-    with pytest.raises(ValueError):
-        t.parse(' ')
-    
-
-def test_object():
-    t = Datatype.fromvalue({'base': 'string', 'length': 5, '@id': 'x', 'dc:type': ''})
-    assert t.validate('abcde') == 'abcde'
-    with pytest.raises(ValueError):
-        t.validate('abc')
-
 
 def test_errors():
     with pytest.raises(ValueError):
@@ -201,31 +213,14 @@ def test_misc():
     t = Datatype.fromvalue({'base': 'any'})
     assert t.formatted(None) == 'None'
 
-    t = Datatype.fromvalue({'base': 'boolean'})
-    with pytest.raises(ValueError):
-        t.parse('J')
-
     t = Datatype.fromvalue({'base': 'boolean'})
     assert '{}'.format(t.basetype()) == 'boolean'
     assert t.parse(False) is False
     assert t.parse('false') is False
     assert t.formatted(True) == 'true'
 
-    t = Datatype.fromvalue({'base': 'binary'})
-    with pytest.raises(ValueError):
-        t.parse('sp\u00e4m')
-    with pytest.raises(ValueError):
-        t.parse('aGVsbG8gd29ybGQ')
-
-    t = Datatype.fromvalue({'base': 'hexBinary'})
-    with pytest.raises(ValueError):
-        t.parse('sp\u00e4m')
-    with pytest.raises(ValueError):
-        t.parse('spam')
-
 
 def test_NumberPattern():
     np = NumberPattern('0.0E#,##0 #')
     assert np.exponent_digits == 4
-
     assert not NumberPattern('#,##,##0').is_valid('234,567')