40097f714fd6562b4ec7eb242b7b6f002cfe556f
[be.git] / libbe / bugdir.py
1 # Copyright (C) 2005-2012 Aaron Bentley <abentley@panoramicfeedback.com>
2 #                         Alexander Belchenko <bialix@ukr.net>
3 #                         Chris Ball <cjb@laptop.org>
4 #                         Gianluca Montecchi <gian@grys.it>
5 #                         Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
6 #                         W. Trevor King <wking@tremily.us>
7 #
8 # This file is part of Bugs Everywhere.
9 #
10 # Bugs Everywhere is free software: you can redistribute it and/or modify it
11 # under the terms of the GNU General Public License as published by the Free
12 # Software Foundation, either version 2 of the License, or (at your option) any
13 # later version.
14 #
15 # Bugs Everywhere is distributed in the hope that it will be useful, but
16 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
17 # FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
18 # more details.
19 #
20 # You should have received a copy of the GNU General Public License along with
21 # Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
22
23 """Define :py:class:`BugDir` for storing a collection of bugs.
24 """
25
26 import copy
27 import errno
28 import os
29 import os.path
30 import time
31 import types
32 try: # import core module, Python >= 2.5
33     from xml.etree import ElementTree
34 except ImportError: # look for non-core module
35     from elementtree import ElementTree
36 import xml.sax.saxutils
37
38 import libbe
39 import libbe.storage as storage
40 from libbe.storage.util.properties import Property, doc_property, \
41     local_property, defaulting_property, checked_property, \
42     fn_checked_property, cached_property, primed_property, \
43     change_hook_property, settings_property
44 import libbe.storage.util.settings_object as settings_object
45 import libbe.storage.util.mapfile as mapfile
46 import libbe.bug as bug
47 import libbe.util.utility as utility
48 import libbe.util.id
49
50 if libbe.TESTING == True:
51     import doctest
52     import sys
53     import unittest
54
55     import libbe.storage.base
56
57
58 class NoBugMatches(libbe.util.id.NoIDMatches):
59     def __init__(self, *args, **kwargs):
60         libbe.util.id.NoIDMatches.__init__(self, *args, **kwargs)
61     def __str__(self):
62         if self.msg == None:
63             return 'No bug matches %s' % self.id
64         return self.msg
65
66
67 class BugDir (list, settings_object.SavedSettingsObject):
68     """A BugDir is a container for :py:class:`~libbe.bug.Bug`\s, with some
69     additional attributes.
70
71     Parameters
72     ----------
73     storage : :py:class:`~libbe.storage.base.Storage`
74        Storage instance containing the bug directory.  If
75        `from_storage` is `False`, `storage` may be `None`.
76     uuid : str, optional
77        Set the bugdir UUID (see :py:mod:`libbe.util.id`).
78        Useful if you are loading one of several bugdirs
79        stored in a single Storage instance.
80     from_storage : bool, optional
81        If `True`, attempt to load from storage.  Otherwise,
82        setup in memory, saving to `storage` if it is not `None`.
83
84     See Also
85     --------
86     SimpleBugDir : bugdir manipulation exampes.
87     """
88
89     settings_properties = []
90     required_saved_properties = []
91     _prop_save_settings = settings_object.prop_save_settings
92     _prop_load_settings = settings_object.prop_load_settings
93     def _versioned_property(settings_properties=settings_properties,
94                             required_saved_properties=required_saved_properties,
95                             **kwargs):
96         if "settings_properties" not in kwargs:
97             kwargs["settings_properties"] = settings_properties
98         if "required_saved_properties" not in kwargs:
99             kwargs["required_saved_properties"]=required_saved_properties
100         return settings_object.versioned_property(**kwargs)
101
102     @_versioned_property(name="target",
103                          doc="The current project development target.")
104     def target(): return {}
105
106     def _setup_severities(self, severities):
107         if severities not in [None, settings_object.EMPTY]:
108             bug.load_severities(severities)
109     def _set_severities(self, old_severities, new_severities):
110         self._setup_severities(new_severities)
111         self._prop_save_settings(old_severities, new_severities)
112     @_versioned_property(name="severities",
113                          doc="The allowed bug severities and their descriptions.",
114                          change_hook=_set_severities)
115     def severities(): return {}
116
117     def _setup_status(self, active_status, inactive_status):
118         bug.load_status(active_status, inactive_status)
119     def _set_active_status(self, old_active_status, new_active_status):
120         self._setup_status(new_active_status, self.inactive_status)
121         self._prop_save_settings(old_active_status, new_active_status)
122     @_versioned_property(name="active_status",
123                          doc="The allowed active bug states and their descriptions.",
124                          change_hook=_set_active_status)
125     def active_status(): return {}
126
127     def _set_inactive_status(self, old_inactive_status, new_inactive_status):
128         self._setup_status(self.active_status, new_inactive_status)
129         self._prop_save_settings(old_inactive_status, new_inactive_status)
130     @_versioned_property(name="inactive_status",
131                          doc="The allowed inactive bug states and their descriptions.",
132                          change_hook=_set_inactive_status)
133     def inactive_status(): return {}
134
135     def _extra_strings_check_fn(value):
136         return utility.iterable_full_of_strings(value, \
137                          alternative=settings_object.EMPTY)
138     def _extra_strings_change_hook(self, old, new):
139         self.extra_strings.sort() # to make merging easier
140         self._prop_save_settings(old, new)
141     @_versioned_property(name="extra_strings",
142                          doc="Space for an array of extra strings.  Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
143                          default=[],
144                          check_fn=_extra_strings_check_fn,
145                          change_hook=_extra_strings_change_hook,
146                          mutable=True)
147     def extra_strings(): return {}
148
149     def _bug_map_gen(self):
150         map = {}
151         for bug in self:
152             map[bug.uuid] = bug
153         for uuid in self.uuids():
154             if uuid not in map:
155                 map[uuid] = None
156         self._bug_map_value = map # ._bug_map_value used by @local_property
157
158     @Property
159     @primed_property(primer=_bug_map_gen)
160     @local_property("bug_map")
161     @doc_property(doc="A dict of (bug-uuid, bug-instance) pairs.")
162     def _bug_map(): return {}
163
164     def __init__(self, storage, uuid=None, from_storage=False):
165         list.__init__(self)
166         settings_object.SavedSettingsObject.__init__(self)
167         self.storage = storage
168         self.id = libbe.util.id.ID(self, 'bugdir')
169         self.uuid = uuid
170         if from_storage == True:
171             if self.uuid == None:
172                 self.uuid = [c for c in self.storage.children()
173                              if c != 'version'][0]
174             self.load_settings()
175         else:
176             if self.uuid == None:
177                 self.uuid = libbe.util.id.uuid_gen()
178             if self.storage != None and self.storage.is_writeable():
179                 self.save()
180
181     # methods for saving/loading/accessing settings and properties.
182
183     def load_settings(self, settings_mapfile=None):
184         if settings_mapfile == None:
185             settings_mapfile = \
186                 self.storage.get(self.id.storage('settings'), default='{}\n')
187         try:
188             settings = mapfile.parse(settings_mapfile)
189         except mapfile.InvalidMapfileContents, e:
190             raise Exception('Invalid settings file for bugdir %s\n'
191                             '(BE version missmatch?)' % self.id.user())
192         self._setup_saved_settings(settings)
193         self._setup_severities(self.severities)
194         self._setup_status(self.active_status, self.inactive_status)
195
196     def save_settings(self):
197         mf = mapfile.generate(self._get_saved_settings())
198         self.storage.set(self.id.storage('settings'), mf)
199
200     def load_all_bugs(self):
201         """
202         Warning: this could take a while.
203         """
204         self._clear_bugs()
205         for uuid in self.uuids():
206             self._load_bug(uuid)
207
208     def save(self):
209         """
210         Save any loaded contents to storage.  Because of lazy loading
211         of bugs and comments, this is actually not too inefficient.
212
213         However, if self.storage.is_writeable() == True, then any
214         changes are automatically written to storage as soon as they
215         happen, so calling this method will just waste time (unless
216         something else has been messing with your stored files).
217         """
218         self.storage.add(self.id.storage(), directory=True)
219         self.storage.add(self.id.storage('settings'), parent=self.id.storage(),
220                          directory=False)
221         self.save_settings()
222         for bug in self:
223             bug.bugdir = self
224             bug.storage = self.storage
225             bug.save()
226
227     # methods for managing bugs
228
229     def uuids(self, use_cached_disk_uuids=True):
230         if use_cached_disk_uuids==False or not hasattr(self, '_uuids_cache'):
231             self._refresh_uuid_cache()
232         self._uuids_cache = self._uuids_cache.union([bug.uuid for bug in self])
233         return self._uuids_cache
234
235     def _refresh_uuid_cache(self):
236         self._uuids_cache = set()
237         # list bugs that are in storage
238         if self.storage != None and self.storage.is_readable():
239             child_uuids = libbe.util.id.child_uuids(
240                 self.storage.children(self.id.storage()))
241             for id in child_uuids:
242                 self._uuids_cache.add(id)
243
244     def _clear_bugs(self):
245         while len(self) > 0:
246             self.pop()
247         if hasattr(self, '_uuids_cache'):
248             del(self._uuids_cache)
249         self._bug_map_gen()
250
251     def _load_bug(self, uuid):
252         bg = bug.Bug(bugdir=self, uuid=uuid, from_storage=True)
253         self.append(bg)
254         self._bug_map_gen()
255         return bg
256
257     def new_bug(self, summary=None, _uuid=None):
258         bg = bug.Bug(bugdir=self, uuid=_uuid, summary=summary,
259                      from_storage=False)
260         self.append(bg, update=True)
261         return bg
262
263     def append(self, bug, update=False):
264         super(BugDir, self).append(bug)
265         if update:
266             bug.bugdir = self
267             bug.storage = self.storage
268             self._bug_map_gen()
269             if (hasattr(self, '_uuids_cache') and
270                 not bug.uuid in self._uuids_cache):
271                 self._uuids_cache.add(bug.uuid)
272
273     def remove_bug(self, bug):
274         if hasattr(self, '_uuids_cache') and bug.uuid in self._uuids_cache:
275             self._uuids_cache.remove(bug.uuid)
276         self.remove(bug)
277         if self.storage != None and self.storage.is_writeable():
278             bug.remove()
279
280     def bug_from_uuid(self, uuid):
281         if not self.has_bug(uuid):
282             raise NoBugMatches(
283                 uuid, self.uuids(),
284                 'No bug matches %s in %s' % (uuid, self.storage))
285         if self._bug_map[uuid] == None:
286             self._load_bug(uuid)
287         return self._bug_map[uuid]
288
289     def has_bug(self, bug_uuid):
290         if bug_uuid not in self._bug_map:
291             self._bug_map_gen()
292             if bug_uuid not in self._bug_map:
293                 return False
294         return True
295
296     def xml(self, indent=0, show_bugs=False, show_comments=False):
297         """
298         >>> bug.load_severities(bug.severity_def)
299         >>> bug.load_status(
300         ...     active_status_def=bug.active_status_def,
301         ...     inactive_status_def=bug.inactive_status_def)
302         >>> bugdirA = SimpleBugDir(memory=True)
303         >>> bugdirA.severities
304         >>> bugdirA.severities = (('minor', 'The standard bug level.'),)
305         >>> bugdirA.inactive_status = (
306         ...     ('closed', 'The bug is no longer relevant.'),)
307         >>> bugA = bugdirA.bug_from_uuid('a')
308         >>> commA = bugA.comment_root.new_reply(body='comment A')
309         >>> commA.uuid = 'commA'
310         >>> commA.date = 'Thu, 01 Jan 1970 00:03:00 +0000'
311         >>> print(bugdirA.xml(show_bugs=True, show_comments=True))
312         ... # doctest: +REPORT_UDIFF
313         <bugdir>
314           <uuid>abc123</uuid>
315           <short-name>abc</short-name>
316           <severities>
317             <entry>
318               <key>minor</key>
319               <value>The standard bug level.</value>
320             </entry>
321           </severities>
322           <inactive-status>
323             <entry>
324               <key>closed</key>
325               <value>The bug is no longer relevant.</value>
326             </entry>
327           </inactive-status>
328           <bug>
329             <uuid>a</uuid>
330             <short-name>abc/a</short-name>
331             <severity>minor</severity>
332             <status>open</status>
333             <creator>John Doe &lt;jdoe@example.com&gt;</creator>
334             <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
335             <summary>Bug A</summary>
336             <comment>
337               <uuid>commA</uuid>
338               <short-name>abc/a/com</short-name>
339               <author></author>
340               <date>Thu, 01 Jan 1970 00:03:00 +0000</date>
341               <content-type>text/plain</content-type>
342               <body>comment A</body>
343             </comment>
344           </bug>
345           <bug>
346             <uuid>b</uuid>
347             <short-name>abc/b</short-name>
348             <severity>minor</severity>
349             <status>closed</status>
350             <creator>Jane Doe &lt;jdoe@example.com&gt;</creator>
351             <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
352             <summary>Bug B</summary>
353           </bug>
354         </bugdir>
355         >>> bug.load_severities(bug.severity_def)
356         >>> bug.load_status(
357         ...     active_status_def=bug.active_status_def,
358         ...     inactive_status_def=bug.inactive_status_def)
359         >>> bugdirA.cleanup()
360         """
361         info = [('uuid', self.uuid),
362                 ('short-name', self.id.user()),
363                 ('target', self.target),
364                 ('severities', self.severities),
365                 ('active-status', self.active_status),
366                 ('inactive-status', self.inactive_status),
367                 ]
368         lines = ['<bugdir>']
369         for (k,v) in info:
370             if v is not None:
371                 if k in ['severities', 'active-status', 'inactive-status']:
372                     lines.append('  <{}>'.format(k))
373                     for vk,vv in v:
374                         lines.extend([
375                                 '    <entry>',
376                                 '      <key>{}</key>'.format(
377                                     xml.sax.saxutils.escape(vk)),
378                                 '      <value>{}</value>'.format(
379                                     xml.sax.saxutils.escape(vv)),
380                                 '    </entry>',
381                                 ])
382                     lines.append('  </{}>'.format(k))
383                 else:
384                     v = xml.sax.saxutils.escape(v)
385                     lines.append('  <{0}>{1}</{0}>'.format(k, v))
386         for estr in self.extra_strings:
387             lines.append('  <extra-string>{}</extra-string>'.format(estr))
388         if show_bugs:
389             for bug in self:
390                 bug_xml = bug.xml(indent=indent+2, show_comments=show_comments)
391                 if bug_xml:
392                     bug_xml = bug_xml[indent:]  # strip leading indent spaces
393                     lines.append(bug_xml)
394         lines.append('</bugdir>')
395         istring = ' '*indent
396         sep = '\n' + istring
397         return istring + sep.join(lines).rstrip('\n')
398
399     def from_xml(self, xml_string, preserve_uuids=False, verbose=True):
400         """
401         Note: If a bugdir uuid is given, set .alt_id to it's value.
402         >>> bug.load_severities(bug.severity_def)
403         >>> bug.load_status(
404         ...     active_status_def=bug.active_status_def,
405         ...     inactive_status_def=bug.inactive_status_def)
406         >>> bugdirA = SimpleBugDir(memory=True)
407         >>> bugdirA.severities = (('minor', 'The standard bug level.'),)
408         >>> bugdirA.inactive_status = (
409         ...     ('closed', 'The bug is no longer relevant.'),)
410         >>> bugA = bugdirA.bug_from_uuid('a')
411         >>> commA = bugA.comment_root.new_reply(body='comment A')
412         >>> commA.uuid = 'commA'
413         >>> xml = bugdirA.xml(show_bugs=True, show_comments=True)
414         >>> bugdirB = BugDir(storage=None)
415         >>> bugdirB.from_xml(xml)
416         >>> bugdirB.xml(show_bugs=True, show_comments=True) == xml
417         False
418         >>> bugdirB.uuid = bugdirB.alt_id
419         >>> for bug_ in bugdirB:
420         ...     bug_.uuid = bug_.alt_id
421         ...     bug_.alt_id = None
422         ...     for comm in bug_.comments():
423         ...         comm.uuid = comm.alt_id
424         ...         comm.alt_id = None
425         >>> bugdirB.xml(show_bugs=True, show_comments=True) == xml
426         True
427         >>> bugdirB.explicit_attrs  # doctest: +NORMALIZE_WHITESPACE
428         ['severities', 'inactive_status']
429         >>> bugdirC = BugDir(storage=None)
430         >>> bugdirC.from_xml(xml, preserve_uuids=True)
431         >>> bugdirC.uuid == bugdirA.uuid
432         True
433         >>> bugdirC.xml(show_bugs=True, show_comments=True) == xml
434         True
435         >>> bug.load_severities(bug.severity_def)
436         >>> bug.load_status(
437         ...     active_status_def=bug.active_status_def,
438         ...     inactive_status_def=bug.inactive_status_def)
439         >>> bugdirA.cleanup()
440         """
441         if type(xml_string) == types.UnicodeType:
442             xml_string = xml_string.strip().encode('unicode_escape')
443         if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
444             bugdir = xml_string
445         else:
446             bugdir = ElementTree.XML(xml_string)
447         if bugdir.tag != 'bugdir':
448             raise utility.InvalidXML(
449                 'bugdir', bugdir, 'root element must be <bugdir>')
450         tags = ['uuid', 'short-name', 'target', 'severities', 'active-status',
451                 'inactive-status', 'extra-string']
452         self.explicit_attrs = []
453         uuid = None
454         estrs = []
455         for child in bugdir.getchildren():
456             if child.tag == 'short-name':
457                 pass
458             elif child.tag == 'bug':
459                 bg = bug.Bug(bugdir=self)
460                 bg.from_xml(
461                     child, preserve_uuids=preserve_uuids, verbose=verbose)
462                 self.append(bg, update=True)
463                 continue
464             elif child.tag in tags:
465                 if child.text == None or len(child.text) == 0:
466                     text = settings_object.EMPTY
467                 elif child.tag in ['severities', 'active-status',
468                                    'inactive-status']:
469                     entries = []
470                     for entry in child.getchildren():
471                         if entry.tag != 'entry':
472                             raise utility.InvalidXML(
473                                 '{} child element {} must be <entry>'.format(
474                                     child.tag, entry))
475                         key = value = None
476                         for kv in entry.getchildren():
477                             if kv.tag == 'key':
478                                 if key is not None:
479                                     raise utility.InvalidXML(
480                                         ('duplicate keys ({} and {}) in {}'
481                                          ).format(key, kv.text, child.tag))
482                                 key = xml.sax.saxutils.unescape(kv.text)
483                             elif kv.tag == 'value':
484                                 if value is not None:
485                                     raise utility.InvalidXML(
486                                         ('duplicate values ({} and {}) in {}'
487                                          ).format(
488                                             value, kv.text, child.tag))
489                                 value = xml.sax.saxutils.unescape(kv.text)
490                             else:
491                                 raise utility.InvalidXML(
492                                     ('{} child element {} must be <key> or '
493                                      '<value>').format(child.tag, kv))
494                         if key is None:
495                             raise utility.InvalidXML(
496                                 'no key for {}'.format(child.tag))
497                         if value is None:
498                             raise utility.InvalidXML(
499                                 'no key for {}'.format(child.tag))
500                         entries.append((key, value))
501                     text = entries
502                 else:
503                     text = xml.sax.saxutils.unescape(child.text)
504                     if not isinstance(text, unicode):
505                         text = text.decode('unicode_escape')
506                     text = text.strip()
507                 if child.tag == 'uuid' and not preserve_uuids:
508                     uuid = text
509                     continue # don't set the bug's uuid tag.
510                 elif child.tag == 'extra-string':
511                     estrs.append(text)
512                     continue # don't set the bug's extra_string yet.
513                 attr_name = child.tag.replace('-','_')
514                 self.explicit_attrs.append(attr_name)
515                 setattr(self, attr_name, text)
516             elif verbose == True:
517                 sys.stderr.write('Ignoring unknown tag {} in {}\n'.format(
518                         child.tag, bugdir.tag))
519         if uuid != self.uuid:
520             if not hasattr(self, 'alt_id') or self.alt_id == None:
521                 self.alt_id = uuid
522         self.extra_strings = estrs
523
524     def merge(self, other, accept_changes=True,
525               accept_extra_strings=True, accept_bugs=True,
526               accept_comments=True, change_exception=False):
527         """Merge info from other into this bugdir.
528
529         Overrides any attributes in self that are listed in
530         other.explicit_attrs.
531
532         >>> bugdirA = SimpleBugDir()
533         >>> bugdirA.extra_strings += ['TAG: favorite']
534         >>> bugdirB = SimpleBugDir()
535         >>> bugdirB.explicit_attrs = ['target']
536         >>> bugdirB.target = '1234'
537         >>> bugdirB.extra_strings += ['TAG: very helpful']
538         >>> bugdirB.extra_strings += ['TAG: useful']
539         >>> bugA = bugdirB.bug_from_uuid('a')
540         >>> commA = bugA.comment_root.new_reply(body='comment A')
541         >>> commA.uuid = 'uuid-commA'
542         >>> commA.date = 'Thu, 01 Jan 1970 00:01:00 +0000'
543         >>> bugC = bugdirB.new_bug(summary='bug C', _uuid='c')
544         >>> bugC.alt_id = 'alt-c'
545         >>> bugC.time_string = 'Thu, 01 Jan 1970 00:02:00 +0000'
546         >>> bugdirA.merge(
547         ...     bugdirB, accept_changes=False, accept_extra_strings=False,
548         ...     accept_bugs=False, change_exception=False)
549         >>> print(bugdirA.target)
550         None
551         >>> bugdirA.merge(
552         ...     bugdirB, accept_changes=False, accept_extra_strings=False,
553         ...     accept_bugs=False, change_exception=True)
554         Traceback (most recent call last):
555           ...
556         ValueError: Merge would change target "None"->"1234" for bugdir abc123
557         >>> print(bugdirA.target)
558         None
559         >>> bugdirA.merge(
560         ...     bugdirB, accept_changes=True, accept_extra_strings=False,
561         ...     accept_bugs=False, change_exception=True)
562         Traceback (most recent call last):
563           ...
564         ValueError: Merge would add extra string "TAG: useful" for bugdir abc123
565         >>> print(bugdirA.target)
566         1234
567         >>> print(bugdirA.extra_strings)
568         ['TAG: favorite']
569         >>> bugdirA.merge(
570         ...     bugdirB, accept_changes=True, accept_extra_strings=True,
571         ...     accept_bugs=False, change_exception=True)
572         Traceback (most recent call last):
573           ...
574         ValueError: Merge would add bug c (alt: alt-c) to bugdir abc123
575         >>> print(bugdirA.extra_strings)
576         ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
577         >>> bugdirA.merge(
578         ...     bugdirB, accept_changes=True, accept_extra_strings=True,
579         ...     accept_bugs=True, change_exception=True)
580         >>> print(bugdirA.xml(show_bugs=True, show_comments=True))
581         ... # doctest: +ELLIPSIS, +REPORT_UDIFF
582         <bugdir>
583           <uuid>abc123</uuid>
584           <short-name>abc</short-name>
585           <target>1234</target>
586           <extra-string>TAG: favorite</extra-string>
587           <extra-string>TAG: useful</extra-string>
588           <extra-string>TAG: very helpful</extra-string>
589           <bug>
590             <uuid>a</uuid>
591             <short-name>abc/a</short-name>
592             <severity>minor</severity>
593             <status>open</status>
594             <creator>John Doe &lt;jdoe@example.com&gt;</creator>
595             <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
596             <summary>Bug A</summary>
597             <comment>
598               <uuid>uuid-commA</uuid>
599               <short-name>abc/a/uui</short-name>
600               <author></author>
601               <date>Thu, 01 Jan 1970 00:01:00 +0000</date>
602               <content-type>text/plain</content-type>
603               <body>comment A</body>
604             </comment>
605           </bug>
606           <bug>
607             <uuid>b</uuid>
608             <short-name>abc/b</short-name>
609             <severity>minor</severity>
610             <status>closed</status>
611             <creator>Jane Doe &lt;jdoe@example.com&gt;</creator>
612             <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
613             <summary>Bug B</summary>
614           </bug>
615           <bug>
616             <uuid>c</uuid>
617             <short-name>abc/c</short-name>
618             <severity>minor</severity>
619             <status>open</status>
620             <created>Thu, 01 Jan 1970 00:02:00 +0000</created>
621             <summary>bug C</summary>
622           </bug>
623         </bugdir>
624         >>> bugdirA.cleanup()
625         >>> bugdirB.cleanup()
626         """
627         if hasattr(other, 'explicit_attrs'):
628             for attr in other.explicit_attrs:
629                 old = getattr(self, attr)
630                 new = getattr(other, attr)
631                 if old != new:
632                     if accept_changes:
633                         setattr(self, attr, new)
634                     elif change_exception:
635                         raise ValueError(
636                             ('Merge would change {} "{}"->"{}" for bugdir {}'
637                              ).format(attr, old, new, self.uuid))
638         for estr in other.extra_strings:
639             if not estr in self.extra_strings:
640                 if accept_extra_strings:
641                     self.extra_strings += [estr]
642                 elif change_exception:
643                     raise ValueError(
644                         ('Merge would add extra string "{}" for bugdir {}'
645                          ).format(estr, self.uuid))
646         for o_bug in other:
647             try:
648                 s_bug = self.bug_from_uuid(o_bug.uuid)
649             except KeyError as e:
650                 try:
651                     s_bug = self.bug_from_uuid(o_bug.alt_id)
652                 except KeyError as e:
653                     s_bug = None
654             if s_bug is None:
655                 if accept_bugs:
656                     o_bug_copy = copy.copy(o_bug)
657                     o_bug_copy.bugdir = self
658                     o_bug_copy.id = libbe.util.id.ID(o_bug_copy, 'bug')
659                     self.append(o_bug_copy)
660                 elif change_exception:
661                     raise ValueError(
662                         ('Merge would add bug {} (alt: {}) to bugdir {}'
663                          ).format(o_bug.uuid, o_bug.alt_id, self.uuid))
664             else:
665                 s_bug.merge(o_bug, accept_changes=accept_changes,
666                             accept_extra_strings=accept_extra_strings,
667                             change_exception=change_exception)
668
669     # methods for id generation
670
671     def sibling_uuids(self):
672         return []
673
674 class RevisionedBugDir (BugDir):
675     """
676     RevisionedBugDirs are read-only copies used for generating
677     diffs between revisions.
678     """
679     def __init__(self, bugdir, revision):
680         storage_version = bugdir.storage.storage_version(revision)
681         if storage_version != libbe.storage.STORAGE_VERSION:
682             raise libbe.storage.InvalidStorageVersion(storage_version)
683         s = copy.deepcopy(bugdir.storage)
684         s.writeable = False
685         class RevisionedStorage (object):
686             def __init__(self, storage, default_revision):
687                 self.s = storage
688                 self.sget = self.s.get
689                 self.sancestors = self.s.ancestors
690                 self.schildren = self.s.children
691                 self.schanged = self.s.changed
692                 self.r = default_revision
693             def get(self, *args, **kwargs):
694                 if not 'revision' in kwargs or kwargs['revision'] == None:
695                     kwargs['revision'] = self.r
696                 return self.sget(*args, **kwargs)
697             def ancestors(self, *args, **kwargs):
698                 print 'getting ancestors', args, kwargs
699                 if not 'revision' in kwargs or kwargs['revision'] == None:
700                     kwargs['revision'] = self.r
701                 ret = self.sancestors(*args, **kwargs)
702                 print 'got ancestors', ret
703                 return ret
704             def children(self, *args, **kwargs):
705                 if not 'revision' in kwargs or kwargs['revision'] == None:
706                     kwargs['revision'] = self.r
707                 return self.schildren(*args, **kwargs)
708             def changed(self, *args, **kwargs):
709                 if not 'revision' in kwargs or kwargs['revision'] == None:
710                     kwargs['revision'] = self.r
711                 return self.schanged(*args, **kwargs)
712         rs = RevisionedStorage(s, revision)
713         s.get = rs.get
714         s.ancestors = rs.ancestors
715         s.children = rs.children
716         s.changed = rs.changed
717         BugDir.__init__(self, s, from_storage=True)
718         self.revision = revision
719     def changed(self):
720         return self.storage.changed()
721     
722
723 if libbe.TESTING == True:
724     class SimpleBugDir (BugDir):
725         """
726         For testing.  Set ``memory=True`` for a memory-only bugdir.
727
728         >>> bugdir = SimpleBugDir()
729         >>> uuids = list(bugdir.uuids())
730         >>> uuids.sort()
731         >>> print uuids
732         ['a', 'b']
733         >>> bugdir.cleanup()
734         """
735         def __init__(self, memory=True, versioned=False):
736             if memory == True:
737                 storage = None
738             else:
739                 dir = utility.Dir()
740                 self._dir_ref = dir # postpone cleanup since dir.cleanup() removes dir.
741                 if versioned == False:
742                     storage = libbe.storage.base.Storage(dir.path)
743                 else:
744                     storage = libbe.storage.base.VersionedStorage(dir.path)
745                 storage.init()
746                 storage.connect()
747             BugDir.__init__(self, storage=storage, uuid='abc123')
748             bug_a = self.new_bug(summary='Bug A', _uuid='a')
749             bug_a.creator = 'John Doe <jdoe@example.com>'
750             bug_a.time = 0
751             bug_b = self.new_bug(summary='Bug B', _uuid='b')
752             bug_b.creator = 'Jane Doe <jdoe@example.com>'
753             bug_b.time = 0
754             bug_b.status = 'closed'
755             if self.storage != None:
756                 self.storage.disconnect() # flush to storage
757                 self.storage.connect()
758
759         def cleanup(self):
760             if self.storage != None:
761                 self.storage.writeable = True
762                 self.storage.disconnect()
763                 self.storage.destroy()
764             if hasattr(self, '_dir_ref'):
765                 self._dir_ref.cleanup()
766
767         def flush_reload(self):
768             if self.storage != None:
769                 self.storage.disconnect()
770                 self.storage.connect()
771                 self._clear_bugs()
772
773 #    class BugDirTestCase(unittest.TestCase):
774 #        def setUp(self):
775 #            self.dir = utility.Dir()
776 #            self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
777 #                                 allow_storage_init=True)
778 #            self.storage = self.bugdir.storage
779 #        def tearDown(self):
780 #            self.bugdir.cleanup()
781 #            self.dir.cleanup()
782 #        def fullPath(self, path):
783 #            return os.path.join(self.dir.path, path)
784 #        def assertPathExists(self, path):
785 #            fullpath = self.fullPath(path)
786 #            self.failUnless(os.path.exists(fullpath)==True,
787 #                            "path %s does not exist" % fullpath)
788 #            self.assertRaises(AlreadyInitialized, BugDir,
789 #                              self.dir.path, assertNewBugDir=True)
790 #        def versionTest(self):
791 #            if self.storage != None and self.storage.versioned == False:
792 #                return
793 #            original = self.bugdir.storage.commit("Began versioning")
794 #            bugA = self.bugdir.bug_from_uuid("a")
795 #            bugA.status = "fixed"
796 #            self.bugdir.save()
797 #            new = self.storage.commit("Fixed bug a")
798 #            dupdir = self.bugdir.duplicate_bugdir(original)
799 #            self.failUnless(dupdir.root != self.bugdir.root,
800 #                            "%s, %s" % (dupdir.root, self.bugdir.root))
801 #            bugAorig = dupdir.bug_from_uuid("a")
802 #            self.failUnless(bugA != bugAorig,
803 #                            "\n%s\n%s" % (bugA.string(), bugAorig.string()))
804 #            bugAorig.status = "fixed"
805 #            self.failUnless(bug.cmp_status(bugA, bugAorig)==0,
806 #                            "%s, %s" % (bugA.status, bugAorig.status))
807 #            self.failUnless(bug.cmp_severity(bugA, bugAorig)==0,
808 #                            "%s, %s" % (bugA.severity, bugAorig.severity))
809 #            self.failUnless(bug.cmp_assigned(bugA, bugAorig)==0,
810 #                            "%s, %s" % (bugA.assigned, bugAorig.assigned))
811 #            self.failUnless(bug.cmp_time(bugA, bugAorig)==0,
812 #                            "%s, %s" % (bugA.time, bugAorig.time))
813 #            self.failUnless(bug.cmp_creator(bugA, bugAorig)==0,
814 #                            "%s, %s" % (bugA.creator, bugAorig.creator))
815 #            self.failUnless(bugA == bugAorig,
816 #                            "\n%s\n%s" % (bugA.string(), bugAorig.string()))
817 #            self.bugdir.remove_duplicate_bugdir()
818 #            self.failUnless(os.path.exists(dupdir.root)==False,
819 #                            str(dupdir.root))
820 #        def testRun(self):
821 #            self.bugdir.new_bug(uuid="a", summary="Ant")
822 #            self.bugdir.new_bug(uuid="b", summary="Cockroach")
823 #            self.bugdir.new_bug(uuid="c", summary="Praying mantis")
824 #            length = len(self.bugdir)
825 #            self.failUnless(length == 3, "%d != 3 bugs" % length)
826 #            uuids = list(self.bugdir.uuids())
827 #            self.failUnless(len(uuids) == 3, "%d != 3 uuids" % len(uuids))
828 #            self.failUnless(uuids == ["a","b","c"], str(uuids))
829 #            bugA = self.bugdir.bug_from_uuid("a")
830 #            bugAprime = self.bugdir.bug_from_shortname("a")
831 #            self.failUnless(bugA == bugAprime, "%s != %s" % (bugA, bugAprime))
832 #            self.bugdir.save()
833 #            self.versionTest()
834 #        def testComments(self, sync_with_disk=False):
835 #            if sync_with_disk == True:
836 #                self.bugdir.set_sync_with_disk(True)
837 #            self.bugdir.new_bug(uuid="a", summary="Ant")
838 #            bug = self.bugdir.bug_from_uuid("a")
839 #            comm = bug.comment_root
840 #            rep = comm.new_reply("Ants are small.")
841 #            rep.new_reply("And they have six legs.")
842 #            if sync_with_disk == False:
843 #                self.bugdir.save()
844 #                self.bugdir.set_sync_with_disk(True)
845 #            self.bugdir._clear_bugs()
846 #            bug = self.bugdir.bug_from_uuid("a")
847 #            bug.load_comments()
848 #            if sync_with_disk == False:
849 #                self.bugdir.set_sync_with_disk(False)
850 #            self.failUnless(len(bug.comment_root)==1, len(bug.comment_root))
851 #            for index,comment in enumerate(bug.comments()):
852 #                if index == 0:
853 #                    repLoaded = comment
854 #                    self.failUnless(repLoaded.uuid == rep.uuid, repLoaded.uuid)
855 #                    self.failUnless(comment.sync_with_disk == sync_with_disk,
856 #                                    comment.sync_with_disk)
857 #                    self.failUnless(comment.content_type == "text/plain",
858 #                                    comment.content_type)
859 #                    self.failUnless(repLoaded.settings["Content-type"] == \
860 #                                        "text/plain",
861 #                                    repLoaded.settings)
862 #                    self.failUnless(repLoaded.body == "Ants are small.",
863 #                                    repLoaded.body)
864 #                elif index == 1:
865 #                    self.failUnless(comment.in_reply_to == repLoaded.uuid,
866 #                                    repLoaded.uuid)
867 #                    self.failUnless(comment.body == "And they have six legs.",
868 #                                    comment.body)
869 #                else:
870 #                    self.failIf(True,
871 #                                "Invalid comment: %d\n%s" % (index, comment))
872 #        def testSyncedComments(self):
873 #            self.testComments(sync_with_disk=True)
874
875     class SimpleBugDirTestCase (unittest.TestCase):
876         def setUp(self):
877             # create a pre-existing bugdir in a temporary directory
878             self.dir = utility.Dir()
879             self.storage = libbe.storage.base.Storage(self.dir.path)
880             self.storage.init()
881             self.storage.connect()
882             self.bugdir = BugDir(self.storage)
883             self.bugdir.new_bug(summary="Hopefully not imported",
884                                 _uuid="preexisting")
885             self.storage.disconnect()
886             self.storage.connect()
887         def tearDown(self):
888             if self.storage != None:
889                 self.storage.disconnect()
890                 self.storage.destroy()
891             self.dir.cleanup()
892         def testOnDiskCleanLoad(self):
893             """
894             SimpleBugDir(memory==False) should not import
895             preexisting bugs.
896             """
897             bugdir = SimpleBugDir(memory=False)
898             self.failUnless(bugdir.storage.is_readable() == True,
899                             bugdir.storage.is_readable())
900             self.failUnless(bugdir.storage.is_writeable() == True,
901                             bugdir.storage.is_writeable())
902             uuids = sorted([bug.uuid for bug in bugdir])
903             self.failUnless(uuids == ['a', 'b'], uuids)
904             bugdir.flush_reload()
905             uuids = sorted(bugdir.uuids())
906             self.failUnless(uuids == ['a', 'b'], uuids)
907             uuids = sorted([bug.uuid for bug in bugdir])
908             self.failUnless(uuids == [], uuids)
909             bugdir.load_all_bugs()
910             uuids = sorted([bug.uuid for bug in bugdir])
911             self.failUnless(uuids == ['a', 'b'], uuids)
912             bugdir.cleanup()
913         def testInMemoryCleanLoad(self):
914             """
915             SimpleBugDir(memory==True) should not import
916             preexisting bugs.
917             """
918             bugdir = SimpleBugDir(memory=True)
919             self.failUnless(bugdir.storage == None, bugdir.storage)
920             uuids = sorted([bug.uuid for bug in bugdir])
921             self.failUnless(uuids == ['a', 'b'], uuids)
922             uuids = sorted([bug.uuid for bug in bugdir])
923             self.failUnless(uuids == ['a', 'b'], uuids)
924             bugdir._clear_bugs()
925             uuids = sorted(bugdir.uuids())
926             self.failUnless(uuids == [], uuids)
927             uuids = sorted([bug.uuid for bug in bugdir])
928             self.failUnless(uuids == [], uuids)
929             bugdir.cleanup()
930
931     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
932     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
933
934 #    def _get_settings(self, settings_path, for_duplicate_bugdir=False):
935 #        allow_no_storage = not self.storage.path_in_root(settings_path)
936 #        if allow_no_storage == True:
937 #            assert for_duplicate_bugdir == True
938 #        if self.sync_with_disk == False and for_duplicate_bugdir == False:
939 #            # duplicates can ignore this bugdir's .sync_with_disk status
940 #            raise DiskAccessRequired("_get settings")
941 #        try:
942 #            settings = mapfile.map_load(self.storage, settings_path, allow_no_storage)
943 #        except storage.NoSuchFile:
944 #            settings = {"storage_name": "None"}
945 #        return settings
946
947 #    def _save_settings(self, settings_path, settings,
948 #                       for_duplicate_bugdir=False):
949 #        allow_no_storage = not self.storage.path_in_root(settings_path)
950 #        if allow_no_storage == True:
951 #            assert for_duplicate_bugdir == True
952 #        if self.sync_with_disk == False and for_duplicate_bugdir == False:
953 #            # duplicates can ignore this bugdir's .sync_with_disk status
954 #            raise DiskAccessRequired("_save settings")
955 #        self.storage.mkdir(self.get_path(), allow_no_storage)
956 #        mapfile.map_save(self.storage, settings_path, settings, allow_no_storage)