#
# 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 re
import sys
from collections.abc import Iterable
_cif_ordinary_char = r'!%&()*+,-./0123456789:<=>?@ABCDEFGHIHJKLMNOPQRSTUVWXYZ\^`abcdefghijklmnopqrstuvwxyz{|}~'
_cif_non_blank_char = _cif_ordinary_char + '"' + "#$" + "'" + "_" + ";[]"
_cif_text_lead_char = _cif_ordinary_char + '"' + "#$" + "'" + "_ \t[]"
_cif_any_print_char = _cif_ordinary_char + '"' + "#$" + "'" + "_ \t;[]"
_cif_helper_table = str.maketrans('', '')
_cif_non_blank_char_table = str.maketrans(_cif_non_blank_char, _cif_non_blank_char)
_cif_unicode_translation_table: dict[int, int | None] = {}
for i in range(sys.maxunicode + 1):
_cif_unicode_translation_table[i] = None
for key, value in _cif_non_blank_char_table.items():
_cif_unicode_translation_table[key] = value
_cif_integer_regex = re.compile(r'^[+-]?[0-9]+$')
_cif_float_regex = re.compile(r'^[+-]?[0-9]+[eE][+-]?[0-9]+|([+-]?[0-9]*\.[0-9]+|[+-]?[0-9]\.)([eE][+-]?[0-9]+)?$')
_cif_simplestring_regex = re.compile(r'^[A-Za-z0-9()][A-Za-z0-9()+-]*$')
def _cif_validate_name(name_unfiltered, context=None):
if context is not None:
context = context + ": " + name_unfiltered
name = _cif_validate_non_blank_char(name_unfiltered, context)
if len(name) > 75:
sys.stderr.write(
"***Warning: write_cif: name length > 75, surplus characters removed in "
+ str(context)
+ ": "
+ name_unfiltered
)
name = name[:75]
return name
def _cif_is_float(data_value):
return _cif_float_regex.match(data_value) is not None
def _cif_is_simplestring(data_value):
return _cif_simplestring_regex.match(data_value) is not None
def _cif_is_int(data_value):
return _cif_integer_regex.match(data_value) is not None
def _cif_validate_non_blank_char(s, context=None):
if sys.version_info[0] == 3:
out = s.translate(_cif_unicode_translation_table)
else:
out = s.translate(_cif_helper_table, _cif_non_blank_char_table)
if out != s:
if context is not None:
sys.stderr.write("***Warning: write_cif: non-permitted characters in " + context + " removed.")
else:
sys.stderr.write("***Warning: write_cif: non-permitted characters removed.")
return out
def _cif_write_semicolontextfield(f, lines, noteol, max_line_length):
if noteol:
f.write("\n")
noteol = False
for i in range(len(lines)):
lines[i] = lines[i].rstrip("\r\n")
if lines[i][0] == ';':
sys.stderr.write(
"***Warning: write_cif: had to insert space before semicolon at the start of a line of a multi-line string to fulfill arcane quoting rules."
)
lines[i] = ' ' + lines[i]
if len(lines[i]) > max_line_length:
f.write(";\\" + "\n")
break
else:
f.write(";")
for line in lines:
if len(line) > max_line_length:
sublines = [line[i : i + max_line_length - 2] for i in range(0, len(line), max_line_length - 2)]
# Handle a wonderful corner case: the line splitting for length creates lines that start with one, or more, semi-colons..., sigh...
for i in range(1, len(sublines)):
if sublines[i][0] == ";":
if len(sublines[i]) > 1 and sublines[i][1] != ";":
# If its just a single semi-colon, move it to the previous line, which we saved space for by splitting at max_line_length-2
sublines[i - 1] += ";"
sublines[i] = sublines[i][1:]
else:
# Multiple semi-colons in a row, or a semi-colon + newline, this is a possibly unresolvable case (think long string of only semi-colons)
# fudge a solution by inserting a space
sys.stderr.write(
"***Warning: write_cif: had to insert space before semicolon in a long string to fulfill arcane quoting rules."
)
sublines[i] = " " + sublines[i]
for subline in sublines:
f.write(subline + "\\" + "\n")
else:
f.write(line + "\n")
f.write(";\n")
return False
def _cif_write_data_value(f, orig_data_value, noteol, max_line_length, use_types, inloop):
if orig_data_value is None:
data_value = ""
else:
data_value = str(orig_data_value)
has_whitespace = len(data_value.split()) > 1
lines = data_value.splitlines()
has_lines = len(lines) > 1
has_single_quote = data_value.find("'") != -1
has_double_quote = data_value.find('"') != -1
too_long = len(data_value) + 2 > max_line_length
if has_lines or (has_single_quote and has_double_quote) or too_long:
noteol = _cif_write_semicolontextfield(f, lines, noteol, max_line_length)
return noteol
elif has_double_quote or (has_whitespace and not has_single_quote) or data_value == "":
f.write("'" + data_value + "'")
return True
elif has_single_quote or (has_whitespace and not has_double_quote):
f.write('"' + data_value + '"')
return True
elif not use_types:
# Skip quotes if it looks like a number or is a simple string used in a loop
if _cif_is_float(data_value):
f.write(data_value)
return True
elif _cif_is_int(data_value):
f.write(data_value)
return True
elif inloop and _cif_is_simplestring(data_value):
f.write(data_value)
return True
else:
f.write("'" + data_value + "'")
return True
else:
# Always quote when a string, never quote otherwise
if isinstance(orig_data_value, str):
f.write("'" + data_value + "'")
else:
f.write(data_value)
return True
[docs]
def is_sequence(val):
return isinstance(val, Iterable) and not isinstance(val, str)
def _cif_write_data_block(f, data_block, max_line_length, use_types):
for key in data_block:
val = data_block[key]
if key.startswith("loop_"):
f.write("loop_\n")
outdata_columns = []
for unfiltered_column in val:
column = _cif_validate_non_blank_char(unfiltered_column, "column name: " + unfiltered_column)
f.write("_" + column + "\n")
outdata_columns += [data_block[unfiltered_column]]
if len(outdata_columns) > 0:
noteol = False
for i in range(len(outdata_columns[0])):
column_count = 0
for j in range(len(outdata_columns)):
column_count += len(str(outdata_columns[j][i])) + 2
if column_count > max_line_length and noteol:
f.write("\n")
column_count = 0
noteol = False
noteol = _cif_write_data_value(
f, outdata_columns[j][i], noteol, max_line_length, use_types, inloop=True
)
if noteol:
f.write(" ")
column_count += 1
else:
column_count = 0
if noteol:
noteol = False
f.write("\n")
elif is_sequence(val):
continue
else:
data_name = _cif_validate_name(key)
# Do we have space _ + key + space + quote + the whole data value + quote?, if not, preemptively break line
f.write("_" + data_name + " ")
if len(data_name) + len(str(val)) + 4 > max_line_length:
f.write("\n")
noteol = False
else:
noteol = True
noteol = _cif_write_data_value(f, val, noteol, max_line_length, use_types, inloop=False)
if noteol:
f.write("\n")
noteol = False
[docs]
def write_cif(ioa, data, header=None, max_line_length=80, use_types=False):
"""
Generic cif writer, given a filename / ioadapter
data = the cif data to write as an (ordered) dictionary of tag_name:value
header = the header (comment) segment
max_line_length = the maximum number of characters allowed on each line. This should not be set < 80
(there is no point, and the length calculating algorithm breaks down at some small line length)
use_types =
if True: always quote values that are of string type. Numeric values are put in the file unquoted (as they should)
if False (default): also strings that look like cif numbers are put in the file unquoted
For loops, value is another dictionary with format column_name:value
The optional parameter pragmatic regulates handling of some counter-intuitive aspects of the cif specification, where
the default pragmatic=True handles these features the way people usually use them, whereas pragmatic=False means
to read the cif file precisely according to the spec. For example, in a multiline text field::
;
some text
;
Means the string '\\nsome text'. For this specific case pragmatic=True removes the leading newline.
set use_types to True to convert things that look like floats and integers to those respective types
"""
if isinstance(ioa, str):
f = open(ioa, "w", encoding="utf-8")
close_when_done = True
else:
f = ioa
close_when_done = False
if header is not None:
lines = header.splitlines()
for line in lines:
if len(line) > max_line_length:
header = "#\n" + header
break
for line in lines:
if len(line) > max_line_length:
sublines = [line[i : i + 79] for i in range(0, len(line), 79)]
for subline in sublines:
f.write(subline + "\\" + "\n")
else:
f.write(line + "\n")
data_block_count = -1
for data_block in data:
data_block_count += 1
data_block_name_unfiltered = data_block[0]
if data_block_name_unfiltered is None:
data_block_name = "data_" + str(data_block_count)
else:
data_block_name = _cif_validate_name(data_block_name_unfiltered, "data block name")
if data_block_name == "":
data_block_name = "data_" + str(data_block_count)
f.write("data_" + data_block_name + "\n")
_cif_write_data_block(f, data_block[1], max_line_length, use_types)
if close_when_done:
f.close()