123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448 |
- #!/usr/bin/python3
- """ Entrouvert test """
-
- import argparse
- import calendar
- import csv
- import datetime
- import shutil
- import sys
- import tempfile
- import time
- import warnings
-
- import git
-
-
- def main():
- """ Main function parse args from CLI, count commits in given
- repository, and display/output them
- """
- args = parse_args()
-
- repo = TempRemoteRepo(args.url, silent=args.silent)
-
- commit_stats = {}
- last_progress = time.time()
- for i, commit in enumerate(repo):
- if not args.silent and time.time() - last_progress > 0.1:
- last_progress = time.time()
- print(f"Processing commit {f'#{i}':5s}", end="\r",
- file=sys.stderr)
- commit_datetime = commit.authored_datetime
- if args.from_date is not None and \
- args.from_date > commit_datetime.date():
- continue
- if args.to_date is not None and\
- args.to_date < commit_datetime.date():
- continue
- in_oh = in_office_hours(commit_datetime,
- starthour=args.daystart,
- stophour=args.daystop,
- weekend=args.weekend)
- commit_by_author(commit.author, commit_datetime, in_oh, commit_stats,
- group=args.group_by)
- if args.verbose and not in_oh:
- print(f"{commit} \
- {commit.authored_datetime.strftime('%a %H:%M %Y-%m-%d')} \
- {commit.author.name!r}")
-
- if len(commit_stats) == 0:
- print("No commit in given repository/date range", file=sys.stderr)
- return
-
- if args.csv_output is None:
- if args.group_by:
- print(result_cli(commit_stats, args.details))
- else:
- print(short_result_cli(commit_stats))
- else:
- result_csv(commit_stats, args.csv_output)
- args.csv_output.close()
-
-
- def in_office_hours(moment=datetime.datetime.now(),
- starthour=datetime.time(8,0,0),
- stophour=datetime.time(20,0,0),
- weekend=(5,6)):
- """ Indicates if a moment is in office hours.
-
- Office hours are localized, comparisons is done without
- taking care of tzoffset : in fact, if a working day starts at
- 08:00, 07:59:59+0200 is off as 07:59:59+0000 is.
-
- Arguments :
- - moment : the datetime.datetime to compare with office hours
- - starthour : standard day start datetime.time
- - stophour : standard day stop datetime.time
- - weekend : list of days off (0,1,....,6)
-
- Returns True if in office hours else False
- """
- for dow in weekend:
- if dow < 0 or dow > 6:
- raise ValueError("Weekend days are integer in [0..6]")
-
- if starthour.tzinfo is not None or stophour.tzinfo is not None:
- warnings.warn("Start & stop hours should not indicate any \
- tzinfo : comparisons are done without taking tzoffset in considaration")
-
- if moment.weekday() in weekend:
- return False
- localtime = moment.time()
- if stophour == datetime.time(0,0):
- return starthour <= localtime
- if stophour < starthour:
- return localtime >= starthour or localtime <= stophour
- return starthour <= localtime <= stophour
-
-
- def commit_by_author(author, moment, in_oh, acc, group=None):
- """ Update stats with commits total and commits off office hour by
- authors
-
- Arguments :
- - author : The commit author (git.util.Author instance)
- - moment : The commit authored datetime.datetime
- - in_oh : boolean indicating if a commit is in office hours
- - acc : the dictionnary accumulating the commits count (modified by
- reference). Keys are author instances and values are dict with
- counters values.
- Counters are dict with group as key. The group 'all' is always
- present.
- When grouping by month group key in format YEAR-MONTH are added.
- When grouping by week group key in format YEAR-WEEK are added.
- Each group is a dict with keys :
- - 'total' for total number of commit
- - 'off_oh' for the number of commit out off office hours
- - group : Commit count can be aggregated by 'month' or 'week'
-
- Returns None, modifications are done in acc by reference
- """
- group_key = None
- if group == 'week':
- cal = moment.isocalendar()
- group_key = f"{cal.year:04d}-W{cal.week:02d}"
- elif group == 'month':
- group_key = f"{moment.year:04d}-{moment.month:02d}"
- elif group is None:
- group_key = None
- else:
- err = f"Invalid group {group!r}. Valid groups are week and month"
- raise ValueError(err)
-
- if author not in acc:
- acc[author] = {'all': {'off_oh': 0, 'total': 0}}
- acc[author]['all']['total'] += 1
- if group_key:
- if group_key not in acc[author]:
- acc[author].update({group_key: {'off_oh': 0, 'total': 0}})
- acc[author][group_key]['total'] += 1
-
- if not in_oh:
- acc[author]['all']['off_oh'] += 1
- if group_key:
- acc[author][group_key]['off_oh'] += 1
-
-
- def iter_commits(repo):
- """ Generator on all git commits in given repository.
-
- Recursively iterate on each commit in each branch/ref not yielding
- duplicates commits (based on commit hashes)
-
- Arguments :
- - repo : The repository instance to fetch commits from
- """
- encountered = set()
- for ref in repo.refs:
- for commit in repo.iter_commits(ref.name):
- if commit.binsha not in encountered:
- encountered.add(commit.binsha)
- yield commit
-
- class TempRemoteRepo(git.repo.base.Repo):
- """ A temporary repository referencing a remote repository
-
- Allows to iterate on all commits without cloning the remote.
- If repository is local iterate on local commit without using
- a temporary repository
- """
-
- def __init__(self, remote_url, silent=True, force_remote=False):
- """ Initialize a new empty repository referencing a remote repo in
- order to iterate on its commit
-
- Arguments :
- - remote_url : The url of the remote repo to reference
- - silent : if False display progress on stderr
- - force_remote : if True create a temporary repo even for
- local file:// url
- """
- self.temppath = None
- if (not force_remote) and remote_url.startswith("file://"):
- repo_path = remote_url[len("file://"):]
- else:
- repo_path = self.temppath = tempfile.mkdtemp(prefix="git_oh_")
- git.Repo.init(self.temppath)
- super().__init__(repo_path)
-
- if self.temppath is not None:
- remote_progress = None if silent else RemoteFetchProgress()
- origin = self.create_remote("origin", remote_url)
- origin.fetch(progress=remote_progress)
-
- def __del__(self):
- """ Clean temporary repository if needed """
- if self.temppath is not None:
- shutil.rmtree(self.temppath)
- super().__del__()
-
- def __iter__(self):
- """ Iterate on commits """
- return iter_commits(self)
-
-
- class RemoteFetchProgress(git.RemoteProgress):
- """ Report progress for remote fetch """
-
- def update(self, op_code, cur_count, max_count=None, message=""):
- """ Display progression on a single line of stderr """
- if max_count:
- pct = (cur_count / max_count)*100
- msg = f"{pct:5.1f}% {message}"
- print(f"{msg:<80s}", end="\r", file=sys.stderr)
-
-
- def result_csv(commit_stats, ofd=sys.stdout):
- """ Output the stats in an open file as CSV
-
- Output 3 columns by group :
- - GRP-total : the number of commit in the group
- - GRP-off_oh : the number of commit off office hour
- - GRP-prop_off_oh : the ratio off_oh / total
-
- Arguments :
- - commit_stats : A dict with author as key and stats as values (
- see commit_by_author() )
- - ofd : Output TextIO
- """
- groups = set()
- for author, stats in commit_stats.items():
- groups.update(stats.keys())
-
- for author in commit_stats:
- for group in groups:
- commit_stats[author].setdefault(group, {'total': 0, 'off_oh': 0})
- gstat = commit_stats[author][group]
- if gstat['total']:
- gstat['prop_off_oh'] = gstat['off_oh']/gstat['total']
- else:
- gstat['prop_off_oh'] = 0.0
-
- groups = sorted(groups)
- all_keys = ['author', 'author_email']
- for group in groups:
- all_keys += [
- f"{group}-total",
- f"{group}-off_oh",
- f"{group}-prop_off_oh"]
- writer = csv.DictWriter(ofd, all_keys)
- writer.writeheader()
- for author, stats in commit_stats.items():
- row = {'author': author.name,
- 'author_email': author.email}
- for kgroup, values in stats.items():
- row.update({f'{kgroup}-{kval}': val
- for kval, val in values.items()})
- writer.writerow(row)
-
-
- def result_cli(commit_stats, details=True):
- """ Format stats for cli output
-
- Arguments :
- - commit_stats : A dict with author as key and stats as values (
- see commit_by_author() )
-
- Returns a string representing an ascii array with percentage of commits
- off office hours.
- """
-
- all_keys = set()
- for stats in commit_stats.values():
- all_keys.update(stats.keys())
- all_keys = sorted(all_keys)
- stats_count = len(all_keys)
-
- stat_width = 10
-
- author_title = "author | % off"
-
- author_width = max((len(author.name) for author in commit_stats))
- author_width = max([len(author_title), author_width])
- author_width += 2
-
- hsep = '+' + ('-' * author_width) + '+'
- hsep += (('-' * stat_width)+ '+') * stats_count
- hsep += "\n"
-
- if details:
- header = "| Author |"
- header += f"{'% off':>{author_width - len('Author |') - 2}s} |"
- for key in all_keys:
- header += f"{key:>{stat_width-1}s} |"
- header += "\n"
- header += f"| {'':>{len('Author')}s} |"
- header += f"{'total':>{author_width - len('Author |') - 2}s} |"
- for key in all_keys:
- header += f"{'':>{stat_width-1}s} |"
- else:
- header = f"|{'Author':>{author_width-1}s} |"
- for key in all_keys:
- header += f"{key:>{stat_width-1}s} |"
-
- result = f"{hsep}{header}\n{hsep}"
- for author, stats in commit_stats.items():
- # First line of stat with percent off
- result += f"|{author.name:>{author_width-1}s} |"
- for key in all_keys:
- if key not in stats:
- result += f"{'none':>{stat_width-1}s} |"
- else:
- pct = (stats[key]['off_oh'] / stats[key]['total'])*100
- result += f"{pct:{stat_width-2}.1f}% |"
- # Second line of stat with total
- if details:
- result += f"\n|{'':>{author_width-1}s} |"
- for key in all_keys:
- if key not in stats:
- result += f"{0:{stat_width-1}d} |"
- else:
- result += f"{stats[key]['total']:{stat_width-1}d} |"
- result += f"\n{hsep}"
-
- return result
-
-
- def short_result_cli(commit_stats):
- """ Format stats for cli output when no grouping
-
- Arguments :
- - commit_stats : A dict with author as key and stats as values (
- see commit_by_author() )
-
- Returns a printable string of author and their details about
- commits off office hours.
- """
- return '\n'.join([f"{author.name:>20s} : \
- {stat['all']['off_oh']/stat['all']['total']*100:5.1f}% \
- ( {stat['all']['off_oh']:4d}/{stat['all']['total']:<5d} off)"
- for author, stat in commit_stats.items()])
-
-
- def valid_day(value):
- """ Take a locale dayname and convert it to a day number
-
- Valid days are calendar.day_name, calendar.day_abbr and integers
- in [0..6]
-
- Arguments :
- - value : the str to convert
-
- Returns an int in [0..6]
- """
-
- value = value.lower()
-
- try:
- return [day.lower() for day in calendar.day_name].index(value)
- except ValueError:
- pass
-
- try:
- return [day.lower() for day in calendar.day_abbr].index(value)
- except ValueError:
- pass
-
- try:
- val = int(value)
- if val not in range(7):
- raise ValueError()
- return val
- except ValueError:
- pass
-
- days = [str(daynum) for daynum in range(7)]
- days += [day.lower() for day in calendar.day_abbr]
- days += [day.lower() for day in calendar.day_name]
- valid_days = ', '.join(days)
- raise ValueError(f"Invalid day {value!r}. Valid days are : {valid_days}")
-
-
- def parse_args(argv=None):
- """ Argument parser for CLI
-
- Arguments :
- - argv : A list like sys.argv[1:]
-
- Print help and sys.exit() if invalid arguments given, else returns
- arguments in a named tuple.
- """
- parser = argparse.ArgumentParser(description="Count the number of \
- commits done in/out of office hours.")
-
- parser.add_argument("url", help="Git repository URL")
- parser.add_argument("-d", "--daystart", type=datetime.time.fromisoformat,
- default=datetime.time(8,0,0),
- help="Day start time (ISO 8601)")
- parser.add_argument("-D", "--daystop", type=datetime.time.fromisoformat,
- default=datetime.time(20,0,0),
- help="Day stop time (ISO 8601)")
- parser.add_argument("-w", "--weekend", type=str, default="5,6",
- metavar='DAYSOFF',
- help="Indicate days off separated by ','. Locale day names or \
- days number (0..6 starting with monday) can be used. By default saturday \
- and sunday are off. Use special value -w NUL to indicate no days off.")
- parser.add_argument("-f", "--from", type=datetime.date.fromisoformat,
- dest="from_date",
- help="Exclude commits before given ISO 8601 date")
- parser.add_argument("-t", "--to", type=datetime.date.fromisoformat,
- dest="to_date",
- help="Exclude commits after given ISO 8601 date")
- parser.add_argument("-g", "--group-by", choices=['week', 'month'],
- help="Generate commit stats by week or month")
- parser.add_argument("-s", "--silent", action="store_true", default=False,
- help="Do not display fetch progression on stderr")
- parser.add_argument("-N", "--no-details", action="store_false",
- default=True, dest="details",
- help="Do not display the total number of commit in result array")
- parser.add_argument("-o", "--csv-output", type=argparse.FileType("w"),
- default=None,
- help="Store commit counts in a CSV file instead of printing it \
- on stdout (use '-' for stdout)")
- parser.add_argument("-v", "--verbose", action="store_true",
- default=False,
- help="Output all commit off office hours on stdout")
-
- args = parser.parse_args(argv)
-
- # parse weekend days list
- if args.weekend == 'NUL':
- args.weekend = []
- else:
- weekend = []
- for day in args.weekend.split(','):
- try:
- weekend.append(valid_day(day.strip()))
- except ValueError as expt:
- print(expt, file=sys.stderr)
- parser.print_help()
- sys.exit(1)
- args.weekend = list(set(weekend))
-
- return args
-
-
- if __name__ == "__main__":
- main()
|