Source code for httk.io.cif.cif_reader

#
#    The high-throughput toolkit (httk)
#    Copyright (C) 2012-2025 The httk AUTHORS
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU Affero General Public License as
#    published by the Free Software Foundation, either version 3 of the
#    License, or (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU Affero General Public License for more details.
#
#    You should have received a copy of the GNU Affero General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.

import logging
import os
import re
from collections.abc import Iterable, Iterator
from pathlib import Path
from typing import Any, Self

from httk.core import TextstreamFileView

from .cif_tags import CIF_TAGS

[docs] logger = logging.getLogger(__name__)
# Normalized tags consumed by cif_parser.py and mcif_parser.py; a loop is droppable only # when none of its columns is in this set. The Fourier coefficient template is checked below. _PROTECTED_LOOP_TAGS = frozenset( { 'cell_modulation_dimension', 'cell_length_a', 'cell_length_b', 'cell_length_c', 'cell_angle_alpha', 'cell_angle_beta', 'cell_angle_gamma', 'atom_site_type_symbol', 'atom_site_label', 'atom_site_fract_x', 'atom_site_fract_y', 'atom_site_fract_z', 'atom_site_occupancy', 'atom_site_wyckoff_label', 'atom_site_symmetry_multiplicity', 'httk_atom_site_fract_x_exact', 'httk_atom_site_fract_y_exact', 'httk_atom_site_fract_z_exact', 'httk_atom_site_occupancy_exact', 'space_group_symop.operation_xyz', 'space_group_symop_operation_xyz', 'symmetry_equiv_pos_as_xyz', 'space_group_name_h-m_alt', 'symmetry_space_group_name_h-m', 'space_group_name_hall', 'symmetry_space_group_name_hall', 'space_group_it_number', 'symmetry_space_group_it_number', 'symmetry_int_tables_number', 'database_code_icsd', 'citation_doi', 'parent_propagation_vector.kxkykz', 'atom_site_moment.label', 'atom_site_moment.crystalaxis_x', 'atom_site_moment.crystalaxis_y', 'atom_site_moment.crystalaxis_z', 'space_group_symop_magn_operation.xyz', 'space_group_symop_magn_ssg_operation.algebraic', 'space_group_symop_magn_centering.xyz', 'space_group_symop_magn_ssg_centering.algebraic', 'space_group_magn.number_bns', 'space_group_magn.name_bns', 'parent_space_group.name_h-m_alt', 'parent_space_group.it_number', *CIF_TAGS['structural_q'], CIF_TAGS['structural_displacement_label'], CIF_TAGS['structural_occupancy_label'], *CIF_TAGS['magnetic_cartesian_moment'], CIF_TAGS['magnetic_fourier_label'], CIF_TAGS['magnetic_ssg_name'], } ) _MAGNETIC_FOURIER_COEFFICIENT_RE = re.compile( '^' + re.escape(CIF_TAGS['magnetic_fourier_coeff']).replace(r'\{\}', r'\d+') + '$' ) _PRAGMATIC_VALUE_SPLIT_RE = re.compile(r'\s+_|\s+data_|\s+loop_') _STRUCTURAL_LOOP_PREFIXES = ('atom_site', 'space_group', 'symmetry') def _is_consumed_tag(name: str) -> bool: return ( name in _PROTECTED_LOOP_TAGS or name.startswith(_STRUCTURAL_LOOP_PREFIXES) or _MAGNETIC_FOURIER_COEFFICIENT_RE.fullmatch(name) is not None ) def _is_repairable_loop(header: list[str]) -> bool: return not any(_is_consumed_tag(name) for name in header) class _RewindableIterator: def __init__(self, iterator: Iterable[str]) -> None: self._iter: Iterator[str] = iter(iterator) self._rewind = False self._cache: str | None = None def __iter__(self) -> Self: return self def __next__(self) -> str: if self._rewind: self._rewind = False else: self._cache = next(self._iter) assert self._cache is not None return self._cache def rewind(self, rewindstr: str | None = None) -> None: if self._rewind: raise RuntimeError("Tried to backup more than one step.") elif self._cache is None: raise RuntimeError("Can't backup past the beginning.") self._rewind = True if rewindstr is not None: self._cache = rewindstr def _read_cif_rewind_if_needed(f: _RewindableIterator, row: str, done_fields: int) -> bool: splitstr = row.lstrip().split(None, done_fields) if len(splitstr) > 1: rest = splitstr[-1] if rest.strip() != "": f.rewind(rest) return True return False else: return False def _read_cif_loop( f: _RewindableIterator, pragmatic: bool = True, allow_cif2: bool = False, *, block_name: str, autocorrect: bool = False, structural_only: bool = False, ) -> dict[str, list[Any]] | None: noteol = False loop_data: dict[str, list[Any]] = {} header = [] for row in f: striprow = row.strip() lowrow = striprow.lower() if lowrow.startswith("_"): name = lowrow[1:] loop_data[name] = [] header.append(name) noteol = _read_cif_rewind_if_needed(f, row, 1) else: f.rewind() break columns = [loop_data[name] for name in header] if structural_only and header and _is_repairable_loop(header) and not allow_cif2: count = _skip_cif_loop(f, pragmatic) counts = {name: count // len(header) + (index < count % len(header)) for index, name in enumerate(header)} _validate_loop_counts(header, counts, block_name, autocorrect) return None # _read_cif_data_value recognizes and rewinds the next loop/data/tag token, so # peeking here would strip, lowercase, and traverse every ordinary value twice. while columns: for column in columns: val, noteol = _read_cif_data_value(f, noteol, pragmatic, allow_cif2, inloop=True) if val is None: break column.append(val) else: continue break counts = {name: len(values) for name, values in loop_data.items()} if not _validate_loop_counts(header, counts, block_name, autocorrect): return None return loop_data def _validate_loop_counts(header: list[str], counts: dict[str, int], block_name: str, autocorrect: bool) -> bool: """Apply the common strict/autocorrect policy to CIF loop column counts.""" if len(set(counts.values())) > 1: rendered_counts = ", ".join(f"{name}={count}" for name, count in counts.items()) message = f"CIF loop with {len(header)} columns has mismatched value counts: {rendered_counts}" if _is_repairable_loop(header): if autocorrect: logger.warning( "CIF block %r: dropped malformed auxiliary loop starting with _%s", block_name, header[0], extra={'context': 'cif'}, ) return False message += " (an auxiliary loop like this can be dropped by loading with autocorrect=True, which applies documented repairs with warnings)" raise ValueError(message) return True def _skip_cif_loop(f: _RewindableIterator, pragmatic: bool) -> int: """Count an unneeded CIF1 loop without materializing its ordinary data rows.""" count = 0 for row in f: striprow = row.strip() if not striprow: continue if striprow.startswith("#"): # Preserve the ordinary parser's strict token-boundary behavior around # comments until that CIF conformance question is settled explicitly. f.rewind() return _skip_cif_loop_tokens(f, pragmatic, count) if row.startswith("_") or (striprow[0] in "dDlL" and striprow.lower().startswith(("data_", "loop_"))): f.rewind() return count if "'" in row or '"' in row: f.rewind() return _skip_cif_loop_tokens(f, pragmatic, count) if row.startswith(";"): count += 1 for continuation in f: if not continuation.startswith(";"): continue tail = continuation[1:].strip() if tail: f.rewind(tail) return _skip_cif_loop_tokens(f, pragmatic, count) break continue tokens = striprow.split() for index, token in enumerate(tokens): if token.startswith("_") or (token[0] in "dDlL" and token.lower().startswith(("data_", "loop_"))): f.rewind(" ".join(tokens[index:])) return count value, marker, _ = token.partition("#") if value: count += 1 if marker: break return count def _skip_cif_loop_tokens(f: _RewindableIterator, pragmatic: bool, count: int) -> int: """Finish skipping a CIF1 loop through the ordinary tokenizer.""" noteol = False while True: try: row = next(f) while row.isspace(): row = next(f) except StopIteration: return count lowrow = row.strip().lower() if not row or row.startswith("_") or lowrow.startswith(("data_", "loop_")): f.rewind() return count f.rewind() value, noteol = _read_cif_data_value(f, noteol, pragmatic, inloop=True) if value is not None: count += 1 def _read_cif_data_value( f: _RewindableIterator, noteol: bool, pragmatic: bool = True, allow_cif2: bool = False, inloop: bool = False, inlist: bool = False, ) -> tuple[Any, bool]: data_value: Any = None for row in f: if inloop and not row: f.rewind() return None, False striprow = row.strip() if striprow.startswith("#") or striprow == "": noteol = False continue elif inloop and ( row.startswith("_") or (striprow[0] in "dDlL" and striprow.lower().startswith(("data_", "loop_"))) ): f.rewind() return None, False elif (not noteol) and row.startswith(';'): folded = False newline = False data_parts = [] if row[1] == "\\" and row[2:].rstrip("\r\n") == "": folded = True elif row[1:].isspace(): if not pragmatic: data_parts.append(row.lstrip().rstrip('\r\n')) newline = True else: data_parts.append(row.lstrip()[1:].rstrip('\r\n')) newline = True last_irow = "" content_lines = [] for irow in f: last_irow = irow if irow.startswith(';'): break content_lines.append(irow.rstrip('\r\n')) # Join once: repeated string concatenation is quadratic for large text fields. if folded: for trimmed in content_lines: if newline: data_parts.append('\n') if trimmed.endswith("\\"): data_parts.append(trimmed.rstrip("\\")) newline = False else: data_parts.append(trimmed) newline = True data_value = ''.join(data_parts) else: if data_parts: data_parts.extend(content_lines) else: data_parts = content_lines data_value = '\n'.join(data_parts) stripirow = last_irow.strip() if len(stripirow) > 1: f.rewind(stripirow[1:]) noteol = True else: noteol = False break elif striprow.startswith(("'", '"')): # The cif quoting rules are ... weird. Quotes are "escaped" if they are not followed by whitespace. quote = striprow[0] starti = 1 for chari in range(1, len(striprow) - 1): if striprow[chari] == quote and str(striprow[chari + 1]).isspace(): endi = chari endq = chari + 1 break else: if striprow[-1] != quote: starti = 0 endi = len(striprow) endq = len(striprow) else: endi = len(striprow) - 1 endq = len(striprow) data_value = striprow[starti:endi] if endq != len(striprow): f.rewind(striprow[endq:]) noteol = True else: noteol = False break elif allow_cif2 and inlist and striprow.startswith("]"): # TODO: Is ] allowed without whitespace after? I need to check the spec splitstr = striprow.split("]", 1) if len(splitstr) > 1 and len(splitstr[1]) > 0: f.rewind(splitstr[1]) noteol = True data_value = None break elif allow_cif2 and striprow.startswith("["): if len(striprow) > 1: f.rewind(striprow[1:]) noteol = True data_value = [] while True: innerval, noteol = _read_cif_data_value(f, noteol, pragmatic, allow_cif2, inloop=False, inlist=True) if innerval is None: break data_value += [innerval] break elif allow_cif2 and inlist and ("]" in striprow): splitstr2 = striprow.split("]", 1) splitstr = splitstr2[0].split(None, 1) data_value = splitstr[0].strip() rightside = "" if len(splitstr) > 1: f.rewind(splitstr[1] + "]" + splitstr2[1]) else: f.rewind("]" + splitstr2[1]) noteol = True break else: if pragmatic and not inloop: # In pragmatic mode, if we are not in a loop and there is more than one data value # separated by whitespace, read all of it. This should always be ok to do, since # multiple data values in this situation would be an # error in the file otherwise, but if there is whitespace + underscore/data_/loop_ we parse that # as a new symbol, since otherwise we COULD misread valid files (with very weird formatting...). splitstr = _PRAGMATIC_VALUE_SPLIT_RE.split(striprow, maxsplit=1) else: splitstr = striprow.split(None, 1) # "Data on a line following a hash character `#' is considered to be a comment, # except if it is contained within a text string." data_value = splitstr[0].partition("#")[0].strip() rightside = "" if len(splitstr) > 1: rightside = splitstr[1].strip() if rightside != "": f.rewind(rightside) noteol = True else: noteol = False break return data_value, noteol def _read_cif_data_block( f: _RewindableIterator, pragmatic: bool = True, allow_cif2: bool = False, *, block_name: str, autocorrect: bool = False, structural_only: bool = False, ) -> dict[str, Any]: data_items: dict[str, Any] = {} loops = 0 for row in f: striprow = row.strip() if striprow.startswith("#"): continue elif striprow and striprow[0] in "dD" and striprow[:5].lower() == "data_": f.rewind() return data_items elif striprow and striprow[0] in "lL" and striprow[:5].lower() == "loop_": _read_cif_rewind_if_needed(f, row, 1) loopdata = _read_cif_loop( f, pragmatic, allow_cif2, block_name=block_name, autocorrect=autocorrect, structural_only=structural_only, ) if loopdata is None: continue data_items['loop_' + str(loops)] = list(loopdata.keys()) loops += 1 data_items.update(loopdata) elif striprow.startswith(";"): # Multi-line string that we've failed to tie to a name, lets just skip it, maybe we should warn for irow in f: if irow.rstrip() == ";": break elif striprow.startswith("_"): lowrow = striprow.lower() lowsplit = lowrow.split() data_name = lowsplit[0][1:] if len(lowsplit) > 1: noteol = True rightside = striprow.split(None, 1)[1].strip() f.rewind(rightside) else: noteol = False data_value, noteol = _read_cif_data_value(f, noteol, pragmatic, allow_cif2, inloop=False) if not structural_only or _is_consumed_tag(data_name): data_items[data_name] = data_value return data_items def _read_cif( f: _RewindableIterator, pragmatic: bool, allow_cif2: bool, autocorrect: bool, structural_only: bool, ) -> tuple[list[tuple[str, dict[str, Any]]], str]: header = "" datalist = [] for row in f: if row.strip().startswith("#"): header += row else: f.rewind() break for row in f: striprow = row.strip() if striprow and striprow[0] in "dD" and striprow[:5].lower() == "data_": lowrow = striprow.lower() data_block_name = lowrow.partition('_')[2].split()[0].strip() _read_cif_rewind_if_needed(f, row, 1) datalist.append( ( data_block_name, _read_cif_data_block( f, pragmatic, allow_cif2, block_name=data_block_name, autocorrect=autocorrect, structural_only=structural_only, ), ) ) return datalist, header
[docs] def read_cif( source: str | os.PathLike[str] | Iterable[str], pragmatic: bool = True, allow_cif2: bool = False, *, autocorrect: bool = False, structural_only: bool = False, ) -> tuple[list[tuple[str, dict[str, Any]]], str]: """Read CIF text as ``(data_blocks, header)``. Paths are opened through :class:`httk.core.TextstreamFileView`, including compressed CIF files. Open streams and iterables are consumed but left open. :param source: A filename, open text stream, or iterable of CIF lines. :param pragmatic: Accept selected common deviations from strict CIF tokenization. :param allow_cif2: Parse CIF2 list values in addition to CIF1 data. :param autocorrect: Drop malformed auxiliary loops and warn about each repair. :param structural_only: Retain only tags consumed by httk's structural adapters and skip auxiliary CIF1 loops. :return: The data blocks and the leading comment header. :raises ValueError: If a loop contains mismatched column value counts. """ if isinstance(source, (str, os.PathLike)): with TextstreamFileView(Path(source)) as stream: return _read_cif(_RewindableIterator(stream), pragmatic, allow_cif2, autocorrect, structural_only) return _read_cif(_RewindableIterator(source), pragmatic, allow_cif2, autocorrect, structural_only)