Added `be html --min-id-length INT` option
[be.git] / libbe / util / id.py
1 # Copyright (C) 2008-2010 Gianluca Montecchi <gian@grys.it>
2 #                         W. Trevor King <wking@drexel.edu>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along
15 # with this program; if not, write to the Free Software Foundation, Inc.,
16 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
18 """Handle ID creation and parsing.
19
20 Format
21 ======
22
23 BE IDs are formatted::
24
25     <bug-directory>[/<bug>[/<comment>]]
26
27 where each ``<..>`` is a UUID.  For example::
28
29     bea86499-824e-4e77-b085-2d581fa9ccab/3438b72c-6244-4f1d-8722-8c8d41484e35
30
31 refers to bug ``3438b72c-6244-4f1d-8722-8c8d41484e35`` which is
32 located in bug directory ``bea86499-824e-4e77-b085-2d581fa9ccab``.
33 This is a bit of a mouthful, so you can truncate each UUID so long as
34 it remains unique.  For example::
35
36     bea/343
37
38 If there were two bugs ``3438...`` and ``343a...`` in ``bea``, you'd
39 have to use::
40
41     bea/3438
42
43 BE will only truncate each UUID down to three characters to slightly
44 future-proof the short user ids.  However, if you want to save keystrokes
45 and you *know* there is only one bug directory, feel free to truncate
46 all the way to zero characters::
47
48     /3438
49
50 Cross references
51 ================
52
53 To refer to other bug-directories/bugs/comments from bug comments, simply
54 enclose the ID in pound signs (``#``).  BE will automatically expand the
55 truncations to the full UUIDs before storing the comment, and the reference
56 will be appropriately truncated (and hyperlinked, if possible) when the
57 comment is displayed.
58
59 Scope
60 =====
61
62 Although bug and comment IDs always appear in compound references,
63 UUIDs at each level are globally unique.  For example, comment
64 ``bea/343/ba96f1c0-ba48-4df8-aaf0-4e3a3144fc46`` will *only* appear
65 under ``bea/343``.  The prefix (``bea/343``) allows BE to reduce
66 caching global comment-lookup tables and enables easy error messages
67 ("I couldn't find ``bea/343/ba9`` because I don't know where the
68 ``bea`` bug directory is located").
69 """
70
71 import os.path
72 import re
73
74 import libbe
75
76 if libbe.TESTING == True:
77     import doctest
78     import sys
79     import unittest
80
81 try:
82     from uuid import uuid4 # Python >= 2.5
83     def uuid_gen():
84         id = uuid4()
85         idstr = id.urn
86         start = "urn:uuid:"
87         assert idstr.startswith(start)
88         return idstr[len(start):]
89 except ImportError:
90     import os
91     import sys
92     from subprocess import Popen, PIPE
93
94     def uuid_gen():
95         # Shell-out to system uuidgen
96         args = ['uuidgen', 'r']
97         try:
98             if sys.platform != "win32":
99                 q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
100             else:
101                 # win32 don't have os.execvp() so have to run command in a shell
102                 q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE,
103                           shell=True, cwd=cwd)
104         except OSError, e :
105             strerror = "%s\nwhile executing %s" % (e.args[1], args)
106             raise OSError, strerror
107         output, error = q.communicate()
108         status = q.wait()
109         if status != 0:
110             strerror = "%s\nwhile executing %s" % (status, args)
111             raise Exception, strerror
112         return output.rstrip('\n')
113
114
115 HIERARCHY = ['bugdir', 'bug', 'comment']
116 """Keep track of the object type hierarchy.
117 """
118
119 class MultipleIDMatches (ValueError):
120     """Multiple IDs match the given user ID.
121
122     Parameters
123     ----------
124     id : str
125       The not-specific-enough truncated UUID.
126     common : str
127       The initial characters common to all matching UUIDs.
128     matches : list of str
129       The list of possibly matching UUIDs.
130     """
131     def __init__(self, id, common, matches):
132         msg = ('More than one id matches %s.  '
133                'Please be more specific (%s*).\n%s' % (id, common, matches))
134         ValueError.__init__(self, msg)
135         self.id = id
136         self.common = common
137         self.matches = matches
138
139 class NoIDMatches (KeyError):
140     """No IDs match the given user ID.
141
142     Parameters
143     ----------
144     id : str
145       The not-matching, possibly truncated UUID.
146     possible_ids : list of str
147       The list of potential UUIDs at that level.
148     msg : str, optional
149       A helpful message explaining what went wrong.
150     """
151     def __init__(self, id, possible_ids, msg=None):
152         KeyError.__init__(self, id)
153         self.id = id
154         self.possible_ids = possible_ids
155         self.msg = msg
156     def __str__(self):
157         if self.msg == None:
158             return 'No id matches %s.\n%s' % (self.id, self.possible_ids)
159         return self.msg
160
161 class InvalidIDStructure (KeyError):
162     """A purported ID does not have the appropriate syntax.
163
164     Parameters
165     ----------
166     id : str
167       The purported ID.
168     msg : str, optional
169       A helpful message explaining what went wrong.
170     """
171     def __init__(self, id, msg=None):
172         KeyError.__init__(self, id)
173         self.id = id
174         self.msg = msg
175     def __str__(self):
176         if self.msg == None:
177             return 'Invalid id structure "%s"' % self.id
178         return self.msg
179
180 def _assemble(args, check_length=False):
181     """Join a bunch of level UUIDs into a single ID.
182
183     See Also
184     --------
185     _split : inverse
186     """
187     args = list(args)
188     for i,arg in enumerate(args):
189         if arg == None:
190             args[i] = ''
191     id = '/'.join(args)
192     if check_length == True:
193         assert len(args) > 0, args
194         if len(args) > len(HIERARCHY):
195             raise InvalidIDStructure(
196                 id, '%d > %d levels in "%s"' % (len(args), len(HIERARCHY), id))
197     return id
198
199 def _split(id, check_length=False):
200     """Split an ID into a list of level UUIDs.
201
202     See Also
203     --------
204     _assemble : inverse
205     """
206     args = id.split('/')
207     for i,arg in enumerate(args):
208         if arg == '':
209             args[i] = None
210     if check_length == True:
211         assert len(args) > 0, args
212         if len(args) > len(HIERARCHY):
213             raise InvalidIDStructure(
214                 id, '%d > %d levels in "%s"' % (len(args), len(HIERARCHY), id))
215     return args
216
217 def _truncate(uuid, other_uuids, min_length=3):
218     """Truncate a UUID to the shortest length >= `min_length` such that it
219     is *not* a truncated form of a UUID in `other_uuids`.
220
221     Parameters
222     ----------
223     uuid : str
224       The UUID to truncate.
225     other_uuids : list of str
226       The other UUIDs which the truncation *might* (but doesn't) refer
227       to.
228     min_length : int
229       Avoid rapidly outdated truncations, even if they are unique now.
230
231     See Also
232     --------
233     _expand : inverse
234     """
235     if min_length == -1:
236         return uuid
237     chars = min_length
238     for id in other_uuids:
239         if id == uuid:
240             continue
241         while (id[:chars] == uuid[:chars]):
242             chars+=1
243     return uuid[:chars]
244
245 def _expand(truncated_id, common, other_ids):
246     """Expand a truncated UUID.
247
248     Parameters
249     ----------
250     truncated_id : str
251       The ID to expand.
252     common : str
253       The common portion `truncated_id` shares with the UUIDs in
254       `other_ids`.  Not used by ``_expand``, but passed on to the
255       matching exceptions if they occur.
256     other_uuids : list of str
257       The other UUIDs which the truncation *might* (but doesn't) refer
258       to.
259
260     Raises
261     ------
262     NoIDMatches
263     MultipleIDMatches
264
265     See Also
266     --------
267     _expand : inverse
268     """
269     other_ids = list(other_ids)
270     if len(other_ids) == 0:
271         raise NoIDMatches(truncated_id, other_ids)
272     if truncated_id == None:
273         if len(other_ids) == 1:
274             return other_ids[0]
275         raise MultipleIDMatches(truncated_id, common, other_ids)
276     matches = []
277     other_ids = list(other_ids)
278     for id in other_ids:
279         if id.startswith(truncated_id):
280             if id == truncated_id:
281                 return id
282             matches.append(id)
283     if len(matches) > 1:
284         raise MultipleIDMatches(truncated_id, common, matches)
285     if len(matches) == 0:
286         raise NoIDMatches(truncated_id, other_ids)
287     return matches[0]
288
289
290 class ID (object):
291     """Store an object ID and produce various representations.
292
293     Parameters
294     ----------
295     object : :class:`~libbe.bugdir.BugDir` or :class:`~libbe.bug.Bug` or :class:`~libbe.comment.Comment`
296       The object that the ID applies to.
297     type : 'bugdir' or 'bug' or 'comment'
298       The type of the object.
299
300     Notes
301     -----
302
303     IDs have several formats specialized for different uses.
304
305     In storage, all objects are represented by their uuid alone,
306     because that is the simplest globally unique identifier.  You can
307     generate ids of this sort with the .storage() method.  Because an
308     object's storage may be distributed across several chunks, and the
309     chunks may not have their own uuid, we generate chunk ids by
310     prepending the objects uuid to the chunk name.  The user id types
311     do not support this chunk extension feature.
312
313     For users, the full uuids are a bit overwhelming, so we truncate
314     them while retaining local uniqueness (with regards to the other
315     objects currently in storage).  We also prepend truncated parent
316     ids for two reasons:
317
318     1. So that a user can locate the repository containing the
319        referenced object.  It would be hard to find bug ``XYZ`` if
320        that's all you knew.  Much easier with ``ABC/XYZ``, where
321        ``ABC`` is the bugdir.  Each project can publish a list of
322        bugdir-id-to-location mappings, e.g.::
323
324             ABC...(full uuid)...DEF   https://server.com/projectX/be/
325
326        which is easier than publishing all-object-ids-to-location
327        mappings.
328
329     2. Because it's easier to generate and parse truncated ids if you
330        don't have to fetch all the ids in the storage repository but
331        can restrict yourself to a specific branch.
332
333     You can generate ids of this sort with the :meth:`user` method,
334     although in order to preform the truncation, your object (and its
335     parents must define a `sibling_uuids` method.
336
337     While users can use the convenient short user ids in the short
338     term, the truncation will inevitably lead to name collision.  To
339     avoid that, we provide a non-truncated form of the short user ids
340     via the :meth:`long_user` method.  These long user ids should be
341     converted to short user ids by intelligent user interfaces.
342
343     See Also
344     --------
345     parse_user : get uuids back out of the user ids.
346     short_to_long_user : convert a single short user id to a long user id.
347     long_to_short_user : convert a single long user id to a short user id.
348     short_to_long_text : scan text for user ids & convert to long user ids.
349     long_to_short_text : scan text for long user ids & convert to short user ids.
350     """
351     def __init__(self, object, type):
352         self._object = object
353         self._type = type
354         assert self._type in HIERARCHY, self._type
355
356     def storage(self, *args):
357         return _assemble([self._object.uuid]+list(args))
358
359     def _ancestors(self):
360         ret = [self._object]
361         index = HIERARCHY.index(self._type)
362         if index == 0:
363             return ret
364         o = self._object
365         for i in range(index, 0, -1):
366             parent_name = HIERARCHY[i-1]
367             o = getattr(o, parent_name, None)
368             ret.insert(0, o)
369         return ret
370
371     def long_user(self):
372         return _assemble([o.uuid for o in self._ancestors()],
373                          check_length=True)
374
375     def user(self):
376         ids = []
377         for o in self._ancestors():
378             if o == None:
379                 ids.append(None)
380             else:
381                 ids.append(_truncate(o.uuid, o.sibling_uuids()))
382         return _assemble(ids, check_length=True)
383
384 def child_uuids(child_storage_ids):
385     """Extract uuid children from other children generated by
386     :meth:`ID.storage`.
387
388     This is useful for separating data belonging to a particular
389     object directly from entries for its child objects.  Since the
390     :class:`~libbe.storage.base.Storage` backend doesn't distinguish
391     between the two.
392
393     Examples
394     --------
395
396     >>> list(child_uuids(['abc123/values', '123abc', '123def']))
397     ['123abc', '123def']
398     """
399     for id in child_storage_ids:
400         fields = _split(id)
401         if len(fields) == 1:
402             yield fields[0]
403
404 def long_to_short_user(bugdirs, id):
405     """Convert a long user ID to a short user ID (see :class:`ID`).
406     The list of bugdirs allows uniqueness-maintaining truncation of
407     the bugdir portion of the ID.
408
409     See Also
410     --------
411     short_to_long_user : inverse
412     long_to_short_text : conversion on a block of text
413     """
414     ids = _split(id, check_length=True)
415     matching_bugdirs = [bd for bd in bugdirs if bd.uuid == ids[0]]
416     if len(matching_bugdirs) == 0:
417         raise NoIDMatches(id, [bd.uuid for bd in bugdirs])
418     elif len(matching_bugdirs) > 1:
419         raise MultipleIDMatches(id, '', [bd.uuid for bd in bugdirs])
420     bugdir = matching_bugdirs[0]
421     objects = [bugdir]
422     if len(ids) >= 2:
423         bug = bugdir.bug_from_uuid(ids[1])
424         objects.append(bug)
425     if len(ids) >= 3:
426         comment = bug.comment_from_uuid(ids[2])
427         objects.append(comment)
428     for i,obj in enumerate(objects):
429         ids[i] = _truncate(ids[i], obj.sibling_uuids())
430     return _assemble(ids)
431
432 def short_to_long_user(bugdirs, id):
433     """Convert a short user ID to a long user ID (see :class:`ID`).  The
434     list of bugdirs allows uniqueness-checking during expansion of the
435     bugdir portion of the ID.
436
437     See Also
438     --------
439     long_to_short_user : inverse
440     short_to_long_text : conversion on a block of text
441     """
442     ids = _split(id, check_length=True)
443     ids[0] = _expand(ids[0], common=None,
444                      other_ids=[bd.uuid for bd in bugdirs])
445     if len(ids) == 1:
446         return _assemble(ids)
447     bugdir = [bd for bd in bugdirs if bd.uuid == ids[0]][0]
448     ids[1] = _expand(ids[1], common=bugdir.id.user(),
449                      other_ids=bugdir.uuids())
450     if len(ids) == 2:
451         return _assemble(ids)
452     bug = bugdir.bug_from_uuid(ids[1])
453     ids[2] = _expand(ids[2], common=bug.id.user(),
454                      other_ids=bug.uuids())
455     return _assemble(ids)
456
457
458 REGEXP = '#([-a-f0-9]*)(/[-a-g0-9]*)?(/[-a-g0-9]*)?#'
459 """Regular expression for matching IDs (both short and long) in text.
460 """
461
462 class IDreplacer (object):
463     """Helper class for ID replacement in text.
464
465     Reassembles the match elements from :data:`REGEXP` matching
466     into the original ID, for easier replacement.
467
468     See Also
469     --------
470     short_to_long_text, long_to_short_text
471     """
472     def __init__(self, bugdirs, replace_fn, wrap=True):
473         self.bugdirs = bugdirs
474         self.replace_fn = replace_fn
475         self.wrap = wrap
476     def __call__(self, match):
477         ids = []
478         for m in match.groups():
479             if m == None:
480                 m = ''
481             ids.append(m)
482         replacement = self.replace_fn(self.bugdirs, ''.join(ids))
483         if self.wrap == True:
484             return '#%s#' % replacement
485         return replacement
486
487 def short_to_long_text(bugdirs, text):
488     """Convert short user IDs to long user IDs in text (see :class:`ID`).
489     The list of bugdirs allows uniqueness-checking during expansion of
490     the bugdir portion of the ID.
491
492     See Also
493     --------
494     short_to_long_user : conversion on a single ID
495     long_to_short_text : inverse
496     """
497     return re.sub(REGEXP, IDreplacer(bugdirs, short_to_long_user), text)
498
499 def long_to_short_text(bugdirs, text):
500     """Convert long user IDs to short user IDs in text (see :class:`ID`).
501     The list of bugdirs allows uniqueness-maintaining truncation of
502     the bugdir portion of the ID.
503
504     See Also
505     --------
506     long_to_short_user : conversion on a single ID
507     short_to_long_text : inverse
508     """
509     return re.sub(REGEXP, IDreplacer(bugdirs, long_to_short_user), text)
510
511 def residual(base, fragment):
512     """Split the short ID `fragment` into a portion corresponding
513     to `base`, and a portion inside `base`.
514
515     Examples
516     --------
517
518     >>> residual('ABC/DEF/', '//GHI')
519     ('//', 'GHI')
520     >>> residual('ABC/DEF/', '/D/GHI')
521     ('/D/', 'GHI')
522     >>> residual('ABC/DEF', 'A/D/GHI')
523     ('A/D/', 'GHI')
524     >>> residual('ABC/DEF', 'A/D/GHI/JKL')
525     ('A/D/', 'GHI/JKL')
526     """
527     base = base.rstrip('/') + '/'
528     ids = fragment.split('/')
529     base_count = base.count('/')
530     root_ids = ids[:base_count] + ['']
531     residual_ids = ids[base_count:]
532     return ('/'.join(root_ids), '/'.join(residual_ids))
533
534 def _parse_user(id):
535     """Parse a user ID (see :class:`ID`), returning a dict of parsed
536     information.
537
538     The returned dict will contain a value for "type" (from
539     :data:`HIERARCHY`) and values for the levels that are defined.
540
541     Examples
542     --------
543
544     >>> _parse_user('ABC/DEF/GHI') == \\
545     ...     {'bugdir':'ABC', 'bug':'DEF', 'comment':'GHI', 'type':'comment'}
546     True
547     >>> _parse_user('ABC/DEF') == \\
548     ...     {'bugdir':'ABC', 'bug':'DEF', 'type':'bug'}
549     True
550     >>> _parse_user('ABC') == \\
551     ...     {'bugdir':'ABC', 'type':'bugdir'}
552     True
553     >>> _parse_user('') == \\
554     ...     {'bugdir':None, 'type':'bugdir'}
555     True
556     >>> _parse_user('/') == \\
557     ...     {'bugdir':None, 'bug':None, 'type':'bug'}
558     True
559     >>> _parse_user('/DEF/') == \\
560     ...     {'bugdir':None, 'bug':'DEF', 'comment':None, 'type':'comment'}
561     True
562     >>> _parse_user('a/b/c/d')
563     Traceback (most recent call last): 
564       ...
565     InvalidIDStructure: 4 > 3 levels in "a/b/c/d"
566     """
567     ret = {}
568     args = _split(id, check_length=True)
569     for i,(type,arg) in enumerate(zip(HIERARCHY, args)):
570         if arg != None and len(arg) == 0:
571             raise InvalidIDStructure(
572                 id, 'Invalid %s part %d "%s" of id "%s"' % (type, i, arg, id))
573         ret['type'] = type
574         ret[type] = arg
575     return ret
576
577 def parse_user(bugdir, id):
578     """Parse a user ID (see :class:`ID`), returning a dict of parsed
579     information.
580
581     The returned dict will contain a value for "type" (from
582     :data:`HIERARCHY`) and values for the levels that are defined.
583
584     Notes
585     -----
586     This function tries to expand IDs before parsing, so it can handle
587     both short and long IDs successfully.
588     """
589     long_id = short_to_long_user([bugdir], id)
590     return _parse_user(long_id)
591
592 if libbe.TESTING == True:
593     class UUIDtestCase(unittest.TestCase):
594         def testUUID_gen(self):
595             id = uuid_gen()
596             self.failUnless(len(id) == 36, 'invalid UUID "%s"' % id)
597
598     class DummyObject (object):
599         def __init__(self, uuid, parent=None, siblings=[]):
600             self.uuid = uuid
601             self._siblings = siblings
602             if parent == None:
603                 type_i = 0
604             else:
605                 assert parent.type in HIERARCHY, parent
606                 setattr(self, parent.type, parent)
607                 type_i = HIERARCHY.index(parent.type) + 1
608             self.type = HIERARCHY[type_i]
609             self.id = ID(self, self.type)
610         def sibling_uuids(self):
611             return self._siblings
612
613     class IDtestCase(unittest.TestCase):
614         def setUp(self):
615             self.bugdir = DummyObject('1234abcd')
616             self.bug = DummyObject('abcdef', self.bugdir, ['a1234', 'ab9876'])
617             self.comment = DummyObject('12345678', self.bug, ['1234abcd', '1234cdef'])
618             self.bd_id = self.bugdir.id
619             self.b_id = self.bug.id
620             self.c_id = self.comment.id
621         def test_storage(self):
622             self.failUnless(self.bd_id.storage() == self.bugdir.uuid,
623                             self.bd_id.storage())
624             self.failUnless(self.b_id.storage() == self.bug.uuid,
625                             self.b_id.storage())
626             self.failUnless(self.c_id.storage() == self.comment.uuid,
627                             self.c_id.storage())
628             self.failUnless(self.bd_id.storage('x', 'y', 'z') == \
629                                 '1234abcd/x/y/z',
630                             self.bd_id.storage('x', 'y', 'z'))
631         def test_long_user(self):
632             self.failUnless(self.bd_id.long_user() == self.bugdir.uuid,
633                             self.bd_id.long_user())
634             self.failUnless(self.b_id.long_user() == \
635                                 '/'.join([self.bugdir.uuid, self.bug.uuid]),
636                             self.b_id.long_user())
637             self.failUnless(self.c_id.long_user() ==
638                                 '/'.join([self.bugdir.uuid, self.bug.uuid,
639                                           self.comment.uuid]),
640                             self.c_id.long_user)
641         def test_user(self):
642             self.failUnless(self.bd_id.user() == '123',
643                             self.bd_id.user())
644             self.failUnless(self.b_id.user() == '123/abc',
645                             self.b_id.user())
646             self.failUnless(self.c_id.user() == '123/abc/12345',
647                             self.c_id.user())
648
649     class ShortLongParseTestCase(unittest.TestCase):
650         def setUp(self):
651             self.bugdir = DummyObject('1234abcd')
652             self.bug = DummyObject('abcdef', self.bugdir, ['a1234', 'ab9876'])
653             self.comment = DummyObject('12345678', self.bug, ['1234abcd', '1234cdef'])
654             self.bd_id = self.bugdir.id
655             self.b_id = self.bug.id
656             self.c_id = self.comment.id
657             self.bugdir.bug_from_uuid = lambda uuid: self.bug
658             self.bugdir.uuids = lambda : self.bug.sibling_uuids() + [self.bug.uuid]
659             self.bug.comment_from_uuid = lambda uuid: self.comment
660             self.bug.uuids = lambda : self.comment.sibling_uuids() + [self.comment.uuid]
661             self.short = 'bla bla #123/abc# bla bla #123/abc/12345# bla bla'
662             self.long = 'bla bla #1234abcd/abcdef# bla bla #1234abcd/abcdef/12345678# bla bla'
663             self.short_id_parse_pairs = [
664                 ('', {'bugdir':'1234abcd', 'type':'bugdir'}),
665                 ('123/abc', {'bugdir':'1234abcd', 'bug':'abcdef',
666                              'type':'bug'}),
667                 ('123/abc/12345', {'bugdir':'1234abcd', 'bug':'abcdef',
668                                    'comment':'12345678', 'type':'comment'}),
669                 ]
670             self.short_id_exception_pairs = [
671                 ('z', NoIDMatches('z', ['1234abcd'])),
672                 ('///', InvalidIDStructure(
673                         '///', msg='4 > 3 levels in "///"')),
674                 ('/', MultipleIDMatches(
675                         None, '123', ['a1234', 'ab9876', 'abcdef'])),
676                 ('123/', MultipleIDMatches(
677                         None, '123', ['a1234', 'ab9876', 'abcdef'])),
678                 ('123/abc/', MultipleIDMatches(
679                         None, '123/abc', ['1234abcd','1234cdef','12345678'])),
680                 ]
681         def test_short_to_long_text(self):
682             self.failUnless(short_to_long_text([self.bugdir], self.short) == self.long,
683                             '\n' + self.short + '\n' + short_to_long_text([self.bugdir], self.short) + '\n' + self.long)
684         def test_long_to_short_text(self):
685             self.failUnless(long_to_short_text([self.bugdir], self.long) == self.short,
686                             '\n' + long_to_short_text([self.bugdir], self.long) + '\n' + self.short)
687         def test_parse_user(self):
688             for short_id,parsed in self.short_id_parse_pairs:
689                 ret = parse_user(self.bugdir, short_id)
690                 self.failUnless(ret == parsed,
691                                 'got %s\nexpected %s' % (ret, parsed))
692         def test_parse_user_exceptions(self):
693             for short_id,exception in self.short_id_exception_pairs:
694                 try:
695                     ret = parse_user(self.bugdir, short_id)
696                     self.fail('Expected parse_user(bugdir, "%s") to raise %s,'
697                               '\n  but it returned %s'
698                               % (short_id, exception.__class__.__name__, ret))
699                 except exception.__class__, e:
700                     for attr in dir(e):
701                         if attr.startswith('_') or attr == 'args':
702                             continue
703                         value = getattr(e, attr)
704                         expected = getattr(exception, attr)
705                         self.failUnless(
706                             value == expected,
707                             'Expected parse_user(bugdir, "%s") %s.%s'
708                             '\n  to be %s, but it is %s\n\n%s'
709                               % (short_id, exception.__class__.__name__,
710                                  attr, expected, value, e))
711
712     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
713     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])