1
2from hgsvn.errors import (
3    EmptySVNLog
4    , ExternalCommandFailed
5    , RunCommandError
6)
7from hgsvn import ui, shell
8from .shell import once_or_more, run_command
9from .ui import encodes
10
11import sys
12import os
13import time
14import calendar
15import operator
16import re
17import functools
18from six import string_types, binary_type, text_type, b
19from six.moves import urllib
20import traceback
21import codecs
22
23try:
24    from xml.etree import cElementTree as ET
25except ImportError:
26    try:
27        from xml.etree import ElementTree as ET
28    except ImportError:
29        try:
30            import cElementTree as ET
31        except ImportError:
32            from elementtree import ElementTree as ET
33
34
35svn_log_args = ['log', '--xml', '-v']
36svn_info_args = ['info', '--xml']
37svn_checkout_args = ['checkout', '-q']
38svn_status_args = ['status', '--xml', '--ignore-externals']
39
40_identity_table = "".join(map(chr, range(256)))
41_forbidden_xml_chars = "".join(
42    set( map(chr, range(32)) ) - set('\x09\x0A\x0D')
43)
44if sys.version_info[0] >= 3:
45    _forbidden_xml_bytes = b"".join( map(bytes, set( range(32) ) - set([ 9, 0x0A, 0x0D])) )
46    _forbidden_xml_trans = b"".maketrans(_forbidden_xml_bytes, b"".ljust(len(_forbidden_xml_bytes), b' ' ) )
47    _forbidden_xml_strans = "".maketrans(_forbidden_xml_chars, "".ljust(len(_forbidden_xml_chars), ' ' ) )
48else:
49    _forbidden_xml_bytes = binary_type(_forbidden_xml_chars)    #b"".join( set( range(32) ) - set([ 9, 0x0A, 0x0D]) )
50    import string
51    _forbidden_xml_trans = string.maketrans(_forbidden_xml_bytes, b"".ljust(len(_forbidden_xml_bytes), b' ' ) )
52    _forbidden_xml_strans = _forbidden_xml_trans
53
54def run_svn(args=None, bulk_args=None, fail_if_stderr=False,
55            mask_atsign=False):
56    """
57    Run an SVN command, returns the (bytes) output.
58    """
59    if mask_atsign:
60        # The @ sign in Subversion revers to a pegged revision number.
61        # SVN treats files with @ in the filename a bit special.
62        # See: http://stackoverflow.com/questions/1985203
63        for idx in range(len(args)):
64            if "@" in args[idx] and args[idx][0] not in ("-", '"'):
65                args[idx] = "%s@" % args[idx]
66        if bulk_args:
67            for idx in range(len(bulk_args)):
68                if ("@" in bulk_args[idx]
69                    and bulk_args[idx][0] not in ("-", '"')):
70                    bulk_args[idx] = "%s@" % bulk_args[idx]
71
72    if sys.version_info[0] < 3:
73        def prepare_args(args, target_encode):
74            return encodes(args, target_encode)
75    else:
76        def prepare_args(args, target_encode):
77            return args
78
79    def decode_output(output, enc):
80        # some hg cmds, for example "log" return output with mixed encoded lines, therefore decode them
81        # line by line independently
82        #outlines = output #.splitlines(True)
83        outlines = text_type(output, encoding=enc, errors = 'strict') #.splitlines(True) #'utf-8'
84        return outlines
85
86    enc = shell.locale_encoding
87
88    res = run_command("svn", args=prepare_args(args, enc), bulk_args=prepare_args(bulk_args, enc), fail_if_stderr=fail_if_stderr)
89
90    return res #decode_output(res, enc)
91
92def svn_decode( textline ):
93    loccodec = codecs.lookup(shell.locale_encoding)
94    ucodec = codecs.lookup('utf-8')
95    # some hg cmds, for example "log" return output with mixed encoded lines, therefore decode them
96    # line by line independently
97    lines = textline.splitlines(True)
98    outlines=[]
99    for line in lines:
100        #locale_encoding or 'UTF-8'
101        try:
102            uline, ulen = loccodec.decode(line, errors='strict')
103        except :
104            uline, ulen = ucodec.decode(line, errors='strict')
105        outlines.append(uline)
106
107    return "\n".join(outlines)
108
109
110def strip_forbidden_xml_chars(xml_string):
111    """
112    Given an XML string, strips forbidden characters as per the XML spec.
113    (these are all control characters except 0x9, 0xA and 0xD).
114    """
115    #filter(lambda x: x not in _forbidden_xml_chars, xml_string)
116    # portale python 2-3 version of same
117
118    #filt = lambda y, x: y+chr(x) if (chr(x) not in _forbidden_xml_chars) else y
119    #return functools.reduce(filt, xml_string, "")
120    if isinstance(xml_string, binary_type): #text_type
121        out = xml_string.translate(_forbidden_xml_trans)
122    else:
123        out = xml_string.translate(_forbidden_xml_strans)
124    ui.status("xml filtered is:%s"%out, level = ui.PARSE)
125    return out
126
127
128def svn_date_to_timestamp(svn_date):
129    """
130    Parse an SVN date as read from the XML output and return the corresponding
131    timestamp.
132    """
133    # Strip microseconds and timezone (always UTC, hopefully)
134    # XXX there are various ISO datetime parsing routines out there,
135    # cf. http://seehuhn.de/comp/pdate
136    date = svn_date.split('.', 2)[0]
137    time_tuple = time.strptime(date, "%Y-%m-%dT%H:%M:%S")
138    return calendar.timegm(time_tuple)
139
140def parse_svn_info_xml(xml_string):
141    """
142    Parse the XML output from an "svn info" command and extract useful information
143    as a dict.
144    """
145    d = {}
146    xml_string = strip_forbidden_xml_chars(xml_string)
147    tree = ET.fromstring(xml_string)
148    entry = tree.find('.//entry')
149    d['url'] =  urllib.parse.unquote(entry.find('url').text) #.decode('utf8')
150    d['revision'] = int(entry.get('revision'))
151    d['repos_url'] = urllib.parse.unquote(tree.find('.//repository/root').text) #.decode('utf8')
152    d['last_changed_rev'] = int(tree.find('.//commit').get('revision'))
153    author_element = tree.find('.//commit/author')
154    if author_element is not None:
155        d['last_changed_author'] = author_element.text
156    d['last_changed_date'] = svn_date_to_timestamp(tree.find('.//commit/date').text)
157    return d
158
159class svnlog_merge_entry():
160    revno = -1
161    entry = {}
162
163def parse_svn_log_xml_entry(entry):
164        d = {}
165        d['revision'] = int(entry.get('revision'))
166        # Some revisions don't have authors, most notably the first revision
167        # in a repository.
168        # logentry nodes targeting directories protected by path-based
169        # authentication have no child nodes at all. We return an entry
170        # in that case. Anyway, as it has no path entries, no further
171        # processing will be made.
172        author = entry.find('author')
173        date = entry.find('date')
174        msg = entry.find('msg')
175        src_paths = entry.find('paths')
176        # Issue 64 - modified to prevent crashes on svn log entries with "No author"
177        d['author'] = author is not None and author.text or "No author"
178        if date is not None:
179            d['date'] = svn_date_to_timestamp(date.text)
180        else:
181            d['date'] = None
182        d['message'] = msg is not None and msg.text or ""
183        paths = d['changed_paths'] = []
184        if not (src_paths is None):
185            for path in src_paths.findall('.//path'):
186                copyfrom_rev = path.get('copyfrom-rev')
187                if copyfrom_rev:
188                    copyfrom_rev = int(copyfrom_rev)
189                paths.append({
190                    'path': path.text,
191                    'action': path.get('action'),
192                    'copyfrom_path': path.get('copyfrom-path'),
193                    'copyfrom_revision': copyfrom_rev,
194                })
195
196        ui.status("svnlog entry: rev=%d "%(d['revision']), level=ui.PARSE)
197        merges = list();
198        for rev in entry.findall('logentry'):
199            revno = int(rev.get('revision'))
200            if revno == d['revision']: continue
201            revdef = svnlog_merge_entry()
202            revdef.revno = revno
203            ui.status("svnlog r%d merge r%d"%(d['revision'], revno), level=ui.DEBUG)
204            revdef.entry = parse_svn_log_xml_entry(rev)
205            merges.append(revdef)
206            break
207        if len(merges) <= 0:
208            merges = None
209        d['merges'] = merges
210        return d
211
212svnlog_revseq_no   = 0
213svnlog_revseq_up   = 1
214svnlog_revseq_down = -1
215
216def svnlog_revseq(revstart, revend):
217    if (revstart > revend):
218        return svnlog_revseq_down
219    elif (revstart < revend):
220        return svnlog_revseq_up
221    return svnlog_revseq_no
222
223def parse_svn_log_xml(xml_string, strict_log_seq = True):
224    """
225    Parse the XML output from an "svn log" command and extract useful information
226    as a list of dicts (one per log changeset).
227
228    strict_log_seq  - no - no resisions order stricts
229                    - up - revisions must flow incrising order
230                    - down - revisions must flow decrising order
231    """
232    l = []
233    xml_string = strip_forbidden_xml_chars(xml_string)
234    ui.status("parse_svn_log_xml: filtered xml:%s"%(xml_string), level=ui.PARSE)
235    tree = ET.fromstring(xml_string)
236    last_rev = -1
237    use_strict = 0;
238    entry = tree.find('logentry')
239
240    while not(entry is None):
241        d = parse_svn_log_xml_entry(entry);
242        now_rev = int(d['revision'])
243        if (strict_log_seq):
244            if use_strict == 0:
245                if last_rev >= 0:
246                    use_strict = (now_rev - last_rev)
247            else:
248                isup = (use_strict > 0) and (last_rev < now_rev)
249                isdn = (use_strict < 0) and (last_rev > now_rev)
250                if not (isup or isdn):
251                    ui.status("svn log broken revisions sequence: %d after %d"%(d['revision'], last_rev), level=ui.WARNING);
252                    if ui.is_debug():
253                        ui.status("xml dump:\n %s"%xml_string, level = ui.DEBUG, truncate = False)
254                    break
255        l.append(d)
256        last_rev = now_rev
257        tree.remove(entry)
258        entry = tree.find('logentry')
259    return l
260
261def parse_svn_status_xml_entry(tree, base_dir=None, ignore_externals=False):
262    isSVNPre180 = False;
263    l = []
264    for entry in tree.findall('.//entry'):
265        d = {}
266        path = entry.get('path')
267        if path is None: continue
268        if base_dir is not None:
269            ui.status("svn status path:%s by entry:%s"%(base_dir, path), level=ui.PARSE)
270            if not isSVNPre180:
271                if os.path.normcase(path).startswith(base_dir):
272                    ui.status("svn status check version: pre 1.8.0 ", level=ui.PARSE)
273                    isSVNPre180 = True
274                    path = path[len(base_dir):].lstrip('/\\')
275                else:
276                    ui.status("svn status check version: looks > 1.8.0 ", level=ui.PARSE)
277                    base_dir = None
278            else:
279                assert os.path.normcase(path).startswith(base_dir)
280                path = path[len(base_dir):].lstrip('/\\')
281        d['path'] = path
282        wc_status = entry.find('wc-status')
283        if wc_status is None:
284            continue
285        if wc_status.get('item') == 'external':
286            if ignore_externals:
287                continue
288            d['type'] = 'external'
289        elif wc_status.get('item') == 'replaced':
290            d['type'] = 'normal'
291        elif wc_status.get('revision') is not None:
292            d['type'] = 'normal'
293        else:
294            d['type'] = 'unversioned'
295        if wc_status.get('tree-conflicted') is None:
296            d['status'] = wc_status.get('item')
297        else:
298            d['status'] = 'conflicted'
299        d['props'] = wc_status.get('props')
300        if d is not None:
301            l.append(d)
302            ui.status("svn status have:%s"%d, level=ui.PARSE)
303    return l
304
305def parse_svn_status_xml(xml_string, base_dir=None, ignore_externals=False):
306    """
307    Parse the XML output from an "svn status" command and extract useful info
308    as a list of dicts (one per status entry).
309    """
310    if base_dir:
311        base_dir = os.path.normcase(base_dir)
312    l = []
313    xml_string = strip_forbidden_xml_chars(xml_string)
314    tree = ET.fromstring(xml_string)
315    for target in tree.findall('.//target'):
316        path = target.get('path')
317        if path is not None:
318            if base_dir is not None:
319                ui.status("svn status target path:%s of base %s"%(path, base_dir), level=ui.PARSE)
320                path = os.path.normcase(path)
321                assert path.startswith(base_dir)
322                #path = path[len(base_dir):].lstrip('/\\')
323                ui.status("svn status target subpath as:\'%s\'"%(path), level=ui.PARSE)
324                if len(path) == 0:
325                    path = None
326        else:
327            path=base_dir
328        subl = parse_svn_status_xml_entry(target, path, ignore_externals)
329        if subl is not None:
330            l.extend(subl)
331        tree.remove(target)
332
333    subl = parse_svn_status_xml_entry(tree, base_dir, ignore_externals)
334    if subl is not None:
335        l.extend(subl)
336
337    return l
338
339def get_svn_info(svn_url_or_wc, rev_number=None):
340    """
341    Get SVN information for the given URL or working copy, with an optionally
342    specified revision number.
343    Returns a dict as created by parse_svn_info_xml().
344    """
345    if rev_number is not None:
346        args = ['-r', rev_number]
347    else:
348        args = []
349    xml_string = run_svn(svn_info_args + args + [svn_url_or_wc],
350        fail_if_stderr=True)
351    return parse_svn_info_xml(xml_string)
352
353def svn_checkout(svn_url, checkout_dir, rev_number=None):
354    """
355    Checkout the given URL at an optional revision number.
356    """
357    args = []
358    if rev_number is not None:
359        args += ['-r', rev_number]
360    args += [svn_url, checkout_dir]
361    return run_svn(svn_checkout_args + args)
362
363def run_svn_log_restricted_merges(svn_url, rev_start, rev_end, limit, stop_on_copy=False):
364    """
365    Fetch up to 'limit' SVN log entries between the given revisions.
366    """
367    if stop_on_copy:
368        args = ['--stop-on-copy']
369    else:
370        args = []
371    reslog = None
372    args += ['-r','%s:%s' % (rev_start, rev_end), '--limit', limit, svn_url]
373    xml_string = run_svn(svn_log_args + args)
374    reslog = parse_svn_log_xml(xml_string)
375    for element in (reslog):
376        rev = element['revision']
377        args = ['-r','%s' % rev, '-g', svn_url]
378        xml_string = run_svn(svn_log_args + args)
379        emerges = parse_svn_log_xml(xml_string) #, strict_log_seq = False
380        if len(emerges) != 1:
381            raise
382        element['merges'] = emerges[0]['merges']
383    return reslog
384
385def run_svn_log(svn_url, rev_start, rev_end, limit, stop_on_copy=False):
386    """
387    Fetch up to 'limit' SVN log entries between the given revisions.
388    """
389    if stop_on_copy:
390        args = ['--stop-on-copy']
391    else:
392        args = []
393    reslog = None
394    try:
395            args += ['-r','%s:%s' % (rev_start, rev_end), '-g', '--limit', limit, svn_url]
396            xml_string = run_svn(svn_log_args + args)
397            reslog = parse_svn_log_xml(xml_string)
398    except (RunCommandError) as e:
399            if e.err_msg.find("truncated HTTP response body") <= 0:
400                ui.status("svn log failed:%s" % e.msg(noout=True) , level= ui.ERROR)
401                raise
402            ui.status("svn log failed, try to fetch in safer way", level= ui.VERBOSE)
403            reslog = run_svn_log_restricted_merges(svn_url, rev_start, rev_end, limit, stop_on_copy)
404    return reslog
405
406def get_svn_status(svn_wc, quiet=False):
407    """
408    Get SVN status information about the given working copy.
409    """
410    # Ensure proper stripping by canonicalizing the path
411    svn_wc = os.path.abspath(svn_wc)
412    args = [svn_wc]
413    if quiet:
414        args += ['-q']
415    else:
416        args += ['-v']
417    xml_string = run_svn(svn_status_args + args)
418    return parse_svn_status_xml(xml_string, svn_wc, ignore_externals=True)
419
420def svn_is_clean(svn_wc):
421    svn_wc = os.path.abspath(svn_wc)
422    changes = run_svn(['st', svn_wc ,'-q'])
423    changes = changes.strip()
424    ui.status("svn status is %s changes", len(changes), level=ui.VERBOSE);
425    return (len(changes) <= 0)
426
427def get_svn_versioned_files(svn_wc):
428    """
429    Get the list of versioned files in the SVN working copy.
430    """
431    contents = []
432    for e in get_svn_status(svn_wc):
433        if e['path'] and e['type'] == 'normal':
434            contents.append(e['path'])
435    return contents
436
437
438def get_one_svn_log_entry(svn_url, rev_start, rev_end, stop_on_copy=False):
439    """
440    Get the first SVN log entry in the requested revision range.
441    """
442    entries = run_svn_log(svn_url, rev_start, rev_end, 1, stop_on_copy)
443    if entries:
444        return entries[0]
445    raise EmptySVNLog("No SVN log for %s between revisions %s and %s" %
446        (svn_url, rev_start, rev_end))
447
448
449def get_first_svn_log_entry(svn_url, rev_start, rev_end):
450    """
451    Get the first log entry after (or at) the given revision number in an SVN branch.
452    By default the revision number is set to 0, which will give you the log
453    entry corresponding to the branch creaction.
454
455    NOTE: to know whether the branch creation corresponds to an SVN import or
456    a copy from another branch, inspect elements of the 'changed_paths' entry
457    in the returned dictionary.
458    """
459    return get_one_svn_log_entry(svn_url, rev_start, rev_end, stop_on_copy=True)
460
461def get_last_svn_log_entry(svn_url, rev_start, rev_end):
462    """
463    Get the last log entry before (or at) the given revision number in an SVN branch.
464    By default the revision number is set to HEAD, which will give you the log
465    entry corresponding to the latest commit in branch.
466    """
467    return get_one_svn_log_entry(svn_url, rev_end, rev_start, stop_on_copy=True)
468
469
470log_duration_threshold = 10.0
471log_min_chunk_length = 10
472
473def iter_svn_log_entries(svn_url, first_rev, last_rev, retry):
474    """
475    Iterate over SVN log entries between first_rev and last_rev.
476
477    This function features chunked log fetching so that it isn't too nasty
478    to the SVN server if many entries are requested.
479    """
480    cur_rev = first_rev
481    chunk_length = log_min_chunk_length
482    first_run = True
483    while last_rev == "HEAD" or cur_rev <= last_rev:
484        start_t = time.time()
485        stop_rev = min(last_rev, cur_rev + chunk_length)
486        ui.status("Fetching %s SVN log entries starting from revision %d...",
487                  chunk_length, cur_rev, level=ui.VERBOSE)
488        entries = once_or_more("Fetching SVN log", retry, run_svn_log, svn_url,
489                               cur_rev, last_rev, chunk_length)
490        duration = time.time() - start_t
491        if not first_run:
492            # skip first revision on subsequent runs, as it is overlapped
493            entries.pop(0)
494        first_run = False
495        if not entries:
496            break
497        for e in entries:
498            if e['revision'] > last_rev:
499                break
500            yield e
501        if e['revision'] >= last_rev:
502            break
503        cur_rev = e['revision']
504        # Adapt chunk length based on measured request duration
505        if duration < log_duration_threshold:
506            chunk_length = int(chunk_length * 2.0)
507        elif duration > log_duration_threshold * 2:
508            chunk_length = max(log_min_chunk_length, int(chunk_length / 2.0))
509
510
511_svn_client_version = None
512
513def get_svn_client_version():
514    """Returns the SVN client version as a tuple.
515
516    The returned tuple only contains numbers, non-digits in version string are
517    silently ignored.
518    """
519    global _svn_client_version
520    if _svn_client_version is None:
521        raw = run_svn(['--version', '-q']).strip()
522        _svn_client_version = tuple(map(int, [x for x in raw.split(b'.')
523                                              if x.isdigit()]))
524    return _svn_client_version
525
526def svn_cleanup():
527    args = ["cleanup"]
528    return run_svn(args)
529
530def svn_resolve_all():
531    args = ["resolve","-R","--non-interactive","--accept=working","."]
532    return run_svn(args)
533
534def svn_revert_all():
535    args = ["revert","-R","--non-interactive","."]
536    try:
537        run_svn(args)
538    except (ExternalCommandFailed) as e:
539        if str(e).find("Run 'svn cleanup'") <= 0:
540            raise
541        svn_cleanup()
542        run_svn(args)
543    return
544
545def svn_switch_to(svn_base, rev_number = None, clean = False, ignore_ancetry=False, propagate_rev = False):
546    # propagate_rev - provides smart handle of situation when rev_number is origin of desired branch base, and branch is pure,
547    #       so that we can take
548    args = ["switch","--non-interactive","--force"]
549    if ignore_ancetry:
550        args += ["--ignore-ancestry"]
551    if rev_number is not None:
552        svn_base += '@%s'%rev_number
553    args += [svn_base]
554    ui.status("switching to %s" % svn_base, level=ui.DEBUG)
555    if clean:
556        # switching is some like merge - on modified working copy try merges changes and got conflicts
557        # therefor revert all before switch for prepare one
558        svn_revert_all()
559    try:
560        run_svn(args)
561    except (ExternalCommandFailed) as e:
562        ui.status("switch failed with %s"%e, level=ui.ERROR)
563        delpath = re.search(r"Can't remove directory '(?P<delpath>\S+)'", str(e))
564        nopath = re.search(r" path not found'(?P<nopath>\S+)'", str(e))
565        if not (delpath is None):
566            #when switchwith externals svn can confuse on removing absent directories
567            if str(e).find("Removed external") <= 0:
568                raise
569            failepath = nestpath.group("delpath")
570            ui.status("try switch again after remove absent dir %s" % failepath, level=ui.VERBOSE)
571            run_svn(args)
572        elif propagate_rev and (not (nopath is None)):
573            ui.status("try to propagate rev_number to branch base if can", level=ui.DEBUG)
574            branch_info = svn_branch_revset()
575            (rev_base, rev_origin, rev_head) = branch_info.eval_path(svn_base)
576            if (rev_base == rev_head) and (rev_origin == rev_number):
577                basehgid = get_hg_cset_of_svn(rev_base)
578                if (len(basehgid) == 0):
579                    ui.status('branch svn:%s@%s used as candidate for its origin %s'%(svn_base, rev_base, rev_origin));
580                    origin_hgid = get_hg_cset_of_svn(rev_number)
581                    hg_rev_tag_by_svn(origin_hgid, rev_base)
582                    svn_switch_to(svn_base, rev_base, clean, ignore_ancetry, False)
583        else:
584            raise
585    except Exception as e:
586        ui.status("err uncknown:%s"%str(e), level = ui.ERROR)
587        raise e
588
589class svn_branch_revset(object):
590    #this class describes svn-branch
591    headrev      = None
592    baserev      = None
593    source       = None
594    srcrev       = None
595    path         = None
596
597    def __repr__(self):
598        return "svn branch: head:%s  base:%s path:%s sourced from %s@%s"%(self.headrev, self.baserev, self.path, self.source, self.srcrev)
599
600    def eval_path(self, svn_base):
601        self.path = svn_base
602        self.source = None
603        try:
604            base   = get_first_svn_log_entry(svn_base, 0, 'HEAD')
605        except (RunCommandError) as e:
606            if e.err_have("File not found") or e.err_have("path not found"):
607                ui.status("svn looks have no branch %s" % svn_base) # , level= ui.ERROR
608                base = None
609            else:
610                raise
611        if base is None:
612            self.baserev = None
613            self.headrev = None
614            return self.baserev, self.source, self.headrev
615        head   = get_last_svn_log_entry(svn_base, 0, 'HEAD')
616        self.baserev = base['revision']
617        self.headrev = head['revision']
618        paths = base['changed_paths']
619        path = None
620        if len(paths) > 0:
621            for path in paths:
622                if (svn_base.endswith(path['path'])):
623                    self.source = path['copyfrom_path']
624                    self.srcrev = path['copyfrom_revision']
625                    break
626        else:
627            ui.status("cant determine branch %s origin source"%svn_base)
628        return self.baserev, self.source, self.headrev
629
630    #true if branch is just a plain copy on another without any evolution
631    def is_clean(self):
632        if (self.baserev != self.headrev):
633            return False
634        if (self.source is None):
635            return False
636        return True
637
638def svn_branch_empty(svn_base):
639    entryes = run_svn_log(svn_base, 0, 'HEAD', 2, stop_on_copy=True)
640    if len(entryes) > 1:
641        return False
642    if len(entryes) == 0:
643        return True
644    base = entryes[0]
645    # the 1st revision can only have 1 path entry - add branch folder
646    if (len(base['changed_paths']) > 1):
647        return False
648    if not(base['merges'] is None):
649        return False
650    return True
651
652def svn_get_prop(prop_name, path, svn_base = '.'):
653    try:
654        report = run_svn(['propget', os.path.join(svn_base, prop_name)], mask_atsign=True)
655    except (RunCommandError) as e:
656        report = ""
657    return report
658