1"""
2svn-Command based Implementation of a Subversion WorkingCopy Path.
3
4  SvnWCCommandPath  is the main class.
5
6"""
7
8import os, sys, time, re, calendar
9import py
10import subprocess
11from py._path import common
12
13#-----------------------------------------------------------
14# Caching latest repository revision and repo-paths
15# (getting them is slow with the current implementations)
16#
17# XXX make mt-safe
18#-----------------------------------------------------------
19
20class cache:
21    proplist = {}
22    info = {}
23    entries = {}
24    prop = {}
25
26class RepoEntry:
27    def __init__(self, url, rev, timestamp):
28        self.url = url
29        self.rev = rev
30        self.timestamp = timestamp
31
32    def __str__(self):
33        return "repo: %s;%s  %s" %(self.url, self.rev, self.timestamp)
34
35class RepoCache:
36    """ The Repocache manages discovered repository paths
37    and their revisions.  If inside a timeout the cache
38    will even return the revision of the root.
39    """
40    timeout = 20 # seconds after which we forget that we know the last revision
41
42    def __init__(self):
43        self.repos = []
44
45    def clear(self):
46        self.repos = []
47
48    def put(self, url, rev, timestamp=None):
49        if rev is None:
50            return
51        if timestamp is None:
52            timestamp = time.time()
53
54        for entry in self.repos:
55            if url == entry.url:
56                entry.timestamp = timestamp
57                entry.rev = rev
58                #print "set repo", entry
59                break
60        else:
61            entry = RepoEntry(url, rev, timestamp)
62            self.repos.append(entry)
63            #print "appended repo", entry
64
65    def get(self, url):
66        now = time.time()
67        for entry in self.repos:
68            if url.startswith(entry.url):
69                if now < entry.timestamp + self.timeout:
70                    #print "returning immediate Etrny", entry
71                    return entry.url, entry.rev
72                return entry.url, -1
73        return url, -1
74
75repositories = RepoCache()
76
77
78# svn support code
79
80ALLOWED_CHARS = "_ -/\\=$.~+%" #add characters as necessary when tested
81if sys.platform == "win32":
82    ALLOWED_CHARS += ":"
83ALLOWED_CHARS_HOST = ALLOWED_CHARS + '@:'
84
85def _getsvnversion(ver=[]):
86    try:
87        return ver[0]
88    except IndexError:
89        v = py.process.cmdexec("svn -q --version")
90        v.strip()
91        v = '.'.join(v.split('.')[:2])
92        ver.append(v)
93        return v
94
95def _escape_helper(text):
96    text = str(text)
97    if sys.platform != 'win32':
98        text = str(text).replace('$', '\\$')
99    return text
100
101def _check_for_bad_chars(text, allowed_chars=ALLOWED_CHARS):
102    for c in str(text):
103        if c.isalnum():
104            continue
105        if c in allowed_chars:
106            continue
107        return True
108    return False
109
110def checkbadchars(url):
111    # (hpk) not quite sure about the exact purpose, guido w.?
112    proto, uri = url.split("://", 1)
113    if proto != "file":
114        host, uripath = uri.split('/', 1)
115        # only check for bad chars in the non-protocol parts
116        if (_check_for_bad_chars(host, ALLOWED_CHARS_HOST) \
117            or _check_for_bad_chars(uripath, ALLOWED_CHARS)):
118            raise ValueError("bad char in %r" % (url, ))
119
120
121#_______________________________________________________________
122
123class SvnPathBase(common.PathBase):
124    """ Base implementation for SvnPath implementations. """
125    sep = '/'
126
127    def _geturl(self):
128        return self.strpath
129    url = property(_geturl, None, None, "url of this svn-path.")
130
131    def __str__(self):
132        """ return a string representation (including rev-number) """
133        return self.strpath
134
135    def __hash__(self):
136        return hash(self.strpath)
137
138    def new(self, **kw):
139        """ create a modified version of this path. A 'rev' argument
140            indicates a new revision.
141            the following keyword arguments modify various path parts::
142
143              http://host.com/repo/path/file.ext
144              |-----------------------|          dirname
145                                        |------| basename
146                                        |--|     purebasename
147                                            |--| ext
148        """
149        obj = object.__new__(self.__class__)
150        obj.rev = kw.get('rev', self.rev)
151        obj.auth = kw.get('auth', self.auth)
152        dirname, basename, purebasename, ext = self._getbyspec(
153             "dirname,basename,purebasename,ext")
154        if 'basename' in kw:
155            if 'purebasename' in kw or 'ext' in kw:
156                raise ValueError("invalid specification %r" % kw)
157        else:
158            pb = kw.setdefault('purebasename', purebasename)
159            ext = kw.setdefault('ext', ext)
160            if ext and not ext.startswith('.'):
161                ext = '.' + ext
162            kw['basename'] = pb + ext
163
164        kw.setdefault('dirname', dirname)
165        kw.setdefault('sep', self.sep)
166        if kw['basename']:
167            obj.strpath = "%(dirname)s%(sep)s%(basename)s" % kw
168        else:
169            obj.strpath = "%(dirname)s" % kw
170        return obj
171
172    def _getbyspec(self, spec):
173        """ get specified parts of the path.  'arg' is a string
174            with comma separated path parts. The parts are returned
175            in exactly the order of the specification.
176
177            you may specify the following parts:
178
179            http://host.com/repo/path/file.ext
180            |-----------------------|          dirname
181                                      |------| basename
182                                      |--|     purebasename
183                                          |--| ext
184        """
185        res = []
186        parts = self.strpath.split(self.sep)
187        for name in spec.split(','):
188            name = name.strip()
189            if name == 'dirname':
190                res.append(self.sep.join(parts[:-1]))
191            elif name == 'basename':
192                res.append(parts[-1])
193            else:
194                basename = parts[-1]
195                i = basename.rfind('.')
196                if i == -1:
197                    purebasename, ext = basename, ''
198                else:
199                    purebasename, ext = basename[:i], basename[i:]
200                if name == 'purebasename':
201                    res.append(purebasename)
202                elif name == 'ext':
203                    res.append(ext)
204                else:
205                    raise NameError("Don't know part %r" % name)
206        return res
207
208    def __eq__(self, other):
209        """ return true if path and rev attributes each match """
210        return (str(self) == str(other) and
211               (self.rev == other.rev or self.rev == other.rev))
212
213    def __ne__(self, other):
214        return not self == other
215
216    def join(self, *args):
217        """ return a new Path (with the same revision) which is composed
218            of the self Path followed by 'args' path components.
219        """
220        if not args:
221            return self
222
223        args = tuple([arg.strip(self.sep) for arg in args])
224        parts = (self.strpath, ) + args
225        newpath = self.__class__(self.sep.join(parts), self.rev, self.auth)
226        return newpath
227
228    def propget(self, name):
229        """ return the content of the given property. """
230        value = self._propget(name)
231        return value
232
233    def proplist(self):
234        """ list all property names. """
235        content = self._proplist()
236        return content
237
238    def size(self):
239        """ Return the size of the file content of the Path. """
240        return self.info().size
241
242    def mtime(self):
243        """ Return the last modification time of the file. """
244        return self.info().mtime
245
246    # shared help methods
247
248    def _escape(self, cmd):
249        return _escape_helper(cmd)
250
251
252    #def _childmaxrev(self):
253    #    """ return maximum revision number of childs (or self.rev if no childs) """
254    #    rev = self.rev
255    #    for name, info in self._listdir_nameinfo():
256    #        rev = max(rev, info.created_rev)
257    #    return rev
258
259    #def _getlatestrevision(self):
260    #    """ return latest repo-revision for this path. """
261    #    url = self.strpath
262    #    path = self.__class__(url, None)
263    #
264    #    # we need a long walk to find the root-repo and revision
265    #    while 1:
266    #        try:
267    #            rev = max(rev, path._childmaxrev())
268    #            previous = path
269    #            path = path.dirpath()
270    #        except (IOError, process.cmdexec.Error):
271    #            break
272    #    if rev is None:
273    #        raise IOError, "could not determine newest repo revision for %s" % self
274    #    return rev
275
276    class Checkers(common.Checkers):
277        def dir(self):
278            try:
279                return self.path.info().kind == 'dir'
280            except py.error.Error:
281                return self._listdirworks()
282
283        def _listdirworks(self):
284            try:
285                self.path.listdir()
286            except py.error.ENOENT:
287                return False
288            else:
289                return True
290
291        def file(self):
292            try:
293                return self.path.info().kind == 'file'
294            except py.error.ENOENT:
295                return False
296
297        def exists(self):
298            try:
299                return self.path.info()
300            except py.error.ENOENT:
301                return self._listdirworks()
302
303def parse_apr_time(timestr):
304    i = timestr.rfind('.')
305    if i == -1:
306        raise ValueError("could not parse %s" % timestr)
307    timestr = timestr[:i]
308    parsedtime = time.strptime(timestr, "%Y-%m-%dT%H:%M:%S")
309    return time.mktime(parsedtime)
310
311class PropListDict(dict):
312    """ a Dictionary which fetches values (InfoSvnCommand instances) lazily"""
313    def __init__(self, path, keynames):
314        dict.__init__(self, [(x, None) for x in keynames])
315        self.path = path
316
317    def __getitem__(self, key):
318        value = dict.__getitem__(self, key)
319        if value is None:
320            value = self.path.propget(key)
321            dict.__setitem__(self, key, value)
322        return value
323
324def fixlocale():
325    if sys.platform != 'win32':
326        return 'LC_ALL=C '
327    return ''
328
329# some nasty chunk of code to solve path and url conversion and quoting issues
330ILLEGAL_CHARS = '* | \\ / : < > ? \t \n \x0b \x0c \r'.split(' ')
331if os.sep in ILLEGAL_CHARS:
332    ILLEGAL_CHARS.remove(os.sep)
333ISWINDOWS = sys.platform == 'win32'
334_reg_allow_disk = re.compile(r'^([a-z]\:\\)?[^:]+$', re.I)
335def _check_path(path):
336    illegal = ILLEGAL_CHARS[:]
337    sp = path.strpath
338    if ISWINDOWS:
339        illegal.remove(':')
340        if not _reg_allow_disk.match(sp):
341            raise ValueError('path may not contain a colon (:)')
342    for char in sp:
343        if char not in string.printable or char in illegal:
344            raise ValueError('illegal character %r in path' % (char,))
345
346def path_to_fspath(path, addat=True):
347    _check_path(path)
348    sp = path.strpath
349    if addat and path.rev != -1:
350        sp = '%s@%s' % (sp, path.rev)
351    elif addat:
352        sp = '%s@HEAD' % (sp,)
353    return sp
354
355def url_from_path(path):
356    fspath = path_to_fspath(path, False)
357    from urllib import quote
358    if ISWINDOWS:
359        match = _reg_allow_disk.match(fspath)
360        fspath = fspath.replace('\\', '/')
361        if match.group(1):
362            fspath = '/%s%s' % (match.group(1).replace('\\', '/'),
363                                quote(fspath[len(match.group(1)):]))
364        else:
365            fspath = quote(fspath)
366    else:
367        fspath = quote(fspath)
368    if path.rev != -1:
369        fspath = '%s@%s' % (fspath, path.rev)
370    else:
371        fspath = '%s@HEAD' % (fspath,)
372    return 'file://%s' % (fspath,)
373
374class SvnAuth(object):
375    """ container for auth information for Subversion """
376    def __init__(self, username, password, cache_auth=True, interactive=True):
377        self.username = username
378        self.password = password
379        self.cache_auth = cache_auth
380        self.interactive = interactive
381
382    def makecmdoptions(self):
383        uname = self.username.replace('"', '\\"')
384        passwd = self.password.replace('"', '\\"')
385        ret = []
386        if uname:
387            ret.append('--username="%s"' % (uname,))
388        if passwd:
389            ret.append('--password="%s"' % (passwd,))
390        if not self.cache_auth:
391            ret.append('--no-auth-cache')
392        if not self.interactive:
393            ret.append('--non-interactive')
394        return ' '.join(ret)
395
396    def __str__(self):
397        return "<SvnAuth username=%s ...>" %(self.username,)
398
399rex_blame = re.compile(r'\s*(\d+)\s*(\S+) (.*)')
400
401class SvnWCCommandPath(common.PathBase):
402    """ path implementation offering access/modification to svn working copies.
403        It has methods similar to the functions in os.path and similar to the
404        commands of the svn client.
405    """
406    sep = os.sep
407
408    def __new__(cls, wcpath=None, auth=None):
409        self = object.__new__(cls)
410        if isinstance(wcpath, cls):
411            if wcpath.__class__ == cls:
412                return wcpath
413            wcpath = wcpath.localpath
414        if _check_for_bad_chars(str(wcpath),
415                                          ALLOWED_CHARS):
416            raise ValueError("bad char in wcpath %s" % (wcpath, ))
417        self.localpath = py.path.local(wcpath)
418        self.auth = auth
419        return self
420
421    strpath = property(lambda x: str(x.localpath), None, None, "string path")
422    rev = property(lambda x: x.info(usecache=0).rev, None, None, "revision")
423
424    def __eq__(self, other):
425        return self.localpath == getattr(other, 'localpath', None)
426
427    def _geturl(self):
428        if getattr(self, '_url', None) is None:
429            info = self.info()
430            self._url = info.url #SvnPath(info.url, info.rev)
431        assert isinstance(self._url, py.builtin._basestring)
432        return self._url
433
434    url = property(_geturl, None, None, "url of this WC item")
435
436    def _escape(self, cmd):
437        return _escape_helper(cmd)
438
439    def dump(self, obj):
440        """ pickle object into path location"""
441        return self.localpath.dump(obj)
442
443    def svnurl(self):
444        """ return current SvnPath for this WC-item. """
445        info = self.info()
446        return py.path.svnurl(info.url)
447
448    def __repr__(self):
449        return "svnwc(%r)" % (self.strpath) # , self._url)
450
451    def __str__(self):
452        return str(self.localpath)
453
454    def _makeauthoptions(self):
455        if self.auth is None:
456            return ''
457        return self.auth.makecmdoptions()
458
459    def _authsvn(self, cmd, args=None):
460        args = args and list(args) or []
461        args.append(self._makeauthoptions())
462        return self._svn(cmd, *args)
463
464    def _svn(self, cmd, *args):
465        l = ['svn %s' % cmd]
466        args = [self._escape(item) for item in args]
467        l.extend(args)
468        l.append('"%s"' % self._escape(self.strpath))
469        # try fixing the locale because we can't otherwise parse
470        string = fixlocale() + " ".join(l)
471        try:
472            try:
473                key = 'LC_MESSAGES'
474                hold = os.environ.get(key)
475                os.environ[key] = 'C'
476                out = py.process.cmdexec(string)
477            finally:
478                if hold:
479                    os.environ[key] = hold
480                else:
481                    del os.environ[key]
482        except py.process.cmdexec.Error:
483            e = sys.exc_info()[1]
484            strerr = e.err.lower()
485            if strerr.find('not found') != -1:
486                raise py.error.ENOENT(self)
487            elif strerr.find("E200009:") != -1:
488                raise py.error.ENOENT(self)
489            if (strerr.find('file exists') != -1 or
490                strerr.find('file already exists') != -1 or
491                strerr.find('w150002:') != -1 or
492                strerr.find("can't create directory") != -1):
493                raise py.error.EEXIST(strerr) #self)
494            raise
495        return out
496
497    def switch(self, url):
498        """ switch to given URL. """
499        self._authsvn('switch', [url])
500
501    def checkout(self, url=None, rev=None):
502        """ checkout from url to local wcpath. """
503        args = []
504        if url is None:
505            url = self.url
506        if rev is None or rev == -1:
507            if (sys.platform != 'win32' and
508                    _getsvnversion() == '1.3'):
509                url += "@HEAD"
510        else:
511            if _getsvnversion() == '1.3':
512                url += "@%d" % rev
513            else:
514                args.append('-r' + str(rev))
515        args.append(url)
516        self._authsvn('co', args)
517
518    def update(self, rev='HEAD', interactive=True):
519        """ update working copy item to given revision. (None -> HEAD). """
520        opts = ['-r', rev]
521        if not interactive:
522            opts.append("--non-interactive")
523        self._authsvn('up', opts)
524
525    def write(self, content, mode='w'):
526        """ write content into local filesystem wc. """
527        self.localpath.write(content, mode)
528
529    def dirpath(self, *args):
530        """ return the directory Path of the current Path. """
531        return self.__class__(self.localpath.dirpath(*args), auth=self.auth)
532
533    def _ensuredirs(self):
534        parent = self.dirpath()
535        if parent.check(dir=0):
536            parent._ensuredirs()
537        if self.check(dir=0):
538            self.mkdir()
539        return self
540
541    def ensure(self, *args, **kwargs):
542        """ ensure that an args-joined path exists (by default as
543            a file). if you specify a keyword argument 'directory=True'
544            then the path is forced  to be a directory path.
545        """
546        p = self.join(*args)
547        if p.check():
548            if p.check(versioned=False):
549                p.add()
550            return p
551        if kwargs.get('dir', 0):
552            return p._ensuredirs()
553        parent = p.dirpath()
554        parent._ensuredirs()
555        p.write("")
556        p.add()
557        return p
558
559    def mkdir(self, *args):
560        """ create & return the directory joined with args. """
561        if args:
562            return self.join(*args).mkdir()
563        else:
564            self._svn('mkdir')
565            return self
566
567    def add(self):
568        """ add ourself to svn """
569        self._svn('add')
570
571    def remove(self, rec=1, force=1):
572        """ remove a file or a directory tree. 'rec'ursive is
573            ignored and considered always true (because of
574            underlying svn semantics.
575        """
576        assert rec, "svn cannot remove non-recursively"
577        if not self.check(versioned=True):
578            # not added to svn (anymore?), just remove
579            py.path.local(self).remove()
580            return
581        flags = []
582        if force:
583            flags.append('--force')
584        self._svn('remove', *flags)
585
586    def copy(self, target):
587        """ copy path to target."""
588        py.process.cmdexec("svn copy %s %s" %(str(self), str(target)))
589
590    def rename(self, target):
591        """ rename this path to target. """
592        py.process.cmdexec("svn move --force %s %s" %(str(self), str(target)))
593
594    def lock(self):
595        """ set a lock (exclusive) on the resource """
596        out = self._authsvn('lock').strip()
597        if not out:
598            # warning or error, raise exception
599            raise ValueError("unknown error in svn lock command")
600
601    def unlock(self):
602        """ unset a previously set lock """
603        out = self._authsvn('unlock').strip()
604        if out.startswith('svn:'):
605            # warning or error, raise exception
606            raise Exception(out[4:])
607
608    def cleanup(self):
609        """ remove any locks from the resource """
610        # XXX should be fixed properly!!!
611        try:
612            self.unlock()
613        except:
614            pass
615
616    def status(self, updates=0, rec=0, externals=0):
617        """ return (collective) Status object for this file. """
618        # http://svnbook.red-bean.com/book.html#svn-ch-3-sect-4.3.1
619        #             2201     2192        jum   test
620        # XXX
621        if externals:
622            raise ValueError("XXX cannot perform status() "
623                             "on external items yet")
624        else:
625            #1.2 supports: externals = '--ignore-externals'
626            externals = ''
627        if rec:
628            rec= ''
629        else:
630            rec = '--non-recursive'
631
632        # XXX does not work on all subversion versions
633        #if not externals:
634        #    externals = '--ignore-externals'
635
636        if updates:
637            updates = '-u'
638        else:
639            updates = ''
640
641        try:
642            cmd = 'status -v --xml --no-ignore %s %s %s' % (
643                    updates, rec, externals)
644            out = self._authsvn(cmd)
645        except py.process.cmdexec.Error:
646            cmd = 'status -v --no-ignore %s %s %s' % (
647                    updates, rec, externals)
648            out = self._authsvn(cmd)
649            rootstatus = WCStatus(self).fromstring(out, self)
650        else:
651            rootstatus = XMLWCStatus(self).fromstring(out, self)
652        return rootstatus
653
654    def diff(self, rev=None):
655        """ return a diff of the current path against revision rev (defaulting
656            to the last one).
657        """
658        args = []
659        if rev is not None:
660            args.append("-r %d" % rev)
661        out = self._authsvn('diff', args)
662        return out
663
664    def blame(self):
665        """ return a list of tuples of three elements:
666            (revision, commiter, line)
667        """
668        out = self._svn('blame')
669        result = []
670        blamelines = out.splitlines()
671        reallines = py.path.svnurl(self.url).readlines()
672        for i, (blameline, line) in enumerate(
673                zip(blamelines, reallines)):
674            m = rex_blame.match(blameline)
675            if not m:
676                raise ValueError("output line %r of svn blame does not match "
677                                 "expected format" % (line, ))
678            rev, name, _ = m.groups()
679            result.append((int(rev), name, line))
680        return result
681
682    _rex_commit = re.compile(r'.*Committed revision (\d+)\.$', re.DOTALL)
683    def commit(self, msg='', rec=1):
684        """ commit with support for non-recursive commits """
685        # XXX i guess escaping should be done better here?!?
686        cmd = 'commit -m "%s" --force-log' % (msg.replace('"', '\\"'),)
687        if not rec:
688            cmd += ' -N'
689        out = self._authsvn(cmd)
690        try:
691            del cache.info[self]
692        except KeyError:
693            pass
694        if out:
695            m = self._rex_commit.match(out)
696            return int(m.group(1))
697
698    def propset(self, name, value, *args):
699        """ set property name to value on this path. """
700        d = py.path.local.mkdtemp()
701        try:
702            p = d.join('value')
703            p.write(value)
704            self._svn('propset', name, '--file', str(p), *args)
705        finally:
706            d.remove()
707
708    def propget(self, name):
709        """ get property name on this path. """
710        res = self._svn('propget', name)
711        return res[:-1] # strip trailing newline
712
713    def propdel(self, name):
714        """ delete property name on this path. """
715        res = self._svn('propdel', name)
716        return res[:-1] # strip trailing newline
717
718    def proplist(self, rec=0):
719        """ return a mapping of property names to property values.
720If rec is True, then return a dictionary mapping sub-paths to such mappings.
721"""
722        if rec:
723            res = self._svn('proplist -R')
724            return make_recursive_propdict(self, res)
725        else:
726            res = self._svn('proplist')
727            lines = res.split('\n')
728            lines = [x.strip() for x in lines[1:]]
729            return PropListDict(self, lines)
730
731    def revert(self, rec=0):
732        """ revert the local changes of this path. if rec is True, do so
733recursively. """
734        if rec:
735            result = self._svn('revert -R')
736        else:
737            result = self._svn('revert')
738        return result
739
740    def new(self, **kw):
741        """ create a modified version of this path. A 'rev' argument
742            indicates a new revision.
743            the following keyword arguments modify various path parts:
744
745              http://host.com/repo/path/file.ext
746              |-----------------------|          dirname
747                                        |------| basename
748                                        |--|     purebasename
749                                            |--| ext
750        """
751        if kw:
752            localpath = self.localpath.new(**kw)
753        else:
754            localpath = self.localpath
755        return self.__class__(localpath, auth=self.auth)
756
757    def join(self, *args, **kwargs):
758        """ return a new Path (with the same revision) which is composed
759            of the self Path followed by 'args' path components.
760        """
761        if not args:
762            return self
763        localpath = self.localpath.join(*args, **kwargs)
764        return self.__class__(localpath, auth=self.auth)
765
766    def info(self, usecache=1):
767        """ return an Info structure with svn-provided information. """
768        info = usecache and cache.info.get(self)
769        if not info:
770            try:
771                output = self._svn('info')
772            except py.process.cmdexec.Error:
773                e = sys.exc_info()[1]
774                if e.err.find('Path is not a working copy directory') != -1:
775                    raise py.error.ENOENT(self, e.err)
776                elif e.err.find("is not under version control") != -1:
777                    raise py.error.ENOENT(self, e.err)
778                raise
779            # XXX SVN 1.3 has output on stderr instead of stdout (while it does
780            # return 0!), so a bit nasty, but we assume no output is output
781            # to stderr...
782            if (output.strip() == '' or
783                    output.lower().find('not a versioned resource') != -1):
784                raise py.error.ENOENT(self, output)
785            info = InfoSvnWCCommand(output)
786
787            # Can't reliably compare on Windows without access to win32api
788            if sys.platform != 'win32':
789                if info.path != self.localpath:
790                    raise py.error.ENOENT(self, "not a versioned resource:" +
791                            " %s != %s" % (info.path, self.localpath))
792            cache.info[self] = info
793        return info
794
795    def listdir(self, fil=None, sort=None):
796        """ return a sequence of Paths.
797
798        listdir will return either a tuple or a list of paths
799        depending on implementation choices.
800        """
801        if isinstance(fil, str):
802            fil = common.FNMatcher(fil)
803        # XXX unify argument naming with LocalPath.listdir
804        def notsvn(path):
805            return path.basename != '.svn'
806
807        paths = []
808        for localpath in self.localpath.listdir(notsvn):
809            p = self.__class__(localpath, auth=self.auth)
810            if notsvn(p) and (not fil or fil(p)):
811                paths.append(p)
812        self._sortlist(paths, sort)
813        return paths
814
815    def open(self, mode='r'):
816        """ return an opened file with the given mode. """
817        return open(self.strpath, mode)
818
819    def _getbyspec(self, spec):
820        return self.localpath._getbyspec(spec)
821
822    class Checkers(py.path.local.Checkers):
823        def __init__(self, path):
824            self.svnwcpath = path
825            self.path = path.localpath
826        def versioned(self):
827            try:
828                s = self.svnwcpath.info()
829            except (py.error.ENOENT, py.error.EEXIST):
830                return False
831            except py.process.cmdexec.Error:
832                e = sys.exc_info()[1]
833                if e.err.find('is not a working copy')!=-1:
834                    return False
835                if e.err.lower().find('not a versioned resource') != -1:
836                    return False
837                raise
838            else:
839                return True
840
841    def log(self, rev_start=None, rev_end=1, verbose=False):
842        """ return a list of LogEntry instances for this path.
843rev_start is the starting revision (defaulting to the first one).
844rev_end is the last revision (defaulting to HEAD).
845if verbose is True, then the LogEntry instances also know which files changed.
846"""
847        assert self.check()   # make it simpler for the pipe
848        rev_start = rev_start is None and "HEAD" or rev_start
849        rev_end = rev_end is None and "HEAD" or rev_end
850        if rev_start == "HEAD" and rev_end == 1:
851                rev_opt = ""
852        else:
853            rev_opt = "-r %s:%s" % (rev_start, rev_end)
854        verbose_opt = verbose and "-v" or ""
855        locale_env = fixlocale()
856        # some blather on stderr
857        auth_opt = self._makeauthoptions()
858        #stdin, stdout, stderr  = os.popen3(locale_env +
859        #                                   'svn log --xml %s %s %s "%s"' % (
860        #                                    rev_opt, verbose_opt, auth_opt,
861        #                                    self.strpath))
862        cmd = locale_env + 'svn log --xml %s %s %s "%s"' % (
863            rev_opt, verbose_opt, auth_opt, self.strpath)
864
865        popen = subprocess.Popen(cmd,
866                    stdout=subprocess.PIPE,
867                    stderr=subprocess.PIPE,
868                    shell=True,
869        )
870        stdout, stderr = popen.communicate()
871        stdout = py.builtin._totext(stdout, sys.getdefaultencoding())
872        minidom,ExpatError = importxml()
873        try:
874            tree = minidom.parseString(stdout)
875        except ExpatError:
876            raise ValueError('no such revision')
877        result = []
878        for logentry in filter(None, tree.firstChild.childNodes):
879            if logentry.nodeType == logentry.ELEMENT_NODE:
880                result.append(LogEntry(logentry))
881        return result
882
883    def size(self):
884        """ Return the size of the file content of the Path. """
885        return self.info().size
886
887    def mtime(self):
888        """ Return the last modification time of the file. """
889        return self.info().mtime
890
891    def __hash__(self):
892        return hash((self.strpath, self.__class__, self.auth))
893
894
895class WCStatus:
896    attrnames = ('modified','added', 'conflict', 'unchanged', 'external',
897                'deleted', 'prop_modified', 'unknown', 'update_available',
898                'incomplete', 'kindmismatch', 'ignored', 'locked', 'replaced'
899                )
900
901    def __init__(self, wcpath, rev=None, modrev=None, author=None):
902        self.wcpath = wcpath
903        self.rev = rev
904        self.modrev = modrev
905        self.author = author
906
907        for name in self.attrnames:
908            setattr(self, name, [])
909
910    def allpath(self, sort=True, **kw):
911        d = {}
912        for name in self.attrnames:
913            if name not in kw or kw[name]:
914                for path in getattr(self, name):
915                    d[path] = 1
916        l = d.keys()
917        if sort:
918            l.sort()
919        return l
920
921    # XXX a bit scary to assume there's always 2 spaces between username and
922    # path, however with win32 allowing spaces in user names there doesn't
923    # seem to be a more solid approach :(
924    _rex_status = re.compile(r'\s+(\d+|-)\s+(\S+)\s+(.+?)\s{2,}(.*)')
925
926    def fromstring(data, rootwcpath, rev=None, modrev=None, author=None):
927        """ return a new WCStatus object from data 's'
928        """
929        rootstatus = WCStatus(rootwcpath, rev, modrev, author)
930        update_rev = None
931        for line in data.split('\n'):
932            if not line.strip():
933                continue
934            #print "processing %r" % line
935            flags, rest = line[:8], line[8:]
936            # first column
937            c0,c1,c2,c3,c4,c5,x6,c7 = flags
938            #if '*' in line:
939            #    print "flags", repr(flags), "rest", repr(rest)
940
941            if c0 in '?XI':
942                fn = line.split(None, 1)[1]
943                if c0 == '?':
944                    wcpath = rootwcpath.join(fn, abs=1)
945                    rootstatus.unknown.append(wcpath)
946                elif c0 == 'X':
947                    wcpath = rootwcpath.__class__(
948                        rootwcpath.localpath.join(fn, abs=1),
949                        auth=rootwcpath.auth)
950                    rootstatus.external.append(wcpath)
951                elif c0 == 'I':
952                    wcpath = rootwcpath.join(fn, abs=1)
953                    rootstatus.ignored.append(wcpath)
954
955                continue
956
957            #elif c0 in '~!' or c4 == 'S':
958            #    raise NotImplementedError("received flag %r" % c0)
959
960            m = WCStatus._rex_status.match(rest)
961            if not m:
962                if c7 == '*':
963                    fn = rest.strip()
964                    wcpath = rootwcpath.join(fn, abs=1)
965                    rootstatus.update_available.append(wcpath)
966                    continue
967                if line.lower().find('against revision:')!=-1:
968                    update_rev = int(rest.split(':')[1].strip())
969                    continue
970                if line.lower().find('status on external') > -1:
971                    # XXX not sure what to do here... perhaps we want to
972                    # store some state instead of just continuing, as right
973                    # now it makes the top-level external get added twice
974                    # (once as external, once as 'normal' unchanged item)
975                    # because of the way SVN presents external items
976                    continue
977                # keep trying
978                raise ValueError("could not parse line %r" % line)
979            else:
980                rev, modrev, author, fn = m.groups()
981            wcpath = rootwcpath.join(fn, abs=1)
982            #assert wcpath.check()
983            if c0 == 'M':
984                assert wcpath.check(file=1), "didn't expect a directory with changed content here"
985                rootstatus.modified.append(wcpath)
986            elif c0 == 'A' or c3 == '+' :
987                rootstatus.added.append(wcpath)
988            elif c0 == 'D':
989                rootstatus.deleted.append(wcpath)
990            elif c0 == 'C':
991                rootstatus.conflict.append(wcpath)
992            elif c0 == '~':
993                rootstatus.kindmismatch.append(wcpath)
994            elif c0 == '!':
995                rootstatus.incomplete.append(wcpath)
996            elif c0 == 'R':
997                rootstatus.replaced.append(wcpath)
998            elif not c0.strip():
999                rootstatus.unchanged.append(wcpath)
1000            else:
1001                raise NotImplementedError("received flag %r" % c0)
1002
1003            if c1 == 'M':
1004                rootstatus.prop_modified.append(wcpath)
1005            # XXX do we cover all client versions here?
1006            if c2 == 'L' or c5 == 'K':
1007                rootstatus.locked.append(wcpath)
1008            if c7 == '*':
1009                rootstatus.update_available.append(wcpath)
1010
1011            if wcpath == rootwcpath:
1012                rootstatus.rev = rev
1013                rootstatus.modrev = modrev
1014                rootstatus.author = author
1015                if update_rev:
1016                    rootstatus.update_rev = update_rev
1017                continue
1018        return rootstatus
1019    fromstring = staticmethod(fromstring)
1020
1021class XMLWCStatus(WCStatus):
1022    def fromstring(data, rootwcpath, rev=None, modrev=None, author=None):
1023        """ parse 'data' (XML string as outputted by svn st) into a status obj
1024        """
1025        # XXX for externals, the path is shown twice: once
1026        # with external information, and once with full info as if
1027        # the item was a normal non-external... the current way of
1028        # dealing with this issue is by ignoring it - this does make
1029        # externals appear as external items as well as 'normal',
1030        # unchanged ones in the status object so this is far from ideal
1031        rootstatus = WCStatus(rootwcpath, rev, modrev, author)
1032        update_rev = None
1033        minidom, ExpatError = importxml()
1034        try:
1035            doc = minidom.parseString(data)
1036        except ExpatError:
1037            e = sys.exc_info()[1]
1038            raise ValueError(str(e))
1039        urevels = doc.getElementsByTagName('against')
1040        if urevels:
1041            rootstatus.update_rev = urevels[-1].getAttribute('revision')
1042        for entryel in doc.getElementsByTagName('entry'):
1043            path = entryel.getAttribute('path')
1044            statusel = entryel.getElementsByTagName('wc-status')[0]
1045            itemstatus = statusel.getAttribute('item')
1046
1047            if itemstatus == 'unversioned':
1048                wcpath = rootwcpath.join(path, abs=1)
1049                rootstatus.unknown.append(wcpath)
1050                continue
1051            elif itemstatus == 'external':
1052                wcpath = rootwcpath.__class__(
1053                    rootwcpath.localpath.join(path, abs=1),
1054                    auth=rootwcpath.auth)
1055                rootstatus.external.append(wcpath)
1056                continue
1057            elif itemstatus == 'ignored':
1058                wcpath = rootwcpath.join(path, abs=1)
1059                rootstatus.ignored.append(wcpath)
1060                continue
1061            elif itemstatus == 'incomplete':
1062                wcpath = rootwcpath.join(path, abs=1)
1063                rootstatus.incomplete.append(wcpath)
1064                continue
1065
1066            rev = statusel.getAttribute('revision')
1067            if itemstatus == 'added' or itemstatus == 'none':
1068                rev = '0'
1069                modrev = '?'
1070                author = '?'
1071                date = ''
1072            elif itemstatus == "replaced":
1073                pass
1074            else:
1075                #print entryel.toxml()
1076                commitel = entryel.getElementsByTagName('commit')[0]
1077                if commitel:
1078                    modrev = commitel.getAttribute('revision')
1079                    author = ''
1080                    author_els = commitel.getElementsByTagName('author')
1081                    if author_els:
1082                        for c in author_els[0].childNodes:
1083                            author += c.nodeValue
1084                    date = ''
1085                    for c in commitel.getElementsByTagName('date')[0]\
1086                            .childNodes:
1087                        date += c.nodeValue
1088
1089            wcpath = rootwcpath.join(path, abs=1)
1090
1091            assert itemstatus != 'modified' or wcpath.check(file=1), (
1092                'did\'t expect a directory with changed content here')
1093
1094            itemattrname = {
1095                'normal': 'unchanged',
1096                'unversioned': 'unknown',
1097                'conflicted': 'conflict',
1098                'none': 'added',
1099            }.get(itemstatus, itemstatus)
1100
1101            attr = getattr(rootstatus, itemattrname)
1102            attr.append(wcpath)
1103
1104            propsstatus = statusel.getAttribute('props')
1105            if propsstatus not in ('none', 'normal'):
1106                rootstatus.prop_modified.append(wcpath)
1107
1108            if wcpath == rootwcpath:
1109                rootstatus.rev = rev
1110                rootstatus.modrev = modrev
1111                rootstatus.author = author
1112                rootstatus.date = date
1113
1114            # handle repos-status element (remote info)
1115            rstatusels = entryel.getElementsByTagName('repos-status')
1116            if rstatusels:
1117                rstatusel = rstatusels[0]
1118                ritemstatus = rstatusel.getAttribute('item')
1119                if ritemstatus in ('added', 'modified'):
1120                    rootstatus.update_available.append(wcpath)
1121
1122            lockels = entryel.getElementsByTagName('lock')
1123            if len(lockels):
1124                rootstatus.locked.append(wcpath)
1125
1126        return rootstatus
1127    fromstring = staticmethod(fromstring)
1128
1129class InfoSvnWCCommand:
1130    def __init__(self, output):
1131        # Path: test
1132        # URL: http://codespeak.net/svn/std.path/trunk/dist/std.path/test
1133        # Repository UUID: fd0d7bf2-dfb6-0310-8d31-b7ecfe96aada
1134        # Revision: 2151
1135        # Node Kind: directory
1136        # Schedule: normal
1137        # Last Changed Author: hpk
1138        # Last Changed Rev: 2100
1139        # Last Changed Date: 2003-10-27 20:43:14 +0100 (Mon, 27 Oct 2003)
1140        # Properties Last Updated: 2003-11-03 14:47:48 +0100 (Mon, 03 Nov 2003)
1141
1142        d = {}
1143        for line in output.split('\n'):
1144            if not line.strip():
1145                continue
1146            key, value = line.split(':', 1)
1147            key = key.lower().replace(' ', '')
1148            value = value.strip()
1149            d[key] = value
1150        try:
1151            self.url = d['url']
1152        except KeyError:
1153            raise  ValueError("Not a versioned resource")
1154            #raise ValueError, "Not a versioned resource %r" % path
1155        self.kind = d['nodekind'] == 'directory' and 'dir' or d['nodekind']
1156        try:
1157            self.rev = int(d['revision'])
1158        except KeyError:
1159            self.rev = None
1160
1161        self.path = py.path.local(d['path'])
1162        self.size = self.path.size()
1163        if 'lastchangedrev' in d:
1164            self.created_rev = int(d['lastchangedrev'])
1165        if 'lastchangedauthor' in d:
1166            self.last_author = d['lastchangedauthor']
1167        if 'lastchangeddate' in d:
1168            self.mtime = parse_wcinfotime(d['lastchangeddate'])
1169            self.time = self.mtime * 1000000
1170
1171    def __eq__(self, other):
1172        return self.__dict__ == other.__dict__
1173
1174def parse_wcinfotime(timestr):
1175    """ Returns seconds since epoch, UTC. """
1176    # example: 2003-10-27 20:43:14 +0100 (Mon, 27 Oct 2003)
1177    m = re.match(r'(\d+-\d+-\d+ \d+:\d+:\d+) ([+-]\d+) .*', timestr)
1178    if not m:
1179        raise ValueError("timestring %r does not match" % timestr)
1180    timestr, timezone = m.groups()
1181    # do not handle timezone specially, return value should be UTC
1182    parsedtime = time.strptime(timestr, "%Y-%m-%d %H:%M:%S")
1183    return calendar.timegm(parsedtime)
1184
1185def make_recursive_propdict(wcroot,
1186                            output,
1187                            rex = re.compile("Properties on '(.*)':")):
1188    """ Return a dictionary of path->PropListDict mappings. """
1189    lines = [x for x in output.split('\n') if x]
1190    pdict = {}
1191    while lines:
1192        line = lines.pop(0)
1193        m = rex.match(line)
1194        if not m:
1195            raise ValueError("could not parse propget-line: %r" % line)
1196        path = m.groups()[0]
1197        wcpath = wcroot.join(path, abs=1)
1198        propnames = []
1199        while lines and lines[0].startswith('  '):
1200            propname = lines.pop(0).strip()
1201            propnames.append(propname)
1202        assert propnames, "must have found properties!"
1203        pdict[wcpath] = PropListDict(wcpath, propnames)
1204    return pdict
1205
1206
1207def importxml(cache=[]):
1208    if cache:
1209        return cache
1210    from xml.dom import minidom
1211    from xml.parsers.expat import ExpatError
1212    cache.extend([minidom, ExpatError])
1213    return cache
1214
1215class LogEntry:
1216    def __init__(self, logentry):
1217        self.rev = int(logentry.getAttribute('revision'))
1218        for lpart in filter(None, logentry.childNodes):
1219            if lpart.nodeType == lpart.ELEMENT_NODE:
1220                if lpart.nodeName == 'author':
1221                    self.author = lpart.firstChild.nodeValue
1222                elif lpart.nodeName == 'msg':
1223                    if lpart.firstChild:
1224                        self.msg = lpart.firstChild.nodeValue
1225                    else:
1226                        self.msg = ''
1227                elif lpart.nodeName == 'date':
1228                    #2003-07-29T20:05:11.598637Z
1229                    timestr = lpart.firstChild.nodeValue
1230                    self.date = parse_apr_time(timestr)
1231                elif lpart.nodeName == 'paths':
1232                    self.strpaths = []
1233                    for ppart in filter(None, lpart.childNodes):
1234                        if ppart.nodeType == ppart.ELEMENT_NODE:
1235                            self.strpaths.append(PathEntry(ppart))
1236    def __repr__(self):
1237        return '<Logentry rev=%d author=%s date=%s>' % (
1238            self.rev, self.author, self.date)
1239
1240
1241