2 # -*- coding: ascii -*-
4 # MPY SVN STATS - Subversion Repository Statistics Generator
5 # Copyright (C) 2004 name of Maciej Pietrzak
7 # This program is free software; you can redistribute it and/or
8 # modify it under the terms of the GNU General Public License
9 # as published by the Free Software Foundation; either version 2
10 # of the License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program; if not, write to the Free Software
19 # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
22 mpy-svn-stats is a simple statistics generator (log analyser) for
23 Subversion repositories.
25 It aims to be easy to use, but still provide some interesting information.
27 It's possible that the profile of the generated stats will promote
28 rivalisation in the project area.
32 mpy-svn-stats [-h] [-o dir] <url>
34 -h --help - print this help message
35 -o --output-dir - set output directory
36 -i --input - input log file, no svn is called, - for stdin
37 --svn-binary - use different svn client instead of ``svn''
38 <url> - repository url
40 Authors: Maciej Pietrzak, Joanna Chmiel, Marcin Mankiewicz
41 MPY SVN STATS is licensed under GPL. See http://www.gnu.org/copyleft/gpl.html
43 Project homepage is http://mpy-svn-stats.berlios.de/
44 You can contact authors by email at mpietrzak@users.berlios.de
47 __docformat__
= 'restructuredtext'
56 from cgi
import escape
57 from xml
.dom
.minidom
import parseString
62 import Image
, ImageDraw
, ImageFont
69 week_seconds
= 7 * 24 * 60 * 60
70 month_seconds
= 30 * 24 * 60 * 60
71 year_seconds
= 365.25 * 24 * 60 * 60
75 if config
.is_not_good(): return config
.usage()
76 if config
.want_help(): return config
.show_help()
78 stats
= AllStatistics(config
)
79 stats
.configure(config
)
82 xmldata
= get_data(config
)
85 run_time_start
= time
.time()
88 revision_data
= RevisionData(config
.get_repository_url(), parseString(xmldata
))
91 print "calculating stats"
92 stats
.calculate(revision_data
)
95 run_time_end
= time
.time()
98 stats
.write(run_time
=(run_time_end
- run_time_start
))
101 print "Have %d stats objects, %d of them are wanted." % (
103 stats
.count_wanted())
105 def get_data(config
):
106 """Get the analysis source data.
107 Data source definition is in config variable.
108 Data is obtained either by calling external svn
109 binary or by reading from standard input.
110 TODO: use python bindings to subversion (although
111 it does not increase neither functionality nor
112 security of script, so this is not critical).
114 if config
.input_file
:
115 xml_data
= get_data_from_file(config
)
117 xml_data
= get_data_from_svn_binary(config
)
120 def get_data_from_file(config
):
121 """Read XML data (bytes) from file."""
122 fname
= config
.input_file
129 def get_data_from_svn_binary(config
):
130 svn_binary
= config
.get_svn_binary()
131 svn_repository
= config
.get_repository_url()
133 assert(svn_repository
)
134 command
= '%s -v --xml log %s' % (svn_binary
, svn_repository
)
135 print 'running command: "%s"' % command
136 f
= os
.popen(command
)
139 def generate_stats(config
, data
):
141 dom
= parseString(data
)
143 print "failed to parse:\n%s\n" % data
145 return Stats(config
, dom
)
147 def _create_output_dir(dir):
148 """Create output dir."""
149 if not os
.path
.isdir(dir):
153 """This class contains all data about configuration, environment
155 Statistics' may choose to tune their parameters or even disable
156 themselves based on this information.
159 def __init__(self
, argv
):
160 """Init based on argv from command line.
164 self
._repository
= None
165 self
._want
_help
= False
166 self
._error
_message
= None
167 self
._svn
_binary
= 'svn'
168 self
.input_file
= None
169 self
._output
_dir
= 'mpy-svn-stats'
171 self
._enabled
_stats
= []
172 self
._disabled
_stats
= []
174 self
.have_pil
= _have_pil
175 if not self
.have_pil
:
176 self
._print
_warning
_about
_pil
()
178 print "Will generate PIL graphs."
181 optlist
, args
= getopt
.getopt(
191 except getopt
.GetoptError
, e
:
193 self
._error
_message
= str(e
)
195 #print "optlist: %s" % str(optlist)
196 #print "args: %s" % str(args)
203 if optdict
.has_key('-h') or optdict
.has_key('--help'):
204 self
._want
_help
= True
206 if optdict
.has_key('--with-diff-stats'):
207 self
._stats
_to
_generate
.update('author_by_diff_size')
209 for key
,value
in optlist
:
210 if key
== '-o': self
._output
_dir
= value
211 elif key
== '--output-dir': self
._output
_dir
= value
212 elif key
== '--svn-binary': self
._svn
_binary
= value
213 elif key
== '-i' or key
== '--input': self
.input_file
= value
215 if self
.input_file
is None:
218 self
._repository
= None
221 self
._repository
= args
[0]
223 self
._repository
= None
226 # by default we will generate stats from the beginning to now
227 self
.start_date
= None
228 self
.end_date
= time
.time()
230 def is_not_good(self
):
234 if self
._error
_message
is not None: print >>sys
.stderr
, 'Error: %s' % self
._error
_message
235 print >>sys
.stderr
, 'Usage: %s [params] <repository-url>' % (self
._argv
[0])
236 print >>sys
.stderr
, 'Use %s --help to get help.' % (self
._argv
[0])
239 def get_repository_url(self
):
240 return self
._repository
242 def get_svn_binary(self
):
246 return self
._svn
_binary
248 def get_output_dir(self
):
249 return self
._output
_dir
251 def want_statistic(self
, statistic_type
):
252 """Test whether statistic of type statistic_type is wanted.
254 if self
._generate
_all
: return True
255 else: return type in self
._stats
_to
_generate
258 return self
._want
_help
264 def _print_warning_about_pil(self
):
265 """Print a warning."""
266 print """Python Imagin Library could not be found - graphs are disabled."""
270 """Abstract class for Stats' elements.
273 wanted_by_default
= True
274 requires_graphics
= False
276 def __init__(self
, config
, name
, title
):
277 assert isinstance(name
, basestring
), ValueError("name must be a string, now: %s (%s)" % (
280 assert isinstance(title
, basestring
), ValueError("title must be a string")
284 self
._wanted
_output
_modes
= []
287 assert(isinstance(self
._title
, basestring
), 'Title of the statistic must be specified!')
291 assert isinstance(self
._name
, basestring
), ValueError('Name must be a string')
294 def is_wanted(self
, mode
=None):
295 """Check if particular output mode is wanted (either by default or
296 explicitly requested).
297 If mode is Node, return True is there is at least one output mode.
300 return mode
in self
._wanted
_output
_modes
302 return len(self
._wanted
_output
_modes
) > 0
304 def _want_output_mode(self
, name
, setting
=True):
306 if name
not in self
._wanted
_output
_modes
:
307 self
._wanted
_output
_modes
.append(name
)
309 if name
in self
._wanted
_output
_modes
:
310 self
._wanted
_output
_modes
.remove(name
)
312 def _set_writer(self
, mode
, writer
):
313 """Set writer object for mode.
315 assert isinstance(mode
, str), ValueError("Mode must be a shor string (identifier)")
316 assert isinstance(writer
, StatisticWriter
), ValueError("Writer must be a Writer instance")
317 self
._writers
[mode
] = writer
319 def configure(self
, config
):
320 self
._configure
_writers
(config
)
321 if self
.requires_graphics
and not config
.have_pil
:
322 print "%s requires graphics - disabling." % str(self
)
323 self
._want
_output
_mode
('html', False)
325 def _configure_writers(self
, config
):
326 for writer
in self
._writers
.values():
327 writer
.configure(config
)
329 def write(self
, run_time
):
330 """Write out stats using all wanted modes."""
331 for mode
in self
._wanted
_output
_modes
:
332 writer
= self
._writers
[mode
]
333 writer
.write(run_time
=run_time
)
335 def output(self
, mode
):
336 writer
= self
._writers
[mode
]
337 return writer
.output()
340 """Return human-readable representation."""
341 return "Statistic(title='%(title)s', name='%(name)s')" % {
342 'title': self
.title(),
347 class TableStatistic(Statistic
):
348 """A statistic that is presented as a table.
350 def __init__(self
, config
, name
, title
):
351 Statistic
.__init
__(self
, config
, name
, title
)
353 # we want to be printed with TableHTMLWriter by default
354 self
._set
_writer
('html', TableHTMLWriter(self
))
355 self
._want
_output
_mode
('html')
360 def show_numbers(self
):
366 class GeneralStatistics(Statistic
):
367 """General (opening) statistics (like first commit, last commit, total commit count etc).
368 Outputted by simple text.
370 def __init__(self
, config
):
372 Statistic
.__init
__(self
, config
, "general_statistics", "General statistics")
373 self
._set
_writer
('html', GeneralStatisticsHTMLWriter(self
))
374 self
._want
_output
_mode
('html')
376 def configure(self
, config
):
379 def calculate(self
, revision_data
):
380 self
._first
_rev
_number
= revision_data
.get_first_revision().get_number()
381 self
._last
_rev
_number
= revision_data
.get_last_revision().get_number()
382 self
._revision
_count
= len(revision_data
)
383 self
._repository
_url
= revision_data
.get_repository_url()
384 self
._first
_rev
_date
= revision_data
.get_first_revision().get_date()
385 self
._last
_rev
_date
= revision_data
.get_last_revision().get_date()
387 def get_first_rev_number(self
):
388 return self
._first
_rev
_number
390 def get_last_rev_number(self
):
391 return self
._last
_rev
_number
393 def get_revision_count(self
):
394 return self
._revision
_count
396 def get_repository_url(self
):
397 return self
._repository
_url
399 def get_first_rev_date(self
):
400 return self
._first
_rev
_date
402 def get_last_rev_date(self
):
403 return self
._last
_rev
_date
406 class AuthorsByCommits(TableStatistic
):
407 """Specific statistic - show table author -> commit count sorted
410 def __init__(self
, config
, start_date
=None, end_date
=None, id=None, title
=None):
411 """Generate statistics out of revision data.
414 id = "authors_by_number_od_commits"
416 id += "_fromdate_" + str(int(start_date
))
418 id += "_todate_" + str(int(end_date
))
420 title
= "Authors by total number of commits"
422 title
+= " from " + str(start_date
)
424 title
+= " to " + str(end_date
)
425 TableStatistic
.__init
__(self
, config
, id, title
)
426 self
.start_date
= start_date
427 self
.end_date
= end_date
429 def column_names(self
):
430 return ('Author', 'Total number of commits', 'Percentage of total commit count')
432 def configure(self
, config
):
433 """Handle configuration - decide whether we are wanted/or possible to
434 be calculated and output.
437 def calculate(self
, revision_data
):
438 """Do calculations based on revision data passed as
439 parameter (which must be a RevisionData instance).
441 This method sets internal _data member.
442 Output writer can then get it by calling rows().
444 assert isinstance(revision_data
, RevisionData
), ValueError(
445 "Expected RevisionData instance, got %s", repr(revision_data
)
450 for rv
in revision_data
.get_revisions():
452 if rv
.get_date() < self
.start_date
:
455 if rv
.get_date() > self
.end_date
:
457 author
= rv
.get_author()
458 if not abc
.has_key(author
): abc
[author
] = 1
459 else: abc
[author
] += 1
461 data
= [(a
, abc
[a
]) for a
in abc
.keys()]
462 data
.sort(lambda x
,y
: cmp(y
[1], x
[1]))
469 "%.2f%%" % (float(v
) * 100.0 / float(len(revision_data
)))])
474 #class AuthorsByChangedPaths(TableStatistic):
475 # """Authors sorted by total number of changed paths.
477 # def __init__(self, config):
478 # """Generate statistics out of revision data.
480 # TableStatistic.__init__(self, config, 'authors_number_of_paths', 'Authors by total number of changed paths')
482 # def configure(self, config):
485 # def calculate(self, revision_data):
486 # """Perform calculations."""
487 # assert(isinstance(revision_data, RevisionData))
492 # for rv in revision_data.get_revisions():
493 # author = rv.get_author()
494 # modified_path_count = len(rv.get_modified_paths())
495 # if not abp.has_key(author): abp[author] = modified_path_count
496 # else: abp[author] += modified_path_count
497 # max += modified_path_count
499 # data = [(a, abp[a]) for a in abp.keys()]
500 # data.sort(lambda x,y: cmp(y[1], x[1]))
507 # percentage = float(v) * 100.0 / float(max)
508 # assert percentage >= 0.0
509 # assert percentage <= 100.0
512 # "%.2f%%" % percentage])
516 # def column_names(self):
517 # """Return names of collumns."""
518 # return ('Author', 'Total number of changed paths', 'Percentage of all changed paths')
522 class GraphStatistic(Statistic
):
523 """This stats are presented as a graph.
525 This class holds graph abstract data.
526 This is allways f(x) -> y graph, so
527 there is a dict of (x,y) pairs.
529 GraphStatistic does not do any output,
530 GraphImageHTMLWriter and possibly others
531 translate logical data info image file.
534 requires_graphics
= True
536 _x_axis_is_time
= True
537 """Default, since most graphs are time based."""
539 def __init__(self
, config
, name
, title
):
540 Statistic
.__init
__(self
, config
, name
, title
)
541 self
._set
_writer
('html', GraphImageHTMLWriter(self
))
542 self
._want
_output
_mode
('html')
547 def __getitem__(self
, key
):
548 return self
._data
[key
]
550 def get_x_range(self
):
551 return (self
._min
_x
, self
._max
_x
)
553 def get_y_range(self
):
554 return (self
._min
_y
, self
._max
_y
)
557 """Return dictionary of labels for
558 horizontal axis of graphs.
559 Keys should be values that are not
560 less than self._min_x and not
562 Values are strings that should
565 Default implementation calls labels_for_time_span
566 if self._x_axis_is_time is True, which is default.
568 if self
._x
_axis
_is
_time
:
569 return labels_for_time_span(
570 datetime
.datetime
.fromtimestamp(self
._min
_x
),
571 datetime
.datetime
.fromtimestamp(self
._max
_x
))
576 class CommitsByWeekGraphStatistic(GraphStatistic
):
577 """Graph showing number of commits by week."""
579 def __init__(self
, config
):
581 GraphStatistic
.__init
__(self
, config
, "commits_by_week_graph", "Number of commits in week")
583 def calculate(self
, revision_data
):
584 """Calculate statistic."""
585 assert len(self
._wanted
_output
_modes
) > 0
587 week_in_seconds
= 7 * 24 * 60 * 60 * 1.0
589 start_of_week
= revision_data
.get_first_revision().get_date()
590 end_of_week
= start_of_week
+ week_in_seconds
592 self
._min
_x
= revision_data
.get_first_revision().get_date()
593 self
._max
_x
= revision_data
.get_last_revision().get_date()
599 while start_of_week
< revision_data
.get_last_revision().get_date():
600 commits
= revision_data
.get_revisions_by_date(start_of_week
, end_of_week
)
602 fx
= float(start_of_week
+(end_of_week
- start_of_week
)/2)
603 fy
= float(y
) * float(end_of_week
- start_of_week
) / float(week_in_seconds
)
606 if y
> self
._max
_y
: self
._max
_y
= y
608 start_of_week
+= week_in_seconds
609 end_of_week
= start_of_week
+ week_in_seconds
610 if end_of_week
> revision_data
.get_last_revision().get_date():
611 end_of_week
= revision_data
.get_last_revision().get_date()
613 self
.series_names
= ['number_of_commits']
615 self
._values
['number_of_commits'] = values
617 self
.colors
['number_of_commits'] = (0, 0, 0)
619 def horizontal_axis_title(self
):
622 def vertical_axis_title(self
):
623 return "Number of commits"
626 #class CommitsByWeekPerUserGraphStatistic(GraphStatistic):
627 # """Show how many commits were made by most active
630 # def __init__(self, config):
632 # self.number_of_users_to_show = 7
633 # GraphStatistic.__init__(self, config,
634 # "commits_by_week_per_user_graph",
635 # "Number of commits in week made by most active users")
637 # def _get_users(self, revision_data):
638 # """Find users to be included in graph."""
639 # return revision_data.get_users_sorted_by_commit_count()[:self.number_of_users_to_show]
641 # def _make_colors(self, users):
642 # """Create different colors for each values."""
647 # for user in self.series_names:
649 # hue = float(n) / float(len(self.series_names))
652 # assert hue >= 0.0 and hue <= 1.0
655 # f = hue * 6.0 - float(i)
656 # p = brightness * (1.0 - saturation)
657 # q = brightness * (1.0 - saturation * f)
658 # t = brightness * (1.0 - saturation * (1.0 - f))
661 # 0: (brightness, t, p),
662 # 1: (q, brightness, p),
663 # 2: (p, brightness, t),
664 # 3: (p, q, brightness),
665 # 4: (t, p, brightness),
666 # 5: (brightness, p, q)
671 # assert r >= 0.0 and r <= 1.0
672 # assert g >= 0.0 and g <= 1.0
673 # assert b >= 0.0 and b <= 1.0
675 # self.colors[user] = (int(r*256.0), int(g*256.0), int(b*256.0))
677 # def calculate(self, revision_data):
678 # """Calculate statistic."""
679 # assert len(self._wanted_output_modes) > 0
681 # users = self._get_users(revision_data)
682 # self.series_names = users
683 # self._make_colors(users)
685 # week_in_seconds = 7 * 24 * 60 * 60 * 1.0
687 # start_of_week = revision_data.get_first_revision().get_date()
688 # end_of_week = start_of_week + week_in_seconds
690 # self._min_x = revision_data.get_first_revision().get_date()
691 # self._max_x = revision_data.get_last_revision().get_date()
697 # self._values[user] = {}
700 # while start_of_week < revision_data.get_last_revision().get_date():
702 # commits = revision_data.get_revisions_by_date(start_of_week, end_of_week)
703 # y = len([rv for rv in revision_data.revisions_by_users[user] if (
704 # (rv.get_date() > start_of_week and rv.get_date() < end_of_week))])
705 # fx = float(start_of_week+(end_of_week - start_of_week)/2)
706 # fy = float(y) * float(end_of_week - start_of_week) / float(week_in_seconds)
708 # self._values[user][fx] = fy
710 # if y > self._max_y: self._max_y = y
712 # start_of_week += week_in_seconds
713 # end_of_week = start_of_week + week_in_seconds
714 # if end_of_week > revision_data.get_last_revision().get_date():
715 # end_of_week = revision_data.get_last_revision().get_date()
718 # def horizontal_axis_title(self):
721 # def vertical_axis_title(self):
722 # return "Number of commits"
725 class GroupStatistic(Statistic
):
726 """Statistic class for grouping other statistics.
727 Every object of this one can contain more statistics.
728 Rendering this type of statistics means rendering
729 all children stats, and putting it in one group
730 (for example - in web page section).
732 def __init__(self
, config
, name
, title
):
733 """Initialize internal variables. Must be called.
735 Statistic
.__init
__(self
, config
, name
, title
)
736 self
._child
_stats
= []
738 def __getitem__(self
, number
):
739 """Get a child Statistic object."""
740 return self
._child
_stats
[number
]
742 def append(self
, statistic
):
743 """Append given statistic to child list.
746 - statistic - must be an instance of Statistic
748 assert isinstance(statistic
, Statistic
), ValueError(
749 "Wrong parameter, expected Statistic instance, got %s" % (
752 self
._child
_stats
.append(statistic
)
756 return self
._child
_stats
758 def descendants(self
):
760 for child
in self
.children():
761 if isinstance(child
, GroupStatistic
):
762 d
+= child
.descendants()
767 def configure(self
, config
):
768 Statistic
.configure(self
, config
)
769 for child
in self
._child
_stats
:
770 child
.configure(config
)
773 """Return the total number of leaf statistics in the group/tree.
774 That is, group statistics are not included.
777 for stat
in self
._child
_stats
:
778 if isinstance(stat
, GroupStatistic
):
779 total
+= stat
.count_all()
784 def count_wanted(self
):
785 return len([descendant
for descendant
in self
.descendants() if descendant
.is_wanted()])
787 def calculate(self
, revision_data
):
788 """Pass data to children."""
790 for child
in self
._child
_stats
:
791 if child
.is_wanted():
792 child
.calculate(revision_data
)
795 class AllStatistics(GroupStatistic
):
796 """This is a special type of group statistic - it
797 is created at startup. It should create
798 whole statistics objects tree.
800 After that, objects are queried whether they
801 are to be calculated, and then written
805 def __init__(self
, config
):
806 """This constructor takes no parameters.
808 GroupStatistic
.__init
__(self
, config
, "mpy_svn_stats", "MPY SVN Statistics")
809 self
.append(GeneralStatistics(config
))
810 self
.append(CommitsGroup(config
))
811 self
.append(ChangedPathsGroup(config
))
812 self
.append(LogMessageLengthGroup(config
))
813 # self.append(AuthorsByChangedPaths(config))
814 # self.append(AuthorsByCommitLogSize(config))
815 # self.append(CommitsByWeekGraphStatistic(config))
816 self
._set
_writer
('html', TopLevelGroupStatisticHTMLWriter(self
))
817 self
._want
_output
_mode
('html')
820 class SimpleFunctionGroup(GroupStatistic
):
821 """A statistic for measuring one function of revision for each author
822 (for example: commit count, changed paths, log message size etc).
824 - authors sorted by value for:
829 - graoh for function's value
832 class SimpleTable(TableStatistic
):
833 """Specific statistic - show table author -> commit count sorted
836 def __init__(self
, config
, parent
, start_date
=None, end_date
=None, subtitle
=None):
837 id = parent
.id + '_simple_table'
840 id += "_fromdate_" + str(int(start_date
))
842 id += "_todate_" + str(int(end_date
))
844 title
= 'Table of authors'
846 title
+= ': ' + subtitle
848 TableStatistic
.__init
__(self
, config
, id, title
)
849 self
.start_date
= start_date
850 self
.end_date
= end_date
852 def column_names(self
):
853 return ('Author', 'Number', 'Percentage')
855 def configure(self
, config
):
856 """Handle configuration - decide whether we are wanted/or possible to
857 be calculated and output.
861 def calculate(self
, revision_data
):
862 """Do calculations based on revision data passed as
863 parameter (which must be a RevisionData instance).
865 This method sets internal _data member.
866 Output writer can then get it by calling rows().
868 assert isinstance(revision_data
, RevisionData
), ValueError(
869 "Expected RevisionData instance, got %s", repr(revision_data
))
873 for rv
in revision_data
.get_revisions():
875 if rv
.get_date() < self
.start_date
:
878 if rv
.get_date() > self
.end_date
:
880 # revision_function always returns author -> some_value relation (dict)
881 values
= self
.parent
.revision_function(rv
)
882 for (author
, value
) in values
.iteritems():
883 if not abc
.has_key(author
): abc
[author
] = value
884 else: abc
[author
] += value
886 data
= [(a
, abc
[a
]) for a
in abc
.keys()]
887 data
.sort(lambda x
,y
: cmp(y
[1], x
[1]))
889 total_sum
= sum(abc
.values())
894 percentage
= float(v
) * 100.0 / float(total_sum
)
895 assert percentage
>= 0.0
896 assert percentage
<= 100.0
899 "%.2f%%" % percentage
])
904 class SimpleMultiAuthorGraphStatistic(GraphStatistic
):
906 def __init__(self
, config
, parent
):
909 self
.id = parent
.id + '_multi_author_graph'
910 self
.number_of_users_to_show
= 9
911 GraphStatistic
.__init
__(self
, config
,
913 "Graph for most active commiters")
915 def _get_users(self
, revision_data
):
916 """Find users to be included in graph."""
917 return revision_data
.get_users_sorted_by_commit_count()[:self
.number_of_users_to_show
]
919 def _make_colors(self
, users
):
920 """Create different colors for each values."""
925 for user
in self
.series_names
:
927 hue
= float(n
) / float(len(self
.series_names
))
930 assert hue
>= 0.0 and hue
<= 1.0
933 f
= hue
* 6.0 - float(i
)
934 p
= brightness
* (1.0 - saturation
)
935 q
= brightness
* (1.0 - saturation
* f
)
936 t
= brightness
* (1.0 - saturation
* (1.0 - f
))
939 0: (brightness
, t
, p
),
940 1: (q
, brightness
, p
),
941 2: (p
, brightness
, t
),
942 3: (p
, q
, brightness
),
943 4: (t
, p
, brightness
),
944 5: (brightness
, p
, q
)
949 assert r
>= 0.0 and r
<= 1.0
950 assert g
>= 0.0 and g
<= 1.0
951 assert b
>= 0.0 and b
<= 1.0
953 self
.colors
[user
] = (int(r
*256.0), int(g
*256.0), int(b
*256.0))
955 def calculate(self
, revision_data
):
956 """Calculate statistic."""
957 assert len(self
._wanted
_output
_modes
) > 0
959 users
= self
._get
_users
(revision_data
)
960 self
.series_names
= users
961 self
._make
_colors
(users
)
964 week_in_seconds
= 7 * 24 * 60 * 60 * 1.0
966 start_of_week
= revision_data
.get_first_revision().get_date()
967 end_of_week
= start_of_week
+ week_in_seconds
969 self
._min
_x
= revision_data
.get_first_revision().get_date()
970 self
._max
_x
= revision_data
.get_last_revision().get_date()
976 self
._values
[user
] = {}
979 while start_of_week
< revision_data
.get_last_revision().get_date():
981 fx
= float(start_of_week
+(end_of_week
- start_of_week
)/2)
982 this_week_revisions
= revision_data
.get_revisions_by_date(start_of_week
, end_of_week
)
983 users_revisions
= [revision
for revision
in this_week_revisions
if revision
.get_author() == user
]
987 for revision
in users_revisions
:
988 values
= self
.parent
.revision_function(revision
)
989 for (author
, value
) in values
.iteritems():
990 assert(author
== user
)
994 fy
= float(y
) * float(end_of_week
- start_of_week
) / float(week_in_seconds
)
995 self
._values
[user
][fx
] = fy
996 if y
> self
._max
_y
: self
._max
_y
= y
998 start_of_week
+= week_in_seconds
999 end_of_week
= start_of_week
+ week_in_seconds
1000 if end_of_week
> revision_data
.get_last_revision().get_date():
1001 end_of_week
= revision_data
.get_last_revision().get_date()
1004 def horizontal_axis_title(self
):
1007 def vertical_axis_title(self
):
1008 return self
.parent
.value_description
1011 def __init__(self
, config
, id, name
):
1014 self
.value_description
= name
1015 GroupStatistic
.__init
__(self
, config
, id, name
)
1017 self
.append(self
.SimpleTable(config
, self
))
1018 self
.append(self
.SimpleTable(config
, self
,
1019 config
.end_date
- month_seconds
, config
.end_date
,
1021 self
.append(self
.SimpleTable(config
, self
,
1022 config
.end_date
- week_seconds
, config
.end_date
,
1024 self
.append(self
.SimpleMultiAuthorGraphStatistic(config
, self
))
1026 self
._set
_writer
('html', GroupStatisticHTMLWriter(self
))
1027 self
._want
_output
_mode
('html')
1030 #class CommitsGroup(GroupStatistic):
1031 # """This class defines group of statistic that shows authors with
1032 # their commit counts."""
1034 # def __init__(self, config):
1035 # """Create group contents."""
1036 # GroupStatistic.__init__(self, config, "authors_by_commits_group", "Number of commits")
1037 # self.append(AuthorsByCommits(config))
1038 # self.append(AuthorsByCommits(config,
1039 # config.end_date - month_seconds, config.end_date,
1040 # title='Authors by commits - last month'))
1041 # self.append(AuthorsByCommits(config,
1042 # config.end_date - week_seconds, config.end_date,
1043 # title='Authors by commits - last week'))
1044 # self.append(CommitsByWeekPerUserGraphStatistic(config))
1045 # self._set_writer('html', GroupStatisticHTMLWriter(self))
1046 # self._want_output_mode('html')
1049 class CommitsGroup(SimpleFunctionGroup
):
1050 """This class defines group of statistic that shows authors with
1051 their commit counts.
1053 def __init__(self
, config
):
1054 SimpleFunctionGroup
.__init
__(self
, config
, 'commits_group', 'Number of commits')
1056 def revision_function(self
, revision
):
1057 """Return a dictionary of values derived from revision."""
1058 return {revision
.get_author(): 1}
1061 class ChangedPathsGroup(SimpleFunctionGroup
):
1062 """Implementation of SimpleFunctionGroup, gives info about changed paths."""
1064 def __init__(self
, config
):
1065 SimpleFunctionGroup
.__init
__(self
, config
, 'changed_paths', 'Number of changed paths')
1067 def revision_function(self
, revision
):
1068 return {revision
.get_author(): len(revision
.get_modified_paths())}
1071 class LogMessageLengthGroup(SimpleFunctionGroup
):
1072 """Log message length."""
1074 def __init__(self
, config
):
1075 SimpleFunctionGroup
.__init
__(self
, config
, 'log_message_length_group', 'Log message length')
1077 def revision_function(self
, revision
):
1079 revision
.author
: len(revision
.log_message
)
1083 class StatisticWriter
:
1084 """Abstract class for all output generators.
1089 class HTMLWriter(StatisticWriter
):
1090 """An abstract class for HTML writing."""
1092 def _standard_statistic_header(self
):
1093 """Make all statistic header look the same."""
1096 h2
= "<h2><a name=\"%s\"></a>%s</h2>\n" % (
1097 escape(self
._statistic
.name()),
1098 escape(self
._statistic
.title())
1101 goToTopLink
= "<a class=\"topLink\" href=\"#top\">top</a>\n"
1104 r
= "<table class=\"statisticHeader\"><tr><td>%s</td><td class=\"topLink\">%s</td></table>\n\n" % (
1109 def _standard_statistic_footer(self
):
1110 """Make all statistic header look the same."""
1111 return "<hr class=\"statisticDelimiter\"/>"
1113 def configure(self
, config
):
1114 self
.is_configured
= True
1117 class GroupStatisticHTMLWriter(HTMLWriter
):
1118 """Class for writing group statistics (abstract)."""
1119 def __init__(self
, group_statistic
=None):
1120 self
._statistic
= group_statistic
1122 def set_statistic(self
, statistic
):
1123 self
._statistic
= statistic
1126 class TopLevelGroupStatisticHTMLWriter(GroupStatisticHTMLWriter
):
1127 """Class for writing one, top level
1131 output_mode
= 'html'
1133 def __init__(self
, statistic
=None):
1134 GroupStatisticHTMLWriter
.__init
__(self
, statistic
)
1136 def configure(self
, config
):
1137 """Configure - generally - get the output directory."""
1138 self
._output
_dir
= config
.get_output_dir()
1140 def write(self
, run_time
):
1141 """Write out generated statistics."""
1142 _create_output_dir(self
._output
_dir
)
1143 filename
= self
._output
_dir
+ '/index.html'
1144 output_file
= file(filename
, "w")
1148 + self
._page
_foot
(run_time
=run_time
)
1152 def _page_head(self
):
1153 """Return HTML page head."""
1155 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
1156 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
1157 <html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
1159 <meta name="Generator" content="mpy-svn-stats v. 0.1"/>
1160 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
1161 <title>mpy-svn-stats</title>
1162 <style type="text/css">
1172 table.statistic tr td {
1173 border-style: solid;
1175 border-color: black;
1179 table.statistic tr th {
1180 border-style: solid;
1182 border-color: black;
1183 background-color: lightgray;
1197 background-color: lightgray;
1201 table.statisticHeader {
1208 table.statisticHeader td {
1209 background-color: lightgray;
1210 border-spacing: 0px;
1215 table.statisticHeader tr td.topLink {
1220 table.statisticHeader td h2 {
1228 vertical-align: top;
1231 td.statistics_column {
1232 vertical-align: top;
1235 .topLink a:link, .topLink a:active, .topLink a:visited {
1243 a.menuLink:link, a.menuLink:active, a.menuLink:visited {
1247 hr.statisticDelimiter {
1248 border-spacing: 0px;
1249 border-width: 2px 0px 0px 0px;
1250 border-style: solid;
1251 margin-bottom: 40pt;
1252 border-color: lightgray;
1258 table.legend td.name {
1259 border-width: 1px 0px 1px 1px;
1260 border-style: solid;
1261 border-color: black;
1265 table.legend td.color {
1266 border-width: 1px 1px 1px 0px;
1267 border-style: solid;
1268 border-color: black;
1279 <h1><a name="top"></a>mpy-svn-stats</h1>
1282 def _page_foot(self
, run_time
):
1283 """Return HTML page foot."""
1287 Stats generated by <a href="http://mpy-svn-stats.berlios.de">mpy-svn-stats</a> in %(run_time).2f seconds.
1290 'run_time': run_time
1293 def _page_body(self
):
1294 return "<table><tr><td class=\"menu_column\">%(menu_column)s</td><td>%(body_column)s</td></tr></table>" % {
1295 'menu_column': self
._page
_menu
(),
1296 'body_column': self
._page
_main
()
1299 def _page_menu(self
):
1300 return "<ul class=\"menu\">" + self
._recursive
_menu
(self
._statistic
) + "</ul>\n"
1302 def _recursive_menu(self
, statistic
):
1303 """Return statistic as li tag.
1306 if not statistic
.is_wanted(self
.output_mode
):
1310 if isinstance(statistic
, GroupStatistic
):
1312 # count wanted children
1313 wanted_children
= len([child
for child
in statistic
.children() if child
.is_wanted(self
.output_mode
)])
1314 if wanted_children
== 0:
1317 r
+= "<li>%s:\n<ul>\n" % statistic
.title()
1318 for child
in statistic
.children():
1319 r
+= self
._recursive
_menu
(child
)
1321 r
+= "</ul>\n</li>\n"
1323 r
+= "<li><a class=\"menuLink\" href=\"#%s\">%s</a></li>\n" % (
1328 def _page_main(self
):
1330 stack
= [self
._statistic
]
1332 while len(stack
) > 0:
1334 if not isinstance(stat
, GroupStatistic
):
1337 children
= stat
.children()
1339 stack
.extend(children
)
1344 if stat
.is_wanted('html'):
1345 r
+= stat
.output('html')
1350 class TableHTMLWriter(HTMLWriter
):
1353 def __init__(self
, stat
):
1354 assert isinstance(stat
, TableStatistic
), ValueError()
1355 self
._statistic
= stat
1359 r
+= self
._standard
_statistic
_header
()
1360 r
+= "<table class=\"statistic\">\n%s\n%s\n</table>\n\n" % (
1361 self
._table
_header
(),
1363 r
+= self
._standard
_statistic
_footer
()
1366 def _table_header(self
):
1368 r
+= " <th>No</th>\n"
1369 for column_name
in self
._statistic
.column_names():
1370 r
+= " <th>" + escape(column_name
) + "</th>\n"
1375 def _table_body(self
):
1378 for row
in self
._statistic
.rows():
1380 r
+= " <td>%d</td>\n" % i
1382 r
+= " <td>" + escape(cell
) + "</td>\n"
1388 class GeneralStatisticsHTMLWriter(HTMLWriter
):
1389 """Specialised GeneralStatistics HTML Writer class."""
1391 def __init__(self
, stat
):
1392 self
._statistic
= stat
1395 statistic
= self
._statistic
1397 <h2><a name=\"%(statistic_name)s\"></a>%(statistic_title)s</h2>
1400 Statistics for repository at: <b>%(repository_url)s</b>.<br/>
1401 Smallest revision number: %(first_rev_number)s.<br/>
1402 Biggest revision number: %(last_rev_number)s.<br/>
1403 Revision count: %(revision_count)s.<br/>
1404 First revision date: %(first_rev_date)s.<br/>
1405 Last revision date: %(last_rev_date)s.<br/>
1406 Age of the repository (from first to last revision): %(age_of_repository)s.<br/>
1407 Commits per year: %(commits_per_year)s.<br/>
1408 Commits per month: %(commits_per_month)s.<br/>
1409 Commits per day: %(commits_per_day)s.
1412 'repository_url': escape(statistic
.get_repository_url()),
1413 'statistic_name': escape(statistic
.name()),
1414 'statistic_title': escape(statistic
.title()),
1415 'revision_count': str(statistic
.get_revision_count()),
1416 'first_rev_number': str(statistic
.get_first_rev_number()),
1417 'last_rev_number': str(statistic
.get_last_rev_number()),
1418 'first_rev_date': time
.strftime('%c', time
.gmtime(statistic
.get_first_rev_date())),
1419 'last_rev_date': time
.strftime('%c', time
.gmtime(statistic
.get_last_rev_date())),
1420 'age_of_repository': self
._format
_time
_span
(
1421 statistic
.get_first_rev_date(),
1422 statistic
.get_last_rev_date()
1424 'commits_per_year': ("%.2f" % (statistic
.get_revision_count() * 365.25 * 24 * 60 * 60
1425 / (statistic
.get_last_rev_date() - statistic
.get_first_rev_date()))
1427 'commits_per_month': ("%.2f" % (statistic
.get_revision_count() * 30 * 24 * 60 * 60
1428 / (statistic
.get_last_rev_date() - statistic
.get_first_rev_date()))
1430 'commits_per_day': ("%.2f" % (statistic
.get_revision_count() * 24 * 60 * 60
1431 / (statistic
.get_last_rev_date() - statistic
.get_first_rev_date()))
1435 def _format_time_span(self
, from_time
, to_time
):
1436 """Format time span as a string."""
1437 seconds
= to_time
- from_time
1442 ('years', 365.25 * 24 * 60 * 60),
1443 ('months', 30 * 24 * 60 * 60),
1444 ('days', 24 * 60 * 60),
1449 have_nonzero_step
= False
1452 n
= reminder
/ step
[1]
1454 have_nonzero_step
= True
1455 reminder
-= int(n
) * step
[1]
1456 s
+= '%d %s' % (int(n
), step
[0])
1457 if have_nonzero_step
:
1458 if step
is steps
[len(steps
)-1]:
1463 s
+= '%d seconds' % int(reminder
)
1468 class GraphImageHTMLWriter(HTMLWriter
):
1469 """A class that writes graphs to image files.
1470 Basically, a GraphStatistic contains data that
1471 makes it possible to draw a graph.
1472 That is: axis max, axis min, axis label,
1473 argument -> value pairs that define function.
1474 Also it may contain type in future releases.
1477 - _statistic - parent statistic (the one data
1481 def __init__(self
, statistic
):
1482 """Initialise instance. Name will be used for
1484 assert isinstance(statistic
, GraphStatistic
)
1485 self
._statistic
= statistic
1486 self
.font
= ImageFont
.load_default()
1488 def configure(self
, config
):
1489 """Configure Graph Image HTML Writer."""
1490 self
._image
_width
= 600
1491 self
._image
_height
= 400
1492 self
._margin
_bottom
= 125
1493 self
._margin
_top
= 20
1494 self
._margin
_left
= 50
1495 self
._margin
_right
= 20
1496 self
._image
_dir
= config
.get_output_dir()
1498 def get_image_fname(self
):
1499 return self
._image
_dir
+ '/' + self
._statistic
.name() + '.png'
1501 def get_image_html_src(self
):
1502 return self
._statistic
.name() + '.png'
1504 def _write_image(self
):
1505 """Write image files."""
1506 image_size
= (self
._image
_width
, self
._image
_height
)
1507 im
= Image
.new("RGB", image_size
, 'white')
1508 draw
= ImageDraw
.Draw(im
)
1510 self
._draw
_axes
(im
, draw
)
1511 self
._draw
_axes
_labels
(im
, draw
)
1512 self
._paint
_content
(im
, draw
)
1517 def _save(self
, im
):
1518 im
.save(self
.get_image_fname())
1520 def _paint_content(self
, im
, draw
):
1521 for k
,values
in self
._statistic
._values
.iteritems():
1522 keys
= values
.keys()
1524 color
= self
._statistic
.colors
[k
]
1526 last_pair
= (None, None)
1529 last_pair
= self
._plot
(draw
, last_pair
, (key
, value
), color
)
1531 def _plot(self
, draw
, from_tuple
, to_tuple
, color
='black'):
1532 if from_tuple
!= (None, None):
1533 (imx1
, imy1
) = self
._graph
_to
_image
(from_tuple
)
1534 (imx2
, imy2
) = self
._graph
_to
_image
(to_tuple
)
1535 draw
.line((imx1
, imy1
, imx2
, imy2
), color
)
1538 def _graph_to_image(self
, point
):
1539 """Convert position from
1540 theoretical (data) coordinates to
1542 Points are tuples of doubles.
1543 TODO: rewrite, it's too long.
1545 gx
= float(point
[0])
1546 gy
= float(point
[1])
1549 assert gx
>= self
._statistic
._min
_x
1550 assert gx
<= self
._statistic
._max
_x
, AssertionError("bad gx: %f (should be smaller than %f)" % (gx
, self
._statistic
._max
_x
))
1551 assert gy
>= self
._statistic
._min
_y
1552 assert gy
<= self
._statistic
._max
_y
1554 margin_left
= self
._margin
_left
1555 margin_right
= self
._margin
_right
1556 margin_top
= self
._margin
_top
1557 margin_bottom
= self
._margin
_bottom
1559 image_width
= self
._image
_width
1560 image_height
= self
._image
_height
1562 range_x
= float(self
._statistic
._max
_x
- self
._statistic
._min
_x
)
1563 range_y
= float(self
._statistic
._max
_y
- self
._statistic
._min
_y
)
1568 min_x
= float(self
._statistic
._min
_x
)
1569 min_y
= float(self
._statistic
._min
_y
)
1571 x
= margin_left
+ (gx
- min_x
) * (image_width
- margin_left
- margin_right
) / range_x
1573 pcy
= (gy
- min_y
) / range_y
1574 graph_height
= image_height
- margin_top
- margin_bottom
1575 pxy
= pcy
* graph_height
1576 y
= margin_top
+ graph_height
- pxy
1580 def _draw_axes(self
, image
, draw
):
1581 self
._draw
_horizontal
_axis
(image
, draw
)
1582 self
._draw
_vertical
_axis
(image
, draw
)
1583 self
._draw
_horizontal
_axis
_title
(image
, draw
)
1584 self
._draw
_vertical
_axis
_title
(image
, draw
)
1587 def _draw_horizontal_axis(self
, image
, draw
):
1588 start_x
= self
._margin
_left
1589 start_y
= self
._image
_height
- self
._margin
_bottom
1590 end_x
= self
._image
_width
- self
._margin
_right
1593 length
= self
._image
_width
- self
._margin
_left
- self
._margin
_right
1595 draw
.line((start_x
, start_y
, end_x
, end_y
), '#999')
1596 draw
.line((end_x
, end_y
, end_x
- 5, end_y
- 3), '#999')
1597 draw
.line((end_x
, end_y
, end_x
- 5, end_y
+ 3), '#999')
1599 def _draw_vertical_axis(self
, image
, draw
):
1600 start_x
= self
._margin
_left
1601 start_y
= self
._image
_height
- self
._margin
_bottom
1603 end_y
= self
._margin
_top
1605 draw
.line((start_x
, start_y
, end_x
, end_y
), '#999')
1606 draw
.line((end_x
, end_y
, end_x
+ 3, end_y
+ 5), '#999')
1607 draw
.line((end_x
, end_y
, end_x
- 3, end_y
+ 5), '#999')
1609 def _draw_horizontal_axis_title(self
, image
, draw
):
1610 text
= self
._statistic
.horizontal_axis_title()
1611 (text_width
, text_height
) = draw
.textsize(text
, font
=self
.font
)
1613 corner_x
= self
._image
_width
- self
._margin
_right
1614 corner_y
= self
._image
_height
- self
._margin
_bottom
1616 pos_x
= corner_x
- text_width
1617 pos_y
= corner_y
- 15
1619 draw
.text((pos_x
, pos_y
), text
, fill
='black', font
=self
.font
)
1621 def _draw_vertical_axis_title(self
, image
, draw
):
1623 text_im_height
= 200
1625 textim
= Image
.new('RGBA',
1626 (text_im_width
, text_im_height
), 'white')
1627 textdraw
= ImageDraw
.Draw(textim
)
1629 text
= self
._statistic
.vertical_axis_title()
1630 (text_width
, text_height
) = textdraw
.textsize(text
, font
=self
.font
)
1632 textdraw
.text((0,0), text
, fill
='black', font
=self
.font
)
1636 textim
= textim
.crop((0, 0, text_width
, text_height
))
1637 textim
= textim
.rotate(90)
1639 corner_x
= self
._margin
_left
1640 corner_y
= self
._margin
_top
1642 pos_x
= corner_x
- text_height
- 10
1643 pos_y
= corner_y
+ 10
1648 pos_x
+ text_height
,
1655 def _draw_axes_labels(self
, image
, draw
):
1656 labels
= self
._statistic
.x_labels()
1657 if len(labels
) == 0: return
1658 #print "%s: have %d labels" % (self, len(labels))
1659 for label_datetime
, label
in labels
.iteritems():
1660 label_position
= time
.mktime(label_datetime
.timetuple())
1661 label_text
= label
.text
1662 position
= self
._graph
_to
_image
( (label_position
, 0) )
1664 #print " putting '%s' at %s" % (label_text, position)
1665 self
._draw
_text
(image
, draw
, (position
[0], position
[1] + 4), label_text
,
1668 (int(position
[0]), int(position
[1] - 4), int(position
[0]), int(position
[1] + 2)),
1671 def _draw_text(self
, im
, draw
, position
, text
, fill
='black', angle
=0):
1672 """Create rotated text. This must be done
1673 by creating temp image, drawing text on it,
1674 rotating it and then copying it to the original
1677 textsize
= draw
.textsize(text
)
1678 tim
= Image
.new('RGBA', textsize
, (0,0,0,0))
1679 tdraw
= ImageDraw
.Draw(tim
)
1681 tdraw
.text( (0,0), text
, fill
=fill
, font
=self
.font
)
1684 tim
= tim
.rotate(-90)
1687 im
.paste(tim
, (int(position
[0] - textsize
[1] / 2), int(position
[1])), tim
)
1696 r
+= self
._standard
_statistic
_header
()
1699 <img border="1" src="%(image_src)s"/>
1702 'image_src': self
.get_image_html_src()
1705 if len(self
._statistic
.colors
.keys()) > 1:
1708 r
+= self
._standard
_statistic
_footer
()
1716 colors
= self
._statistic
.colors
1717 names
= self
._statistic
.series_names
1719 o
+= "<table class=\"legend\">\n"
1723 for col_num
in range(0, cols
):
1725 if i
< len(colors
.keys()):
1728 (r
,g
,b
) = colors
[name
]
1731 color
= '#%s%s%s' % (
1732 hex(r
)[2:].zfill(2),
1733 hex(g
)[2:].zfill(2),
1734 hex(b
)[2:].zfill(2))
1736 o
+= " <td class=\"name\">%s</td>\n<td class=\"color\" style=\"background-color: %s\"> </td>\n" % (
1741 o
+= " <td></td>\n<td>\n</td>"
1747 if i
>= len(colors
.keys()):
1755 """Data about all revisions."""
1756 def __init__(self
, url
, dom
):
1757 """Create revision data from xml.dom.Document."""
1759 self
._repository
_url
= url
1761 log
= dom
.childNodes
[0]
1764 for logentry
in log
.childNodes
:
1765 if logentry
.nodeType
!= logentry
.ELEMENT_NODE
: continue
1766 if logentry
.nodeType
== logentry
.ELEMENT_NODE
and logentry
.nodeName
!= 'logentry':
1767 raise '%s found, logentry expected' % str(logentry
)
1769 revisions
.append(RevisionInfo(logentry
))
1770 self
._revisions
= revisions
1771 self
._revisions
_by
_keys
= {}
1772 for rv
in self
._revisions
:
1773 self
._revisions
_by
_keys
[rv
.get_revision_number()] = rv
1775 self
._revisions
.sort(lambda r1
,r2
: cmp(r1
.get_revision_number(), r2
.get_revision_number()))
1777 self
._generate
_user
_data
()
1779 def _generate_user_data(self
):
1781 self
.revisions_by_users
= {}
1783 for rv
in self
._revisions
:
1784 user
= rv
.get_author()
1785 if not user
in self
.users
:
1786 self
.users
.append(user
)
1787 self
.revisions_by_users
[user
] = []
1788 self
.revisions_by_users
[user
].append(rv
)
1790 self
.users_sorted_by_revision_count
= self
.users
1791 self
.users_sorted_by_revision_count
.sort(lambda u1
, u2
: cmp(len(self
.revisions_by_users
[u2
]), len(self
.revisions_by_users
[u1
])))
1793 def get_users_sorted_by_commit_count(self
):
1794 return self
.users_sorted_by_revision_count
1796 def get_revision(self
, number
):
1797 return self
._revisions
_by
_keys
[number
]
1799 def get_first_revision(self
):
1800 return self
._revisions
[0]
1802 def get_last_revision(self
):
1803 return self
._revisions
[len(self
._revisions
)-1]
1805 def get_repository_url(self
):
1806 if self
._repository
_url
:
1807 return self
._repository
_url
1812 return len(self
._revisions
)
1814 def __getitem__(self
, index
):
1815 return self
._revisions
_by
_keys
(index
)
1818 return self
._revisions
_keys
1820 def get_revisions(self
):
1821 return self
._revisions
1824 return self
.get_revisions()
1826 def get_revisions_by_date(self
, start_date
, end_date
):
1828 for rv
in self
.get_revisions():
1829 if start_date
<= rv
.get_date() < end_date
:
1830 revisions
.append(rv
)
1835 """All known data about single revision."""
1836 def __init__(self
, message
):
1837 self
._modified
_paths
= []
1838 self
._parse
_message
(message
)
1839 self
._have
_diffs
= False
1842 def get_author(self
):
1845 def get_revision_number(self
):
1846 return self
._revision
_number
1848 def get_number(self
):
1849 """Same as get_revision_number."""
1850 return self
._revision
_number
1852 def get_modified_paths(self
):
1853 return self
._modified
_paths
1858 def _parse_message(self
, message
):
1859 assert(isinstance(message
, xml
.dom
.Node
))
1860 self
.author
= self
._parse
_author
(message
)
1861 self
._revision
_number
= self
._parse
_revision
_number
(message
)
1862 self
._modified
_paths
= self
._parse
_paths
(message
)
1863 self
._date
= self
._parse
_date
(message
)
1864 self
.log_message
= self
._parse
_commit
_log
_message
(message
)
1866 def _parse_author(self
, message
):
1867 """Get author out of logentry.
1868 Suprisingly, not all logentries have authors.
1869 In that case, author is set to '' (empty string).
1872 a
= message
.getElementsByTagName('author')
1873 assert len(a
) <= 1, AssertionError(
1874 'There should be at most one author in revision.\nXML is:\n%s' % (
1875 message
.toprettyxml())
1879 assert(len(a
[0].childNodes
) == 1)
1880 return a
[0].childNodes
[0].data
1884 def _parse_commit_log_message(self
, message
):
1885 l
= message
.getElementsByTagName('msg')
1888 return l
[0].childNodes
[0].data
1892 def _parse_revision_number(self
, message
):
1893 return int(message
.getAttribute('revision'))
1895 def _parse_paths(self
, message
):
1896 path_nodes
= message
.getElementsByTagName('path')
1898 for path_node
in path_nodes
:
1899 path_node
.normalize()
1900 action
= path_node
.getAttribute('action')
1901 path
= self
._get
_element
_contents
(path_node
)
1902 modified_paths
.append(ModifiedPath(action
, path
))
1903 return modified_paths
1905 def _parse_date(self
, message
):
1906 date_element
= message
.getElementsByTagName('date')[0]
1907 isodate
= self
._get
_element
_contents
(date_element
)
1908 return time
.mktime(time
.strptime(isodate
[:19], '%Y-%m-%dT%H:%M:%S'))
1910 def _get_element_contents(self
, node
):
1911 assert(isinstance(node
, xml
.dom
.Node
))
1912 children
= node
.childNodes
1914 for child
in children
:
1915 if child
.nodeType
== child
.TEXT_NODE
:
1916 contents
+= child
.data
1919 def get_revision_number(self
):
1920 return self
._revision
_number
1924 def __init__(self
, action
, path
):
1925 assert(isinstance(action
, str) and len(action
) == 1,
1926 'should be one-letter string, is: %s' % str(action
))
1927 assert(isinstance(path
, basestring
), 'should be modified path, is: %s' % path
)
1928 self
._action
= action
1931 def get_action(self
):
1938 #class AuthorsByCommitLogSize(TableStatistic):
1939 # """Specific statistic - show table author -> commit log, sorted
1940 # by commit log size.
1942 # def __init__(self, config):
1943 # """Generate statistics out of revision data.
1945 # TableStatistic.__init__(self, config, 'authors_by_log_size', """Authors by total size of commit log messages""")
1947 # def configure(self, config):
1948 # """Handle configuration."""
1951 # def column_names(self):
1953 # 'Total numer od characters in all log messages',
1954 # 'Percentage of all log messages')
1956 # def calculate(self, revision_data):
1957 # """Do calculations."""
1958 # assert(isinstance(revision_data, RevisionData))
1963 # for rv in revision_data.get_revisions():
1964 # author = rv.get_author()
1965 # log = rv.get_commit_log()
1967 # if not abc.has_key(author): abc[author] = size
1968 # else: abc[author] += size
1971 # data = [(a, abc[a]) for a in abc.keys()]
1972 # data.sort(lambda x,y: cmp(y[1], x[1]))
1979 # "%2.2f%%" % (float(v) * 100.0 / float(sum))])
1984 #class AuthorsByDiffSize(TableStatistic):
1985 # """Specific statistic - shows table author -> diffs size, sorted by
1989 # wanted_by_default = False
1991 # def __init__(self, config, revision_data):
1992 # """Generate statistics out of revision data and `svn diff`.
1994 # TableStatistic.__init__(self, 'author_by_diff_size', 'Authors by total size of diffs')
1995 # assert(isinstance(revision_data, RevisionData))
1999 # for rv in revision_data.get_revisions():
2000 # author = rv.get_author()
2001 # rev_number = rv.get_revision_number()
2002 # command = "%s -r %d:%d diff %s" % (config.get_svn_binary(),
2003 # rev_number-1, rev_number,
2004 # config.get_repository_url())
2005 # f = os.popen(command)
2008 # if not abc.has_key(author):
2009 # abc[author] = (len(result), len(result.split()))
2011 # abc[author] = (abc[author][0] + len(result), abc[author][1] + len(result.split()))
2013 # data = [(a, abc[a][0], abc[a][1]) for a in abc.keys()]
2014 # data.sort(lambda x,y: cmp(y[1], x[1]))
2018 # def column_names(self):
2019 # return ('Author', 'Size of diffs', 'Number of lines in diffs')
2022 labels_for_time_span_cache
= {}
2024 def labels_for_time_span(start_time
, end_time
, max_labels
=20):
2026 if labels_for_time_span_cache
.has_key((start_time
, end_time
, max_labels
)):
2027 return labels_for_time_span_cache
[(start_time
, end_time
, max_labels
)]
2030 for unit
in RoundedTimeIterator
.units
:
2033 for t
in RoundedTimeIterator(start_time
, end_time
, unit
):
2034 units_labels
[t
] = GraphTimeLabel(t
, unit
)
2036 labels_candidate
= {}
2037 labels_candidate
.update(units_labels
)
2038 labels_candidate
.update(labels
)
2040 if len(labels_candidate
) > max_labels
:
2043 labels
= labels_candidate
2045 labels_for_time_span_cache
[(start_time
, end_time
, max_labels
)] = labels
2048 class GraphTimeLabel(object):
2049 """Handle graph's time labels.
2050 Used as a value in labels dict.
2051 Actually, created to have "weight" or
2052 "importance" attached to label - so
2053 we can draw bigger strokes with years,
2054 and smaller with days.
2056 def __init__(self
, label_datetime
, unit
, weight
=None):
2057 """Initialise instance.
2058 Weight means "importance" of the label, for example
2059 year is more important than month and gets bigger
2060 "stroke" or "tick" on graphs axis.
2062 If weight is not specified as parameter it is taken from
2063 units index from RoundedTimeIterator.
2065 self
.datetime
= label_datetime
2066 tt
= label_datetime
.timetuple()
2068 if tt
[3] == 0 and tt
[4] == 0 and tt
[5] == 0:
2070 self
.text
= '%04d-%02d-%02d' % (tt
[0], tt
[1], tt
[2])
2073 self
.text
= '%04d-%02d-%02d %02d:%02d:%02d' % tuple(tt
[0:6])
2076 self
.weight
= weight
2078 self
.weight
= list(RoundedTimeIterator
.units
).index(unit
)
2086 class RoundedTimeIterator(object):
2087 """Provide object, that iterates over time period by some fuzzy "round"
2088 time intervals like months, weeks etc.
2090 TODO: Write doctest or unit test for this to define behaviour strictly.
2091 Then, rewrite again. Problem with this is that I'm not sure what results it
2092 should give in first place.
2097 'fiveyears': (5, 0),
2100 'sixmonths': (6, 1),
2124 def __init__(self
, start_datetime
, end_datetime
, unit
):
2126 unit is a string - name of unit.
2127 start_datetime and end_datetime are datetime.datetime objects.
2129 if unit
not in self
.units
:
2130 raise Exception('illegal unit value: %s' % repr(unit
))
2131 assert isinstance(start_datetime
, datetime
.datetime
)
2132 assert isinstance(end_datetime
, datetime
.datetime
)
2134 self
.start_datetime
= start_datetime
2135 self
.end_datetime
= end_datetime
2137 self
.current_datetime
= start_datetime
2140 def _find_next(self
, current_datetime
):
2141 return self
._increase
_date
(self
._reset
_date
(current_datetime
))
2143 def _increase_date(self
, date
):
2144 tl
= list(date
.timetuple())
2145 ch
= self
._unit
_settings
[self
.unit
]
2147 nts
= time
.mktime(tuple(tl
))
2148 return datetime
.datetime
.fromtimestamp(nts
, date
.tzinfo
)
2150 def _reset_date(self
, date
):
2151 tl
= list(date
.timetuple())
2152 ch
= self
._unit
_settings
[self
.unit
]
2153 default_values
= (0, 1, 1, 0, 0, 0)
2154 for i
in range(ch
[1] + 1, len(default_values
)):
2155 tl
[i
] = default_values
[i
]
2157 return datetime
.datetime
.fromtimestamp(
2158 time
.mktime(tuple(tl
)), date
.tzinfo
)
2161 """Please don't read this ;)
2163 if self
.first
and self
.current_datetime
== self
._reset
_date
(self
.current_datetime
):
2167 self
.current_datetime
= self
._find
_next
(self
.current_datetime
)
2171 if self
.current_datetime
>= self
.start_datetime
and self
.current_datetime
< self
.end_datetime
:
2172 return self
.current_datetime
2174 raise StopIteration()
2179 if __name__
== '__main__':
2180 locale
.setlocale(locale
.LC_ALL
)