Enhance Bzr.version_cmp to handle non-numeric versions
[be.git] / libbe / storage / vcs / bzr.py
1 # Copyright (C) 2005-2011 Aaron Bentley <abentley@panoramicfeedback.com>
2 #                         Ben Finney <benf@cybersource.com.au>
3 #                         Chris Ball <cjb@laptop.org>
4 #                         Gianluca Montecchi <gian@grys.it>
5 #                         Marien Zwart <marien.zwart@gmail.com>
6 #                         W. Trevor King <wking@drexel.edu>
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
12 # Free Software Foundation, either version 2 of the License, or (at your
13 # option) any 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
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
18 # General Public License for more details.
19 #
20 # You should have received a copy of the GNU General Public License
21 # along with Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
22
23 """Bazaar_ (bzr) backend.
24
25 .. _Bazaar: http://bazaar.canonical.com/
26 """
27
28 try:
29     import bzrlib
30     import bzrlib.branch
31     import bzrlib.builtins
32     import bzrlib.config
33     import bzrlib.errors
34     import bzrlib.option
35 except ImportError:
36     bzrlib = None
37 import os
38 import os.path
39 import re
40 import shutil
41 import StringIO
42 import sys
43 import types
44
45 import libbe
46 import base
47
48 if libbe.TESTING == True:
49     import doctest
50     import unittest
51
52
53 def new():
54     return Bzr()
55
56 class Bzr(base.VCS):
57     """:class:`base.VCS` implementation for Bazaar.
58     """
59     name = 'bzr'
60     client = None # bzrlib module
61
62     def __init__(self, *args, **kwargs):
63         base.VCS.__init__(self, *args, **kwargs)
64         self.versioned = True
65
66     def _vcs_version(self):
67         if bzrlib == None:
68             return None
69         return bzrlib.__version__
70
71     def version_cmp(self, *args):
72         """Compare the installed Bazaar version `V_i` with another version
73         `V_o` (given in `*args`).  Returns
74
75            === ===============
76             1  if `V_i > V_o`
77             0  if `V_i == V_o`
78            -1  if `V_i < V_o`
79            === ===============
80
81         Examples
82         --------
83
84         >>> b = Bzr(repo='.')
85         >>> b._version = '2.3.1 (release)'
86         >>> b.version_cmp(2,3,1)
87         0
88         >>> b.version_cmp(2,3,2)
89         -1
90         >>> b.version_cmp(2,3,'a',5)
91         1
92         >>> b.version_cmp(2,3,0)
93         1
94         >>> b.version_cmp(2,3,1,'a',5)
95         1
96         >>> b.version_cmp(2,3,1,1)
97         -1
98         >>> b.version_cmp(3)
99         -1
100         >>> b._version = '2.0.0pre2'
101         >>> b._parsed_version = None
102         >>> b.version_cmp(3)
103         -1
104         >>> b.version_cmp(2,0,1)
105         -1
106         >>> b.version_cmp(2,0,0,'pre',1)
107         1
108         >>> b.version_cmp(2,0,0,'pre',2)
109         0
110         >>> b.version_cmp(2,0,0,'pre',3)
111         -1
112         >>> b.version_cmp(2,0,0,'a',3)
113         1
114         >>> b.version_cmp(2,0,0,'rc',1)
115         -1
116         """
117         if not hasattr(self, '_parsed_version') \
118                 or self._parsed_version == None:
119             num_part = self.version().split(' ')[0]
120             self._parsed_version = []
121             for num in num_part.split('.'):
122                 try:
123                     self._parsed_version.append(int(num))
124                 except ValueError, e:
125                     # bzr version number might contain non-numerical tags
126                     import re
127                     splitter = re.compile(r'[\D]') # Match non-digits
128                     splits = splitter.split(num)
129                     # if len(tag) > 1 some splits will be empty; remove
130                     splits = filter(lambda s: s != '', splits)
131                     tag_starti = len(splits[0])
132                     num_starti = num.find(splits[1], tag_starti)
133                     tag = num[tag_starti:num_starti]
134                     self._parsed_version.append(int(splits[0]))
135                     self._parsed_version.append(tag)
136                     self._parsed_version.append(int(splits[1]))
137         for current,other in zip(self._parsed_version, args):
138             if type(current) != type (other):
139                 # one of them is a pre-release string
140                 if type(current) != types.IntType:
141                     return -1
142                 else:
143                     return 1
144             c = cmp(current,other)
145             if c != 0:
146                 return c
147         # see if one is longer than the other
148         verlen = len(self._parsed_version)
149         arglen = len(args)
150         if verlen == arglen:
151             return 0
152         elif verlen > arglen:
153             if type(self._parsed_version[arglen]) != types.IntType:
154                 return -1 # self is a prerelease
155             else:
156                 return 1
157         else:
158             if type(args[verlen]) != types.IntType:
159                 return 1 # args is a prerelease
160             else:
161                 return -1
162
163     def _vcs_get_user_id(self):
164         # excerpted from bzrlib.builtins.cmd_whoami.run()
165         try:
166             c = bzrlib.branch.Branch.open_containing(self.repo)[0].get_config()
167         except errors.NotBranchError:
168             c = bzrlib.config.GlobalConfig()
169         return c.username()
170
171     def _vcs_detect(self, path):
172         if self._u_search_parent_directories(path, '.bzr') != None :
173             return True
174         return False
175
176     def _vcs_root(self, path):
177         """Find the root of the deepest repository containing path."""
178         cmd = bzrlib.builtins.cmd_root()
179         cmd.outf = StringIO.StringIO()
180         cmd.run(filename=path)
181         if self.version_cmp(2,2,0) < 0:
182             cmd.cleanup_now()
183         return cmd.outf.getvalue().rstrip('\n')
184
185     def _vcs_init(self, path):
186         cmd = bzrlib.builtins.cmd_init()
187         cmd.outf = StringIO.StringIO()
188         cmd.run(location=path)
189         if self.version_cmp(2,2,0) < 0:
190             cmd.cleanup_now()
191
192     def _vcs_destroy(self):
193         vcs_dir = os.path.join(self.repo, '.bzr')
194         if os.path.exists(vcs_dir):
195             shutil.rmtree(vcs_dir)
196
197     def _vcs_add(self, path):
198         path = os.path.join(self.repo, path)
199         cmd = bzrlib.builtins.cmd_add()
200         cmd.outf = StringIO.StringIO()
201         kwargs = {'file_ids_from': self.repo}
202         if self.repo == os.path.realpath(os.getcwd()):
203             # Work around bzr file locking on Windows.
204             # See: https://lists.ubuntu.com/archives/bazaar/2011q1/071705.html
205             kwargs.pop('file_ids_from')
206         cmd.run(file_list=[path], **kwargs)
207         if self.version_cmp(2,2,0) < 0:
208             cmd.cleanup_now()
209
210     def _vcs_exists(self, path, revision=None):
211         manifest = self._vcs_listdir(
212             self.repo, revision=revision, recursive=True)
213         if path in manifest:
214             return True
215         return False
216
217     def _vcs_remove(self, path):
218         # --force to also remove unversioned files.
219         path = os.path.join(self.repo, path)
220         cmd = bzrlib.builtins.cmd_remove()
221         cmd.outf = StringIO.StringIO()
222         cmd.run(file_list=[path], file_deletion_strategy='force')
223         if self.version_cmp(2,2,0) < 0:
224             cmd.cleanup_now()
225
226     def _vcs_update(self, path):
227         pass
228
229     def _parse_revision_string(self, revision=None):
230         if revision == None:
231             return revision
232         rev_opt = bzrlib.option.Option.OPTIONS['revision']
233         try:
234             rev_spec = rev_opt.type(revision)
235         except bzrlib.errors.NoSuchRevisionSpec:
236             raise base.InvalidRevision(revision)
237         return rev_spec
238
239     def _vcs_get_file_contents(self, path, revision=None):
240         if revision == None:
241             return base.VCS._vcs_get_file_contents(self, path, revision)
242         path = os.path.join(self.repo, path)
243         revision = self._parse_revision_string(revision)
244         cmd = bzrlib.builtins.cmd_cat()
245         cmd.outf = StringIO.StringIO()
246         if self.version_cmp(1,6,0) < 0:
247             # old bzrlib cmd_cat uses sys.stdout not self.outf for output.
248             stdout = sys.stdout
249             sys.stdout = cmd.outf
250         try:
251             cmd.run(filename=path, revision=revision)
252         except bzrlib.errors.BzrCommandError, e:
253             if 'not present in revision' in str(e):
254                 raise base.InvalidPath(path, root=self.repo, revision=revision)
255             raise
256         finally:
257             if self.version_cmp(2,0,0) < 0:
258                 cmd.outf = sys.stdout
259                 sys.stdout = stdout
260             if self.version_cmp(2,2,0) < 0:
261                 cmd.cleanup_now()
262         return cmd.outf.getvalue()
263
264     def _vcs_path(self, id, revision):
265         manifest = self._vcs_listdir(
266             self.repo, revision=revision, recursive=True)
267         return self._u_find_id_from_manifest(id, manifest, revision=revision)
268
269     def _vcs_isdir(self, path, revision):
270         try:
271             self._vcs_listdir(path, revision)
272         except AttributeError, e:
273             if 'children' in str(e):
274                 return False
275             raise
276         return True
277
278     def _vcs_listdir(self, path, revision, recursive=False):
279         path = os.path.join(self.repo, path)
280         revision = self._parse_revision_string(revision)
281         cmd = bzrlib.builtins.cmd_ls()
282         cmd.outf = StringIO.StringIO()
283         try:
284             if self.version_cmp(2,0,0) >= 0:
285                 cmd.run(revision=revision, path=path, recursive=recursive)
286             else:
287                 # Pre-2.0 Bazaar (non_recursive)
288                 # + working around broken non_recursive+path implementation
289                 #   (https://bugs.launchpad.net/bzr/+bug/158690)
290                 cmd.run(revision=revision, path=path,
291                         non_recursive=False)
292         except bzrlib.errors.BzrCommandError, e:
293             if 'not present in revision' in str(e):
294                 raise base.InvalidPath(path, root=self.repo, revision=revision)
295             raise
296         finally:
297             if self.version_cmp(2,2,0) < 0:
298                 cmd.cleanup_now()
299         children = cmd.outf.getvalue().rstrip('\n').splitlines()
300         children = [self._u_rel_path(c, path) for c in children]
301         if self.version_cmp(2,0,0) < 0 and recursive == False:
302             children = [c for c in children if os.path.sep not in c]
303         return children
304
305     def _vcs_commit(self, commitfile, allow_empty=False):
306         cmd = bzrlib.builtins.cmd_commit()
307         cmd.outf = StringIO.StringIO()
308         cwd = os.getcwd()
309         os.chdir(self.repo)
310         try:
311             cmd.run(file=commitfile, unchanged=allow_empty)
312         except bzrlib.errors.BzrCommandError, e:
313             strings = ['no changes to commit.', # bzr 1.3.1
314                        'No changes to commit.'] # bzr 1.15.1
315             if self._u_any_in_string(strings, str(e)) == True:
316                 raise base.EmptyCommit()
317             raise
318         finally:
319             os.chdir(cwd)
320             if self.version_cmp(2,2,0) < 0:
321                 cmd.cleanup_now()
322         return self._vcs_revision_id(-1)
323
324     def _vcs_revision_id(self, index):
325         cmd = bzrlib.builtins.cmd_revno()
326         cmd.outf = StringIO.StringIO()
327         cmd.run(location=self.repo)
328         if self.version_cmp(2,2,0) < 0:
329             cmd.cleanup_now()
330         current_revision = int(cmd.outf.getvalue())
331         if index > current_revision or index < -current_revision:
332             return None
333         if index >= 0:
334             return str(index) # bzr commit 0 is the empty tree.
335         return str(current_revision+index+1)
336
337     def _diff(self, revision):
338         revision = self._parse_revision_string(revision)
339         cmd = bzrlib.builtins.cmd_diff()
340         cmd.outf = StringIO.StringIO()
341         # for some reason, cmd_diff uses sys.stdout not self.outf for output.
342         stdout = sys.stdout
343         sys.stdout = cmd.outf
344         try:
345             status = cmd.run(revision=revision, file_list=[self.repo])
346         finally:
347             sys.stdout = stdout
348             if self.version_cmp(2,2,0) < 0:
349                 cmd.cleanup_now()
350         assert status in [0,1], "Invalid status %d" % status
351         return cmd.outf.getvalue()
352
353     def _parse_diff(self, diff_text):
354         """_parse_diff(diff_text) -> (new,modified,removed)
355
356         `new`, `modified`, and `removed` are lists of files.
357
358         Example diff text::
359
360           === modified file 'dir/changed'
361           --- dir/changed       2010-01-16 01:54:53 +0000
362           +++ dir/changed       2010-01-16 01:54:54 +0000
363           @@ -1,3 +1,3 @@
364            hi
365           -there
366           +everyone and
367            joe
368           
369           === removed file 'dir/deleted'
370           --- dir/deleted       2010-01-16 01:54:53 +0000
371           +++ dir/deleted       1970-01-01 00:00:00 +0000
372           @@ -1,3 +0,0 @@
373           -in
374           -the
375           -beginning
376           
377           === removed file 'dir/moved'
378           --- dir/moved 2010-01-16 01:54:53 +0000
379           +++ dir/moved 1970-01-01 00:00:00 +0000
380           @@ -1,4 +0,0 @@
381           -the
382           -ants
383           -go
384           -marching
385           
386           === added file 'dir/moved2'
387           --- dir/moved2        1970-01-01 00:00:00 +0000
388           +++ dir/moved2        2010-01-16 01:54:34 +0000
389           @@ -0,0 +1,4 @@
390           +the
391           +ants
392           +go
393           +marching
394           
395           === added file 'dir/new'
396           --- dir/new   1970-01-01 00:00:00 +0000
397           +++ dir/new   2010-01-16 01:54:54 +0000
398           @@ -0,0 +1,2 @@
399           +hello
400           +world
401           
402         """
403         new = []
404         modified = []
405         removed = []
406         for line in diff_text.splitlines():
407             if not line.startswith('=== '):
408                 continue
409             fields = line.split()
410             action = fields[1]
411             file = fields[-1].strip("'")
412             if action == 'added':
413                 new.append(file)
414             elif action == 'modified':
415                 modified.append(file)
416             elif action == 'removed':
417                 removed.append(file)
418         return (new,modified,removed)
419
420     def _vcs_changed(self, revision):
421         return self._parse_diff(self._diff(revision))
422
423
424 if libbe.TESTING == True:
425     base.make_vcs_testcase_subclasses(Bzr, sys.modules[__name__])
426
427     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
428     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])