nwcommitaverages 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: nwcommitaverages
3
+ Version: 2.0.1
4
+ Summary: A library designed to calculate the average time between Git commits.
5
+ Home-page: https://github.com/numbworks/nwcommitaverages
6
+ Author: numbworks
7
+ License: MIT
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: tabulate>=0.9.0
11
+ Requires-Dist: pyyaml>=6.0.3
12
+ Dynamic: author
13
+ Dynamic: description
14
+ Dynamic: description-content-type
15
+ Dynamic: home-page
16
+ Dynamic: license
17
+ Dynamic: requires-dist
18
+ Dynamic: requires-python
19
+ Dynamic: summary
20
+
21
+ # nwcommitaverages
22
+
23
+ ## Introduction
24
+
25
+ This packages consists of the following modules:
26
+
27
+ - `nwcommitaverages` is an application designed to calculate the average time between Git commits.
28
+ - `nwcommitaveragescli` is a command-line application built on the top of `nwcommitaverages`.
29
+
30
+ ## Pre-Requisites
31
+
32
+ In order to work, this library requires to be run against a local Git repository and that Git itself is installed on the machine.
33
+
34
+ ## Getting Started
35
+
36
+ I'll use Debian as operating system, but the concept is similar for Windows or Mac.
37
+
38
+ Procedure:
39
+
40
+ 1. Install `pip` and `venv`:
41
+
42
+ ```sh
43
+ sudo apt install python3-pip python3.12-venv
44
+ ```
45
+
46
+ 2. Create a virtual environment:
47
+
48
+ ```sh
49
+ python3 -m venv /tmp/nwcommitaverages
50
+ source /tmp/nwcommitaverages/bin/activate
51
+ ```
52
+
53
+ 3. Download and install the package:
54
+
55
+ ```sh
56
+ pip install nwcommitaverages
57
+ ```
58
+
59
+ 4. Enter in the interactive Python environment:
60
+
61
+ ```sh
62
+ python3
63
+ ```
64
+ ```
65
+ Python 3.12.3 (main, Aug 31 2026, 10:18:26) [GCC 13.3.0] on linux
66
+ Type "help", "copyright", "credits" or "license" for more information.
67
+ >>>
68
+ ```
69
+
70
+ 5. Try out if the library works:
71
+
72
+ ```python
73
+ from nwcommitaverages import CommitAverageCalculator
74
+
75
+ ca_calculator : CommitAverageCalculator = CommitAverageCalculator()
76
+ ```
77
+
78
+ 6. Exit from the interactive Python environment:
79
+
80
+ ```python
81
+ exit()
82
+ ```
83
+
84
+ 7. Try out if the CLI application works:
85
+
86
+ ```sh
87
+ nwcommitaveragescli
88
+ ```
89
+
90
+ 8. Deactivate and delete the virtual environment:
91
+
92
+ ```sh
93
+ deactivate
94
+ rm -rf /tmp/nwcommitaverages
95
+ ```
96
+
97
+ 9. Done!
98
+
99
+ ## Examples
100
+
101
+ To run it against the current folder (we assume it contains a `git` repository and that `git` itself is installed):
102
+
103
+ ```sh
104
+ nwcommitaveragescli
105
+ ```
106
+
107
+ ```
108
+ *****************************************************************
109
+ '##::: ##:'##:::::'##::'######:::::'###::::'##::::'##::'######:::
110
+ ###:: ##: ##:'##: ##:'##... ##:::'## ##::: ##:::: ##:'##... ##::
111
+ ####: ##: ##: ##: ##: ##:::..:::'##:. ##:: ##:::: ##: ##:::..:::
112
+ ## ## ##: ##: ##: ##: ##:::::::'##:::. ##: ##:::: ##: ##::'####:
113
+ ##. ####: ##: ##: ##: ##::::::: #########:. ##:: ##:: ##::: ##::
114
+ ##:. ###: ##: ##: ##: ##::: ##: ##.... ##::. ## ##::: ##::: ##::
115
+ ##::. ##:. ###. ###::. ######:: ##:::: ##:::. ###::::. ######:::
116
+ ..::::..:::...::...::::......:::..:::::..:::::...::::::......::::
117
+ **********************************************Version: 2.0.1*****
118
+
119
+ Folder:'/home'
120
+
121
+ +-------------+--------+-----------+---------------+------------+
122
+ | YearMonth | Days | Commits | DailyAvgMin | RefNames |
123
+ +=============+========+===========+===============+============+
124
+ | 2025-05 | 2 | 29 | 16.71 | |
125
+ +-------------+--------+-----------+---------------+------------+
126
+ | 2026-03 | 2 | 26 | 16.43 | |
127
+ +-------------+--------+-----------+---------------+------------+
128
+ | 2026-04 | 1 | 4 | 73.76 | |
129
+ +-------------+--------+-----------+---------------+------------+
130
+ ```
131
+
132
+ To run it against another folder (which doesn't contain a `git` repository):
133
+
134
+ ```sh
135
+ nwcommitaveragescli --folder_path /home/test
136
+ ```
137
+
138
+ ```
139
+ *****************************************************************
140
+ '##::: ##:'##:::::'##::'######:::::'###::::'##::::'##::'######:::
141
+ ###:: ##: ##:'##: ##:'##... ##:::'## ##::: ##:::: ##:'##... ##::
142
+ ####: ##: ##: ##: ##: ##:::..:::'##:. ##:: ##:::: ##: ##:::..:::
143
+ ## ## ##: ##: ##: ##: ##:::::::'##:::. ##: ##:::: ##: ##::'####:
144
+ ##. ####: ##: ##: ##: ##::::::: #########:. ##:: ##:: ##::: ##::
145
+ ##:. ###: ##: ##: ##: ##::: ##: ##.... ##::. ## ##::: ##::: ##::
146
+ ##::. ##:. ###. ###::. ######:: ##:::: ##:::. ###::::. ######:::
147
+ ..::::..:::...::...::::......:::..:::::..:::::...::::::......::::
148
+ **********************************************Version: 2.0.1*****
149
+
150
+ Folder:'/home/test'
151
+
152
+ Command '['git', '-C', '/home/test', 'log', '--pretty=format:%cs;%ct;%D', '--reverse']' returned non-zero exit status 128.
153
+ ```
154
+
155
+ ## Links
156
+
157
+ - Source: https://github.com/numbworks/nwcommitaverages
158
+ - License: https://github.com/numbworks/nwcommitaverages/blob/master/LICENSE
159
+
160
+ ## Total Downloads (PyPi)
161
+
162
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/nwcommitaverages?period=total&units=NONE&left_color=BLACK&right_color=BLUE&left_text=TOTAL+DOWNLOADS)](https://pepy.tech/projects/nwcommitaverages)
@@ -0,0 +1,8 @@
1
+ nwcommitaverages.py,sha256=bluldKPfdUR6mm1WEIyuTAMjC9BH6PfRrht6ePCHypE,10307
2
+ nwcommitaveragescli.py,sha256=C60Xs8UOstkj9P6iWKanOOukxdGn86RJFaoIeNegf6k,14728
3
+ setupinfo.py,sha256=eO1b2hkURCT5LotPPCK0S_Ehj33WYys4ZZqDcHmT8Kg,521
4
+ nwcommitaverages-2.0.1.dist-info/METADATA,sha256=jrZZBbKfPloPaqdmS7b3gAOztyM6JTriKkyIvWYFnKs,4777
5
+ nwcommitaverages-2.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ nwcommitaverages-2.0.1.dist-info/entry_points.txt,sha256=-1fPgBfCMEm-7bwELkDFtdWjJfGytqrAoENyZAXbPdE,65
7
+ nwcommitaverages-2.0.1.dist-info/top_level.txt,sha256=ALyn6uonUIMxthhxGW0glYBCmfiNp9H8kE-ZiBILy5I,47
8
+ nwcommitaverages-2.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nwcommitaveragescli = nwcommitaveragescli:main
@@ -0,0 +1,3 @@
1
+ nwcommitaverages
2
+ nwcommitaveragescli
3
+ setupinfo
nwcommitaverages.py ADDED
@@ -0,0 +1,299 @@
1
+ '''
2
+ A CLI application designed to calculate the average time between git commits.
3
+
4
+ Alias: nwcavg
5
+ '''
6
+
7
+ # GLOBAL MODULES
8
+ import subprocess
9
+ from collections import defaultdict
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from subprocess import CompletedProcess
13
+ from typing import Optional
14
+
15
+ # NW/LOCAL MODULES
16
+ # CONSTANTS
17
+ # STATIC CLASSES
18
+ # CLASSES
19
+ @dataclass(frozen = True)
20
+ class CommitItem():
21
+
22
+ '''Collects all the necessary information related to a commit timestamp.'''
23
+
24
+ date_str : str
25
+ timestamp_int : int
26
+ timestamp_dt : datetime
27
+ ref_names : list[str]
28
+ @dataclass(frozen = True)
29
+ class DailyStatus():
30
+
31
+ '''Represents a daily status.'''
32
+
33
+ date_str : str
34
+ timestamps : list[int]
35
+ avg_minutes : float
36
+ ref_names : list[str]
37
+ @dataclass(frozen = True)
38
+ class MonthlyStatus():
39
+
40
+ '''Represents a monthly status.'''
41
+
42
+ year_month : str
43
+ dates : int
44
+ timestamps : list[int]
45
+ avg_minutes : float
46
+ ref_names : list[str]
47
+ @dataclass(frozen = True)
48
+ class Summary():
49
+
50
+ '''Represents a summary.'''
51
+
52
+ commit_items : list[CommitItem]
53
+ daily_statuses : list[DailyStatus]
54
+ monthly_statuses : list[MonthlyStatus]
55
+ class CommitAverageCalculator():
56
+
57
+ '''Calculates custom averages related to the current git repository.'''
58
+
59
+ def __create_timestamp_dt(self, timestamp_int : int) -> datetime:
60
+
61
+ '''Converts an integer timestamp to datetime.'''
62
+
63
+ dt : datetime = datetime.fromtimestamp(int(timestamp_int), tz = timezone.utc)
64
+
65
+ return dt
66
+ def __create_commit_item(self, triplet_lst : list[str]) -> CommitItem:
67
+
68
+ '''Creates a CommitItem object out of the provided arguments.'''
69
+
70
+ date_str : str = triplet_lst[0]
71
+ timestamp_int : int = int(triplet_lst[1])
72
+ timestamp_dt : datetime = self.__create_timestamp_dt(timestamp_int = timestamp_int)
73
+
74
+ ref_names : list[str] = []
75
+ if triplet_lst[2]:
76
+ ref_names = triplet_lst[2].split(",")
77
+
78
+ commit_item : CommitItem = CommitItem(
79
+ date_str = date_str,
80
+ timestamp_int = timestamp_int,
81
+ timestamp_dt = timestamp_dt,
82
+ ref_names = ref_names
83
+ )
84
+
85
+ return commit_item
86
+ def __update_ref_names(self, commit_item : CommitItem, ref_names : list[str]) -> CommitItem:
87
+
88
+ '''Update commit_item.ref_names with ref_names.'''
89
+
90
+ updated : CommitItem = CommitItem(
91
+ date_str = commit_item.date_str,
92
+ timestamp_int = commit_item.timestamp_int,
93
+ timestamp_dt = commit_item.timestamp_dt,
94
+ ref_names = ref_names
95
+ )
96
+
97
+ return updated
98
+ def __clean_ref_names(self, ref_names : list[str]) -> list[str]:
99
+
100
+ '''Clean branch names.'''
101
+
102
+ if not ref_names:
103
+ return ref_names
104
+
105
+ cleaned : list[str] = []
106
+
107
+ for ref_name in ref_names:
108
+
109
+ ref_name = ref_name.replace(" ", "")
110
+
111
+ if ref_name in ["origin/HEAD", "origin/master", "HEAD->master"]:
112
+ continue
113
+
114
+ if ref_name.startswith("tag:"):
115
+ continue
116
+
117
+ if ref_name.startswith("origin/"):
118
+ ref_name = ref_name.replace("origin/", "")
119
+ cleaned.append(ref_name)
120
+ continue
121
+
122
+ return sorted(set(cleaned))
123
+ def __create_year_month(self, date_str : str) -> str:
124
+
125
+ '''"2023-11-17" -> 2023-11'''
126
+
127
+ year_month : str = date_str[:7]
128
+
129
+ return year_month
130
+ def __count_days_in_month(self, daily_statuses : list[DailyStatus]) -> int:
131
+
132
+ '''Counts days in month.'''
133
+
134
+ return len(set(daily_status.date_str for daily_status in daily_statuses))
135
+ def __extract_avg_minutes(self, daily_statuses : list[DailyStatus]) -> list[float]:
136
+
137
+ '''Extracts the avg_minutes from daily_statuses.'''
138
+
139
+ return [status.avg_minutes for status in daily_statuses]
140
+ def __get_commit_items(self, folder_path: Optional[str]) -> list[CommitItem]:
141
+
142
+ '''
143
+ Retrieve a collection of CommitItem objects out of the git log.
144
+
145
+ git log output:
146
+ 2023-08-16;1692207406;
147
+ 2023-08-21;1692636918;origin/v4.6.0
148
+ 2023-08-21;1692637081;HEAD -> master, origin/master, origin/HEAD
149
+ ...
150
+
151
+ If "folder_path" is None, "git log" is run against the current folder.
152
+ Otherwise, it's run against the provided folder ("git -C file_path log").
153
+ '''
154
+
155
+ git_command : list[str] = ["git", "log", "--pretty=format:%cs;%ct;%D", "--reverse"]
156
+
157
+ if folder_path:
158
+ git_command = ["git", "-C", folder_path, "log", "--pretty=format:%cs;%ct;%D", "--reverse"]
159
+
160
+ output : CompletedProcess = subprocess.run(
161
+ git_command,
162
+ capture_output = True,
163
+ text = True,
164
+ check = True
165
+ )
166
+
167
+ triplets : list[str] = list(map(str, output.stdout.splitlines()))
168
+ commit_items : list[CommitItem] = []
169
+
170
+ for triplet in triplets:
171
+ triplet_lst : list[str] = triplet.split(";")
172
+ commit_item : CommitItem = self.__create_commit_item(triplet_lst = triplet_lst)
173
+ commit_items.append(commit_item)
174
+
175
+ return commit_items
176
+ def __clean_commit_items(self, commit_items : list[CommitItem]) -> list[CommitItem]:
177
+
178
+ '''Cleans every commit_item.ref_names in commit items.'''
179
+
180
+ cleaned : list[CommitItem] = []
181
+
182
+ for commit_item in commit_items:
183
+
184
+ new_ref_names : list[str] = self.__clean_ref_names(ref_names = commit_item.ref_names)
185
+ new_commit_item : CommitItem = self.__update_ref_names(commit_item = commit_item, ref_names = new_ref_names)
186
+
187
+ cleaned.append(new_commit_item)
188
+
189
+ return cleaned
190
+ def __create_daily_statuses(self, commit_items : list[CommitItem]) -> list[DailyStatus]:
191
+
192
+ '''
193
+ Creates a collection of DailyStatus objects out of commit_items.
194
+
195
+ Example:
196
+ [
197
+ (date_str = "2023-11-17", timestamps = [1700239075, 1700239305, 1700240579, 1700246557], avg_minutes = 41.57, ref_names = ["ref1", "ref2"]),
198
+ (date_str = "2023-11-20", timestamps = [1700508271, 1700508711], avg_minutes = 7.33, ref_names = ["ref3', "ref4']),
199
+ ...
200
+ ]
201
+ '''
202
+
203
+ grouped_by_date : defaultdict = defaultdict(list)
204
+
205
+ for item in commit_items:
206
+ grouped_by_date[item.date_str].append(item)
207
+
208
+ daily_statuses : list[DailyStatus] = []
209
+
210
+ for date_str, items in grouped_by_date.items():
211
+
212
+ timestamps : list[int] = [item.timestamp_int for item in items]
213
+ timestamps.sort()
214
+
215
+ ref_names : list[str] = list({ref for item in items for ref in item.ref_names})
216
+
217
+ avg_minutes : float = 0.0
218
+ if len(timestamps) > 1:
219
+ differences : list[int] = [timestamps[i + 1] - timestamps[i] for i in range(len(timestamps) - 1)]
220
+ avg_minutes = round((sum(differences) / len(differences) / 60), 2)
221
+
222
+ daily_status : DailyStatus = DailyStatus(date_str = date_str, timestamps = timestamps, avg_minutes = avg_minutes, ref_names = ref_names)
223
+ daily_statuses.append(daily_status)
224
+
225
+ return daily_statuses
226
+ def __create_monthly_statuses(self, daily_statuses : list[DailyStatus]) -> list[MonthlyStatus]:
227
+
228
+ '''
229
+ Creates a collection of MonthlyStatus objects out of daily_statuses.
230
+
231
+ Example:
232
+ [
233
+ (year_month = "2023-11", timestamps = [1700239075, 1700239305, 1700240579, 1700246557, 1700508271, 1700508711], avg_minutes = 24.45, ref_names = ["ref1", "ref2", "ref3", "ref4"]),
234
+ (year_month = "2023-12", timestamps = [...], avg_minutes = ...),
235
+ ...
236
+ ]
237
+ '''
238
+
239
+ grouped_by_ym : defaultdict = defaultdict(list)
240
+
241
+ for daily_status in daily_statuses:
242
+ year_month : str = self.__create_year_month(date_str = daily_status.date_str)
243
+ grouped_by_ym[year_month].append(daily_status)
244
+
245
+ monthly_statuses : list[MonthlyStatus] = []
246
+
247
+ for year_month, daily_statuses in grouped_by_ym.items():
248
+
249
+ dates : int = self.__count_days_in_month(daily_statuses = daily_statuses)
250
+
251
+ timestamps : list[int] = []
252
+ ref_names : list[str] = []
253
+
254
+ for daily_status in daily_statuses:
255
+ timestamps.extend(daily_status.timestamps)
256
+ ref_names.extend(daily_status.ref_names)
257
+
258
+ timestamps.sort()
259
+ ref_names = sorted(list(set(ref_names)))
260
+
261
+ daily_avg_minutes : list[float] = self.__extract_avg_minutes(daily_statuses = daily_statuses)
262
+
263
+ monthly_avg_minutes : float = 0.0
264
+ if len(timestamps) > 1:
265
+ monthly_avg_minutes = round(sum(daily_avg_minutes) / len(daily_avg_minutes), 2)
266
+
267
+ monthly_status : MonthlyStatus = MonthlyStatus(
268
+ year_month = year_month,
269
+ dates = dates,
270
+ timestamps = timestamps,
271
+ avg_minutes = monthly_avg_minutes,
272
+ ref_names = ref_names
273
+ )
274
+
275
+ monthly_statuses.append(monthly_status)
276
+
277
+ return monthly_statuses
278
+
279
+ def run(self, folder_path: Optional[str] = None) -> Summary:
280
+
281
+ '''Returns a Summary or raises an Exception.'''
282
+
283
+ commit_items : list[CommitItem] = self.__get_commit_items(folder_path)
284
+ commit_items = self.__clean_commit_items(commit_items = commit_items)
285
+
286
+ daily_statuses : list[DailyStatus] = self.__create_daily_statuses(commit_items = commit_items)
287
+ monthly_statuses : list[MonthlyStatus] = self.__create_monthly_statuses(daily_statuses = daily_statuses)
288
+
289
+ summary : Summary = Summary(
290
+ commit_items = commit_items,
291
+ daily_statuses = daily_statuses,
292
+ monthly_statuses = monthly_statuses
293
+ )
294
+
295
+ return summary
296
+
297
+ # MAIN
298
+ if __name__ == "__main__":
299
+ pass
nwcommitaveragescli.py ADDED
@@ -0,0 +1,434 @@
1
+ '''
2
+ A CLI application built around nwcommitaverages.
3
+
4
+ Alias: nwcavg
5
+ '''
6
+
7
+ # GLOBAL MODULES
8
+ import os
9
+ import subprocess
10
+ import yaml
11
+ from pathlib import Path
12
+ from argparse import ArgumentParser, Namespace
13
+ from enum import StrEnum
14
+ from shutil import get_terminal_size
15
+ from subprocess import CompletedProcess
16
+ from tabulate import tabulate
17
+ from typing import Callable, Final, Optional
18
+
19
+ # NW/LOCAL MODULES
20
+ from nwcommitaverages import CommitAverageCalculator, MonthlyStatus, Summary
21
+ from setupinfo import CLI_NAME, CLI_DESCRIPTION, PROJECT_VERSION
22
+
23
+ # GENERIC CLASSES
24
+ # CONSTANTS
25
+ class CLISTRING:
26
+
27
+ '''Collects all the CLI-related strings.'''
28
+
29
+ OPTION_FOLDERPATH_FLAGS : Final[list[str]] = ["--folder_path"]
30
+ OPTION_FOLDERPATH_REQUIRED : Final[bool] = False
31
+ OPTION_FOLDERPATH_HELP : Final[str] = "The path to the Git repository folder for which the average commit value is calculated."
32
+ class HEADER(StrEnum):
33
+
34
+ '''Represents a collection of headers.'''
35
+
36
+ YEARMONTH = "YearMonth"
37
+ DAYS = "Days"
38
+ COMMITS = "Commits"
39
+ DAILYAVGMIN = "DailyAvgMin"
40
+ REFNAMES = "RefNames"
41
+
42
+ # STATIC CLASSES
43
+ class _MessageCollectionAsciiBannerManager():
44
+
45
+ '''Collects all the messages used for logging and for the exceptions.'''
46
+
47
+ @staticmethod
48
+ def provided_version_empty_whitespace() -> str:
49
+ return "The provided 'version' is empty or whitespace."
50
+ class _MessageCollectionCLIManager():
51
+
52
+ '''Collects all the messages used for logging and for the exceptions used by CLIManager.'''
53
+
54
+ @staticmethod
55
+ def not_enough_data() -> str:
56
+ return "Not enough data"
57
+ @staticmethod
58
+ def field_equals_to(name: str, value: str) -> str:
59
+ return f"{name}:'{value}'"
60
+ class _MessageCollection(
61
+ _MessageCollectionCLIManager,
62
+ _MessageCollectionAsciiBannerManager):
63
+
64
+ '''Collects all the messages used for logging and for the exceptions.'''
65
+
66
+ pass
67
+
68
+ # CLASSES
69
+ class AsciiBannerManager:
70
+
71
+ """
72
+ Creates the ASCII banner for the provided library's version.
73
+
74
+ The figlet can be generated using
75
+ - 'http://www.network-science.de/ascii/' (font: "banner3-D", width: 120)
76
+ - 'https://www.askapache.com/online-tools/figlet-ascii/'.
77
+ """
78
+
79
+ def __validate(self, version: str) -> None:
80
+
81
+ """Validates the provided 'version'."""
82
+
83
+ if not version or not version.strip():
84
+ raise ValueError(_MessageCollection.provided_version_empty_whitespace())
85
+ def __create_figlet(self) -> tuple:
86
+
87
+ """Returns a tuple containing the figlet and its width."""
88
+
89
+ lines : list[str] = [
90
+ "'##::: ##:'##:::::'##::'######:::::'###::::'##::::'##::'######:::",
91
+ " ###:: ##: ##:'##: ##:'##... ##:::'## ##::: ##:::: ##:'##... ##::",
92
+ " ####: ##: ##: ##: ##: ##:::..:::'##:. ##:: ##:::: ##: ##:::..:::",
93
+ " ## ## ##: ##: ##: ##: ##:::::::'##:::. ##: ##:::: ##: ##::'####:",
94
+ " ##. ####: ##: ##: ##: ##::::::: #########:. ##:: ##:: ##::: ##::",
95
+ " ##:. ###: ##: ##: ##: ##::: ##: ##.... ##::. ## ##::: ##::: ##::",
96
+ " ##::. ##:. ###. ###::. ######:: ##:::: ##:::. ###::::. ######:::",
97
+ "..::::..:::...::...::::......:::..:::::..:::::...::::::......::::"
98
+ ]
99
+
100
+ return (os.linesep.join(lines), len(lines[0]))
101
+ def __create_frame(self, version: str, max_length: int) -> tuple:
102
+
103
+ """Returns a tuple containing the frame of the figlet."""
104
+
105
+ version_token : str = f"Version: {version}"
106
+
107
+ margin_length : int = 5
108
+ total_length : int = max_length - len(version_token) - margin_length
109
+
110
+ top_line : str = "*" * max_length
111
+ bottom_line : str = f"{top_line[:total_length]}{version_token}{'*' * margin_length}"
112
+
113
+ return (top_line, bottom_line)
114
+
115
+ def create_standard(self, version : str) -> str:
116
+
117
+ """Creates the standard ASCII banner."""
118
+
119
+ self.__validate(version)
120
+
121
+ figlet, max_length = self.__create_figlet()
122
+ top_line, bottom_line = self.__create_frame(version, max_length)
123
+
124
+ ascii_banner : str = os.linesep.join([
125
+ top_line,
126
+ figlet,
127
+ bottom_line,
128
+ ""
129
+ ])
130
+
131
+ return ascii_banner
132
+ def create_mini(self, version : str) -> str:
133
+
134
+ """
135
+ Creates the mini ASCII banner:
136
+
137
+ *****************
138
+ * NWCAVG v1.0.0 *
139
+ *****************
140
+ """
141
+
142
+ self.__validate(version)
143
+
144
+ assembly_name : str = "NWCAVG"
145
+ middle_line : str = f"* {assembly_name} v{version} *"
146
+
147
+ top_line : str = "*" * len(middle_line)
148
+ bottom_line : str = top_line
149
+
150
+ ascii_banner : str = os.linesep.join([
151
+ top_line,
152
+ middle_line,
153
+ bottom_line,
154
+ ""
155
+ ])
156
+
157
+ return ascii_banner
158
+ def create(self, version : str, terminal_width : int) -> str:
159
+
160
+ """Creates either a standard or mini ASCII banner depending on the terminal width."""
161
+
162
+ _, max_length = self.__create_figlet()
163
+
164
+ if max_length <= terminal_width:
165
+ return self.create_standard(version)
166
+ else:
167
+ return self.create_mini(version)
168
+ class TerminalWindowManager:
169
+
170
+ '''Handles terminal window size.'''
171
+
172
+ __shutil_width_function : Callable[[], Optional[int]]
173
+ __stty_width_function : Callable[[], Optional[int]]
174
+
175
+ cutoff_width : Final[int] = 70
176
+
177
+ @staticmethod
178
+ def default_shutil_width_function() -> Optional[int]:
179
+
180
+ """Get terminal width using shutil (multi-platform)."""
181
+
182
+ try:
183
+
184
+ terminal_width : int = get_terminal_size().columns
185
+
186
+ return terminal_width
187
+
188
+ except:
189
+ return None
190
+
191
+ @staticmethod
192
+ def default_stty_width_function() -> Optional[int]:
193
+
194
+ """Get terminal width using stty command (Linux)."""
195
+
196
+ try:
197
+
198
+ process : CompletedProcess[str] = subprocess.run(
199
+ ["/bin/sh", "-c", "stty size | cut -d' ' -f2"],
200
+ capture_output = True,
201
+ text = True,
202
+ check = False,
203
+ )
204
+
205
+ stty_output : str = process.stdout.strip()
206
+ terminal_width : int = int(stty_output)
207
+
208
+ if terminal_width >= 0:
209
+ return terminal_width
210
+
211
+ return None
212
+ except:
213
+ return None
214
+
215
+ def __init__(
216
+ self,
217
+ shutil_width_function : Optional[Callable[[], Optional[int]]] = None,
218
+ stty_width_function : Optional[Callable[[], Optional[int]]] = None,
219
+ ) -> None:
220
+
221
+ if shutil_width_function is None:
222
+ shutil_width_function = self.default_shutil_width_function
223
+
224
+ if stty_width_function is None:
225
+ stty_width_function = self.default_stty_width_function
226
+
227
+ self.__shutil_width_function = shutil_width_function
228
+ self.__stty_width_function = stty_width_function
229
+
230
+ def get_or_cutoff(self) -> int:
231
+
232
+ terminal_width : Optional[int] = self.__shutil_width_function()
233
+
234
+ if terminal_width is None:
235
+ terminal_width = self.__stty_width_function()
236
+
237
+ if terminal_width is None:
238
+ terminal_width = self.cutoff_width
239
+
240
+ return terminal_width
241
+ class APFactory():
242
+
243
+ '''Encapsulates all the logic related to the creation of a custom instance of argparse.ArgumentParser.'''
244
+
245
+ def create(self) -> ArgumentParser:
246
+
247
+ '''Creates a custom instance of argparse.ArgumentParser.'''
248
+
249
+ argument_parser : ArgumentParser = ArgumentParser(prog = CLI_NAME, description = CLI_DESCRIPTION)
250
+
251
+ argument_parser.add_argument(
252
+ *CLISTRING.OPTION_FOLDERPATH_FLAGS,
253
+ required = CLISTRING.OPTION_FOLDERPATH_REQUIRED,
254
+ help = CLISTRING.OPTION_FOLDERPATH_HELP)
255
+
256
+ return argument_parser
257
+ class APAdapter():
258
+
259
+ '''Customizes argparse.ArgumentParser for this use case.'''
260
+
261
+ __ap_factory : APFactory
262
+
263
+ def __init__(self, ap_factory : APFactory = APFactory()) -> None:
264
+ self.__ap_factory = ap_factory
265
+
266
+ def parse_args(self) -> Optional[str]:
267
+
268
+ '''Parses provided arguments.'''
269
+
270
+ parser : ArgumentParser = self.__ap_factory.create()
271
+ args : Namespace = parser.parse_args()
272
+
273
+ return args.folder_path
274
+ class CLIManager():
275
+
276
+ '''Collects all the logic related to the CLI management.'''
277
+
278
+ __ap_adapter : APAdapter
279
+ __ca_calculator : CommitAverageCalculator
280
+ __ascii_banner_manager : AsciiBannerManager
281
+ __tw_manager : TerminalWindowManager
282
+ __logging_function : Callable[[str], None]
283
+
284
+ def __init__(
285
+ self,
286
+ ap_adapter : APAdapter = APAdapter(),
287
+ ca_calculator : CommitAverageCalculator = CommitAverageCalculator(),
288
+ ascii_banner_manager : AsciiBannerManager = AsciiBannerManager(),
289
+ tw_manager : TerminalWindowManager = TerminalWindowManager(),
290
+ logging_function : Callable[[str], None] = lambda msg : print(msg)) -> None:
291
+
292
+ self.__ap_adapter = ap_adapter
293
+ self.__ca_calculator = ca_calculator
294
+ self.__ascii_banner_manager = ascii_banner_manager
295
+ self.__tw_manager = tw_manager
296
+ self.__logging_function = logging_function
297
+
298
+ def __convert_to_table(self, monthly_statuses : list[MonthlyStatus]) -> str:
299
+
300
+ '''
301
+ Converts monthly_statuses to a table.
302
+
303
+ Example:
304
+ +-------------+--------+-----------+---------------+--------------------------------+
305
+ | YearMonth | Days | Commits | DailyAvgMin | RefNames |
306
+ +=============+========+===========+===============+================================+
307
+ | 2023-08 | 2 | 6 | 721.28 | v3.2.0, v3.3.0 |
308
+ +-------------+--------+-----------+---------------+--------------------------------+
309
+ ...
310
+ '''
311
+
312
+ rows : list[list[object]] = []
313
+
314
+ for monthly_status in monthly_statuses:
315
+ row : list[object] = [
316
+ monthly_status.year_month,
317
+ monthly_status.dates,
318
+ len(monthly_status.timestamps),
319
+ str(f"{monthly_status.avg_minutes:.2f}"),
320
+ ", ".join(monthly_status.ref_names)
321
+ ]
322
+ row[3] = str(row[3]).replace("0.00", _MessageCollection.not_enough_data())
323
+ rows.append(row)
324
+
325
+ table : str = tabulate(
326
+ rows,
327
+ headers = [HEADER.YEARMONTH, HEADER.DAYS, HEADER.COMMITS, HEADER.DAILYAVGMIN, HEADER.REFNAMES],
328
+ tablefmt = "grid",
329
+ disable_numparse = True
330
+ )
331
+
332
+ return table
333
+ def __calculate_table_max_lenght(self, table : str) -> int:
334
+
335
+ """Calculates table's max lenght."""
336
+
337
+ max_length : int = max((len(line) for line in table.splitlines()), default=0)
338
+
339
+ return max_length
340
+ def __convert_to_yaml(self, monthly_statuses : list[MonthlyStatus]) -> str:
341
+
342
+ '''
343
+ Converts monthly_statuses to a YAML block.
344
+
345
+ Example:
346
+ - YearMonth: "2023-08"
347
+ Days: 2
348
+ Commits: 6
349
+ DailyAvgMin: 721.28
350
+ RefNames: "v3.2.0, v3.3.0"
351
+ - ...
352
+
353
+ Note: "sort_keys = False" preserves the insertion order of the headers.
354
+ '''
355
+
356
+ yaml_data : list[dict[str, object]] = []
357
+
358
+ for monthly_status in monthly_statuses:
359
+ avg_min = str(f"{monthly_status.avg_minutes:.2f}").replace("0.00", _MessageCollection.not_enough_data())
360
+
361
+ record : dict[str, object] = {
362
+ HEADER.YEARMONTH.value: monthly_status.year_month,
363
+ HEADER.DAYS.value: monthly_status.dates,
364
+ HEADER.COMMITS.value: len(monthly_status.timestamps),
365
+ HEADER.DAILYAVGMIN.value: float(avg_min) if avg_min.replace('.', '', 1).isdigit() else avg_min,
366
+ HEADER.REFNAMES.value: ", ".join(monthly_status.ref_names)
367
+ }
368
+ yaml_data.append(record)
369
+
370
+ yaml_str : str = yaml.dump(yaml_data, sort_keys = False, default_flow_style = False)
371
+ yaml_str = yaml_str.replace(f"\n- {HEADER.YEARMONTH.value}:", f"\n\n- {HEADER.YEARMONTH.value}:")
372
+
373
+ return yaml_str
374
+
375
+ def __log_ascii_banner(self):
376
+
377
+ """Logs the ascii banner."""
378
+
379
+ terminal_width : int = self.__tw_manager.get_or_cutoff()
380
+ ascii_banner : str = self.__ascii_banner_manager.create(PROJECT_VERSION, terminal_width)
381
+
382
+ self.__logging_function("")
383
+ self.__logging_function(ascii_banner)
384
+ def __log_folder_path(self, folder_path : Optional[str]):
385
+
386
+ """Logs the folder_path."""
387
+
388
+ if (folder_path):
389
+ self.__logging_function(_MessageCollection.field_equals_to("Folder", folder_path))
390
+ else:
391
+ self.__logging_function(_MessageCollection.field_equals_to("Folder", str(Path.cwd())))
392
+
393
+ self.__logging_function("")
394
+ def __log_monthly_statuses(self, monthly_statuses : list[MonthlyStatus]) -> None:
395
+
396
+ '''
397
+ Logs monthly_statuses as table or YAML.
398
+ '''
399
+
400
+ terminal_width : int = self.__tw_manager.get_or_cutoff()
401
+
402
+ table : str = self.__convert_to_table(monthly_statuses)
403
+ table_max_length : int = self.__calculate_table_max_lenght(table)
404
+
405
+ if table_max_length < terminal_width:
406
+ self.__logging_function(table)
407
+ else:
408
+ yaml_str : str = self.__convert_to_yaml(monthly_statuses)
409
+ self.__logging_function(yaml_str)
410
+
411
+ def parse(self) -> None:
412
+
413
+ '''Calculates the average commit value and logs the result.'''
414
+
415
+ try:
416
+
417
+ self.__log_ascii_banner()
418
+
419
+ folder_path : Optional[str] = self.__ap_adapter.parse_args()
420
+
421
+ self.__log_folder_path(folder_path = folder_path)
422
+
423
+ summary : Summary = self.__ca_calculator.run(folder_path = folder_path)
424
+ self.__log_monthly_statuses(monthly_statuses = summary.monthly_statuses);
425
+
426
+ except Exception as e:
427
+
428
+ self.__logging_function(str(e))
429
+
430
+ # MAIN
431
+ def main(): CLIManager().parse()
432
+
433
+ if __name__ == "__main__":
434
+ main()
setupinfo.py ADDED
@@ -0,0 +1,14 @@
1
+ '''Contains project information.'''
2
+
3
+ # INFORMATION
4
+ PROJECT_VERSION : str = "2.0.1"
5
+ PROJECT_AUTHOR : str = "numbworks"
6
+ PROJECT_ALIAS : str = "nwcavg"
7
+
8
+ LIBRARY_NAME : str = "nwcommitaverages"
9
+ LIBRARY_DESCRIPTION : str = "A library designed to calculate the average time between Git commits."
10
+
11
+ CLI_NAME : str = "nwcommitaveragescli"
12
+ CLI_DESCRIPTION : str = "A CLI application designed to calculate the average time between Git commits."
13
+
14
+ PROJECT_URL : str = f"https://github.com/{PROJECT_AUTHOR}/{LIBRARY_NAME}"