You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. #!/usr/bin/python3
  2. import os, sys, re, time, datetime
  3. import logging
  4. log = logging.getLogger()
  5. class DatabaseError(Exception):
  6. pass
  7. class ItemNotFoundError(DatabaseError, LookupError):
  8. pass
  9. class ItemNameCheckError(DatabaseError, ValueError):
  10. pass
  11. class ItemExistsError(DatabaseError):
  12. pass
  13. class List(object):
  14. """Base class for database lists.
  15. This class provides a real-time view to a file-based database. It can be
  16. used as an iterable with a little bit of magic (like indexing by a
  17. dictionary of matching attributes).
  18. """
  19. def __init__(self, year, data_path, db=None):
  20. self._year = year
  21. self._path = os.path.expanduser(data_path.format(
  22. year=year, directory=self._directory))
  23. self._db = db
  24. log.debug("{0}: {1}".format(self.__class__.__name__, self._path))
  25. def _item_class(self):
  26. """Returns class object used to instantiate items.
  27. Override in subclasses.
  28. """
  29. return Item
  30. def _item_name(self):
  31. return self._item_class().__name__.lower()
  32. def __iter__(self):
  33. for name in os.listdir(self._path):
  34. match = self._regex.match(name)
  35. if match:
  36. yield self._item_class()(self, year=self._year, **match.groupdict())
  37. def last(self):
  38. return max(iter(self), key=lambda item: item._name)
  39. def __contains__(self, selector):
  40. return bool(self._select(selector))
  41. def __getitem__(self, selector):
  42. items = self.select(selector)
  43. assert(len(items) < 2)
  44. if not items:
  45. raise ItemNotFoundError("{0} '{1}' not found.".format(self._item_class().__name__, selector))
  46. item = items[0]
  47. log.debug("Found matching item: {0}".format(item))
  48. return item
  49. def select(self, selector=None):
  50. """Select items by multiple attributes specified in a selector dict.
  51. Non-dict selectors can be specialcased. See _select docs to
  52. find out built-in special cases.
  53. """
  54. if selector is not None:
  55. return sorted(self._select(selector))
  56. else:
  57. return [self.last()]
  58. def _select(self, selector):
  59. """Return a list of items matching 'name', 'number' or other attributes.
  60. This function specialcases string and int. Other specializations
  61. can be done in subclasses that should call super()._select() with
  62. a dict, str or int argument.
  63. """
  64. if isinstance(selector, str):
  65. selector = {"name": selector}
  66. elif isinstance(selector, int):
  67. selector = {"number": selector}
  68. log.debug("Selecting: {0}".format(selector))
  69. assert isinstance(selector, dict)
  70. return [item for item in self
  71. if all(getattr(item, key) == selector[key] for key in selector)]
  72. def new(self, name):
  73. """Create a new item in this list.
  74. Keyword arguments:
  75. name -- filesystem name of the new item
  76. edit -- edit the item after creation
  77. Returns
  78. """
  79. log.info("Creating {0}: {1}".format(self._item_name(), name))
  80. if not self._regex.match(name):
  81. raise ItemNameCheckError("Name {0} doesn't match {1} regex.".format(name, self._item_name()))
  82. if name in self:
  83. raise ItemExistsError("Item {0} of type {1} already exists.".format(name, self._item_name()))
  84. self._new(os.path.join(self._path, name))
  85. return self[name]
  86. def _new(self, path):
  87. log.debug("Creating {0} file: {1}".format(self._item_name(), path))
  88. stream = os.fdopen(os.open(path, os.O_WRONLY|os.O_EXCL|os.O_CREAT, 0o644), "w")
  89. stream.write(self.data_template)
  90. stream.close()
  91. class Item(object):
  92. """Base class for database list items."""
  93. def __init__(self, list_, **selector):
  94. self._list = list_
  95. self._selector = selector
  96. self._postprocess()
  97. self._name = self._list._template.format(**selector)
  98. self._path = os.path.join(list_._path, self._name)
  99. log.debug("{0!r}".format(self))
  100. def _postprocess(self):
  101. """Postprocess the _selector attribute.
  102. Override in subclasses.
  103. """
  104. def __lt__(self, other):
  105. return self._name < other._name
  106. def __repr__(self):
  107. return "{0}({1!r}, **{2})".format(self.__class__.__name__, self._name, self._selector)
  108. def __str__(self):
  109. return self._name
  110. def __getattr__(self, key):
  111. return self._selector[key]
  112. def delete(self):
  113. log.info("Deleting: {0}".format(self))
  114. path = self._path
  115. newpath = path + "~"
  116. log.debug("Renaming file {0} to {1}.".format(path, newpath))
  117. assert os.path.exists(path)
  118. os.rename(path, newpath)
  119. def data(self):
  120. """Return item's data.
  121. Override in subclasses.
  122. """
  123. return self._data_class()(self)
  124. def _data_class(self):
  125. return Data
  126. class Data(object):
  127. """Base class for database list item data objects."""
  128. _fields = []
  129. _multivalue_fields = []
  130. _line_regex = re.compile(r"^([A-Z][a-zA-Z-]*):\s+(.*?)\s+$")
  131. _comment_regex = re.compile(r"^\s*#")
  132. def __init__(self, item):
  133. self._item = item
  134. self._parse(open(self._item._path))
  135. self._postprocess()
  136. def __getattr__(self, key):
  137. return self._data[key]
  138. def _parse(self, stream):
  139. self._data = self._item._selector.copy()
  140. for f in self._fields:
  141. self._data[f] = None
  142. for f in self._multivalue_fields:
  143. self._data[f] = []
  144. for n, line in enumerate(stream):
  145. if self._comment_regex.match(line):
  146. continue
  147. match = self._line_regex.match(line)
  148. if not match:
  149. log.warning("Ignoring {0}:{1}: {2}".format(n, self._item._name, line))
  150. continue
  151. key, value = match.groups()
  152. key = key.lower().replace("-", "_")
  153. if key in self._fields:
  154. self._data[key] = value
  155. elif key in self._multivalue_fields:
  156. self._data[key].append(value)
  157. else:
  158. log.warning("Key ignored: {0}".format(key))
  159. def _postprocess(self):
  160. """Postprocess item data.
  161. Override in subclasses.
  162. """
  163. def rename_key(self, oldkey, newkey):
  164. """Convenience function mainly intended for subclasses."""
  165. if not self._data.get(newkey) and oldkey in self._data:
  166. self._data[newkey] = self._data[oldkey]
  167. del self._data[oldkey]