-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathchangelog-checker.py
502 lines (415 loc) · 15.9 KB
/
changelog-checker.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
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
#!/usr/bin/python3
# Copyright (c) 2019,2020 adamius.dev@gmail.com, MIT licensed
# regexes can do this. but why do it that way when you can demonstrate to the class a much longer way?!
import sys
import os
import zipfile
import tempfile
from collections import defaultdict
HEADER="-"*99
VERSION_HEADER_TEMPLATES = [HEADER+"\n",HEADER+"\r",HEADER+"\r\n"]
FIELDS="Version","Date"
zorro_mode=False
line_classification=[] # linenum, classification, linetext
def strip_crlf(line):
return line.replace("\r","").replace("\n","")
def count_text(s,needle):
count=0
for c in s :
if c == needle:
count +=1
return count
deferredoutput=[]
def stderr_level_line(text):
global deferredoutput
deferredoutput.append(text)
def stderr_level_line_sort_rule(line):
l_level = line.split(" ")[0]
l_linenum = int(line.split(":")[0].split(" ")[3])
return l_linenum+ord(l_level[0])/1000.
def get_stderr_level_line():
global deferredoutput
# no duplicate output messages
unique = {}
for line in deferredoutput:
unique[line]=1
deferredoutput=[]
for line in unique :
deferredoutput.append(line)
deferredoutput.sort(key=stderr_level_line_sort_rule)
return "".join([x.replace("Line # 0 : ","") for x in deferredoutput])
from collections import Counter
counters=Counter()
linenum=0
def output(level,message,file=False,overridelinenum=None):
global count_errors, count_warnings, count_info, linenum
counters[level]+=1
if level == "ZORRO":
parts=message.split("|")
classification = parts[0].strip()
text = message[len(classification):]
line_classification.append((linenum,classification,text))
if zorro_mode :
if file :
stderr_level_line("%s Line # 0 : %s\n" % (level,message))
else :
if overridelinenum :
stderr_level_line("%s Line # %05d : %s\n" % (level,overridelinenum,message))
else :
stderr_level_line("%s Line # %05d : %s\n" % (level,linenum,message))
else :
if level == "ZORRO":
return
if file :
stderr_level_line("%s Line # 0 : %s\n" % (level,message))
else :
if overridelinenum :
stderr_level_line("%s Line # %d : %s\n" % (level,overridelinenum,message))
else :
stderr_level_line("%s Line # %d : %s\n" % (level,linenum,message))
def best_guess(line):
global counters
if count_text(line,"-")>3:
output("ERROR","header must contain 99 dashes")
return
if line.strip()==":":
output("ERROR","category or field missing")
return
# end of the line, give up
output("ERROR","Unrecognised line.")
output("DATA",line.replace("\n",""))
def verify_category(line):
global counters
spaces=count_text(line[0:line.find(line.strip())]," ")
if spaces != 2 :
output("ERROR","category MUST have 2 spaces, eg ' Bugfixes:'")
counters["expect_category"]-=1
# any text after category on same line?
parts=line.replace("\n","").replace("\r","").split(":")
if parts[1]:
if parts[1].strip():
output("ERROR","category should not have anything after the colon.")
else :
output("ERROR","category should not have spaces after the colon.")
version_fields=[]
def verify_field(line):
global counters
global version_fields
if line.lstrip() != line :
output("ERROR","Fields can't be indented.")
if line.strip().startswith("Version:"):
version_fields.append((linenum,line.strip()))
if line.strip() in counters :
output("ERROR","Can't have duplicate versions listed.")
counters[line.strip()]+=1 # record this particular version
parts = line.lstrip().replace("\r","").replace("\n","").split(":")
if parts[1].startswith(" "):
pass
else :
output("ERROR","Version: must have a space between the colon and the version specifier")
if parts[1].strip():
pass
else :
output("ERROR","No actual version specified.")
counters["version_fields_found"]+=1
counters["expect_version_field"]-=1
return
if line.strip().startswith("Date:"):
parts = line.strip().split(":")
if parts[1].strip():
pass
else :
output("ERROR","No actual date specified.")
def verify_item(line):
spaces=count_text(line[0:line.find("-")]," ")
if spaces < 4 or spaces==5:
output("ERROR","item MUST have 4 spaces before its dash.")
if line.strip()=="-":
output("ERROR","item line contains no text after the dash")
def verify_continuation(line):
if line.startswith(" "):
pass
else :
if (len(line)-len(line.lstrip()))<6 :
output("ERROR","Continuation lines MUST have at least 6 spaces before them.")
def verify_previous_version() :
global counters
if counters["got_header"]==0:
if counters["expect_version_field"]<1 or counters["expect_field"]<1 or counters["expect_category"]<1 or counters["expect_item"]<1 :
output("ERROR","a changelog MUST have a valid header",False,counters["previous_version_linenum"])
return
# make sure it had a Version field
if counters["expect_version_field"]==1:
output("ERROR","a change MUST have a version.",False,counters["previous_version_linenum"])
return
if counters["expect_version_field"]<0:
if counters["previous_version_linenum"]!=1 :
output("ERROR","a change MUST have only one version.",False,counters["previous_version_linenum"])
return
# make sure it had a category
if counters["expect_category"]==1:
output("ERROR","a change MUST have some category.",False,counters["previous_version_linenum"])
return
# make sure it had a item
if counters["expect_item"]==1:
output("ERROR","a change MUST have an item.",False,counters["previous_version_linenum"])
return
pass
numbers="1234567890"
if False :
def remove_numerics(t):
def empty_if_numeric(c):
if c in numbers :
return ""
else :
return
return "".join([empty_if_numeric(x) for x in t])
def version_text_to_number(t):
if not t :
return 0
# x -> 0
# a -> 0
# 1x ->
# 1a ->
changes=1
s=t[:]
# end of line text is ignored
while changes==0:
changes=0
if s[-1] not in numbers :
s=s[0:-1]
changes+=1
return int(s)
def effective_version(t):
print("--")
print(t)
v=[str(version_text_to_number(x)) for x in t.split(".")]
return ".".join(v)
print(effective_version("0.0.1"))
print(effective_version("0.0.x"))
print(effective_version("0.0.1x"))
print(effective_version("0.x.1"))
print(effective_version("0.1x.1"))
print(effective_version("1.x.1"))
print(effective_version("0x.x.1"))
print(effective_version("x1.x.1"))
print(effective_version("x0.x.1"))
def verify_discovered_version_numbers():
global version_fields
v=Counter()
def emptylist(): return []
duplicates=defaultdict(emptylist)
# check the versions we've found
for vf_linenum,vf_version in version_fields :
#print(vf_linenum,vf_version)
s=vf_version.replace("Version:","").strip()
# find what is not numbers
debris=s[:]
scrub=numbers+". "
for ch in scrub :
debris=debris.replace(ch,"")
if debris:
output("INFO","Non-numeric version identifier found. Letters such as x will be interpreted by Factorio as zeroes in confusing ways.",False,vf_linenum)
duplicates[vf_version].append(vf_linenum)
# find duplicate versions
for version in duplicates :
if len(duplicates[version])>1:
found = ", ".join(["%d" % x for x in duplicates[version]])
for vf_linenum in duplicates[version]:
output("INFO",version+" found in lines: %s" % found,False,vf_linenum)
def process_changelog_file(filepath,description=""):
global counters,linenum
global version_fields
global deferredoutput
global line_classification
deferredoutput=[]
version_fields=[]
counters=Counter()
linenum=1
if description :
sys.stderr.write("\nprocessing: %s\n examining changelog at: %s\n" % (description,filepath))
else:
sys.stderr.write("\nprocessing: %s\n" % filepath)
issues=0
versions=[]
try :
f=open(filepath,"r")
except : # TODO increase care factor. perhaps handle exceptions from open... more civilised.
if os.path.isfile(filepath) :
output("ERROR","Unable to open file",True)
f=None
if os.path.isdir(filepath):
output("ERROR","Unable to open a folder",True)
f=None
output("WARNING","File not found",True)
f=None
if f:
within_item = False
# predefine the "previous" version
counters["got_header"]=0
counters["expect_version_field"]=1
counters["expect_field"]=1
counters["expect_category"]=1
counters["expect_item"]=1
counters["previous_version_linenum"]=1
for line in f:
# classify the line
if line == "" :
output("ZORRO", "BLANK LINE |"+strip_crlf(line))
elif "\t" in line : ##### Tab characters in line
output("ZORRO", "ILLEGAL CHARS |"+strip_crlf(line))
output("ERROR","No tab characters allowed.")
elif strip_crlf(line)==" ":
if not within_item :
output("ZORRO", "ITEM |"+strip_crlf(line))
output("ERROR", "Blank continuation line without a item before it")
else:
output("ZORRO", "BLANK LINE |"+strip_crlf(line))
elif line.strip() == "": ##### Empty line
output("ZORRO", "BLANK LINE |"+strip_crlf(line))
if " " in line :
output("ERROR","empty lines MUST actually be empty (no spaces).")
if counters["expect_version_field"]==1 :
output("ERROR","empty lines can't occur before the Version: field.")
elif line.strip()=="-" : ##### Item (defective line)
output("ZORRO", "ITEM |"+strip_crlf(line))
within_item = True # item
verify_item(line)
counters["expect_item"]-=1
elif line.strip().startswith("- ") : ##### Item
output("ZORRO", "ITEM |"+strip_crlf(line))
within_item = True # item
verify_item(line)
counters["expect_item"]-=1
elif line.startswith(" ") and line.strip() and line.strip()[0]!="-" and line.strip()[-1]!=":":
output("ZORRO", "ITEM |"+strip_crlf(line))
if not within_item :
output("ERROR","Continuation line without a item before it")
verify_continuation(line)
elif line.startswith(" ") and ":" in line: ##### Category
within_item = False
if line.strip().replace(":","") in FIELDS : # catch Version: or Date: as on line by themselves with no value after
output("ZORRO", "FIELD |"+strip_crlf(line))
verify_field(line)
else :
if line.startswith(" "): # catch category and verify
output("ZORRO","CATEGORY |"+strip_crlf(line))
verify_category(line)
else :
output("ZORRO","FIELD |"+strip_crlf(line))
verify_field(line) # catch a non-listed field with no value
elif ": " in line : ##### Field
for fieldname in FIELDS :
if line.strip().startswith(fieldname+":"):
output("ZORRO","FIELD |"+strip_crlf(line))
verify_field(line)
elif line in VERSION_HEADER_TEMPLATES: ##### header line for the version
output("ZORRO","VERSION HEADER|"+strip_crlf(line))
within_item = False # item
verify_previous_version()
counters["previous_version_linenum"]=linenum+1
counters["got_header"]=1
counters["expect_version_field"]=1
counters["expect_field"]=1
counters["expect_category"]=1
counters["expect_item"]=1
counters["change entries"]+=1
elif line.startswith(" "): ##### Continuation line
output("ZORRO","ITEM |"+line.strip())
verify_continuation(line)
else :
##### catchall, this tool has no clue what to do with this line
output("ZORRO","UNKNOWN |"+line.strip())
best_guess(line)
linenum+=1
verify_previous_version()
if counters["change entries"]==0 :
output("ERROR","No change entries found",True)
if counters["version_fields_found"] != counters["change entries"] :
output("WARNING","Number of Version fields does not match the number of dash separators",True)
verify_discovered_version_numbers()
# summarise
sys.stderr.write(get_stderr_level_line())
sys.stderr.write("Errors: %d, warnings: %d, information: %d\n" % (counters["ERROR"], counters["WARNING"], counters["INFO"]))
def process_zip_file(filepath):
if not zipfile.is_zipfile(filepath):
output("ERROR","Not actually a zip file",True)
return
try :
folder_from_filename = os.path.split(filepath)[1].replace(".zip","").replace(".ZIP","")
except :
output("DEBUG","Unlikely error in process_zip_file")
return
# create a temp folder
logtempfolder = tempfile.mkdtemp()
with zipfile.ZipFile(filepath) as z:
# try the root folder of the zip
try :
z.extract("changelog.txt",logtempfolder)
expandedfilepath=logtempfolder+os.sep+"changelog.txt"
except KeyError :
# open the subfolder with same name as zip and try again
try :
z.extract("%s/changelog.txt" % folder_from_filename,logtempfolder)
expandedfilepath=logtempfolder+os.sep+folder_from_filename+os.sep+"changelog.txt"
except KeyError:
output("ERROR","Unable to find a changelog inside the ZIP file.",True)
expandedfilepath=None
if expandedfilepath :
if os.path.isfile(expandedfilepath):
process_changelog_file(expandedfilepath,filepath)
def process(filepath):
extension = filepath.lower()
# if its name is changelog.txt then
if extension.endswith(".txt"):
process_changelog_file(filepath)
return
if extension.endswith(".zip"):
process_zip_file(filepath)
return
print(filepath,"+++out of cheese+++")
if __name__=="__main__":
args = sys.argv[:]
scriptname = args.pop(0)
if len(scriptname.split(os.sep))>1:
scriptname = os.path.split(scriptname)[1]
if len(args)==0:
args=["-h"]
modfolderpath = ""
filenames = []
check_mods_folder = False
check_individual_files = False
while args :
cmd = args.pop(0)
if cmd == "--mods-folder":
modfolderpath = args.pop(0)
check_mods_folder = True
elif cmd == "-f" or cmd == "--changelog":
filenames.append(args.pop(0))
check_individual_files=True
elif cmd == "--zorro":
zorro_mode=True
elif cmd == "--help" or cmd == "-h":
sys.stderr.write("\nchangelog-checker.py\nadamius 2019 https://mods.factorio.com/mod/da-changelog-tools \nMIT Licensed.\n\n")
sys.stderr.write("%s [--mods-folder <folder path>] [--changelog <filepath>]\n\n" % scriptname)
sys.stderr.write("--mods-folder <folder path>\n scan a folder as if it contains multiple subfolders or zip files. each subfolder or zip has a single mod.\n\n")
sys.stderr.write("--changelog\n specify a particular filepath and treat is as a changelog.txt or a zipfile with a changelog.txt inside it.\n")
sys.stderr.write("--zorro\n show tool's line classification. Cuts to the question of why a particular message is appearing.\n")
sys.stderr.write("\nMultiple --changelog pairs can be put on same command line to allow checking multiple individual files. eg --changelog mod1.zip --changelog mod2.zip\n")
sys.stderr.write("-f is an alias for --changelog\n")
sys.stderr.write("-h is an alias for --help\n")
sys.exit(0)
else :
sys.stderr.write("unknown command: %s\n" % cmd)
sys.exit(1)
if check_mods_folder :
for name in os.listdir(modfolderpath):
if os.path.isdir(modfolderpath+os.sep+name):
process(modfolderpath+os.sep+name+os.sep+"changelog.txt")
else :
if name.lower().endswith(".zip") :
process(modfolderpath+os.sep+name)
# --changelog
if check_individual_files :
for filename in filenames :
process(filename)