Repository: Electrostatics/mmcif_pdbx

Issue:
Distinguish unknown (?) and inapplicable (.) values from strings
CIF has two special values: `?` for unknown data, and `.` for inapplicable data. As far as I can tell, the API in this library doesn't distinguish these values from strings.

I think it would be very useful if these values are represented by a special constant, just like the JSON `null` value corresponds to `None` in the standard library [json](https://docs.python.org/3/library/json.html) module.

### Example
```python
import io
from pdbx.reader import PdbxReader

cif_str = '''
data_example
_unknown.a ?
_unknown.b "?"
_inapplicable.a .
_inapplicable.b "."
'''

cfr = PdbxReader(io.StringIO(cif_str))
data = []
cfr.read(data)

for cat in ['unknown', 'inapplicable']:
    for key in ['a', 'b']:
        value = data[0].get_object(cat).get_value(key)
        print(type(value), repr(value))
```
### Output
```
<class 'str'> '?'
<class 'str'> '?'
<class 'str'> '.'
<class 'str'> '.'
```
### Expected Output
Option 1: Custom constants. This is the most expressive/explicit option.
```
<class 'pdbx.UnknownType'> Unknown
<class 'str'> '?'
<class 'pdbx.InapplicableType'> Inapplicable
<class 'str'> '.'
```
Option 2: Just use `None` for both. For **reading only**, this would be perfectly sufficient (haven't seen a use case yet were data interpretation of `?` and `.` was different). But information is lost when writing back a CIF file.
```
<class 'NoneType'> None
<class 'str'> '?'
<class 'NoneType'> None
<class 'str'> '.'
```
Option 3: Use `None` for one of the values, and a custom constant for the other one. In the spirit of `dict.get(missing-key) -> None` this might be the most Pythonic option.
```
<class 'NoneType'> None
<class 'str'> '?'
<class 'pdbx.InapplicableType'> Inapplicable
<class 'str'> '.'
```

Comments:

Patch:
diff --git a/pdbx/__init__.py b/pdbx/__init__.py
index 17ecc8a..1ad970d 100644
--- a/pdbx/__init__.py
+++ b/pdbx/__init__.py
@@ -8,3 +8,31 @@ http://mmcif.wwpdb.org/docs/sw-examples/python/html/.
 See http://mmcif.wwpdb.org/docs/sw-examples/python/html/ for more information
 about this package, including examples.
 """
+
+
+def load(fp) -> list:
+    """Parse a CIF file into a list of DataContainer objects"""
+    from .reader import PdbxReader
+    data = []
+    PdbxReader(fp).read(data)
+    return data
+
+
+def loads(s: str) -> list:
+    """Parse a CIF string into a list of DataContainer objects"""
+    import io
+    return load(io.StringIO(s))
+
+
+def dump(datacontainers: list, fp):
+    """Write a list of DataContainer objects to a CIF file"""
+    from .writer import PdbxWriter
+    PdbxWriter(fp).write(datacontainers)
+
+
+def dumps(datacontainers: list) -> str:
+    """Serialize a list of DataContainer objects to a CIF formatted string"""
+    import io
+    fp = io.StringIO()
+    dump(datacontainers, fp)
+    return fp.getvalue()
diff --git a/pdbx/containers.py b/pdbx/containers.py
index 8664573..e185162 100644
--- a/pdbx/containers.py
+++ b/pdbx/containers.py
@@ -628,7 +628,8 @@ class DataCategory(DataCategoryBase):
                 return ([str(inp)], 'DT_FLOAT')
             # null value handling
             if inp in (".", "?"):
-                return ([inp], 'DT_NULL_VALUE')
+                return (self.__double_quoted_list(inp),
+                        'DT_DOUBLE_QUOTED_STRING')
             if inp == "":
                 return (["."], 'DT_NULL_VALUE')
             # Contains white space or quotes ?
@@ -689,7 +690,7 @@ class DataCategory(DataCategoryBase):
             return 'DT_FLOAT'
         # null value handling
         if inp in (".", "?"):
-            return 'DT_NULL_VALUE'
+            return 'DT_DOUBLE_QUOTED_STRING'
         if inp == "":
             return 'DT_NULL_VALUE'
         # Contains white space or quotes ?
diff --git a/pdbx/reader.py b/pdbx/reader.py
index c5bd339..95394f5 100644
--- a/pdbx/reader.py
+++ b/pdbx/reader.py
@@ -160,7 +160,11 @@ class PdbxReader:
                     self.__syntax_error(
                         "Missing data for item _%s.%s" % (
                             current_category_name, current_attribute_name))
-                if current_word is not None:
+                if current_word == '?':
+                    current_row.append(None)
+                elif current_word == '.':
+                    current_row.append('')
+                elif current_word is not None:
                     # Validation check token for misplaced reserved words
                     reserved_word, state = self.__get_state(current_word)
                     if reserved_word is not None:
@@ -233,7 +237,11 @@ class PdbxReader:
                     current_row = []
                     current_category.append(current_row)
                     for _ in current_category.attribute_list:
-                        if current_word is not None:
+                        if current_word == '?':
+                            current_row.append(None)
+                        elif current_word == '.':
+                            current_row.append('')
+                        elif current_word is not None:
                             current_row.append(current_word)
                         elif current_quoted_string is not None:
                             current_row.append(current_quoted_string)
@@ -264,7 +272,7 @@ class PdbxReader:
                     current_category = None
             elif state == "ST_DATA_CONTAINER":
                 data_name = self.__get_container_name(current_word)
-                if data_name:
+                if not data_name:
                     data_name = "unidentified"
                 current_container = DataContainer(data_name)
                 container_list.append(current_container)


Test patch:
diff --git a/tests/reader_test.py b/tests/reader_test.py
index e1fb3d5..f685180 100644
--- a/tests/reader_test.py
+++ b/tests/reader_test.py
@@ -15,6 +15,7 @@ from pathlib import Path
 import pytest
 from pdbx.errors import PdbxSyntaxError
 from pdbx.reader import PdbxReader
+from pdbx import loads as read_cifstr
 
 
 DATA_DIR = Path("tests/data")
@@ -44,13 +45,6 @@ def test_structure_factor_file(input_cif):
     assert refln_object is not None
 
 
-def read_cifstr(cifstr: str) -> list:
-    """Helper function"""
-    data = []
-    PdbxReader(io.StringIO(cifstr)).read(data)
-    return data
-
-
 def test_empty_file():
     assert read_cifstr('') == []