forked from Andrew6rant/Andrew6rant
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathtoday.py
472 lines (420 loc) · 20.4 KB
/
today.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
import datetime
from dateutil import relativedelta
import requests
import os
from xml.dom import minidom
import time
import hashlib
# Fine-grained personal access token with All Repositories access:
# Account permissions: read:Followers, read:Starring, read:Watching
# Repository permissions: read:Commit statuses, read:Contents, read:Issues, read:Metadata, read:Pull Requests
# Issues and pull requests permissions not needed at the moment, but may be used in the future
HEADERS = {'authorization': 'token '+ os.environ['ACCESS_TOKEN']}
USER_NAME = os.environ['USER_NAME'] # 'Andrew6rant'
QUERY_COUNT = {'user_getter': 0, 'follower_getter': 0, 'graph_repos_stars': 0, 'recursive_loc': 0, 'graph_commits': 0, 'loc_query': 0}
def daily_readme(birthday):
"""
Returns the length of time since I was born
e.g. 'XX years, XX months, XX days'
"""
diff = relativedelta.relativedelta(datetime.datetime.today(), birthday)
return '{} {}, {} {}, {} {}{}'.format(
diff.years, 'year' + format_plural(diff.years),
diff.months, 'month' + format_plural(diff.months),
diff.days, 'day' + format_plural(diff.days),
' 🎂' if (diff.months == 0 and diff.days == 0) else '')
def format_plural(unit):
"""
Returns a properly formatted number
e.g.
'day' + format_plural(diff.days) == 5
>>> '5 days'
'day' + format_plural(diff.days) == 1
>>> '1 day'
"""
return 's' if unit != 1 else ''
def simple_request(func_name, query, variables):
"""
Returns a request, or raises an Exception if the response does not succeed.
"""
request = requests.post('https://api.github.com/graphql', json={'query': query, 'variables':variables}, headers=HEADERS)
if request.status_code == 200:
return request
raise Exception(func_name, ' has failed with a', request.status_code, request.text, QUERY_COUNT)
def graph_commits(start_date, end_date):
"""
Uses GitHub's GraphQL v4 API to return my total commit count
"""
query_count('graph_commits')
query = '''
query($start_date: DateTime!, $end_date: DateTime!, $login: String!) {
user(login: $login) {
contributionsCollection(from: $start_date, to: $end_date) {
contributionCalendar {
totalContributions
}
}
}
}'''
variables = {'start_date': start_date,'end_date': end_date, 'login': USER_NAME}
request = simple_request(graph_commits.__name__, query, variables)
return int(request.json()['data']['user']['contributionsCollection']['contributionCalendar']['totalContributions'])
def graph_repos_stars(count_type, owner_affiliation, cursor=None, add_loc=0, del_loc=0):
"""
Uses GitHub's GraphQL v4 API to return my total repository, star, or lines of code count.
"""
query_count('graph_repos_stars')
query = '''
query ($owner_affiliation: [RepositoryAffiliation], $login: String!, $cursor: String) {
user(login: $login) {
repositories(first: 100, after: $cursor, ownerAffiliations: $owner_affiliation) {
totalCount
edges {
node {
... on Repository {
nameWithOwner
stargazers {
totalCount
}
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}'''
variables = {'owner_affiliation': owner_affiliation, 'login': USER_NAME, 'cursor': cursor}
request = simple_request(graph_repos_stars.__name__, query, variables)
if request.status_code == 200:
if count_type == 'repos':
return request.json()['data']['user']['repositories']['totalCount']
elif count_type == 'stars':
return stars_counter(request.json()['data']['user']['repositories']['edges'])
def recursive_loc(owner, repo_name, data, cache_comment, addition_total=0, deletion_total=0, my_commits=0, cursor=None):
"""
Uses GitHub's GraphQL v4 API and cursor pagination to fetch 100 commits from a repository at a time
"""
query_count('recursive_loc')
query = '''
query ($repo_name: String!, $owner: String!, $cursor: String) {
repository(name: $repo_name, owner: $owner) {
defaultBranchRef {
target {
... on Commit {
history(first: 100, after: $cursor) {
totalCount
edges {
node {
... on Commit {
committedDate
}
author {
user {
id
}
}
deletions
additions
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
}
}
}'''
variables = {'repo_name': repo_name, 'owner': owner, 'cursor': cursor}
request = requests.post('https://api.github.com/graphql', json={'query': query, 'variables':variables}, headers=HEADERS) # I cannot use simple_request(), because I want to save the file before raising Exception
if request.status_code == 200:
if request.json()['data']['repository']['defaultBranchRef'] != None: # Only count commits if repo isn't empty
return loc_counter_one_repo(owner, repo_name, data, cache_comment, request.json()['data']['repository']['defaultBranchRef']['target']['history'], addition_total, deletion_total, my_commits)
else: return 0
force_close_file(data, cache_comment) # saves what is currently in the file before this program crashes
if request.status_code == 403:
raise Exception('Too many requests in a short amount of time!\nYou\'ve hit the non-documented anti-abuse limit!')
raise Exception('recursive_loc() has failed with a', request.status_code, request.text, QUERY_COUNT)
def loc_counter_one_repo(owner, repo_name, data, cache_comment, history, addition_total, deletion_total, my_commits):
"""
Recursively call recursive_loc (since GraphQL can only search 100 commits at a time)
only adds the LOC value of commits authored by me
"""
for node in history['edges']:
if node['node']['author']['user'] == OWNER_ID:
my_commits += 1
addition_total += node['node']['additions']
deletion_total += node['node']['deletions']
if history['edges'] == [] or not history['pageInfo']['hasNextPage']:
return addition_total, deletion_total, my_commits
else: return recursive_loc(owner, repo_name, data, cache_comment, addition_total, deletion_total, my_commits, history['pageInfo']['endCursor'])
def loc_query(owner_affiliation, comment_size=0, force_cache=False, cursor=None, edges=[]):
"""
Uses GitHub's GraphQL v4 API to query all the repositories I have access to (with respect to owner_affiliation)
Queries 60 repos at a time, because larger queries give a 502 timeout error and smaller queries send too many
requests and also give a 502 error.
Returns the total number of lines of code in all repositories
"""
query_count('loc_query')
query = '''
query ($owner_affiliation: [RepositoryAffiliation], $login: String!, $cursor: String) {
user(login: $login) {
repositories(first: 60, after: $cursor, ownerAffiliations: $owner_affiliation) {
edges {
node {
... on Repository {
nameWithOwner
defaultBranchRef {
target {
... on Commit {
history {
totalCount
}
}
}
}
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}'''
variables = {'owner_affiliation': owner_affiliation, 'login': USER_NAME, 'cursor': cursor}
request = simple_request(loc_query.__name__, query, variables)
if request.json()['data']['user']['repositories']['pageInfo']['hasNextPage']: # If repository data has another page
edges += request.json()['data']['user']['repositories']['edges'] # Add on to the LoC count
return loc_query(owner_affiliation, comment_size, force_cache, request.json()['data']['user']['repositories']['pageInfo']['endCursor'], edges)
else:
return cache_builder(edges + request.json()['data']['user']['repositories']['edges'], comment_size, force_cache)
def cache_builder(edges, comment_size, force_cache, loc_add=0, loc_del=0):
"""
Checks each repository in edges to see if it has been updated since the last time it was cached
If it has, run recursive_loc on that repository to update the LOC count
"""
cached = True # Assume all repositories are cached
filename = 'cache/'+hashlib.sha256(USER_NAME.encode('utf-8')).hexdigest()+'.txt' # Create a unique filename for each user
try:
with open(filename, 'r') as f:
data = f.readlines()
except FileNotFoundError: # If the cache file doesn't exist, create it
data = []
if comment_size > 0:
for _ in range(comment_size): data.append('This line is a comment block. Write whatever you want here.\n')
with open(filename, 'w') as f:
f.writelines(data)
if len(data)-comment_size != len(edges) or force_cache: # If the number of repos has changed, or force_cache is True
cached = False
flush_cache(edges, filename, comment_size)
with open(filename, 'r') as f:
data = f.readlines()
cache_comment = data[:comment_size] # save the comment block
data = data[comment_size:] # remove those lines
for index in range(len(edges)):
repo_hash, commit_count, *__ = data[index].split()
if repo_hash == hashlib.sha256(edges[index]['node']['nameWithOwner'].encode('utf-8')).hexdigest():
try:
if int(commit_count) != edges[index]['node']['defaultBranchRef']['target']['history']['totalCount']:
# if commit count has changed, update loc for that repo
owner, repo_name = edges[index]['node']['nameWithOwner'].split('/')
loc = recursive_loc(owner, repo_name, data, cache_comment)
data[index] = repo_hash + ' ' + str(edges[index]['node']['defaultBranchRef']['target']['history']['totalCount']) + ' ' + str(loc[2]) + ' ' + str(loc[0]) + ' ' + str(loc[1]) + '\n'
except TypeError: # If the repo is empty
data[index] = repo_hash + ' 0 0 0 0\n'
with open(filename, 'w') as f:
f.writelines(cache_comment)
f.writelines(data)
for line in data:
loc = line.split()
loc_add += int(loc[3])
loc_del += int(loc[4])
return [loc_add, loc_del, loc_add - loc_del, cached]
def flush_cache(edges, filename, comment_size):
"""
Wipes the cache file
This is called when the number of repositories changes or when the file is first created
"""
with open(filename, 'r') as f:
data = []
if comment_size > 0:
data = f.readlines()[:comment_size] # only save the comment
with open(filename, 'w') as f:
f.writelines(data)
for node in edges:
f.write(hashlib.sha256(node['node']['nameWithOwner'].encode('utf-8')).hexdigest() + ' 0 0 0 0\n')
def add_archive():
"""
Several repositories I have contributed to have since been deleted.
This function adds them using their last known data
"""
with open('cache/repository_archive.txt', 'r') as f:
data = f.readlines()
old_data = data
data = data[7:len(data)-3] # remove the comment block
added_loc, deleted_loc, added_commits = 0, 0, 0
contributed_repos = len(data)
for line in data:
repo_hash, total_commits, my_commits, *loc = line.split()
added_loc += int(loc[0])
deleted_loc += int(loc[1])
if (my_commits.isdigit()): added_commits += int(my_commits)
added_commits += int(old_data[-1].split()[4][:-1])
return [added_loc, deleted_loc, added_loc - deleted_loc, added_commits, contributed_repos]
def force_close_file(data, cache_comment):
"""
Forces the file to close, preserving whatever data was written to it
This is needed because if this function is called, the program would've crashed before the file is properly saved and closed
"""
filename = 'cache/'+hashlib.sha256(USER_NAME.encode('utf-8')).hexdigest()+'.txt'
with open(filename, 'w') as f:
f.writelines(cache_comment)
f.writelines(data)
print('There was an error while writing to the cache file. The file,', filename, 'has had the partial data saved and closed.')
def stars_counter(data):
"""
Count total stars in repositories owned by me
"""
total_stars = 0
for node in data: total_stars += node['node']['stargazers']['totalCount']
return total_stars
def svg_overwrite(filename, age_data, commit_data, star_data, repo_data, contrib_data, follower_data, loc_data):
"""
Parse SVG files and update elements with my age, commits, stars, repositories, and lines written
"""
svg = minidom.parse(filename)
f = open(filename, mode='w', encoding='utf-8')
tspan = svg.getElementsByTagName('tspan')
tspan[30].firstChild.data = age_data
tspan[65].firstChild.data = repo_data
tspan[67].firstChild.data = contrib_data
tspan[69].firstChild.data = commit_data
tspan[71].firstChild.data = star_data
tspan[73].firstChild.data = follower_data
tspan[75].firstChild.data = loc_data[2]
tspan[76].firstChild.data = loc_data[0] + '++'
tspan[77].firstChild.data = loc_data[1] + '--'
f.write(svg.toxml('utf-8').decode('utf-8'))
f.close()
def commit_counter(comment_size):
"""
Counts up my total commits, using the cache file created by cache_builder.
"""
total_commits = 0
filename = 'cache/'+hashlib.sha256(USER_NAME.encode('utf-8')).hexdigest()+'.txt' # Use the same filename as cache_builder
with open(filename, 'r') as f:
data = f.readlines()
cache_comment = data[:comment_size] # save the comment block
data = data[comment_size:] # remove those lines
for line in data:
total_commits += int(line.split()[2])
return total_commits
def svg_element_getter(filename):
"""
Prints the element index of every element in the SVG file
"""
svg = minidom.parse(filename)
open(filename, mode='r', encoding='utf-8')
tspan = svg.getElementsByTagName('tspan')
for index in range(len(tspan)): print(index, tspan[index].firstChild.data)
def user_getter(username):
"""
Returns the account ID and creation time of the user
"""
query_count('user_getter')
query = '''
query($login: String!){
user(login: $login) {
id
createdAt
}
}'''
variables = {'login': username}
request = simple_request(user_getter.__name__, query, variables)
return {'id': request.json()['data']['user']['id']}, request.json()['data']['user']['createdAt']
def follower_getter(username):
"""
Returns the number of followers of the user
"""
query_count('follower_getter')
query = '''
query($login: String!){
user(login: $login) {
followers {
totalCount
}
}
}'''
request = simple_request(follower_getter.__name__, query, {'login': username})
return int(request.json()['data']['user']['followers']['totalCount'])
def query_count(funct_id):
"""
Counts how many times the GitHub GraphQL API is called
"""
global QUERY_COUNT
QUERY_COUNT[funct_id] += 1
def perf_counter(funct, *args):
"""
Calculates the time it takes for a function to run
Returns the function result and the time differential
"""
start = time.perf_counter()
funct_return = funct(*args)
return funct_return, time.perf_counter() - start
def formatter(query_type, difference, funct_return=False, whitespace=0):
"""
Prints a formatted time differential
Returns formatted result if whitespace is specified, otherwise returns raw result
"""
print('{:<23}'.format(' ' + query_type + ':'), sep='', end='')
print('{:>12}'.format('%.4f' % difference + ' s ')) if difference > 1 else print('{:>12}'.format('%.4f' % (difference * 1000) + ' ms'))
if whitespace:
return f"{'{:,}'.format(funct_return): <{whitespace}}"
return funct_return
if __name__ == '__main__':
"""
Andrew Grant (Andrew6rant), 2022-2024
"""
print('Calculation times:')
# define global variable for owner ID and calculate user's creation date
# e.g {'id': 'MDQ6VXNlcjU3MzMxMTM0'} and 2019-11-03T21:15:07Z for username 'Andrew6rant'
user_data, user_time = perf_counter(user_getter, USER_NAME)
OWNER_ID, acc_date = user_data
formatter('account data', user_time)
age_data, age_time = perf_counter(daily_readme, datetime.datetime(2002, 7, 5))
formatter('age calculation', age_time)
total_loc, loc_time = perf_counter(loc_query, ['OWNER', 'COLLABORATOR', 'ORGANIZATION_MEMBER'], 7)
formatter('LOC (cached)', loc_time) if total_loc[-1] else formatter('LOC (no cache)', loc_time)
commit_data, commit_time = perf_counter(commit_counter, 7)
star_data, star_time = perf_counter(graph_repos_stars, 'stars', ['OWNER'])
repo_data, repo_time = perf_counter(graph_repos_stars, 'repos', ['OWNER'])
contrib_data, contrib_time = perf_counter(graph_repos_stars, 'repos', ['OWNER', 'COLLABORATOR', 'ORGANIZATION_MEMBER'])
follower_data, follower_time = perf_counter(follower_getter, USER_NAME)
# several repositories that I've contributed to have since been deleted.
if OWNER_ID == {'id': 'MDQ6VXNlcjU3MzMxMTM0'}: # only calculate for user Andrew6rant
archived_data = add_archive()
for index in range(len(total_loc)-1):
total_loc[index] += archived_data[index]
contrib_data += archived_data[-1]
commit_data += int(archived_data[-2])
commit_data = formatter('commit counter', commit_time, commit_data, 7)
star_data = formatter('star counter', star_time, star_data)
repo_data = formatter('my repositories', repo_time, repo_data, 2)
contrib_data = formatter('contributed repos', contrib_time, contrib_data, 2)
follower_data = formatter('follower counter', follower_time, follower_data, 4)
for index in range(len(total_loc)-1): total_loc[index] = '{:,}'.format(total_loc[index]) # format added, deleted, and total LOC
svg_overwrite('dark_mode.svg', age_data, commit_data, star_data, repo_data, contrib_data, follower_data, total_loc[:-1])
svg_overwrite('light_mode.svg', age_data, commit_data, star_data, repo_data, contrib_data, follower_data, total_loc[:-1])
# move cursor to override 'Calculation times:' with 'Total function time:' and the total function time, then move cursor back
print('\033[F\033[F\033[F\033[F\033[F\033[F\033[F\033[F',
'{:<21}'.format('Total function time:'), '{:>11}'.format('%.4f' % (user_time + age_time + loc_time + commit_time + star_time + repo_time + contrib_time)),
' s \033[E\033[E\033[E\033[E\033[E\033[E\033[E\033[E', sep='')
print('Total GitHub GraphQL API calls:', '{:>3}'.format(sum(QUERY_COUNT.values())))
for funct_name, count in QUERY_COUNT.items(): print('{:<28}'.format(' ' + funct_name + ':'), '{:>6}'.format(count))