<commit_before>#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
nmrstarlib.nmrstarlib
~~~~~~~~~~~~~~~~~~~~~

This module provides the :class:`~nmrstarlib.nmrstarlib.StarFile` class
that stores the data from a single NMR-STAR file in the form of an
:py:class:`~collections.OrderedDict`. Data can be accessed directly from the
:class:`~nmrstarlib.nmrstarlib.StarFile` instance using bracket
accessors.

The NMR-STAR format is a hierachical dictionary containing data on
NMR experiments. The data is divided into a series of "saveframes"
which each contain a number of key-value pairs and "loops".

Each saveframe has a unique name, which is used as the key in
the dictionary, corresponding to another dictionary containing the
information in the saveframe.  Since loops in NMR-Star format do
not have names, the keys for them inside the saveframe dictionary
are simply loop_0, loop_1, etc.
"""

from __future__ import print_function, division
from collections import OrderedDict

import sys
import os
import io
import pprint


UJSON = False

if sys.version_info.major == 3 and sys.version_info.minor == 6:
    try:
        import ujson as json
        UJSON = True
    except ImportError:
        UJSON = False
        import json
else:
    import json

try:
    from .cbmrblex import bmrblex
except ImportError:
    from .bmrblex import bmrblex


BMRB_REST = "http://rest.bmrb.wisc.edu/bmrb/NMR-STAR3/"
VERBOSE = False
NMRSTAR_VERSION = "3"
NMRSTAR_CONSTANTS = {}
RESONANCE_CLASSES = {}
SPECTRUM_DESCRIPTIONS = {}


def update_constants(nmrstar2cfg="", nmrstar3cfg="", resonance_classes_cfg="", spectrum_descriptions_cfg=""):
    """Update constant variables.

    :return: None
    :rtype: :py:obj:`None`
    """
    nmrstar_constants = {}
    resonance_classes = {}
    spectrum_descriptions = {}

    this_directory = os.path.dirname(__file__)

    nmrstar2_config_filepath = os.path.join(this_directory, "conf/constants_nmrstar2.json")
    nmrstar3_config_filepath = os.path.join(this_directory, "conf/constants_nmrstar3.json")
    resonance_classes_config_filepath = os.path.join(this_directory, "conf/resonance_classes.json")
    spectrum_descriptions_config_filepath = os.path.join(this_directory, "conf/spectrum_descriptions.json")

    with open(nmrstar2_config_filepath, "r") as nmrstar2config, open(nmrstar3_config_filepath, "r") as nmrstar3config:
        nmrstar_constants["2"] = json.load(nmrstar2config)
        nmrstar_constants["3"] = json.load(nmrstar3config)

    with open(resonance_classes_config_filepath, "r") as config:
        resonance_classes.update(json.load(config))

    with open(spectrum_descriptions_config_filepath, "r") as config:
        spectrum_descriptions.update(json.load(config))

    if nmrstar2cfg:
        with open(nmrstar2cfg, "r") as nmrstar2config:
            nmrstar_constants["2"].update(json.load(nmrstar2config))

    if nmrstar3cfg:
        with open(nmrstar2cfg, "r") as nmrstar3config:
            nmrstar_constants["3"].update(json.load(nmrstar3config))

    if resonance_classes_cfg:
        with open(nmrstar2cfg, "r") as config:
            resonance_classes.update(json.load(config))

    if spectrum_descriptions_cfg:
        with open(spectrum_descriptions_cfg, "r") as config:
            spectrum_descriptions.update(json.load(config))

    NMRSTAR_CONSTANTS.update(nmrstar_constants)
    RESONANCE_CLASSES.update(resonance_classes)
    SPECTRUM_DESCRIPTIONS.update(spectrum_descriptions)

update_constants()


class StarFile(OrderedDict):
    """StarFile class that stores the data from a single NMR-STAR file in the form of an
    :py:class:`~collections.OrderedDict`."""

    def __init__(self, source="", frame_categories=None, *args, **kwds):
        """`StarFile` initializer. Leave `frame_categories` as :py:obj:`None` to
        read everything. Otherwise it can be a list of saveframe categories to read, skipping the rest.

        :param str source: Source `StarFile` instance was created from - local file or URL address.
        :param list frame_categories: List of saveframe names.
        """
        super(StarFile, self).__init__(*args, **kwds)
        self.source = source
        self._frame_categories = frame_categories
        self.bmrbid = ""

    def read(self, filehandle):
        """Read data into a :class:`~nmrstarlib.nmrstarlib.StarFile` instance.

        :param filehandle: file-like object.
        :type filehandle: :py:class:`io.TextIOWrapper`, :py:class:`gzip.GzipFile`,
                          :py:class:`bz2.BZ2File`, :py:class:`zipfile.ZipFile`
        :return: None
        :rtype: :py:obj:`None`
        """
        inputstr = filehandle.read()
        nmrstar_str = self._is_nmrstar(inputstr)
        json_str = self._is_json(inputstr)

        if not inputstr:
            pass
        elif nmrstar_str:
            self._build_starfile(nmrstar_str)
        elif json_str:
            self.update(json_str)
            self.bmrbid = self[u"data"]
        else:
            print("Unknown file format")
            # raise TypeError("Unknown file format")
        filehandle.close()

    def write(self, filehandle, file_format):
        """Write :class:`~nmrstarlib.nmrstarlib.StarFile` data into file.

        :param filehandle: file-like object.
        :type filehandle: :py:class:`io.TextIOWrapper`
        :param str file_format: Format to use to write data: `nmrstar` or `json`.
        :return: None
        :rtype: :py:obj:`None`
        """
        try:
            if file_format == "json":
                json_str = self._to_json()
                filehandle.write(json_str)
            elif file_format == "nmrstar":
                nmrstar_str = self._to_nmrstar()
                filehandle.write(nmrstar_str)
            else:
                raise TypeError("Unknown file format.")
        except IOError:
            raise IOError('"filehandle" parameter must be writable.')
        filehandle.close()

    def writestr(self, file_format):
        """Write :class:`~nmrstarlib.nmrstarlib.StarFile` data into string.

        :param str file_format: Format to use to write data: `nmrstar` or `json`.
        :return: String representing the :class:`~nmrstarlib.nmrstarlib.StarFile` instance.
        :rtype: :py:class:`str`
        """
        try:
            if file_format == "json":
                json_str = self._to_json()
                return json_str
            elif file_format == "nmrstar":
                nmrstar_str = self._to_nmrstar()
                return nmrstar_str
            else:
                raise TypeError("Unknown file format.")
        except IOError:
            raise IOError('"filehandle" parameter must be writable.')

    def _build_starfile(self, nmrstar_str):
        """Build :class:`~nmrstarlib.nmrstarlib.StarFile` object.

        :param nmrstar_str: NMR-STAR-formatted string.
        :type nmrstar_str: :py:class:`str` or :py:class:`bytes`
        :return: instance of :class:`~nmrstarlib.nmrstarlib.StarFile`.
        :rtype: :class:`~nmrstarlib.nmrstarlib.StarFile`
        """
        odict = self
        comment_count = 0
        lexer = bmrblex(nmrstar_str)
        token = next(lexer)

        while token != u"":
            try:
                if token[0:5] == u"save_":
                    name = token
                    frame = self._build_saveframe(lexer)
                    if frame:
                        odict[name] = frame
                    
                elif token[0:5] == u"data_":
                    self.bmrbid = token[5:]
                    self[u"data"] = self.bmrbid

                elif token.lstrip().startswith(u"#"):
                    odict[u"comment_{}".format(comment_count)] = token
                    comment_count += 1

                else:
                    print("Error: Invalid token {}".format(token), file=sys.stderr)
                    print("In _build_starfile try block", file=sys.stderr)
                    raise InvalidToken("{}".format(token))

            except IndexError:
                print("Error: Invalid token {}".format(token), file=sys.stderr)
                print("In _build_starfile except block", file=sys.stderr)
                raise

            finally:
                token = next(lexer)
        return self

    def _build_saveframe(self, lexer):
        """Build NMR-STAR file saveframe.

        :param lexer: instance of the BMRB lexical analyzer.
        :type lexer: :func:`~nmrstarlib.bmrblex.bmrblex`
        :return: Saveframe dictionary.
        :rtype: :py:class:`collections.OrderedDict`
        """
        odict = OrderedDict()
        loop_count = 0
        token = next(lexer)

        while token != u"save_":
            try:
                if token[0] == u"_":
                    # This strips off the leading underscore of tagnames for readability 
                    odict[token[1:]] = next(lexer)

                    # Skip the saveframe if it's not in the list of wanted categories
                    if self._frame_categories:
                        if token == "_Saveframe_category" and odict[token[1:]] not in self._frame_categories:
                            raise SkipSaveFrame()

                elif token == u"loop_":
                    odict[u"loop_{}".format(loop_count)] = self._build_loop(lexer)
                    loop_count += 1

                elif token.lstrip().startswith(u"#"):
                    continue

                else:
                    print("Error: Invalid token {}".format(token), file=sys.stderr)
                    print("In _build_saveframe try block", file=sys.stderr)
                    raise InvalidToken("{}".format(token))

            except IndexError:
                print("Error: Invalid token {}".format(token), file=sys.stderr)
                print("In _build_saveframe except block", file=sys.stderr)
                raise
            except SkipSaveFrame:
                self._skip_saveframe(lexer)
                odict = None
            finally:
                if odict is None:
                    token = u"save_"
                else:
                    token = next(lexer)
        return odict

    def _build_loop(self, lexer):
        """Build saveframe loop.

        :param lexer: instance of BMRB lexical analyzer.
        :type lexer: :func:`~nmrstarlib.bmrblex.bmrblex`
        :return: Fields and values of the loop.
        :rtype: :py:class:`tuple`
        """
        fields = []
        values = []

        token = next(lexer)
        while token[0] == u"_":
            fields.append(token[1:])
            token = next(lexer)

        while token != u"stop_":
            values.append(token)
            token = next(lexer)

        assert float(len(values)/len(fields)).is_integer(), \
            "Error in loop construction: number of fields must be equal to number of values."

        values = [OrderedDict(zip(fields, values[i:i + len(fields)])) for i in range(0, len(values), len(fields))]
        return fields, values

    def print_file(self, f=sys.stdout, file_format="nmrstar", tw=3):
        """Print :class:`~nmrstarlib.nmrstarlib.StarFile` into a file or stdout.

        :param io.StringIO f: writable file-like stream.
        :param str file_format: Format to use: `nmrstar` or `json`.
        :param int tw: Tab width.
        :return: None
        :rtype: :py:obj:`None`
        """
        if file_format is "nmrstar":
            for saveframe in self.keys():
                if saveframe == u"data":
                    print(u"{}_{}\n".format(saveframe, self[saveframe]), file=f)
                elif saveframe.startswith(u"comment"):
                    print(u"{}".format(self[saveframe]), file=f)
                else:
                    print(u"{}".format(saveframe), file=f)
                    self.print_saveframe(saveframe, f, file_format, tw)
                    print(u"\nsave_\n\n", file=f)

        elif file_format is "json":
            print(self._to_json(), file=f)

    def print_saveframe(self, sf, f=sys.stdout, file_format="nmrstar", tw=3):
        """Print saveframe into a file or stdout.
        We need to keep track of how far over everything is tabbed. The "tab width"
        variable tw does this for us.

        :param str sf: Saveframe name.
        :param io.StringIO f: writable file-like stream.
        :param str file_format: Format to use: `nmrstar` or `json`.
        :param int tw: Tab width.
        :return: None
        :rtype: :py:obj:`None`
        """
        if file_format is "nmrstar":
            if sf == u"data":
                print(u"{}_{}\n".format(sf, self[sf]), file=f)
            else:
                for sftag in self[sf].keys():
                    # handle loops
                    if sftag[:5] == "loop_":
                        print(u"\n{}loop_".format(tw * u" "), file=f)
                        self.print_loop(sf, sftag, f, file_format, tw * 2)
                        print(u"\n{}stop_".format(tw * u" "), file=f)

                    # handle the NMR-Star "multiline string"
                    # if self[sf][sftag][0] == u";":
                    elif self[sf][sftag].endswith(u"\n"):
                        print(u"{}_{}".format(tw * u" ", sftag), file=f)
                        print(u";\n{};\n".format(self[sf][sftag]), file=f)

                    elif len(self[sf][sftag].split()) > 1:
                        # need to escape value with quotes (i.e. u"'{}'".format()) if value consists of two or more words
                        print(u"{}_{}\t {}".format(tw * u" ", sftag, u"'{}'".format(self[sf][sftag])), file=f)

                    else:
                        print(u"{}_{}\t {}".format(tw * u" ", sftag, self[sf][sftag]), file=f)

        elif file_format is "json":
            print(json.dumps(self[sf], sort_keys=False, indent=4), file=f)

    def print_loop(self, sf, sftag, f=sys.stdout, file_format="nmrstar", tw=3):
        """Print loop into a file or stdout.

        :param str sf: Saveframe name.
        :param str sftag: Saveframe tag, i.e. field name.
        :param io.StringIO f: writable file-like stream.<commit_msg>Cleaned up file printing functionality.<commit_after>#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
nmrstarlib.nmrstarlib
~~~~~~~~~~~~~~~~~~~~~

This module provides the :class:`~nmrstarlib.nmrstarlib.StarFile` class
that stores the data from a single NMR-STAR file in the form of an
:py:class:`~collections.OrderedDict`. Data can be accessed directly from the
:class:`~nmrstarlib.nmrstarlib.StarFile` instance using bracket
accessors.

The NMR-STAR format is a hierachical dictionary containing data on
NMR experiments. The data is divided into a series of "saveframes"
which each contain a number of key-value pairs and "loops".

Each saveframe has a unique name, which is used as the key in
the dictionary, corresponding to another dictionary containing the
information in the saveframe.  Since loops in NMR-Star format do
not have names, the keys for them inside the saveframe dictionary
are simply loop_0, loop_1, etc.
"""

from __future__ import print_function, division
from collections import OrderedDict

import sys
import os
import io
import pprint


UJSON = False

if sys.version_info.major == 3 and sys.version_info.minor == 6:
    try:
        import ujson as json
        UJSON = True
    except ImportError:
        UJSON = False
        import json
else:
    import json

try:
    from .cbmrblex import bmrblex
except ImportError:
    from .bmrblex import bmrblex


BMRB_REST = "http://rest.bmrb.wisc.edu/bmrb/NMR-STAR3/"
VERBOSE = False
NMRSTAR_VERSION = "3"
NMRSTAR_CONSTANTS = {}
RESONANCE_CLASSES = {}
SPECTRUM_DESCRIPTIONS = {}


def update_constants(nmrstar2cfg="", nmrstar3cfg="", resonance_classes_cfg="", spectrum_descriptions_cfg=""):
    """Update constant variables.

    :return: None
    :rtype: :py:obj:`None`
    """
    nmrstar_constants = {}
    resonance_classes = {}
    spectrum_descriptions = {}

    this_directory = os.path.dirname(__file__)

    nmrstar2_config_filepath = os.path.join(this_directory, "conf/constants_nmrstar2.json")
    nmrstar3_config_filepath = os.path.join(this_directory, "conf/constants_nmrstar3.json")
    resonance_classes_config_filepath = os.path.join(this_directory, "conf/resonance_classes.json")
    spectrum_descriptions_config_filepath = os.path.join(this_directory, "conf/spectrum_descriptions.json")

    with open(nmrstar2_config_filepath, "r") as nmrstar2config, open(nmrstar3_config_filepath, "r") as nmrstar3config:
        nmrstar_constants["2"] = json.load(nmrstar2config)
        nmrstar_constants["3"] = json.load(nmrstar3config)

    with open(resonance_classes_config_filepath, "r") as config:
        resonance_classes.update(json.load(config))

    with open(spectrum_descriptions_config_filepath, "r") as config:
        spectrum_descriptions.update(json.load(config))

    if nmrstar2cfg:
        with open(nmrstar2cfg, "r") as nmrstar2config:
            nmrstar_constants["2"].update(json.load(nmrstar2config))

    if nmrstar3cfg:
        with open(nmrstar2cfg, "r") as nmrstar3config:
            nmrstar_constants["3"].update(json.load(nmrstar3config))

    if resonance_classes_cfg:
        with open(nmrstar2cfg, "r") as config:
            resonance_classes.update(json.load(config))

    if spectrum_descriptions_cfg:
        with open(spectrum_descriptions_cfg, "r") as config:
            spectrum_descriptions.update(json.load(config))

    NMRSTAR_CONSTANTS.update(nmrstar_constants)
    RESONANCE_CLASSES.update(resonance_classes)
    SPECTRUM_DESCRIPTIONS.update(spectrum_descriptions)

update_constants()


class StarFile(OrderedDict):
    """StarFile class that stores the data from a single NMR-STAR file in the form of an
    :py:class:`~collections.OrderedDict`."""

    def __init__(self, source="", frame_categories=None, *args, **kwds):
        """`StarFile` initializer. Leave `frame_categories` as :py:obj:`None` to
        read everything. Otherwise it can be a list of saveframe categories to read, skipping the rest.

        :param str source: Source `StarFile` instance was created from - local file or URL address.
        :param list frame_categories: List of saveframe names.
        """
        super(StarFile, self).__init__(*args, **kwds)
        self.source = source
        self._frame_categories = frame_categories
        self.bmrbid = ""

    def read(self, filehandle):
        """Read data into a :class:`~nmrstarlib.nmrstarlib.StarFile` instance.

        :param filehandle: file-like object.
        :type filehandle: :py:class:`io.TextIOWrapper`, :py:class:`gzip.GzipFile`,
                          :py:class:`bz2.BZ2File`, :py:class:`zipfile.ZipFile`
        :return: None
        :rtype: :py:obj:`None`
        """
        inputstr = filehandle.read()
        nmrstar_str = self._is_nmrstar(inputstr)
        json_str = self._is_json(inputstr)

        if not inputstr:
            pass
        elif nmrstar_str:
            self._build_starfile(nmrstar_str)
        elif json_str:
            self.update(json_str)
            self.bmrbid = self[u"data"]
        else:
            print("Unknown file format")
            # raise TypeError("Unknown file format")
        filehandle.close()

    def write(self, filehandle, file_format):
        """Write :class:`~nmrstarlib.nmrstarlib.StarFile` data into file.

        :param filehandle: file-like object.
        :type filehandle: :py:class:`io.TextIOWrapper`
        :param str file_format: Format to use to write data: `nmrstar` or `json`.
        :return: None
        :rtype: :py:obj:`None`
        """
        try:
            if file_format == "json":
                json_str = self._to_json()
                filehandle.write(json_str)
            elif file_format == "nmrstar":
                nmrstar_str = self._to_nmrstar()
                filehandle.write(nmrstar_str)
            else:
                raise TypeError("Unknown file format.")
        except IOError:
            raise IOError('"filehandle" parameter must be writable.')
        filehandle.close()

    def writestr(self, file_format):
        """Write :class:`~nmrstarlib.nmrstarlib.StarFile` data into string.

        :param str file_format: Format to use to write data: `nmrstar` or `json`.
        :return: String representing the :class:`~nmrstarlib.nmrstarlib.StarFile` instance.
        :rtype: :py:class:`str`
        """
        try:
            if file_format == "json":
                json_str = self._to_json()
                return json_str
            elif file_format == "nmrstar":
                nmrstar_str = self._to_nmrstar()
                return nmrstar_str
            else:
                raise TypeError("Unknown file format.")
        except IOError:
            raise IOError('"filehandle" parameter must be writable.')

    def _build_starfile(self, nmrstar_str):
        """Build :class:`~nmrstarlib.nmrstarlib.StarFile` object.

        :param nmrstar_str: NMR-STAR-formatted string.
        :type nmrstar_str: :py:class:`str` or :py:class:`bytes`
        :return: instance of :class:`~nmrstarlib.nmrstarlib.StarFile`.
        :rtype: :class:`~nmrstarlib.nmrstarlib.StarFile`
        """
        odict = self
        comment_count = 0
        lexer = bmrblex(nmrstar_str)
        token = next(lexer)

        while token != u"":
            try:
                if token[0:5] == u"save_":
                    name = token
                    frame = self._build_saveframe(lexer)
                    if frame:
                        odict[name] = frame
                    
                elif token[0:5] == u"data_":
                    self.bmrbid = token[5:]
                    self[u"data"] = self.bmrbid

                elif token.lstrip().startswith(u"#"):
                    odict[u"comment_{}".format(comment_count)] = token
                    comment_count += 1

                else:
                    print("Error: Invalid token {}".format(token), file=sys.stderr)
                    print("In _build_starfile try block", file=sys.stderr)
                    raise InvalidToken("{}".format(token))

            except IndexError:
                print("Error: Invalid token {}".format(token), file=sys.stderr)
                print("In _build_starfile except block", file=sys.stderr)
                raise

            finally:
                token = next(lexer)
        return self

    def _build_saveframe(self, lexer):
        """Build NMR-STAR file saveframe.

        :param lexer: instance of the BMRB lexical analyzer.
        :type lexer: :func:`~nmrstarlib.bmrblex.bmrblex`
        :return: Saveframe dictionary.
        :rtype: :py:class:`collections.OrderedDict`
        """
        odict = OrderedDict()
        loop_count = 0
        token = next(lexer)

        while token != u"save_":
            try:
                if token[0] == u"_":
                    # This strips off the leading underscore of tagnames for readability 
                    odict[token[1:]] = next(lexer)

                    # Skip the saveframe if it's not in the list of wanted categories
                    if self._frame_categories:
                        if token == "_Saveframe_category" and odict[token[1:]] not in self._frame_categories:
                            raise SkipSaveFrame()

                elif token == u"loop_":
                    odict[u"loop_{}".format(loop_count)] = self._build_loop(lexer)
                    loop_count += 1

                elif token.lstrip().startswith(u"#"):
                    continue

                else:
                    print("Error: Invalid token {}".format(token), file=sys.stderr)
                    print("In _build_saveframe try block", file=sys.stderr)
                    raise InvalidToken("{}".format(token))

            except IndexError:
                print("Error: Invalid token {}".format(token), file=sys.stderr)
                print("In _build_saveframe except block", file=sys.stderr)
                raise
            except SkipSaveFrame:
                self._skip_saveframe(lexer)
                odict = None
            finally:
                if odict is None:
                    token = u"save_"
                else:
                    token = next(lexer)
        return odict

    def _build_loop(self, lexer):
        """Build saveframe loop.

        :param lexer: instance of BMRB lexical analyzer.
        :type lexer: :func:`~nmrstarlib.bmrblex.bmrblex`
        :return: Fields and values of the loop.
        :rtype: :py:class:`tuple`
        """
        fields = []
        values = []

        token = next(lexer)
        while token[0] == u"_":
            fields.append(token[1:])
            token = next(lexer)

        while token != u"stop_":
            values.append(token)
            token = next(lexer)

        assert float(len(values)/len(fields)).is_integer(), \
            "Error in loop construction: number of fields must be equal to number of values."

        values = [OrderedDict(zip(fields, values[i:i + len(fields)])) for i in range(0, len(values), len(fields))]
        return fields, values

    def print_file(self, f=sys.stdout, file_format="nmrstar", tw=3):
        """Print :class:`~nmrstarlib.nmrstarlib.StarFile` into a file or stdout.

        :param io.StringIO f: writable file-like stream.
        :param str file_format: Format to use: `nmrstar` or `json`.
        :param int tw: Tab width.
        :return: None
        :rtype: :py:obj:`None`
        """
        if file_format is "nmrstar":
            for saveframe in self.keys():
                if saveframe == u"data":
                    print(u"{}_{}\n".format(saveframe, self[saveframe]), file=f)
                elif saveframe.startswith(u"comment"):
                    print(u"{}".format(self[saveframe]), file=f)
                else:
                    print(u"{}".format(saveframe), file=f)
                    self.print_saveframe(saveframe, f, file_format, tw)
                    print(u"\nsave_\n\n", file=f)


        elif file_format is "json":
            print(self._to_json(), file=f)

    def print_saveframe(self, sf, f=sys.stdout, file_format="nmrstar", tw=3):
        """Print saveframe into a file or stdout.
        We need to keep track of how far over everything is tabbed. The "tab width"
        variable tw does this for us.

        :param str sf: Saveframe name.
        :param io.StringIO f: writable file-like stream.
        :param str file_format: Format to use: `nmrstar` or `json`.
        :param int tw: Tab width.
        :return: None
        :rtype: :py:obj:`None`
        """
        if file_format is "nmrstar":
            for sftag in self[sf].keys():
                # handle loops
                if sftag[:5] == "loop_":
                    print(u"\n{}loop_".format(tw * u" "), file=f)
                    self.print_loop(sf, sftag, f, file_format, tw * 2)
                    print(u"\n{}stop_".format(tw * u" "), file=f)

                # handle the NMR-Star "multiline string"
                elif self[sf][sftag].endswith(u"\n"):
                    print(u"{}_{}".format(tw * u" ", sftag), file=f)
                    print(u";\n{};".format(self[sf][sftag]), file=f)

                elif len(self[sf][sftag].split()) > 1:
                    # need to escape value with quotes (i.e. u"'{}'".format()) if value consists of two or more words
                    print(u"{}_{}\t {}".format(tw * u" ", sftag, u"'{}'".format(self[sf][sftag])), file=f)

                else:
                    print(u"{}_{}\t {}".format(tw * u" ", sftag, self[sf][sftag]), file=f)

        elif file_format is "json":
            print(json.dumps(self[sf], sort_keys=False, indent=4), file=f)

    def print_loop(self, sf, sftag, f=sys.stdout, file_format="nmrstar", tw=3):
        """Print loop into a file or stdout.

        :param str sf: Saveframe name.
        :param str sftag: Saveframe tag, i.e. field name.
        :param io.StringIO f: writable file-like stream.
{'fraction_non_alphanumeric': 0.11379912352584887, 'fraction_numerical': 0.004097338511419104, 'mean_word_length': 2.1540622541858636, 'pattern_counts': {'":': 38, '<': 3, '<?xml version=': 0, '>': 5, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '3794471', 'n_tokens_mistral': 8697, 'n_tokens_neox': 8274, 'n_words': 2083}