Skip to content

cif

Read and write CIF (Crystallographic Information File) data.

The parser is pure Python and imports nothing outside the standard library, not even :mod:re. It is built around one observation about real CIF files: almost every token in them is a bare, whitespace-delimited word, and only a small minority are quoted strings, comments, text fields or data names. So rather than matching a pattern against every token, it runs one :meth:str.find cursor per interesting character (', ", #, ; and _) and lets :meth:str.split -- a single C-level call -- handle everything in between. Those cursors also record where the data names are, which is what lets the parser step from one to the next without looking at the values.

The result behaves like a dictionary of dictionaries::

>>> cif = Cif.from_string('''
... data_example
... _cell_length_a   5.1234(3)
... loop_
... _atom_site_label
... _atom_site_fract_x
... C1 0.1234
... O1 0.5678
... ''')
>>> cif["example"]["cell_length_a"]
5.1234
>>> cif["example"]["atom_site_label"]
['C1', 'O1']

Data names are matched case-insensitively, with or without the leading underscore, and across the naming conventions of different CIF flavours, so an mmCIF and a small-molecule CIF can be read through the same keys::

>>> mm = Cif.from_string("data_x\n_cell.length_a  5.1234\n")
>>> mm["x"]["cell_length_a"]
5.1234

Cif

The contents of a CIF: an ordered mapping of block name to :class:CifBlock.

Parameters:

Name Type Description Default
cif_data

existing {block name: {data name: value}} contents

None

cif = Cif.from_string("data_one\n_a 1\ndata_two\n_a 2\n") list(cif) ['one', 'two'] cif["two"]["a"] 2

Source code in chmpy/fmt/cif.py
class Cif:
    """The contents of a CIF: an ordered mapping of block name to
    :class:`CifBlock`.

    Args:
        cif_data: existing ``{block name: {data name: value}}`` contents

    >>> cif = Cif.from_string("data_one\\n_a 1\\ndata_two\\n_a 2\\n")
    >>> list(cif)
    ['one', 'two']
    >>> cif["two"]["a"]
    2
    """

    def __init__(self, cif_data=None):
        self.data = {} if cif_data is None else dict(cif_data)

    def __getitem__(self, key):
        return self.data[key]

    def __setitem__(self, key, value):
        self.data[key] = value

    def __iter__(self):
        return iter(self.data)

    def __len__(self):
        return len(self.data)

    def __contains__(self, key):
        return key in self.data

    def keys(self):
        return self.data.keys()

    def values(self):
        return self.data.values()

    def items(self):
        return self.data.items()

    def get(self, key, default=None):
        return self.data.get(key, default)

    @property
    def first_block(self):
        "the first data block in the file"
        return next(iter(self.data.values()))

    def __repr__(self):
        return f"<Cif: {', '.join(self.data)}>"

    @classmethod
    def from_file(cls, filename, options=None):
        """initialize a :obj:`Cif` from a file path

        ``options`` is a :class:`CifOptions`; the default reading is CIF 1.1.
        """
        return cls.from_string(Path(filename).read_text(), options=options)

    @classmethod
    def from_string(cls, contents, options=None):
        """initialize a :obj:`Cif` from string contents

        ``options`` is a :class:`CifOptions`; the default reading is CIF 1.1.
        """
        cif = cls()
        cif.data = _parse(contents, options or DEFAULT_OPTIONS)
        return cif

    def to_string(self):
        "represent the data in this :obj:`Cif` textually in the CIF format"
        lines = []
        for block_name, block in self.data.items():
            lines.append(f"data_{_data_block_name(block_name)}")
            _write_block(block, lines)
        lines.append("#END")
        return "\n".join(lines)

    def to_file(self, filename):
        "write this :obj:`Cif` to file"
        Path(filename).write_text(self.to_string())

first_block property

the first data block in the file

from_file(filename, options=None) classmethod

initialize a :obj:Cif from a file path

options is a :class:CifOptions; the default reading is CIF 1.1.

Source code in chmpy/fmt/cif.py
@classmethod
def from_file(cls, filename, options=None):
    """initialize a :obj:`Cif` from a file path

    ``options`` is a :class:`CifOptions`; the default reading is CIF 1.1.
    """
    return cls.from_string(Path(filename).read_text(), options=options)

from_string(contents, options=None) classmethod

initialize a :obj:Cif from string contents

options is a :class:CifOptions; the default reading is CIF 1.1.

Source code in chmpy/fmt/cif.py
@classmethod
def from_string(cls, contents, options=None):
    """initialize a :obj:`Cif` from string contents

    ``options`` is a :class:`CifOptions`; the default reading is CIF 1.1.
    """
    cif = cls()
    cif.data = _parse(contents, options or DEFAULT_OPTIONS)
    return cif

to_file(filename)

write this :obj:Cif to file

Source code in chmpy/fmt/cif.py
def to_file(self, filename):
    "write this :obj:`Cif` to file"
    Path(filename).write_text(self.to_string())

to_string()

represent the data in this :obj:Cif textually in the CIF format

Source code in chmpy/fmt/cif.py
def to_string(self):
    "represent the data in this :obj:`Cif` textually in the CIF format"
    lines = []
    for block_name, block in self.data.items():
        lines.append(f"data_{_data_block_name(block_name)}")
        _write_block(block, lines)
    lines.append("#END")
    return "\n".join(lines)

CifBlock

Bases: dict

One data_ block: a dict of data names to values.

Keys are stored as they were written in the file, minus the leading underscore. An exact key lookup is a plain dict lookup; anything else falls back to matching without regard to case, the leading underscore, or which CIF dictionary the name came from (see :data:CIF_ALIAS_GROUPS).

block = CifBlock({"Cell_Length_A": 5.0}, name="x") block["Cell_Length_A"], block["_cell_length_a"], block["cell.length_a"] (5.0, 5.0, 5.0) "cell_volume" in block False

Source code in chmpy/fmt/cif.py
class CifBlock(dict):
    """One ``data_`` block: a dict of data names to values.

    Keys are stored as they were written in the file, minus the leading
    underscore.  An exact key lookup is a plain dict lookup; anything else
    falls back to matching without regard to case, the leading underscore, or
    which CIF dictionary the name came from (see :data:`CIF_ALIAS_GROUPS`).

    >>> block = CifBlock({"Cell_Length_A": 5.0}, name="x")
    >>> block["Cell_Length_A"], block["_cell_length_a"], block["cell.length_a"]
    (5.0, 5.0, 5.0)
    >>> "cell_volume" in block
    False
    """

    def __init__(self, *args, name="", **kwargs):
        super().__init__(*args, **kwargs)
        self.name = name
        #: the data names of each ``loop_``, in the order they were read
        self.loops = []
        #: nested ``save_`` frames, by name (only used by CIF dictionaries)
        self.save_frames = {}
        self._index = None

    def _resolve(self, key):
        """The stored key matching ``key``, or None."""
        index = self._index
        if index is None or len(index) != len(self):
            index = self._index = {k.lower(): k for k in self}
        name = key[1:] if key[:1] == "_" else key
        name = name.lower()
        found = index.get(name)
        if found is not None:
            return found
        for alias in CIF_ALIASES.get(name, ()):
            found = index.get(alias)
            if found is not None:
                return found
        return None

    def __missing__(self, key):
        found = self._resolve(key)
        if found is None:
            raise KeyError(key)
        return dict.__getitem__(self, found)

    def __contains__(self, key):
        return dict.__contains__(self, key) or self._resolve(key) is not None

    def get(self, key, default=None):
        value = dict.get(self, key, _MISSING)
        if value is not _MISSING:
            return value
        found = self._resolve(key)
        return default if found is None else dict.__getitem__(self, found)

    def is_loop(self, key):
        "whether ``key`` was read as a column of a ``loop_``"
        found = self._resolve(key)
        return any(found in loop for loop in self.loops)

    def __repr__(self):
        return f"<CifBlock {self.name!r}: {len(self)} data names>"

is_loop(key)

whether key was read as a column of a loop_

Source code in chmpy/fmt/cif.py
def is_loop(self, key):
    "whether ``key`` was read as a column of a ``loop_``"
    found = self._resolve(key)
    return any(found in loop for loop in self.loops)

CifOptions dataclass

Settings for reading a CIF, passed to :meth:Cif.from_file and :meth:Cif.from_string.

Both default to the CIF 1.1 reading that suits nearly every file in circulation; each is here because a reasonable person might want the other behaviour.

Parameters:

Name Type Description Default
join_text_field_lines bool

join the lines of a multi-line text field into one line separated by single spaces, instead of keeping the text as it was written. Off by default, because the line structure is part of the value -- but it is what chmpy did before, and it is convenient when a text field only ever holds a wrapped sentence.

False
cif2_triple_quotes bool

read '''...''' and """...""" as CIF 2.0 triple-quoted strings, which may span lines and contain the delimiter singly or doubly. Off by default because the same characters mean something else under CIF 1.1, where '''a''' is the string ''a''. Single and double quotes keep their CIF 1.1 meaning either way, so an apostrophe inside a quoted string still needs no escaping.

False

text = "data_a\n_x\n;\nfirst line\nsecond line\n;\n" Cif.from_string(text)["a"]["x"] 'first line\nsecond line' Cif.from_string(text, options=CifOptions(join_text_field_lines=True))["a"]["x"] 'first line second line'

Source code in chmpy/fmt/cif.py
@dataclass(frozen=True)
class CifOptions:
    """Settings for reading a CIF, passed to :meth:`Cif.from_file` and
    :meth:`Cif.from_string`.

    Both default to the CIF 1.1 reading that suits nearly every file in
    circulation; each is here because a reasonable person might want the
    other behaviour.

    Args:
        join_text_field_lines: join the lines of a multi-line text field into
            one line separated by single spaces, instead of keeping the text
            as it was written.  Off by default, because the line structure is
            part of the value -- but it is what chmpy did before, and it is
            convenient when a text field only ever holds a wrapped sentence.
        cif2_triple_quotes:
            read ``'''...'''`` and ``\"\"\"...\"\"\"`` as CIF 2.0
            triple-quoted strings, which may span lines and contain the
            delimiter singly or doubly.  Off by default because the same
            characters mean something else under CIF 1.1, where ``'''a'''``
            is the string ``''a''``.  Single and double quotes keep their
            CIF 1.1 meaning either way, so an apostrophe inside a quoted
            string still needs no escaping.

    >>> text = "data_a\\n_x\\n;\\nfirst line\\nsecond line\\n;\\n"
    >>> Cif.from_string(text)["a"]["x"]
    'first line\\nsecond line'
    >>> Cif.from_string(text, options=CifOptions(join_text_field_lines=True))["a"]["x"]
    'first line second line'
    """

    join_text_field_lines: bool = False
    cif2_triple_quotes: bool = False

CifParseError

Bases: ValueError

Raised when the contents of a CIF cannot be interpreted.

Source code in chmpy/fmt/cif.py
class CifParseError(ValueError):
    """Raised when the contents of a CIF cannot be interpreted."""

canonical_data_name(name)

The first spelling of name's equivalence class, or name itself.

canonical_data_name("_cell.length_a") 'cell_length_a' canonical_data_name("not_a_known_name") 'not_a_known_name'

Source code in chmpy/fmt/cif.py
def canonical_data_name(name):
    """The first spelling of ``name``'s equivalence class, or ``name`` itself.

    >>> canonical_data_name("_cell.length_a")
    'cell_length_a'
    >>> canonical_data_name("not_a_known_name")
    'not_a_known_name'
    """
    low = name.lower().lstrip("_")
    if low in CIF_ALIASES:
        for group in CIF_ALIAS_GROUPS:
            if low in group:
                return group[0]
    return low

format_field(x)

format a value as a CIF field

len(format_field(5.4)) 20 format_field("3.4") '3.4'

Source code in chmpy/fmt/cif.py
def format_field(x):
    """format a value as a CIF field

    >>> len(format_field(5.4))
    20
    >>> format_field("3.4")
    '3.4'
    """
    if isinstance(x, float):
        return f"{x:20.12f}"
    elif isinstance(x, bool):
        return "1" if x else "0"
    elif isinstance(x, int):
        return f"{x:20d}"
    elif x is None:
        return "?"
    elif isinstance(x, str):
        return quote(x) if needs_quote(x) else x
    return str(x)

is_scalar(value)

check if the value is a string or has no len dunder method

Source code in chmpy/fmt/cif.py
def is_scalar(value):
    "check if the value is a string or has no __len__ dunder method"
    return isinstance(value, str) or (not hasattr(value, "__len__"))

needs_quote(string)

check whether a value has to be quoted to survive a round trip

needs_quote("this string will need quoting") True needs_quote("thiswon't") False needs_quote(3.45) False

Source code in chmpy/fmt/cif.py
def needs_quote(string):
    """check whether a value has to be quoted to survive a round trip

    >>> needs_quote("this string will need quoting")
    True
    >>> needs_quote("thiswon't")
    False
    >>> needs_quote(3.45)
    False
    """
    if not isinstance(string, str):
        return False
    if not string or string in (".", "?"):
        return True
    if string[0] in _QUOTE_FIRST:
        return True
    if any(ws in string for ws in _WHITESPACE):
        return True
    return string.lower().startswith(_RESERVED_PREFIXES)

parse_quote(string, delimiter=';')

extract a value contained within quotes, with an optional change of delimiter

Parameters:

Name Type Description Default
string

the string containing the value to parse

required
delimiter

the quote delimiter, default ';'

';'

Returns:

Type Description

the string contained inside the quotes

parse_quote(";quote text;") 'quote text' parse_quote(":'quote text':", delimiter="'") ":'quote text':" parse_quote(":'quote text':", delimiter=":") "'quote text'" parse_quote("'-y, x-y, z'", delimiter="'") '-y, x-y, z'

Source code in chmpy/fmt/cif.py
def parse_quote(string, delimiter=";"):
    """extract a value contained within quotes, with an optional change
    of delimiter

    Args:
        string: the string containing the value to parse
        delimiter: the quote delimiter, default ';'

    Returns:
        the string contained inside the quotes

    >>> parse_quote(";quote text;")
    'quote text'
    >>> parse_quote(":'quote text':", delimiter="'")
    ":'quote text':"
    >>> parse_quote(":'quote text':", delimiter=":")
    "'quote text'"
    >>> parse_quote("'-y, x-y, z'", delimiter="'")
    '-y, x-y, z'
    """
    if len(string) > 1 and string[0] == delimiter and string[-1] == delimiter:
        return string[1:-1].strip()
    return string

parse_value(string, with_uncertainty=False)

parse a single value from a cif file to its appropriate type e.g. int, float, str etc. Will handle uncertainty values contained in parentheses.

Parameters:

Name Type Description Default
string

the string containing the value to parse

required
with_uncertainty

return a tuple including uncertainty if a numeric type is expected

False

Returns:

Type Description

the value coerced into the appropriate type

parse_value("2.3(1)", with_uncertainty=True) (2.3, 1) parse_value("string help") 'string help' parse_value("3.1415") * 4 12.566

Source code in chmpy/fmt/cif.py
def parse_value(string, with_uncertainty=False):
    """parse a single value from a cif file to its appropriate type
    e.g. int, float, str etc. Will handle uncertainty values
    contained in parentheses.

    Args:
        string: the string containing the value to parse
        with_uncertainty: return a tuple including uncertainty if a numeric
            type is expected

    Returns:
        the value coerced into the appropriate type

    >>> parse_value("2.3(1)", with_uncertainty=True)
    (2.3, 1)
    >>> parse_value("string help")
    'string help'
    >>> parse_value("3.1415") * 4
    12.566
    """
    string = string.strip()
    if not string:
        return string
    value = _convert(string)
    if not with_uncertainty:
        return value
    if isinstance(value, str) or string[-1] != ")":
        return value, 0
    return value, int(string[string.find("(") + 1 : -1])

quote(string)

quote a value so that it reads back as the same string

Text fields are used for anything a one-line quote cannot hold.

quote("P 21/c") "'P 21/c'" quote("it's") '"it\'s"'

Source code in chmpy/fmt/cif.py
def quote(string):
    """quote a value so that it reads back as the same string

    Text fields are used for anything a one-line quote cannot hold.

    >>> quote("P 21/c")
    "'P 21/c'"
    >>> quote("it's")
    '"it\\'s"'
    """
    if "\n" not in string and "\r" not in string:
        if "'" not in string:
            return f"'{string}'"
        if '"' not in string:
            return f'"{string}"'
    return f"\n;\n{string}\n;"

register_aliases(*names)

Declare that names are different spellings of the same data name.

Useful for programs that write CIFs with their own data names. Existing equivalence classes containing any of names are merged into one.

register_aliases("cell_length_a", "my_program_cell_a") cif = Cif.from_string("data_x\n_my_program_cell_a 3.0\n") cif["x"]["cell_length_a"] 3.0

Source code in chmpy/fmt/cif.py
def register_aliases(*names):
    """Declare that ``names`` are different spellings of the same data name.

    Useful for programs that write CIFs with their own data names.  Existing
    equivalence classes containing any of ``names`` are merged into one.

    >>> register_aliases("cell_length_a", "my_program_cell_a")
    >>> cif = Cif.from_string("data_x\\n_my_program_cell_a 3.0\\n")
    >>> cif["x"]["cell_length_a"]
    3.0
    """
    group = set()
    for name in names:
        low = name.lower().lstrip("_")
        group.add(low)
        group.update(CIF_ALIASES.get(low, ()))
    for name in group:
        CIF_ALIASES[name] = tuple(group - {name})

tokenize(text, options=DEFAULT_OPTIONS)

Split CIF text into raw tokens.

Quoted values keep their delimiters and text fields keep their leading and trailing semicolons, so that a token always says what kind of value it is. Comments are dropped.

tokenize("_a 1 _b 'two words' # comment\n") ['_a', '1', '_b', "'two words'"] tokenize("_x\n;\nline one\nline two\n;\n") ['_x', ';\nline one\nline two\n;']

Source code in chmpy/fmt/cif.py
def tokenize(text, options=DEFAULT_OPTIONS):
    """Split CIF text into raw tokens.

    Quoted values keep their delimiters and text fields keep their leading and
    trailing semicolons, so that a token always says what kind of value it is.
    Comments are dropped.

    >>> tokenize("_a 1 _b 'two words' # comment\\n")
    ['_a', '1', '_b', "'two words'"]
    >>> tokenize("_x\\n;\\nline one\\nline two\\n;\\n")
    ['_x', ';\\nline one\\nline two\\n;']
    """
    return _scan(text, options)[0]