from __future__ import print_function
import collections
import re
import six
from .reading import loads, parse
from .writing import join_key_value
try:
from collections import OrderedDict
except ImportError:
from ordereddict import OrderedDict
_type_err = 'Keys & values of PropertiesFile objects must be strings'
PropertyLine = collections.namedtuple('PropertyLine', 'key value source')
[docs]class PropertiesFile(collections.MutableMapping):
"""
.. versionadded:: 0.3.0
A custom mapping class for reading from, editing, and writing to a
``.properties`` file while preserving comments & whitespace in the original
input.
A `PropertiesFile` instance can be constructed from another mapping and/or
iterable of pairs, after which it will act like an
`~collections.OrderedDict`. Alternatively, an instance can be constructed
from a file or string with `PropertiesFile.load()` or
`PropertiesFile.loads()`, and the resulting instance will remember the
formatting of its input and retain that formatting when written back to a
file or string with the `~PropertiesFile.dump()` or
`~PropertiesFile.dumps()` method. The formatting information attached to
an instance ``pf`` can be forgotten by constructing another mapping from it
via ``dict(pf)``, ``OrderedDict(pf)``, or even ``PropertiesFile(pf)`` (Use
the `copy()` method if you want to create another `PropertiesFile` instance
with the same data & formatting).
When not reading or writing, `PropertiesFile` behaves like a normal
`~collections.MutableMapping` class (i.e., you can do ``props[key] =
value`` and so forth), except that (a) like `~collections.OrderedDict`, key
insertion order is remembered and is used when iterating & dumping (and
`reversed` is supported), and (b) like `Properties`, it may only be used to
store strings and will raise a `~exceptions.TypeError` if passed a
non-string object as key or value.
Two `PropertiesFile` instances compare equal iff both their key-value pairs
and comment & whitespace lines are equal and in the same order. When
comparing a `PropertiesFile` to any other type of mapping, only the
key-value pairs are considered, and order is ignored.
`PropertiesFile` currently only supports reading & writing the simple
line-oriented format, not XML.
"""
def __init__(self, mapping=None, **kwargs):
#: mapping from keys to list of line numbers
self._indices = OrderedDict()
#: mapping from line numbers to (key, value, source) tuples
self._lines = OrderedDict()
if mapping is not None:
self.update(mapping)
self.update(kwargs)
def _check(self):
"""
Assert the internal consistency of the instance's data structures.
This method is for debugging only.
"""
for k,ix in six.iteritems(self._indices):
assert k is not None, 'null key'
assert ix, 'Key does not map to any indices'
assert ix == sorted(ix), "Key's indices are not in order"
for i in ix:
assert i in self._lines, 'Key index does not map to line'
assert self._lines[i].key is not None, 'Key maps to comment'
assert self._lines[i].key == k, 'Key does not map to itself'
assert self._lines[i].value is not None, 'Key has null value'
prev = None
for i, line in six.iteritems(self._lines):
assert prev is None or prev < i, 'Line indices out of order'
prev = i
if line.key is None:
assert line.value is None, 'Comment/blank has value'
assert line.source is not None, 'Comment source not stored'
assert loads(line.source) == {}, 'Comment source is not comment'
else:
assert line.value is not None, 'Key has null value'
if line.source is not None:
assert loads(line.source) == {line.key: line.value}, \
'Key source does not deserialize to itself'
assert line.key in self._indices, 'Key is missing from map'
assert i in self._indices[line.key], \
'Key does not map to itself'
def __getitem__(self, key):
if not isinstance(key, six.string_types):
raise TypeError(_type_err)
return self._lines[self._indices[key][-1]].value
def __setitem__(self, key, value):
if not isinstance(key, six.string_types) or \
not isinstance(value, six.string_types):
raise TypeError(_type_err)
try:
ixes = self._indices[key]
except KeyError:
try:
lasti = next(reversed(self._lines))
except StopIteration:
ix = 0
else:
ix = lasti + 1
# We're adding a line to the end of the file, so make sure the
# line before it ends with a newline and (if it's not a
# comment) doesn't end with a trailing line continuation.
lastline = self._lines[lasti]
if lastline.source is not None:
lastsrc = lastline.source
if lastline.key is not None:
lastsrc=re.sub(r'(?<!\\)((?:\\\\)*)\\$', r'\1', lastsrc)
if not lastsrc.endswith(('\r', '\n')):
lastsrc += '\n'
self._lines[lasti] = lastline._replace(source=lastsrc)
else:
# Update the first occurrence of the key and discard the rest.
# This way, the order in which the keys are listed in the file and
# dict will be preserved.
ix = ixes.pop(0)
for i in ixes:
del self._lines[i]
self._indices[key] = [ix]
self._lines[ix] = PropertyLine(key, value, None)
def __delitem__(self, key):
if not isinstance(key, six.string_types):
raise TypeError(_type_err)
for i in self._indices.pop(key):
del self._lines[i]
def __iter__(self):
return iter(self._indices)
def __reversed__(self):
return reversed(self._indices)
def __len__(self):
return len(self._indices)
def _comparable(self):
return [
(None, line.source) if line.key is None else (line.key, line.value)
for i, line in six.iteritems(self._lines)
### TODO: Also include non-final repeated keys???
if line.key is None or self._indices[line.key][-1] == i
]
def __eq__(self, other):
if isinstance(other, PropertiesFile):
return self._comparable() == other._comparable()
### TODO: Special-case OrderedDict?
elif isinstance(other, collections.Mapping):
return dict(self) == other
else:
return NotImplemented
def __ne__(self, other):
return not (self == other)
#def __repr__(self):
@classmethod
[docs] def load(cls, fp):
"""
Parse the contents of the `~io.IOBase.readline`-supporting file-like
object ``fp`` as a simple line-oriented ``.properties`` file and return
a `PropertiesFile` instance.
``fp`` may be either a text or binary filehandle, with or without
universal newlines enabled. If it is a binary filehandle, its contents
are decoded as Latin-1.
:param fp: the file from which to read the ``.properties`` document
:type fp: file-like object
:rtype: PropertiesFile
"""
obj = cls()
for i, (k, v, src) in enumerate(parse(fp)):
if k is not None:
obj._indices.setdefault(k, []).append(i)
obj._lines[i] = PropertyLine(k, v, src)
return obj
@classmethod
[docs] def loads(cls, s):
"""
Parse the contents of the string ``s`` as a simple line-oriented
``.properties`` file and return a `PropertiesFile` instance.
``s`` may be either a text string or bytes string. If it is a bytes
string, its contents are decoded as Latin-1.
:param string s: the string from which to read the ``.properties``
document
:rtype: PropertiesFile
"""
if isinstance(s, six.binary_type):
fp = six.BytesIO(s)
else:
fp = six.StringIO(s)
return cls.load(fp)
[docs] def dump(self, fp, separator='='):
"""
Write the mapping to a file in simple line-oriented ``.properties``
format.
If the instance was originally created from a file or string with
`PropertiesFile.load()` or `PropertiesFile.loads()`, then the output
will include the comments and whitespace from the original input, and
any keys that haven't been deleted or reassigned will retain their
original formatting and multiplicity. Key-value pairs that have been
modified or added to the mapping will be reformatted with
`join_key_value()` using the given separator. All key-value pairs are
output in the order they were defined, with new keys added to the end.
.. note::
Serializing a `PropertiesFile` instance with the :func:`dump()`
function instead will cause all formatting information to be
ignored, as :func:`dump()` will treat the instance like a normal
mapping.
:param fp: A file-like object to write the mapping to. It must have
been opened as a text file with a Latin-1-compatible encoding.
:param separator: The string to use for separating new or modified keys
& values. Only ``" "``, ``"="``, and ``":"`` (possibly with added
whitespace) should ever be used as the separator.
:type separator: text string
:return: `None`
"""
### TODO: Support setting the timestamp
for line in six.itervalues(self._lines):
if line.source is None:
print(join_key_value(line.key, line.value, separator), file=fp)
else:
fp.write(line.source)
[docs] def dumps(self, separator='='):
"""
Convert the mapping to a text string in simple line-oriented
``.properties`` format.
If the instance was originally created from a file or string with
`PropertiesFile.load()` or `PropertiesFile.loads()`, then the output
will include the comments and whitespace from the original input, and
any keys that haven't been deleted or reassigned will retain their
original formatting and multiplicity. Key-value pairs that have been
modified or added to the mapping will be reformatted with
`join_key_value()` using the given separator. All key-value pairs are
output in the order they were defined, with new keys added to the end.
.. note::
Serializing a `PropertiesFile` instance with the :func:`dumps()`
function instead will cause all formatting information to be
ignored, as :func:`dumps()` will treat the instance like a normal
mapping.
:param separator: The string to use for separating new or modified keys
& values. Only ``" "``, ``"="``, and ``":"`` (possibly with added
whitespace) should ever be used as the separator.
:type separator: text string
:rtype: text string
"""
s = six.StringIO()
self.dump(s, separator=separator)
return s.getvalue()
[docs] def copy(self):
""" Create a copy of the mapping, including formatting information """
dup = self.__class__()
dup._indices = OrderedDict(
(k, list(v)) for k,v in six.iteritems(self._indices)
)
dup._lines = self._lines.copy()
return dup