1#!/usr/bin/env python
2#
3# svnlook.py : alternative svnlook in Python with library API
4#
5######################################################################
6#    Licensed to the Apache Software Foundation (ASF) under one
7#    or more contributor license agreements.  See the NOTICE file
8#    distributed with this work for additional information
9#    regarding copyright ownership.  The ASF licenses this file
10#    to you under the Apache License, Version 2.0 (the
11#    "License"); you may not use this file except in compliance
12#    with the License.  You may obtain a copy of the License at
13#
14#      http://www.apache.org/licenses/LICENSE-2.0
15#
16#    Unless required by applicable law or agreed to in writing,
17#    software distributed under the License is distributed on an
18#    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
19#    KIND, either express or implied.  See the License for the
20#    specific language governing permissions and limitations
21#    under the License.
22######################################################################
23#
24# $HeadURL: https://svn.apache.org/repos/asf/subversion/branches/1.14.x/tools/examples/svnlook.py $
25# $LastChangedDate: 2013-11-14 11:11:07 +0000 (Thu, 14 Nov 2013) $
26# $LastChangedRevision: 1541878 $
27
28"""
29svnlook.py can also be used as a Python module::
30
31  >>> import svnlook
32  >>> svnlook = svnlook.SVNLook("/testrepo")
33  >>> svnlook.get_author()
34  'randomjoe'
35
36
37Accessible API::
38
39[x] author
40[x] changed
41[x] date
42[ ] diff
43[x] dirs-changed
44[ ] ids
45[x] info
46[x] log
47[ ] tree
48---
49[ ] generator API to avoid passing lists
50"""
51
52
53import sys
54import time
55import os
56
57from svn import core, fs, delta, repos
58
59class SVNLook(object):
60  def __init__(self, path, rev=None, txn=None, cmd=None):
61    """
62    path  - path to repository
63    rev   - revision number
64    txn   - name of transaction (usually the one about to be committed)
65    cmd   - if set, specifies cmd_* method to execute
66
67    txn takes precedence over rev; if both are None, inspect the head revision
68    """
69    path = core.svn_path_canonicalize(path)
70    repos_ptr = repos.open(path)
71    self.fs_ptr = repos.fs(repos_ptr)
72
73    # if set, txn takes precendence
74    if txn:
75      self.txn_ptr = fs.open_txn(self.fs_ptr, txn)
76    else:
77      self.txn_ptr = None
78      if rev is None:
79        rev = fs.youngest_rev(self.fs_ptr)
80      else:
81        rev = int(rev)
82    self.rev = rev
83
84    if cmd != None:
85      getattr(self, 'cmd_' + cmd)()
86
87  def cmd_default(self):
88    self.cmd_info()
89    self.cmd_tree()
90
91  def cmd_author(self):
92    print(self.get_author() or '')
93
94  def cmd_changed(self):
95    for status, path in self.get_changed():
96      print("%-3s %s" % (status, path))
97
98  def cmd_date(self):
99    # duplicate original svnlook format, which is
100    # 2010-02-08 21:53:15 +0200 (Mon, 08 Feb 2010)
101    secs = self.get_date(unixtime=True)
102    if secs is None:
103      print("")
104    else:
105      # convert to tuple, detect time zone and format
106      stamp = time.localtime(secs)
107      isdst = stamp.tm_isdst
108      utcoffset = -(time.altzone if (time.daylight and isdst) else time.timezone) // 60
109
110      suffix = "%+03d%02d" % (utcoffset // 60, abs(utcoffset) % 60)
111      outstr = time.strftime('%Y-%m-%d %H:%M:%S ', stamp) + suffix
112      outstr += time.strftime(' (%a, %d %b %Y)', stamp)
113      print(outstr)
114
115
116  def cmd_diff(self):
117    self._print_tree(DiffEditor, pass_root=1)
118
119  def cmd_dirs_changed(self):
120    for dir in self.get_changed_dirs():
121      print(dir)
122
123  def cmd_ids(self):
124    self._print_tree(Editor, base_rev=0, pass_root=1)
125
126  def cmd_info(self):
127    """print the author, data, log_size, and log message"""
128    self.cmd_author()
129    self.cmd_date()
130    log = self.get_log() or ''
131    print(len(log))
132    print(log)
133
134  def cmd_log(self):
135    print(self.get_log() or '')
136
137  def cmd_tree(self):
138    self._print_tree(Editor, base_rev=0)
139
140
141  # --- API getters
142  def get_author(self):
143    """return string with the author name or None"""
144    return self._get_property(core.SVN_PROP_REVISION_AUTHOR)
145
146  def get_changed(self):
147    """return list of tuples (status, path)"""
148    ret = []
149    def list_callback(status, path):
150      ret.append( (status, path) )
151    self._walk_tree(ChangedEditor, pass_root=1, callback=list_callback)
152    return ret
153
154  def get_date(self, unixtime=False):
155    """return commit timestamp in RFC 3339 format (2010-02-08T20:37:25.195000Z)
156       if unixtime is True, return unix timestamp
157       return None for a txn, or if date property is not set
158    """
159    if self.txn_ptr:
160      return None
161
162    date = self._get_property(core.SVN_PROP_REVISION_DATE)
163    if not unixtime or date == None:
164      return date
165
166    # convert to unix time
167    aprtime = core.svn_time_from_cstring(date)
168    # ### convert to a time_t; this requires intimate knowledge of
169    # ### the apr_time_t type
170    secs = aprtime / 1000000  # aprtime is microseconds; make seconds
171    return secs
172
173  def get_changed_dirs(self):
174    """return list of changed dirs
175       dir names end with trailing forward slash even on windows
176    """
177    dirlist = []
178    def list_callback(item):
179      dirlist.append(item)
180    self._walk_tree(DirsChangedEditor, callback=list_callback)
181    return dirlist
182
183  def get_log(self):
184    """return log message string or None if not present"""
185    return self._get_property(core.SVN_PROP_REVISION_LOG)
186
187
188  # --- Internal helpers
189  def _get_property(self, name):
190    if self.txn_ptr:
191      return fs.txn_prop(self.txn_ptr, name)
192    return fs.revision_prop(self.fs_ptr, self.rev, name)
193
194  def _print_tree(self, e_factory, base_rev=None, pass_root=0):
195    def print_callback(msg):
196       print(msg)
197    self._walk_tree(e_factory, base_rev, pass_root, callback=print_callback)
198
199  # svn fs, delta, repos calls needs review according to DeltaEditor documentation
200  def _walk_tree(self, e_factory, base_rev=None, pass_root=0, callback=None):
201    if base_rev is None:
202      # a specific base rev was not provided. use the transaction base,
203      # or the previous revision
204      if self.txn_ptr:
205        base_rev = fs.txn_base_revision(self.txn_ptr)
206      elif self.rev == 0:
207        base_rev = 0
208      else:
209        base_rev = self.rev - 1
210
211    # get the current root
212    if self.txn_ptr:
213      root = fs.txn_root(self.txn_ptr)
214    else:
215      root = fs.revision_root(self.fs_ptr, self.rev)
216
217    # the base of the comparison
218    base_root = fs.revision_root(self.fs_ptr, base_rev)
219
220    if callback == None:
221      callback = lambda msg: None
222
223    if pass_root:
224      editor = e_factory(root, base_root, callback)
225    else:
226      editor = e_factory(callback=callback)
227
228    # construct the editor for printing these things out
229    e_ptr, e_baton = delta.make_editor(editor)
230
231    # compute the delta, printing as we go
232    def authz_cb(root, path, pool):
233      return 1
234    repos.dir_delta(base_root, '', '', root, '',
235		    e_ptr, e_baton, authz_cb, 0, 1, 0, 0)
236
237
238# ---------------------------------------------------------
239# Delta Editors. For documentation see:
240# http://subversion.apache.org/docs/community-guide/#docs
241
242# this one doesn't process delete_entry, change_dir_prop, apply_text_delta,
243# change_file_prop, close_file, close_edit, abort_edit
244# ?set_target_revision
245# need review
246class Editor(delta.Editor):
247  def __init__(self, root=None, base_root=None, callback=None):
248    """callback argument is unused for this editor"""
249    self.root = root
250    # base_root ignored
251
252    self.indent = ''
253
254  def open_root(self, base_revision, dir_pool):
255    print('/' + self._get_id('/'))
256    self.indent = self.indent + ' '    # indent one space
257
258  def add_directory(self, path, *args):
259    id = self._get_id(path)
260    print(self.indent + _basename(path) + '/' + id)
261    self.indent = self.indent + ' '    # indent one space
262
263  # we cheat. one method implementation for two entry points.
264  open_directory = add_directory
265
266  def close_directory(self, baton):
267    # note: if indents are being performed, this slice just returns
268    # another empty string.
269    self.indent = self.indent[:-1]
270
271  def add_file(self, path, *args):
272    id = self._get_id(path)
273    print(self.indent + _basename(path) + id)
274
275  # we cheat. one method implementation for two entry points.
276  open_file = add_file
277
278  def _get_id(self, path):
279    if self.root:
280      id = fs.node_id(self.root, path)
281      return ' <%s>' % fs.unparse_id(id)
282    return ''
283
284# doesn't process close_directory, apply_text_delta,
285# change_file_prop, close_file, close_edit, abort_edit
286# ?set_target_revision
287class DirsChangedEditor(delta.Editor):
288  """print names of changed dirs, callback(dir) is a printer function"""
289  def __init__(self, callback):
290    self.callback = callback
291
292  def open_root(self, base_revision, dir_pool):
293    return [ 1, '' ]
294
295  def delete_entry(self, path, revision, parent_baton, pool):
296    self._dir_changed(parent_baton)
297
298  def add_directory(self, path, parent_baton,
299                    copyfrom_path, copyfrom_revision, dir_pool):
300    self._dir_changed(parent_baton)
301    return [ 1, path ]
302
303  def open_directory(self, path, parent_baton, base_revision, dir_pool):
304    return [ 1, path ]
305
306  def change_dir_prop(self, dir_baton, name, value, pool):
307    self._dir_changed(dir_baton)
308
309  def add_file(self, path, parent_baton,
310               copyfrom_path, copyfrom_revision, file_pool):
311    self._dir_changed(parent_baton)
312
313  def open_file(self, path, parent_baton, base_revision, file_pool):
314    # some kind of change is going to happen
315    self._dir_changed(parent_baton)
316
317  def _dir_changed(self, baton):
318    if baton[0]:
319      # the directory hasn't been printed yet. do it.
320      self.callback(baton[1] + '/')
321      baton[0] = 0
322
323class ChangedEditor(delta.Editor):
324  def __init__(self, root, base_root, callback):
325    """callback(status, path) is a printer function"""
326    self.root = root
327    self.base_root = base_root
328    self.callback = callback
329
330  def open_root(self, base_revision, dir_pool):
331    return [ 1, '' ]
332
333  def delete_entry(self, path, revision, parent_baton, pool):
334    ### need more logic to detect 'replace'
335    if fs.is_dir(self.base_root, '/' + path):
336      self.callback('D', path + '/')
337    else:
338      self.callback('D', path)
339
340  def add_directory(self, path, parent_baton,
341                    copyfrom_path, copyfrom_revision, dir_pool):
342    self.callback('A', path + '/')
343    return [ 0, path ]
344
345  def open_directory(self, path, parent_baton, base_revision, dir_pool):
346    return [ 1, path ]
347
348  def change_dir_prop(self, dir_baton, name, value, pool):
349    if dir_baton[0]:
350      # the directory hasn't been printed yet. do it.
351      self.callback('_U', dir_baton[1] + '/')
352      dir_baton[0] = 0
353
354  def add_file(self, path, parent_baton,
355               copyfrom_path, copyfrom_revision, file_pool):
356    self.callback('A', path)
357    return [ '_', ' ', None ]
358
359  def open_file(self, path, parent_baton, base_revision, file_pool):
360    return [ '_', ' ', path ]
361
362  def apply_textdelta(self, file_baton, base_checksum):
363    file_baton[0] = 'U'
364
365    # no handler
366    return None
367
368  def change_file_prop(self, file_baton, name, value, pool):
369    file_baton[1] = 'U'
370
371  def close_file(self, file_baton, text_checksum):
372    text_mod, prop_mod, path = file_baton
373    # test the path. it will be None if we added this file.
374    if path:
375      status = text_mod + prop_mod
376      # was there some kind of change?
377      if status != '_ ':
378        self.callback(status.rstrip(), path)
379
380
381class DiffEditor(delta.Editor):
382  def __init__(self, root, base_root, callback=None):
383    """callback argument is unused for this editor"""
384    self.root = root
385    self.base_root = base_root
386    self.target_revision = 0
387
388  def _do_diff(self, base_path, path):
389    if base_path is None:
390      print("Added: " + path)
391      label = path
392    elif path is None:
393      print("Removed: " + base_path)
394      label = base_path
395    else:
396      print("Modified: " + path)
397      label = path
398    print("===============================================================" + \
399          "===============")
400    args = []
401    args.append("-L")
402    args.append(label + "\t(original)")
403    args.append("-L")
404    args.append(label + "\t(new)")
405    args.append("-u")
406    differ = fs.FileDiff(self.base_root, base_path, self.root,
407                         path, diffoptions=args)
408    pobj = differ.get_pipe()
409    while True:
410      line = pobj.readline()
411      if not line:
412        break
413      sys.stdout.write("%s " % line)
414    print("")
415
416  def _do_prop_diff(self, path, prop_name, prop_val, pool):
417    print("Property changes on: " + path)
418    print("_______________________________________________________________" + \
419          "_______________")
420
421    old_prop_val = None
422
423    try:
424      old_prop_val = fs.node_prop(self.base_root, path, prop_name, pool)
425    except core.SubversionException:
426      pass # Must be a new path
427
428    if old_prop_val:
429      if prop_val:
430        print("Modified: " + prop_name)
431        print("   - " + str(old_prop_val))
432        print("   + " + str(prop_val))
433      else:
434        print("Deleted: " + prop_name)
435        print("   - " + str(old_prop_val))
436    else:
437      print("Added: " + prop_name)
438      print("   + " + str(prop_val))
439
440    print("")
441
442  def delete_entry(self, path, revision, parent_baton, pool):
443    ### need more logic to detect 'replace'
444    if not fs.is_dir(self.base_root, '/' + path):
445      self._do_diff(path, None)
446
447  def add_directory(self, path, parent_baton, copyfrom_path,
448                    copyfrom_revision, dir_pool):
449    return [ 1, path ]
450
451  def add_file(self, path, parent_baton,
452               copyfrom_path, copyfrom_revision, file_pool):
453    self._do_diff(None, path)
454    return [ '_', ' ', None ]
455
456  def open_root(self, base_revision, dir_pool):
457    return [ 1, '' ]
458
459  def open_directory(self, path, parent_baton, base_revision, dir_pool):
460    return [ 1, path ]
461
462  def open_file(self, path, parent_baton, base_revision, file_pool):
463    return [ '_', ' ', path ]
464
465  def apply_textdelta(self, file_baton, base_checksum):
466    if file_baton[2] is not None:
467      self._do_diff(file_baton[2], file_baton[2])
468    return None
469
470  def change_file_prop(self, file_baton, name, value, pool):
471    if file_baton[2] is not None:
472      self._do_prop_diff(file_baton[2], name, value, pool)
473    return None
474
475  def change_dir_prop(self, dir_baton, name, value, pool):
476    if dir_baton[1] is not None:
477      self._do_prop_diff(dir_baton[1], name, value, pool)
478    return None
479
480  def set_target_revision(self, target_revision):
481    self.target_revision = target_revision
482
483def _basename(path):
484  "Return the basename for a '/'-separated path."
485  idx = path.rfind('/')
486  if idx == -1:
487    return path
488  return path[idx+1:]
489
490
491def usage(exit):
492  if exit:
493    output = sys.stderr
494  else:
495    output = sys.stdout
496
497  output.write(
498     "usage: %s REPOS_PATH rev REV [COMMAND] - inspect revision REV\n"
499     "       %s REPOS_PATH txn TXN [COMMAND] - inspect transaction TXN\n"
500     "       %s REPOS_PATH [COMMAND] - inspect the youngest revision\n"
501     "\n"
502     "REV is a revision number > 0.\n"
503     "TXN is a transaction name.\n"
504     "\n"
505     "If no command is given, the default output (which is the same as\n"
506     "running the subcommands `info' then `tree') will be printed.\n"
507     "\n"
508     "COMMAND can be one of: \n"
509     "\n"
510     "   author:        print author.\n"
511     "   changed:       print full change summary: all dirs & files changed.\n"
512     "   date:          print the timestamp (revisions only).\n"
513     "   diff:          print GNU-style diffs of changed files and props.\n"
514     "   dirs-changed:  print changed directories.\n"
515     "   ids:           print the tree, with nodes ids.\n"
516     "   info:          print the author, data, log_size, and log message.\n"
517     "   log:           print log message.\n"
518     "   tree:          print the tree.\n"
519     "\n"
520     % (sys.argv[0], sys.argv[0], sys.argv[0]))
521
522  sys.exit(exit)
523
524def main():
525  if len(sys.argv) < 2:
526    usage(1)
527
528  rev = txn = None
529
530  args = sys.argv[2:]
531  if args:
532    cmd = args[0]
533    if cmd == 'rev':
534      if len(args) == 1:
535        usage(1)
536      try:
537        rev = int(args[1])
538      except ValueError:
539        usage(1)
540      del args[:2]
541    elif cmd == 'txn':
542      if len(args) == 1:
543        usage(1)
544      txn = args[1]
545      del args[:2]
546
547  if args:
548    if len(args) > 1:
549      usage(1)
550    cmd = args[0].replace('-', '_')
551  else:
552    cmd = 'default'
553
554  if not hasattr(SVNLook, 'cmd_' + cmd):
555    usage(1)
556
557  SVNLook(sys.argv[1], rev, txn, cmd)
558
559if __name__ == '__main__':
560  main()
561