-
Notifications
You must be signed in to change notification settings - Fork 22
/
gen-set.py
executable file
·445 lines (315 loc) · 15 KB
/
gen-set.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
#!/usr/bin/env python3
# pylint: disable=docstring-first-line-empty
# we shouldn't change this default header
"""
" ScummVM - Graphic Adventure Engine
"
" ScummVM is the legal property of its developers, whose names
" are too numerous to list here. Please refer to the COPYRIGHT
" file distributed with this source distribution.
"
" This program is free software; you can redistribute it and/or
" modify it under the terms of the GNU General Public License
" as published by the Free Software Foundation; either version 2
" of the License, or (at your option) any later version.
"
" This program is distributed in the hope that it will be useful,
" but WITHOUT ANY WARRANTY; without even the implied warranty of
" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
" GNU General Public License for more details.
"
" You should have received a copy of the GNU General Public License
" along with this program; if not, write to the Free Software
" Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"
"""
# pylint: enable=docstring-first-line-empty
import argparse
import csv
import io
import os
import subprocess
import sys
import urllib.request
import xml.dom.minidom
import xml.etree.ElementTree as ElemTree
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Tuple, final, Set, AnyStr, List
from zipfile import ZipFile
@dataclass(frozen=True, eq=True) # immutable
class GUID:
"""GUID Data for XML generation"""
filename_root: str
gid: str
element_name: str
GUIDS: final(Set[GUID]) = {GUID(filename_root='games', gid='1775285192', element_name='game'),
GUID(filename_root='engines', gid='0', element_name='engine'),
GUID(filename_root='companies', gid='226191984', element_name='company'),
GUID(filename_root='series', gid='1095671818', element_name='serie')
}
MIN_PYTHON: final(Tuple[int]) = (3, 8) # min python version is 3.8
URL_HEAD: final = ("https://docs.google.com/spreadsheets/d/e/"
+ "2PACX-1vQamumX0p-DYQa5Umi3RxX-pHM6RZhAj1qvUP0jTmaqutN9FwzyriRSXlO9rq6kR60pGIuPvCDzZL3s"
+ "/pub?output=tsv")
URL_ICONS_LIST: final = 'https://downloads.scummvm.org/frs/icons/LIST'
ICON_DIR: final = 'icons'
ENCODING: final = 'utf-8'
ZIP_NAME_PREFIX: final = 'gui-icons-'
ZIP_NAME_EXTENSION: final = '.dat'
ZIP_DATE_FORMAT: final = '%Y%m%d'
LIST_NAME: final = 'LIST'
LIST_DELIM: final = ','
DATE_FORMAT: final = '%Y-%m-%d'
FIRST_HASH: final = 'b2a20aad85714e0fea510483007e5e96d84225ca'
ChangedFileSet = Set[str]
def main(last_update: datetime or None, last_hash: str, listfile_entries: List[str]) -> None:
"""Our main function.
:param last_update: datetime
An optional last_update datetime. Day + 1 after the last creation of icons.zip
If not present please provide last_hash
:param last_hash: str
The (newest) last_hash value of the LIST file. It is preferred to use this param.
:param listfile_entries: List[str]
When the LIST file is already read (finding last_hash) than we could reuse it.
"""
if last_update is None and last_hash is None:
print('Please provider either last_update or last_hash')
sys.exit(1)
# ### Step 1: Generating XMLs
xml_file_names = generate_xmls()
# ### Step 2: Creating a zip file with the changed icons (only icons directory!)
changed_icon_file_names = get_changed_icon_file_names(last_update, last_hash)
# ### Step 3: pack xmls / icons into one zip
new_iconsdat_name = write_iconsdat(list(changed_icon_file_names) + xml_file_names)
# ### Step 4: create new LIST file
new_listfile_name = write_new_listfile(new_iconsdat_name, listfile_entries)
print('\nPls upload/commit the new files:')
print('\t' + new_iconsdat_name)
print('\t' + new_listfile_name)
def generate_xmls() -> List[str]:
"""Generates the XMLs to be stored in the new zip file.
:return: a List of generated XML files.
"""
print('Step 1: generate XMLs')
xml_files: List[str] = []
for guid in GUIDS:
url = URL_HEAD + "&gid=" + guid.gid
print("Processing " + guid.filename_root + "... ", end="", flush=True)
root = ElemTree.Element(guid.filename_root)
with urllib.request.urlopen(url) as file:
output = csv.DictReader(io.StringIO(file.read().decode(ENCODING)), delimiter='\t')
for product in output:
product_xml = ElemTree.SubElement(root, guid.element_name)
for key, value in product.items():
# For the games sheet and its ids, only use the suffix after the colon
if key == 'id' and guid.filename_root == 'games':
value = value.partition(':')[2]
product_xml.set(key, value)
dom = xml.dom.minidom.parseString(ElemTree.tostring(root).decode(ENCODING))
# on win machines there could be an error without specifying utf-8
xml_file_name = guid.filename_root + ".xml"
with open(xml_file_name, "w", encoding=ENCODING) as file:
file.write(dom.toprettyxml())
xml_files.append(xml_file_name)
print("done")
return xml_files
def get_changed_icon_file_names(last_update: datetime, last_hash: str) -> ChangedFileSet:
"""Returns all changed ICON file names.
:param last_update: last update as datetime (hash is preferred)
:param last_hash: the hash of the last commit (stored in last entry of the LIST file)
:return: a ChangedFileSet with all changed icons.
"""
if last_hash:
print('\nStep 2: fetching changed icons using hash ' + last_hash)
last_iconsdat_date = None
else:
last_iconsdat_date = last_update.strftime(DATE_FORMAT)
print('\nStep 2: fetching changed icons since ' + last_iconsdat_date)
check_isscummvmicons_repo()
is_repo_uptodate()
if last_hash:
commit_hash = last_hash
else:
commit_hashes = get_commit_hashes(last_iconsdat_date)
# no changes nothing to do
if len(commit_hashes) < 1:
print('no new /changed icons since: ' + last_iconsdat_date)
sys.exit(1)
# last (sorted reverse!) commit_hash is sufficient
commit_hash = commit_hashes[0]
return collect_commit_file_names(commit_hash)
def write_new_listfile(new_iconsdat_name: str, listfile_entries: List[str]) -> str:
"""Writes a new LIST file.
:param new_iconsdat_name: the name of the new icons-dat file.
:param listfile_entries: the entries of the LIST file (if already read) - an empty list is Ok.
:return: the name of the LIST file written.
"""
print('\nStep 4: generating a new ' + LIST_NAME + ' file')
if len(listfile_entries) < 1:
tmp_listfile_entries = get_listfile_entries()
else:
print(LIST_NAME + ' already read - using given values')
tmp_listfile_entries = listfile_entries
last_commit_master = get_last_hash_from_master()
new_iconsdat_size = os.path.getsize(new_iconsdat_name)
tmp_listfile_entries.append(
new_iconsdat_name + LIST_DELIM + str(new_iconsdat_size) + LIST_DELIM + last_commit_master)
if os.path.exists(LIST_NAME):
print(LIST_NAME + ' exists - file will be overwritten')
print('writing new ' + LIST_NAME + ' entries...', end='', flush=True)
with open(LIST_NAME, mode='w', encoding=ENCODING) as outfile:
outfile.write('\n'.join(tmp_listfile_entries))
print('done')
return LIST_NAME
def get_last_hash_from_master() -> str:
"""Reads the last hash code from the origin/master.
:return: the hash of the latest commit.
"""
lines = run_git('rev-parse', 'HEAD')
if len(lines) < 1:
print('ERROR: no commit found')
sys.exit(1)
return lines[0].decode(ENCODING).rstrip()
def get_listfile_lasthash() -> Tuple[str, List[str]]:
"""Reads the LIST file and returns the last hash and the list of lines.
:return: A String with the last hash (from the LIST file) and a List containing all the lines of the LIST file.
"""
print('no inputDate argument - fetching last hash from ' + LIST_NAME + '... ', flush=True)
listfile_entries = get_listfile_entries()
last_entry_values = listfile_entries[-1].split(LIST_DELIM)
return last_entry_values[2], listfile_entries
def get_listfile_entries() -> List[str]:
"""Reads and returns all lines / entries of the LIST file.
:return: a List of strings with the content of the LIST file.
"""
print('reading existing ' + LIST_NAME + ' entries...', end='', flush=True)
with urllib.request.urlopen(URL_ICONS_LIST) as file:
output = file.read().decode(ENCODING).splitlines()
print('done')
return output
def check_isscummvmicons_repo() -> None:
"""Different checks for the local repo - will quit() the script if there is any error."""
print('checking local directory is scummvm-icons repo ... ', end='', flush=True)
output_show_origin = run_git('remote', 'show', 'origin')
if not is_any_git_repo(output_show_origin):
print('error')
print('not a git repository (or any of the parent directories)')
sys.exit(1)
# wrong repo
if not is_scummvmicons_repo(output_show_origin):
print('error')
print('local folder is not a scummvm-icons git repo')
sys.exit(1)
print('done')
def is_scummvmicons_repo(output_showorigin: List[AnyStr]) -> bool:
""" Checks if the local repo is a scummvm-icons repo"""
# should be the correct repo
if any('Fetch URL: https://github.com/scummvm/scummvm-icons' in line.decode(ENCODING)
for line in output_showorigin):
return True
return False
def is_any_git_repo(output_showorigin: List[AnyStr]) -> bool:
"""Checks if the local folder belongs to a git repo.
:param output_showorigin: The output of 'show origin'.
:return: True if it is a git repo
"""
# outside of any local git repo
if any('fatal: not a git repository' in line.decode(ENCODING) for line in output_showorigin):
return False
return True
def is_repo_uptodate() -> bool:
"""Checks if the local repo is up to date.
:return: True if the local repo is up-to-date
"""
# ### check local repo is up to date
print('checking local repo is up to date...', end='', flush=True)
if len(run_git('fetch', '--dry-run')) > 0:
print('warning')
print('fetch with changes - make sure that your local branch is up to date')
return False
# second variant of check
run_git('update-index', '--refresh', '--unmerged')
if len(run_git('diff-index', '--quiet', 'HEAD')) > 0:
print('warning')
print('fetch with changes - make sure that your local branch is up to date')
return False
print('done')
return True
def get_commit_hashes(last_icondat_date: str) -> List[str]:
"""Collects all commit hashes since a given date.
:param last_icondat_date: last icon-dat generation date.
:return: all commits since last_icondat_date.
"""
commit_hashes: List[str] = []
# using log with reverse to fetch the commit_hashes
for commit_lines in run_git('log', '--reverse', '--oneline', "--since='" + last_icondat_date + "'"):
# split without sep - runs of consecutive whitespace are regarded as a single separator
commit_hashes.append(commit_lines.decode(ENCODING).split(maxsplit=1)[0])
return commit_hashes
def collect_commit_file_names(commit_hash: str) -> ChangedFileSet:
"""Collects all filnames (icons) from a git commit.
:param commit_hash: the hash of the git commit.
:return: all changed icons (from the 'icons' directory)
"""
changed_file_set: Set[str] = set() # set, no duplicates
print('fetching file names for commit:' + commit_hash + ' ... ', end='', flush=True)
for file in run_git('diff', '--name-only', commit_hash + '..'):
# stdout will contain bytes - convert to utf-8 and strip cr/lf if present
git_file_name = file.decode(ENCODING).rstrip()
if git_file_name.startswith(ICON_DIR + '/') or git_file_name.startswith(ICON_DIR + 'icons\\'):
# build local path with a defined local folder / sanitize filenames
local_path = '.' + os.path.sep + ICON_DIR + os.path.sep + Path(git_file_name).name
# file must exist / running from wrong path would result in non-existing files
if os.path.exists(local_path):
changed_file_set.add(local_path)
else:
print('WARNING: file "' + local_path + '" is not in local repo - deleted? ')
print('done')
print(f'icons (files) changed: {len(changed_file_set)}')
return changed_file_set
def write_iconsdat(changed_files: List[str]) -> str:
"""Creates a new file (will overwrite existing files) packing all changed_files into it.
:param changed_files: The changes files (icons) for the new icons-dat file.
:return: a string with the name of the created zip (icons-dat) file.
"""
print('\nStep 3: generating a new zip file...')
# using today for the zip name
today = date.today()
zip_name = ZIP_NAME_PREFIX + today.strftime(ZIP_DATE_FORMAT) + ZIP_NAME_EXTENSION
if os.path.exists(zip_name):
print(zip_name + ' exists - file will be overwritten')
print('creating zip ' + zip_name + '... ', end='', flush=True)
with ZipFile(zip_name, mode='w', compresslevel=9) as new_entries:
for changed_file in changed_files:
new_entries.write(changed_file)
print('done')
return zip_name
def run_git(*git_args) -> List[AnyStr]:
"""Executes a git command and returns the stdout (as Line[AnyStr])
:param *git_args: A string, or a sequence of program arguments.
:return: The StdOut as List[AnyStr]
"""
my_env = os.environ.copy() # copy current environ
my_env["LANG"] = "C" # add lang C
with subprocess.Popen(args=['git'] + list(git_args), stdout=subprocess.PIPE, env=my_env) as child_proc:
return child_proc.stdout.readlines()
###########
if sys.version_info < MIN_PYTHON:
sys.exit(f"Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or later is required.\n")
# check args / get date
argParser = argparse.ArgumentParser(usage='%(prog)s [lastUpdate]')
argParser.add_argument('lastUpdate', help='last update - date format: yyyymmdd', default=argparse.SUPPRESS, nargs='?')
args = argParser.parse_args()
# optional param, if not present fetch last_update from the LIST file
if 'lastUpdate' in args:
arg_last_update = datetime.strptime(args.lastUpdate, '%Y%m%d')
print('using provided inputDate: ' + arg_last_update.strftime(DATE_FORMAT) + '\n')
# we have to read the LIST later (if needed)
main(arg_last_update, "", [])
else:
arg_last_hash, arg_listfile_entries = get_listfile_lasthash()
print('using last hash from ' + LIST_NAME + ': ' + arg_last_hash + '\n')
# listfile_entries as param, no need the read the LIST file twice
main(None, arg_last_hash, arg_listfile_entries)