|
1 """ |
|
2 module defining a subversion path object based on the external |
|
3 command 'svn'. This modules aims to work with svn 1.3 and higher |
|
4 but might also interact well with earlier versions. |
|
5 """ |
|
6 |
|
7 import os, sys, time, re |
|
8 import py |
|
9 from py import path, process |
|
10 from py._path import common |
|
11 from py._path import svnwc as svncommon |
|
12 from py._path.cacheutil import BuildcostAccessCache, AgingCache |
|
13 |
|
14 DEBUG=False |
|
15 |
|
16 class SvnCommandPath(svncommon.SvnPathBase): |
|
17 """ path implementation that offers access to (possibly remote) subversion |
|
18 repositories. """ |
|
19 |
|
20 _lsrevcache = BuildcostAccessCache(maxentries=128) |
|
21 _lsnorevcache = AgingCache(maxentries=1000, maxseconds=60.0) |
|
22 |
|
23 def __new__(cls, path, rev=None, auth=None): |
|
24 self = object.__new__(cls) |
|
25 if isinstance(path, cls): |
|
26 rev = path.rev |
|
27 auth = path.auth |
|
28 path = path.strpath |
|
29 svncommon.checkbadchars(path) |
|
30 path = path.rstrip('/') |
|
31 self.strpath = path |
|
32 self.rev = rev |
|
33 self.auth = auth |
|
34 return self |
|
35 |
|
36 def __repr__(self): |
|
37 if self.rev == -1: |
|
38 return 'svnurl(%r)' % self.strpath |
|
39 else: |
|
40 return 'svnurl(%r, %r)' % (self.strpath, self.rev) |
|
41 |
|
42 def _svnwithrev(self, cmd, *args): |
|
43 """ execute an svn command, append our own url and revision """ |
|
44 if self.rev is None: |
|
45 return self._svnwrite(cmd, *args) |
|
46 else: |
|
47 args = ['-r', self.rev] + list(args) |
|
48 return self._svnwrite(cmd, *args) |
|
49 |
|
50 def _svnwrite(self, cmd, *args): |
|
51 """ execute an svn command, append our own url """ |
|
52 l = ['svn %s' % cmd] |
|
53 args = ['"%s"' % self._escape(item) for item in args] |
|
54 l.extend(args) |
|
55 l.append('"%s"' % self._encodedurl()) |
|
56 # fixing the locale because we can't otherwise parse |
|
57 string = " ".join(l) |
|
58 if DEBUG: |
|
59 print("execing %s" % string) |
|
60 out = self._svncmdexecauth(string) |
|
61 return out |
|
62 |
|
63 def _svncmdexecauth(self, cmd): |
|
64 """ execute an svn command 'as is' """ |
|
65 cmd = svncommon.fixlocale() + cmd |
|
66 if self.auth is not None: |
|
67 cmd += ' ' + self.auth.makecmdoptions() |
|
68 return self._cmdexec(cmd) |
|
69 |
|
70 def _cmdexec(self, cmd): |
|
71 try: |
|
72 out = process.cmdexec(cmd) |
|
73 except py.process.cmdexec.Error: |
|
74 e = sys.exc_info()[1] |
|
75 if (e.err.find('File Exists') != -1 or |
|
76 e.err.find('File already exists') != -1): |
|
77 raise py.error.EEXIST(self) |
|
78 raise |
|
79 return out |
|
80 |
|
81 def _svnpopenauth(self, cmd): |
|
82 """ execute an svn command, return a pipe for reading stdin """ |
|
83 cmd = svncommon.fixlocale() + cmd |
|
84 if self.auth is not None: |
|
85 cmd += ' ' + self.auth.makecmdoptions() |
|
86 return self._popen(cmd) |
|
87 |
|
88 def _popen(self, cmd): |
|
89 return os.popen(cmd) |
|
90 |
|
91 def _encodedurl(self): |
|
92 return self._escape(self.strpath) |
|
93 |
|
94 def _norev_delentry(self, path): |
|
95 auth = self.auth and self.auth.makecmdoptions() or None |
|
96 self._lsnorevcache.delentry((str(path), auth)) |
|
97 |
|
98 def open(self, mode='r'): |
|
99 """ return an opened file with the given mode. """ |
|
100 if mode not in ("r", "rU",): |
|
101 raise ValueError("mode %r not supported" % (mode,)) |
|
102 assert self.check(file=1) # svn cat returns an empty file otherwise |
|
103 if self.rev is None: |
|
104 return self._svnpopenauth('svn cat "%s"' % ( |
|
105 self._escape(self.strpath), )) |
|
106 else: |
|
107 return self._svnpopenauth('svn cat -r %s "%s"' % ( |
|
108 self.rev, self._escape(self.strpath))) |
|
109 |
|
110 def dirpath(self, *args, **kwargs): |
|
111 """ return the directory path of the current path joined |
|
112 with any given path arguments. |
|
113 """ |
|
114 l = self.strpath.split(self.sep) |
|
115 if len(l) < 4: |
|
116 raise py.error.EINVAL(self, "base is not valid") |
|
117 elif len(l) == 4: |
|
118 return self.join(*args, **kwargs) |
|
119 else: |
|
120 return self.new(basename='').join(*args, **kwargs) |
|
121 |
|
122 # modifying methods (cache must be invalidated) |
|
123 def mkdir(self, *args, **kwargs): |
|
124 """ create & return the directory joined with args. |
|
125 pass a 'msg' keyword argument to set the commit message. |
|
126 """ |
|
127 commit_msg = kwargs.get('msg', "mkdir by py lib invocation") |
|
128 createpath = self.join(*args) |
|
129 createpath._svnwrite('mkdir', '-m', commit_msg) |
|
130 self._norev_delentry(createpath.dirpath()) |
|
131 return createpath |
|
132 |
|
133 def copy(self, target, msg='copied by py lib invocation'): |
|
134 """ copy path to target with checkin message msg.""" |
|
135 if getattr(target, 'rev', None) is not None: |
|
136 raise py.error.EINVAL(target, "revisions are immutable") |
|
137 self._svncmdexecauth('svn copy -m "%s" "%s" "%s"' %(msg, |
|
138 self._escape(self), self._escape(target))) |
|
139 self._norev_delentry(target.dirpath()) |
|
140 |
|
141 def rename(self, target, msg="renamed by py lib invocation"): |
|
142 """ rename this path to target with checkin message msg. """ |
|
143 if getattr(self, 'rev', None) is not None: |
|
144 raise py.error.EINVAL(self, "revisions are immutable") |
|
145 self._svncmdexecauth('svn move -m "%s" --force "%s" "%s"' %( |
|
146 msg, self._escape(self), self._escape(target))) |
|
147 self._norev_delentry(self.dirpath()) |
|
148 self._norev_delentry(self) |
|
149 |
|
150 def remove(self, rec=1, msg='removed by py lib invocation'): |
|
151 """ remove a file or directory (or a directory tree if rec=1) with |
|
152 checkin message msg.""" |
|
153 if self.rev is not None: |
|
154 raise py.error.EINVAL(self, "revisions are immutable") |
|
155 self._svncmdexecauth('svn rm -m "%s" "%s"' %(msg, self._escape(self))) |
|
156 self._norev_delentry(self.dirpath()) |
|
157 |
|
158 def export(self, topath): |
|
159 """ export to a local path |
|
160 |
|
161 topath should not exist prior to calling this, returns a |
|
162 py.path.local instance |
|
163 """ |
|
164 topath = py.path.local(topath) |
|
165 args = ['"%s"' % (self._escape(self),), |
|
166 '"%s"' % (self._escape(topath),)] |
|
167 if self.rev is not None: |
|
168 args = ['-r', str(self.rev)] + args |
|
169 self._svncmdexecauth('svn export %s' % (' '.join(args),)) |
|
170 return topath |
|
171 |
|
172 def ensure(self, *args, **kwargs): |
|
173 """ ensure that an args-joined path exists (by default as |
|
174 a file). If you specify a keyword argument 'dir=True' |
|
175 then the path is forced to be a directory path. |
|
176 """ |
|
177 if getattr(self, 'rev', None) is not None: |
|
178 raise py.error.EINVAL(self, "revisions are immutable") |
|
179 target = self.join(*args) |
|
180 dir = kwargs.get('dir', 0) |
|
181 for x in target.parts(reverse=True): |
|
182 if x.check(): |
|
183 break |
|
184 else: |
|
185 raise py.error.ENOENT(target, "has not any valid base!") |
|
186 if x == target: |
|
187 if not x.check(dir=dir): |
|
188 raise dir and py.error.ENOTDIR(x) or py.error.EISDIR(x) |
|
189 return x |
|
190 tocreate = target.relto(x) |
|
191 basename = tocreate.split(self.sep, 1)[0] |
|
192 tempdir = py.path.local.mkdtemp() |
|
193 try: |
|
194 tempdir.ensure(tocreate, dir=dir) |
|
195 cmd = 'svn import -m "%s" "%s" "%s"' % ( |
|
196 "ensure %s" % self._escape(tocreate), |
|
197 self._escape(tempdir.join(basename)), |
|
198 x.join(basename)._encodedurl()) |
|
199 self._svncmdexecauth(cmd) |
|
200 self._norev_delentry(x) |
|
201 finally: |
|
202 tempdir.remove() |
|
203 return target |
|
204 |
|
205 # end of modifying methods |
|
206 def _propget(self, name): |
|
207 res = self._svnwithrev('propget', name) |
|
208 return res[:-1] # strip trailing newline |
|
209 |
|
210 def _proplist(self): |
|
211 res = self._svnwithrev('proplist') |
|
212 lines = res.split('\n') |
|
213 lines = [x.strip() for x in lines[1:]] |
|
214 return svncommon.PropListDict(self, lines) |
|
215 |
|
216 def info(self): |
|
217 """ return an Info structure with svn-provided information. """ |
|
218 parent = self.dirpath() |
|
219 nameinfo_seq = parent._listdir_nameinfo() |
|
220 bn = self.basename |
|
221 for name, info in nameinfo_seq: |
|
222 if name == bn: |
|
223 return info |
|
224 raise py.error.ENOENT(self) |
|
225 |
|
226 |
|
227 def _listdir_nameinfo(self): |
|
228 """ return sequence of name-info directory entries of self """ |
|
229 def builder(): |
|
230 try: |
|
231 res = self._svnwithrev('ls', '-v') |
|
232 except process.cmdexec.Error: |
|
233 e = sys.exc_info()[1] |
|
234 if e.err.find('non-existent in that revision') != -1: |
|
235 raise py.error.ENOENT(self, e.err) |
|
236 elif e.err.find('File not found') != -1: |
|
237 raise py.error.ENOENT(self, e.err) |
|
238 elif e.err.find('not part of a repository')!=-1: |
|
239 raise py.error.ENOENT(self, e.err) |
|
240 elif e.err.find('Unable to open')!=-1: |
|
241 raise py.error.ENOENT(self, e.err) |
|
242 elif e.err.lower().find('method not allowed')!=-1: |
|
243 raise py.error.EACCES(self, e.err) |
|
244 raise py.error.Error(e.err) |
|
245 lines = res.split('\n') |
|
246 nameinfo_seq = [] |
|
247 for lsline in lines: |
|
248 if lsline: |
|
249 info = InfoSvnCommand(lsline) |
|
250 if info._name != '.': # svn 1.5 produces '.' dirs, |
|
251 nameinfo_seq.append((info._name, info)) |
|
252 nameinfo_seq.sort() |
|
253 return nameinfo_seq |
|
254 auth = self.auth and self.auth.makecmdoptions() or None |
|
255 if self.rev is not None: |
|
256 return self._lsrevcache.getorbuild((self.strpath, self.rev, auth), |
|
257 builder) |
|
258 else: |
|
259 return self._lsnorevcache.getorbuild((self.strpath, auth), |
|
260 builder) |
|
261 |
|
262 def listdir(self, fil=None, sort=None): |
|
263 """ list directory contents, possibly filter by the given fil func |
|
264 and possibly sorted. |
|
265 """ |
|
266 if isinstance(fil, str): |
|
267 fil = common.FNMatcher(fil) |
|
268 nameinfo_seq = self._listdir_nameinfo() |
|
269 if len(nameinfo_seq) == 1: |
|
270 name, info = nameinfo_seq[0] |
|
271 if name == self.basename and info.kind == 'file': |
|
272 #if not self.check(dir=1): |
|
273 raise py.error.ENOTDIR(self) |
|
274 paths = [self.join(name) for (name, info) in nameinfo_seq] |
|
275 if fil: |
|
276 paths = [x for x in paths if fil(x)] |
|
277 self._sortlist(paths, sort) |
|
278 return paths |
|
279 |
|
280 |
|
281 def log(self, rev_start=None, rev_end=1, verbose=False): |
|
282 """ return a list of LogEntry instances for this path. |
|
283 rev_start is the starting revision (defaulting to the first one). |
|
284 rev_end is the last revision (defaulting to HEAD). |
|
285 if verbose is True, then the LogEntry instances also know which files changed. |
|
286 """ |
|
287 assert self.check() #make it simpler for the pipe |
|
288 rev_start = rev_start is None and "HEAD" or rev_start |
|
289 rev_end = rev_end is None and "HEAD" or rev_end |
|
290 |
|
291 if rev_start == "HEAD" and rev_end == 1: |
|
292 rev_opt = "" |
|
293 else: |
|
294 rev_opt = "-r %s:%s" % (rev_start, rev_end) |
|
295 verbose_opt = verbose and "-v" or "" |
|
296 xmlpipe = self._svnpopenauth('svn log --xml %s %s "%s"' % |
|
297 (rev_opt, verbose_opt, self.strpath)) |
|
298 from xml.dom import minidom |
|
299 tree = minidom.parse(xmlpipe) |
|
300 result = [] |
|
301 for logentry in filter(None, tree.firstChild.childNodes): |
|
302 if logentry.nodeType == logentry.ELEMENT_NODE: |
|
303 result.append(svncommon.LogEntry(logentry)) |
|
304 return result |
|
305 |
|
306 #01234567890123456789012345678901234567890123467 |
|
307 # 2256 hpk 165 Nov 24 17:55 __init__.py |
|
308 # XXX spotted by Guido, SVN 1.3.0 has different aligning, breaks the code!!! |
|
309 # 1312 johnny 1627 May 05 14:32 test_decorators.py |
|
310 # |
|
311 class InfoSvnCommand: |
|
312 # the '0?' part in the middle is an indication of whether the resource is |
|
313 # locked, see 'svn help ls' |
|
314 lspattern = re.compile( |
|
315 r'^ *(?P<rev>\d+) +(?P<author>.+?) +(0? *(?P<size>\d+))? ' |
|
316 '*(?P<date>\w+ +\d{2} +[\d:]+) +(?P<file>.*)$') |
|
317 def __init__(self, line): |
|
318 # this is a typical line from 'svn ls http://...' |
|
319 #_ 1127 jum 0 Jul 13 15:28 branch/ |
|
320 match = self.lspattern.match(line) |
|
321 data = match.groupdict() |
|
322 self._name = data['file'] |
|
323 if self._name[-1] == '/': |
|
324 self._name = self._name[:-1] |
|
325 self.kind = 'dir' |
|
326 else: |
|
327 self.kind = 'file' |
|
328 #self.has_props = l.pop(0) == 'P' |
|
329 self.created_rev = int(data['rev']) |
|
330 self.last_author = data['author'] |
|
331 self.size = data['size'] and int(data['size']) or 0 |
|
332 self.mtime = parse_time_with_missing_year(data['date']) |
|
333 self.time = self.mtime * 1000000 |
|
334 |
|
335 def __eq__(self, other): |
|
336 return self.__dict__ == other.__dict__ |
|
337 |
|
338 |
|
339 #____________________________________________________ |
|
340 # |
|
341 # helper functions |
|
342 #____________________________________________________ |
|
343 def parse_time_with_missing_year(timestr): |
|
344 """ analyze the time part from a single line of "svn ls -v" |
|
345 the svn output doesn't show the year makes the 'timestr' |
|
346 ambigous. |
|
347 """ |
|
348 import calendar |
|
349 t_now = time.gmtime() |
|
350 |
|
351 tparts = timestr.split() |
|
352 month = time.strptime(tparts.pop(0), '%b')[1] |
|
353 day = time.strptime(tparts.pop(0), '%d')[2] |
|
354 last = tparts.pop(0) # year or hour:minute |
|
355 try: |
|
356 if ":" in last: |
|
357 raise ValueError() |
|
358 year = time.strptime(last, '%Y')[0] |
|
359 hour = minute = 0 |
|
360 except ValueError: |
|
361 hour, minute = time.strptime(last, '%H:%M')[3:5] |
|
362 year = t_now[0] |
|
363 |
|
364 t_result = (year, month, day, hour, minute, 0,0,0,0) |
|
365 if t_result > t_now: |
|
366 year -= 1 |
|
367 t_result = (year, month, day, hour, minute, 0,0,0,0) |
|
368 return calendar.timegm(t_result) |
|
369 |
|
370 class PathEntry: |
|
371 def __init__(self, ppart): |
|
372 self.strpath = ppart.firstChild.nodeValue.encode('UTF-8') |
|
373 self.action = ppart.getAttribute('action').encode('UTF-8') |
|
374 if self.action == 'A': |
|
375 self.copyfrom_path = ppart.getAttribute('copyfrom-path').encode('UTF-8') |
|
376 if self.copyfrom_path: |
|
377 self.copyfrom_rev = int(ppart.getAttribute('copyfrom-rev')) |
|
378 |