1
0

Compare commits

...

4 Commits

Author SHA1 Message Date
6c9f6b157d
Sample filter cmdline argument implemented
All checks were successful
Test Python / Test (push) Successful in 20s
2024-06-08 15:17:04 +02:00
c1b95997d9
Year labels 2024-06-08 15:09:57 +02:00
173de2c252
Improved time formatting 2024-06-08 14:49:26 +02:00
c2e90943e9
Ruff 2024-06-08 14:43:44 +02:00
5 changed files with 85 additions and 28 deletions

View File

@ -1,20 +1,16 @@
import argparse import argparse
import dataclasses
import datetime
import logging
import sys import sys
import time import time
import dataclasses
import git
import datetime
from collections.abc import Iterator, Sequence from collections.abc import Iterator, Sequence
from pathlib import Path from pathlib import Path
import logging import git
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def parse_arguments():
parser = argparse.ArgumentParser()
parser.add_argument('repositories', action='extend', nargs='+', type=Path)
return parser.parse_args()
@dataclasses.dataclass(frozen=True, order=True) @dataclasses.dataclass(frozen=True, order=True)
class WorkSample: class WorkSample:
@ -29,15 +25,18 @@ def determine_default(repo: git.Repo):
except: except:
return 'master' return 'master'
HIDDEN_LABEL_PREFIX = '__' HIDDEN_LABEL_PREFIX = '__'
HIDDEN_LABEL_TOTAL = HIDDEN_LABEL_PREFIX + 'TOTAL' HIDDEN_LABEL_TOTAL = HIDDEN_LABEL_PREFIX + 'TOTAL'
def determine_project_name(repo: git.Repo) -> str: def determine_project_name(repo: git.Repo) -> str:
remotes = repo.remotes remotes = repo.remotes
if len(remotes) > 0: if len(remotes) > 0:
return remotes.origin.url.removeprefix('git@gitfub.space:') return remotes.origin.url.removeprefix('git@gitfub.space:')
return Path(repo.working_tree_dir).name return Path(repo.working_tree_dir).name
def get_samples_from_project(repo: git.Repo) -> Iterator[WorkSample]: def get_samples_from_project(repo: git.Repo) -> Iterator[WorkSample]:
project_name = determine_project_name(repo) project_name = determine_project_name(repo)
assert project_name is not None assert project_name is not None
@ -49,46 +48,80 @@ def get_samples_from_project(repo: git.Repo) -> Iterator[WorkSample]:
for commit in repo.iter_commits(determine_default(repo)): for commit in repo.iter_commits(determine_default(repo)):
labels = [HIDDEN_LABEL_TOTAL] labels = [HIDDEN_LABEL_TOTAL]
labels.append('project:' + project_name) labels.append('project:' + project_name)
labels.append('author:' + commit.author.email) labels.append('author:' + commit.author.email)
yield WorkSample(datetime.datetime.fromtimestamp(commit.authored_date, tz=datetime.UTC), tuple(labels)) yield WorkSample(
yield WorkSample(datetime.datetime.fromtimestamp(commit.committed_date, tz=datetime.UTC), tuple(labels)) datetime.datetime.fromtimestamp(commit.authored_date, tz=datetime.UTC),
tuple(labels),
)
yield WorkSample(
datetime.datetime.fromtimestamp(commit.committed_date, tz=datetime.UTC),
tuple(labels),
)
del labels del labels
DEFAULT_EST_TIME=datetime.timedelta(hours=1)
ZERO_DURATION = datetime.timedelta(seconds = 0) DEFAULT_EST_TIME = datetime.timedelta(hours=1)
HOUR = datetime.timedelta(hours = 1)
def generate_report(samples: list[WorkSample]) -> Iterator[str]: ZERO_DURATION = datetime.timedelta(seconds=0)
SAMPLE_FILTER = {} HOUR = datetime.timedelta(hours=1)
MINUTE = datetime.timedelta(minutes=1)
def fmt_year_ranges_internal(years: list[int]) -> Iterator[str]:
years = sorted(years)
for idx, year in enumerate(years):
at_end = idx == len(years) - 1
range_before = idx > 0 and years[idx-1] == year - 1
range_after = not at_end and years[idx+1] == year + 1
if not range_before or not range_after:
yield str(year)
if not at_end:
if not range_before and range_after:
yield '-'
elif not range_after:
yield ','
def fmt_year_ranges(years: list[int]) -> str:
return ''.join(list(fmt_year_ranges_internal(years)))
def fmt_line(label_type: str, label: str, total_time: datetime.timedelta) -> str:
hours = int(total_time / HOUR)
minutes = int((total_time - hours*HOUR)/MINUTE)
return f' {label_type:10} {label:40} {hours:-4d}h {minutes:-2d}m'
def generate_report(samples: list[WorkSample], sample_filter = frozenset()) -> Iterator[str]:
LABEL_FILTER = {} LABEL_FILTER = {}
# Time spent per label # Time spent per label
time_per_label: dict[str, datetime.timedelta] = {} time_per_label: dict[str, datetime.timedelta] = {}
years_per_label: dict[str, set[int]] = {}
prev_time = datetime.datetime.fromtimestamp(0, datetime.UTC) prev_time = datetime.datetime.fromtimestamp(0, datetime.UTC)
for sample in samples: for sample in samples:
est_time: datetime.timedelta = DEFAULT_EST_TIME est_time: datetime.timedelta = DEFAULT_EST_TIME
est_time = min(sample.registered_at - prev_time, est_time) est_time = min(sample.registered_at - prev_time, est_time)
if len(SAMPLE_FILTER) == 0: if len(sample_filter) == 0:
pass pass
elif not set(sample.labels).intersection(SAMPLE_FILTER): elif not set(sample.labels).intersection(sample_filter):
continue continue
for label in sample.labels: for label in sample.labels:
time_per_label.setdefault(label,ZERO_DURATION) time_per_label.setdefault(label, ZERO_DURATION)
time_per_label[label] += est_time time_per_label[label] += est_time
years_per_label.setdefault(label,set()).add(sample.registered_at.year)
prev_time = sample.registered_at prev_time = sample.registered_at
del sample, est_time del sample, est_time
time_and_label = [(duration, label) for label,duration in time_per_label.items()] time_and_label = [(duration, label) for label, duration in time_per_label.items()]
time_and_label.sort(reverse=True) time_and_label.sort(reverse=True)
# #
yield '-' * 66 yield '-' * 66
yield '\n' yield '\n'
for (total_time, label_and_type) in time_and_label: for total_time, label_and_type in time_and_label:
if label_and_type.startswith(HIDDEN_LABEL_PREFIX): if label_and_type.startswith(HIDDEN_LABEL_PREFIX):
continue continue
@ -97,15 +130,26 @@ def generate_report(samples: list[WorkSample]) -> Iterator[str]:
if len(LABEL_FILTER) > 0 and label_type not in LABEL_FILTER: if len(LABEL_FILTER) > 0 and label_type not in LABEL_FILTER:
continue continue
label_type = '' # TODO yield fmt_line(label_type, label, total_time)
yield ' ('
yield f' {label_type:8} {label:40} {total_time / HOUR:-4.2f} hours\n' yield fmt_year_ranges(years_per_label.get(label_and_type,[]))
yield ')'
yield '\n'
del label, total_time del label, total_time
yield '-' * 66 yield '-' * 66
yield '\n' yield '\n'
yield ' {label_type:8} {label:40} {hours:-4.0f} hours\n'.format(label_type='', label='TOTAL', hours = time_per_label.get(HIDDEN_LABEL_TOTAL, ZERO_DURATION) / HOUR) yield fmt_line('', 'TOTAL', time_per_label.get(HIDDEN_LABEL_TOTAL, ZERO_DURATION))
yield '\n'
def parse_arguments():
parser = argparse.ArgumentParser()
parser.add_argument('repositories', action='extend', nargs='+', type=Path)
parser.add_argument('--filter', action='extend', nargs='+', type=str, dest='sample_filter', default=[])
return parser.parse_args()
def main(): def main():
logging.basicConfig() logging.basicConfig()
@ -124,5 +168,5 @@ def main():
shared_time_stamps = sorted(shared_time_stamps) shared_time_stamps = sorted(shared_time_stamps)
for t in generate_report(shared_time_stamps): for t in generate_report(shared_time_stamps, sample_filter=args.sample_filter):
sys.stdout.write(t) sys.stdout.write(t)

View File

@ -2,4 +2,3 @@ from git_time_tracker import main
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@ -1,4 +1,5 @@
import git_time_tracker import git_time_tracker
def test_report_empty(): def test_report_empty():
assert list(git_time_tracker.generate_report([])) assert list(git_time_tracker.generate_report([]))

13
test/test_util.py Normal file
View File

@ -0,0 +1,13 @@
import git_time_tracker
def test_year_ranges_1():
assert git_time_tracker.fmt_year_ranges([1,2,3]) == '1-3'
def test_year_ranges_2():
assert git_time_tracker.fmt_year_ranges([1,3]) == '1,3'
def test_year_ranges_3():
assert git_time_tracker.fmt_year_ranges([1,2,4]) == '1-2,4'
def test_year_ranges_4():
assert git_time_tracker.fmt_year_ranges([1,2,4,5]) == '1-2,4-5'