Transition to libbe.LOG for logging
[be.git] / libbe / bugdir.py
index fa8edb93ebc4a7c9a21d2eb11e06980117856cb5..8b9e1e75d9b180881e84640a1d440249b5374aec 100644 (file)
@@ -1,25 +1,26 @@
-# Copyright (C) 2005-2010 Aaron Bentley and Panometrics, Inc.
+# Copyright (C) 2005-2012 Aaron Bentley <abentley@panoramicfeedback.com>
 #                         Alexander Belchenko <bialix@ukr.net>
 #                         Chris Ball <cjb@laptop.org>
 #                         Gianluca Montecchi <gian@grys.it>
 #                         Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
-#                         W. Trevor King <wking@drexel.edu>
+#                         W. Trevor King <wking@tremily.us>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This file is part of Bugs Everywhere.
 #
-# 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 General Public License for more details.
+# Bugs Everywhere is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 2 of the License, or (at your option) any
+# later version.
 #
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+# Bugs Everywhere 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 General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
 
-"""Define the :class:`BugDir` class for storing a collection of bugs.
+"""Define :py:class:`BugDir` for storing a collection of bugs.
 """
 
 import copy
@@ -27,6 +28,12 @@ import errno
 import os
 import os.path
 import time
+import types
+try: # import core module, Python >= 2.5
+    from xml.etree import ElementTree
+except ImportError: # look for non-core module
+    from elementtree import ElementTree
+import xml.sax.saxutils
 
 import libbe
 import libbe.storage as storage
@@ -48,31 +55,6 @@ if libbe.TESTING == True:
     import libbe.storage.base
 
 
-class NoBugDir(Exception):
-    def __init__(self, path):
-        msg = "The directory \"%s\" has no bug directory." % path
-        Exception.__init__(self, msg)
-        self.path = path
-
-class NoRootEntry(Exception):
-    def __init__(self, path):
-        self.path = path
-        Exception.__init__(self, "Specified root does not exist: %s" % path)
-
-class AlreadyInitialized(Exception):
-    def __init__(self, path):
-        self.path = path
-        Exception.__init__(self,
-                           "Specified root is already initialized: %s" % path)
-
-class MultipleBugMatches(ValueError):
-    def __init__(self, shortname, matches):
-        msg = ("More than one bug matches %s.  "
-               "Please be more specific.\n%s" % (shortname, matches))
-        ValueError.__init__(self, msg)
-        self.shortname = shortname
-        self.matches = matches
-
 class NoBugMatches(libbe.util.id.NoIDMatches):
     def __init__(self, *args, **kwargs):
         libbe.util.id.NoIDMatches.__init__(self, *args, **kwargs)
@@ -81,17 +63,27 @@ class NoBugMatches(libbe.util.id.NoIDMatches):
             return 'No bug matches %s' % self.id
         return self.msg
 
-class DiskAccessRequired (Exception):
-    def __init__(self, goal):
-        msg = "Cannot %s without accessing the disk" % goal
-        Exception.__init__(self, msg)
-
 
 class BugDir (list, settings_object.SavedSettingsObject):
-    """A BugDir is a container for :class:`libbe.bug.Bug`\s, with some
+    """A BugDir is a container for :py:class:`~libbe.bug.Bug`\s, with some
     additional attributes.
 
-    See :class:`SimpleBugDir` for some bugdir manipulation exampes.
+    Parameters
+    ----------
+    storage : :py:class:`~libbe.storage.base.Storage`
+       Storage instance containing the bug directory.  If
+       `from_storage` is `False`, `storage` may be `None`.
+    uuid : str, optional
+       Set the bugdir UUID (see :py:mod:`libbe.util.id`).
+       Useful if you are loading one of several bugdirs
+       stored in a single Storage instance.
+    from_storage : bool, optional
+       If `True`, attempt to load from storage.  Otherwise,
+       setup in memory, saving to `storage` if it is not `None`.
+
+    See Also
+    --------
+    SimpleBugDir : bugdir manipulation exampes.
     """
 
     settings_properties = []
@@ -191,7 +183,7 @@ class BugDir (list, settings_object.SavedSettingsObject):
     def load_settings(self, settings_mapfile=None):
         if settings_mapfile == None:
             settings_mapfile = \
-                self.storage.get(self.id.storage('settings'), default='\n')
+                self.storage.get(self.id.storage('settings'), default='{}\n')
         try:
             settings = mapfile.parse(settings_mapfile)
         except mapfile.InvalidMapfileContents, e:
@@ -228,20 +220,26 @@ class BugDir (list, settings_object.SavedSettingsObject):
                          directory=False)
         self.save_settings()
         for bug in self:
+            bug.bugdir = self
+            bug.storage = self.storage
             bug.save()
 
     # methods for managing bugs
 
     def uuids(self, use_cached_disk_uuids=True):
         if use_cached_disk_uuids==False or not hasattr(self, '_uuids_cache'):
-            self._uuids_cache = []
-            # list bugs that are in storage
-            if self.storage != None and self.storage.is_readable():
-                child_uuids = libbe.util.id.child_uuids(
-                    self.storage.children(self.id.storage()))
-                for id in child_uuids:
-                    self._uuids_cache.append(id)
-        return list(set([bug.uuid for bug in self] + self._uuids_cache))
+            self._refresh_uuid_cache()
+        self._uuids_cache = self._uuids_cache.union([bug.uuid for bug in self])
+        return self._uuids_cache
+
+    def _refresh_uuid_cache(self):
+        self._uuids_cache = set()
+        # list bugs that are in storage
+        if self.storage != None and self.storage.is_readable():
+            child_uuids = libbe.util.id.child_uuids(
+                self.storage.children(self.id.storage()))
+            for id in child_uuids:
+                self._uuids_cache.add(id)
 
     def _clear_bugs(self):
         while len(self) > 0:
@@ -259,12 +257,19 @@ class BugDir (list, settings_object.SavedSettingsObject):
     def new_bug(self, summary=None, _uuid=None):
         bg = bug.Bug(bugdir=self, uuid=_uuid, summary=summary,
                      from_storage=False)
-        self.append(bg)
-        self._bug_map_gen()
-        if hasattr(self, '_uuids_cache') and not bg.uuid in self._uuids_cache:
-            self._uuids_cache.append(bg.uuid)
+        self.append(bg, update=True)
         return bg
 
+    def append(self, bug, update=False):
+        super(BugDir, self).append(bug)
+        if update:
+            bug.bugdir = self
+            bug.storage = self.storage
+            self._bug_map_gen()
+            if (hasattr(self, '_uuids_cache') and
+                not bug.uuid in self._uuids_cache):
+                self._uuids_cache.add(bug.uuid)
+
     def remove_bug(self, bug):
         if hasattr(self, '_uuids_cache') and bug.uuid in self._uuids_cache:
             self._uuids_cache.remove(bug.uuid)
@@ -288,6 +293,379 @@ class BugDir (list, settings_object.SavedSettingsObject):
                 return False
         return True
 
+    def xml(self, indent=0, show_bugs=False, show_comments=False):
+        """
+        >>> bug.load_severities(bug.severity_def)
+        >>> bug.load_status(
+        ...     active_status_def=bug.active_status_def,
+        ...     inactive_status_def=bug.inactive_status_def)
+        >>> bugdirA = SimpleBugDir(memory=True)
+        >>> bugdirA.severities
+        >>> bugdirA.severities = (('minor', 'The standard bug level.'),)
+        >>> bugdirA.inactive_status = (
+        ...     ('closed', 'The bug is no longer relevant.'),)
+        >>> bugA = bugdirA.bug_from_uuid('a')
+        >>> commA = bugA.comment_root.new_reply(body='comment A')
+        >>> commA.uuid = 'commA'
+        >>> commA.date = 'Thu, 01 Jan 1970 00:03:00 +0000'
+        >>> print(bugdirA.xml(show_bugs=True, show_comments=True))
+        ... # doctest: +REPORT_UDIFF
+        <bugdir>
+          <uuid>abc123</uuid>
+          <short-name>abc</short-name>
+          <severities>
+            <entry>
+              <key>minor</key>
+              <value>The standard bug level.</value>
+            </entry>
+          </severities>
+          <inactive-status>
+            <entry>
+              <key>closed</key>
+              <value>The bug is no longer relevant.</value>
+            </entry>
+          </inactive-status>
+          <bug>
+            <uuid>a</uuid>
+            <short-name>abc/a</short-name>
+            <severity>minor</severity>
+            <status>open</status>
+            <creator>John Doe &lt;jdoe@example.com&gt;</creator>
+            <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
+            <summary>Bug A</summary>
+            <comment>
+              <uuid>commA</uuid>
+              <short-name>abc/a/com</short-name>
+              <author></author>
+              <date>Thu, 01 Jan 1970 00:03:00 +0000</date>
+              <content-type>text/plain</content-type>
+              <body>comment A</body>
+            </comment>
+          </bug>
+          <bug>
+            <uuid>b</uuid>
+            <short-name>abc/b</short-name>
+            <severity>minor</severity>
+            <status>closed</status>
+            <creator>Jane Doe &lt;jdoe@example.com&gt;</creator>
+            <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
+            <summary>Bug B</summary>
+          </bug>
+        </bugdir>
+        >>> bug.load_severities(bug.severity_def)
+        >>> bug.load_status(
+        ...     active_status_def=bug.active_status_def,
+        ...     inactive_status_def=bug.inactive_status_def)
+        >>> bugdirA.cleanup()
+        """
+        info = [('uuid', self.uuid),
+                ('short-name', self.id.user()),
+                ('target', self.target),
+                ('severities', self.severities),
+                ('active-status', self.active_status),
+                ('inactive-status', self.inactive_status),
+                ]
+        lines = ['<bugdir>']
+        for (k,v) in info:
+            if v is not None:
+                if k in ['severities', 'active-status', 'inactive-status']:
+                    lines.append('  <{}>'.format(k))
+                    for vk,vv in v:
+                        lines.extend([
+                                '    <entry>',
+                                '      <key>{}</key>'.format(
+                                    xml.sax.saxutils.escape(vk)),
+                                '      <value>{}</value>'.format(
+                                    xml.sax.saxutils.escape(vv)),
+                                '    </entry>',
+                                ])
+                    lines.append('  </{}>'.format(k))
+                else:
+                    v = xml.sax.saxutils.escape(v)
+                    lines.append('  <{0}>{1}</{0}>'.format(k, v))
+        for estr in self.extra_strings:
+            lines.append('  <extra-string>{}</extra-string>'.format(estr))
+        if show_bugs:
+            for bug in self:
+                bug_xml = bug.xml(indent=indent+2, show_comments=show_comments)
+                if bug_xml:
+                    bug_xml = bug_xml[indent:]  # strip leading indent spaces
+                    lines.append(bug_xml)
+        lines.append('</bugdir>')
+        istring = ' '*indent
+        sep = '\n' + istring
+        return istring + sep.join(lines).rstrip('\n')
+
+    def from_xml(self, xml_string, preserve_uuids=False):
+        """
+        Note: If a bugdir uuid is given, set .alt_id to it's value.
+        >>> bug.load_severities(bug.severity_def)
+        >>> bug.load_status(
+        ...     active_status_def=bug.active_status_def,
+        ...     inactive_status_def=bug.inactive_status_def)
+        >>> bugdirA = SimpleBugDir(memory=True)
+        >>> bugdirA.severities = (('minor', 'The standard bug level.'),)
+        >>> bugdirA.inactive_status = (
+        ...     ('closed', 'The bug is no longer relevant.'),)
+        >>> bugA = bugdirA.bug_from_uuid('a')
+        >>> commA = bugA.comment_root.new_reply(body='comment A')
+        >>> commA.uuid = 'commA'
+        >>> xml = bugdirA.xml(show_bugs=True, show_comments=True)
+        >>> bugdirB = BugDir(storage=None)
+        >>> bugdirB.from_xml(xml)
+        >>> bugdirB.xml(show_bugs=True, show_comments=True) == xml
+        False
+        >>> bugdirB.uuid = bugdirB.alt_id
+        >>> for bug_ in bugdirB:
+        ...     bug_.uuid = bug_.alt_id
+        ...     bug_.alt_id = None
+        ...     for comm in bug_.comments():
+        ...         comm.uuid = comm.alt_id
+        ...         comm.alt_id = None
+        >>> bugdirB.xml(show_bugs=True, show_comments=True) == xml
+        True
+        >>> bugdirB.explicit_attrs  # doctest: +NORMALIZE_WHITESPACE
+        ['severities', 'inactive_status']
+        >>> bugdirC = BugDir(storage=None)
+        >>> bugdirC.from_xml(xml, preserve_uuids=True)
+        >>> bugdirC.uuid == bugdirA.uuid
+        True
+        >>> bugdirC.xml(show_bugs=True, show_comments=True) == xml
+        True
+        >>> bug.load_severities(bug.severity_def)
+        >>> bug.load_status(
+        ...     active_status_def=bug.active_status_def,
+        ...     inactive_status_def=bug.inactive_status_def)
+        >>> bugdirA.cleanup()
+        """
+        if type(xml_string) == types.UnicodeType:
+            xml_string = xml_string.strip().encode('unicode_escape')
+        if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
+            bugdir = xml_string
+        else:
+            bugdir = ElementTree.XML(xml_string)
+        if bugdir.tag != 'bugdir':
+            raise utility.InvalidXML(
+                'bugdir', bugdir, 'root element must be <bugdir>')
+        tags = ['uuid', 'short-name', 'target', 'severities', 'active-status',
+                'inactive-status', 'extra-string']
+        self.explicit_attrs = []
+        uuid = None
+        estrs = []
+        for child in bugdir.getchildren():
+            if child.tag == 'short-name':
+                pass
+            elif child.tag == 'bug':
+                bg = bug.Bug(bugdir=self)
+                bg.from_xml(child, preserve_uuids=preserve_uuids)
+                self.append(bg, update=True)
+                continue
+            elif child.tag in tags:
+                if child.text == None or len(child.text) == 0:
+                    text = settings_object.EMPTY
+                elif child.tag in ['severities', 'active-status',
+                                   'inactive-status']:
+                    entries = []
+                    for entry in child.getchildren():
+                        if entry.tag != 'entry':
+                            raise utility.InvalidXML(
+                                '{} child element {} must be <entry>'.format(
+                                    child.tag, entry))
+                        key = value = None
+                        for kv in entry.getchildren():
+                            if kv.tag == 'key':
+                                if key is not None:
+                                    raise utility.InvalidXML(
+                                        ('duplicate keys ({} and {}) in {}'
+                                         ).format(key, kv.text, child.tag))
+                                key = xml.sax.saxutils.unescape(kv.text)
+                            elif kv.tag == 'value':
+                                if value is not None:
+                                    raise utility.InvalidXML(
+                                        ('duplicate values ({} and {}) in {}'
+                                         ).format(
+                                            value, kv.text, child.tag))
+                                value = xml.sax.saxutils.unescape(kv.text)
+                            else:
+                                raise utility.InvalidXML(
+                                    ('{} child element {} must be <key> or '
+                                     '<value>').format(child.tag, kv))
+                        if key is None:
+                            raise utility.InvalidXML(
+                                'no key for {}'.format(child.tag))
+                        if value is None:
+                            raise utility.InvalidXML(
+                                'no key for {}'.format(child.tag))
+                        entries.append((key, value))
+                    text = entries
+                else:
+                    text = xml.sax.saxutils.unescape(child.text)
+                    if not isinstance(text, unicode):
+                        text = text.decode('unicode_escape')
+                    text = text.strip()
+                if child.tag == 'uuid' and not preserve_uuids:
+                    uuid = text
+                    continue # don't set the bug's uuid tag.
+                elif child.tag == 'extra-string':
+                    estrs.append(text)
+                    continue # don't set the bug's extra_string yet.
+                attr_name = child.tag.replace('-','_')
+                self.explicit_attrs.append(attr_name)
+                setattr(self, attr_name, text)
+            else:
+                libbe.LOG.warning(
+                    'ignoring unknown tag {0} in {1}'.format(
+                        child.tag, bugdir.tag))
+        if uuid != self.uuid:
+            if not hasattr(self, 'alt_id') or self.alt_id == None:
+                self.alt_id = uuid
+        self.extra_strings = estrs
+
+    def merge(self, other, accept_changes=True,
+              accept_extra_strings=True, accept_bugs=True,
+              accept_comments=True, change_exception=False):
+        """Merge info from other into this bugdir.
+
+        Overrides any attributes in self that are listed in
+        other.explicit_attrs.
+
+        >>> bugdirA = SimpleBugDir()
+        >>> bugdirA.extra_strings += ['TAG: favorite']
+        >>> bugdirB = SimpleBugDir()
+        >>> bugdirB.explicit_attrs = ['target']
+        >>> bugdirB.target = '1234'
+        >>> bugdirB.extra_strings += ['TAG: very helpful']
+        >>> bugdirB.extra_strings += ['TAG: useful']
+        >>> bugA = bugdirB.bug_from_uuid('a')
+        >>> commA = bugA.comment_root.new_reply(body='comment A')
+        >>> commA.uuid = 'uuid-commA'
+        >>> commA.date = 'Thu, 01 Jan 1970 00:01:00 +0000'
+        >>> bugC = bugdirB.new_bug(summary='bug C', _uuid='c')
+        >>> bugC.alt_id = 'alt-c'
+        >>> bugC.time_string = 'Thu, 01 Jan 1970 00:02:00 +0000'
+        >>> bugdirA.merge(
+        ...     bugdirB, accept_changes=False, accept_extra_strings=False,
+        ...     accept_bugs=False, change_exception=False)
+        >>> print(bugdirA.target)
+        None
+        >>> bugdirA.merge(
+        ...     bugdirB, accept_changes=False, accept_extra_strings=False,
+        ...     accept_bugs=False, change_exception=True)
+        Traceback (most recent call last):
+          ...
+        ValueError: Merge would change target "None"->"1234" for bugdir abc123
+        >>> print(bugdirA.target)
+        None
+        >>> bugdirA.merge(
+        ...     bugdirB, accept_changes=True, accept_extra_strings=False,
+        ...     accept_bugs=False, change_exception=True)
+        Traceback (most recent call last):
+          ...
+        ValueError: Merge would add extra string "TAG: useful" for bugdir abc123
+        >>> print(bugdirA.target)
+        1234
+        >>> print(bugdirA.extra_strings)
+        ['TAG: favorite']
+        >>> bugdirA.merge(
+        ...     bugdirB, accept_changes=True, accept_extra_strings=True,
+        ...     accept_bugs=False, change_exception=True)
+        Traceback (most recent call last):
+          ...
+        ValueError: Merge would add bug c (alt: alt-c) to bugdir abc123
+        >>> print(bugdirA.extra_strings)
+        ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
+        >>> bugdirA.merge(
+        ...     bugdirB, accept_changes=True, accept_extra_strings=True,
+        ...     accept_bugs=True, change_exception=True)
+        >>> print(bugdirA.xml(show_bugs=True, show_comments=True))
+        ... # doctest: +ELLIPSIS, +REPORT_UDIFF
+        <bugdir>
+          <uuid>abc123</uuid>
+          <short-name>abc</short-name>
+          <target>1234</target>
+          <extra-string>TAG: favorite</extra-string>
+          <extra-string>TAG: useful</extra-string>
+          <extra-string>TAG: very helpful</extra-string>
+          <bug>
+            <uuid>a</uuid>
+            <short-name>abc/a</short-name>
+            <severity>minor</severity>
+            <status>open</status>
+            <creator>John Doe &lt;jdoe@example.com&gt;</creator>
+            <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
+            <summary>Bug A</summary>
+            <comment>
+              <uuid>uuid-commA</uuid>
+              <short-name>abc/a/uui</short-name>
+              <author></author>
+              <date>Thu, 01 Jan 1970 00:01:00 +0000</date>
+              <content-type>text/plain</content-type>
+              <body>comment A</body>
+            </comment>
+          </bug>
+          <bug>
+            <uuid>b</uuid>
+            <short-name>abc/b</short-name>
+            <severity>minor</severity>
+            <status>closed</status>
+            <creator>Jane Doe &lt;jdoe@example.com&gt;</creator>
+            <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
+            <summary>Bug B</summary>
+          </bug>
+          <bug>
+            <uuid>c</uuid>
+            <short-name>abc/c</short-name>
+            <severity>minor</severity>
+            <status>open</status>
+            <created>Thu, 01 Jan 1970 00:02:00 +0000</created>
+            <summary>bug C</summary>
+          </bug>
+        </bugdir>
+        >>> bugdirA.cleanup()
+        >>> bugdirB.cleanup()
+        """
+        if hasattr(other, 'explicit_attrs'):
+            for attr in other.explicit_attrs:
+                old = getattr(self, attr)
+                new = getattr(other, attr)
+                if old != new:
+                    if accept_changes:
+                        setattr(self, attr, new)
+                    elif change_exception:
+                        raise ValueError(
+                            ('Merge would change {} "{}"->"{}" for bugdir {}'
+                             ).format(attr, old, new, self.uuid))
+        for estr in other.extra_strings:
+            if not estr in self.extra_strings:
+                if accept_extra_strings:
+                    self.extra_strings += [estr]
+                elif change_exception:
+                    raise ValueError(
+                        ('Merge would add extra string "{}" for bugdir {}'
+                         ).format(estr, self.uuid))
+        for o_bug in other:
+            try:
+                s_bug = self.bug_from_uuid(o_bug.uuid)
+            except KeyError as e:
+                try:
+                    s_bug = self.bug_from_uuid(o_bug.alt_id)
+                except KeyError as e:
+                    s_bug = None
+            if s_bug is None:
+                if accept_bugs:
+                    o_bug_copy = copy.copy(o_bug)
+                    o_bug_copy.bugdir = self
+                    o_bug_copy.id = libbe.util.id.ID(o_bug_copy, 'bug')
+                    self.append(o_bug_copy)
+                elif change_exception:
+                    raise ValueError(
+                        ('Merge would add bug {} (alt: {}) to bugdir {}'
+                         ).format(o_bug.uuid, o_bug.alt_id, self.uuid))
+            else:
+                s_bug.merge(o_bug, accept_changes=accept_changes,
+                            accept_extra_strings=accept_extra_strings,
+                            change_exception=change_exception)
+
     # methods for id generation
 
     def sibling_uuids(self):