summaryrefslogtreecommitdiffstats
path: root/src/silfont/scripts/psfrunfbchecks.py
blob: 69ff1920fa0c88f207d81ba41686cd961c4ac6bb (plain)
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
#!/usr/bin/env python3
'''Run Font Bakery tests using a standard profile with option to specify an alternative profile
It defaults to ttfchecks.py - ufo checks are not supported yet'''
__url__ = 'https://github.com/silnrsi/pysilfont'
__copyright__ = 'Copyright (c) 2020 SIL International (https://www.sil.org)'
__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)'
__author__ = 'David Raymond'

import glob, os, csv

from textwrap import TextWrapper

# Error message for users installing pysilfont manually
try:
    import fontbakery
except ImportError:
    print("\nError: Fontbakery is not installed by default, type pip3 install fontbakery[all]\n")
else:
    from fontbakery.reporters.serialize import SerializeReporter
    from fontbakery.reporters.html import HTMLReporter
    from fontbakery.checkrunner import distribute_generator, CheckRunner, get_module_profile
    from fontbakery.status import PASS, FAIL, WARN, ERROR, INFO, SKIP
    from fontbakery.configuration import Configuration
    from fontbakery.commands.check_profile import get_module
    from fontbakery import __version__ as version

from silfont.core import execute

argspec = [
    ('fonts',{'help': 'font(s) to run checks against; wildcards allowed', 'nargs': "+"}, {'type': 'filename'}),
    ('--profile', {'help': 'profile to use instead of Pysilfont default'}, {}),
    ('--html', {'help': 'Write html report to htmlfile', 'metavar': "HTMLFILE"}, {}),
    ('--csv',{'help': 'Write results to csv file'}, {'type': 'filename', 'def': None}),
    ('-F', '--full-lists',{'help': "Don't truncate lists of items" ,'action': 'store_true', 'default': False}, {}),
    ('--ttfaudit', {'help': 'Compare the list of ttf checks in pysilfont with those in Font Bakery and output a csv to "fonts". No checks are actually run',
     'action': 'store_true', 'default': False}, {}),
    ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_runfbchecks.log'})]

def doit(args):
    global version
    v = version.split(".")
    version = f'{v[0]}.{v[1]}.{v[2]}' # Set version to just the number part - ie without .dev...

    logger = args.logger
    htmlfile = args.html

    if args.ttfaudit: # Special action to compare checks in profile against check_list values
        audit(args.fonts, logger) # args.fonts used as output file name for audit
        return

    if args.csv:
        try:
            csvfile = open(args.csv, 'w')
            csvwriter = csv.writer(csvfile)
            csvlines = []
        except Exception as e:
            logger.log("Failed to open " + args.csv + ": " + str(e), "S")
    else:
        csvfile = None

    # Process list of fonts supplied, expanding wildcards using glob if needed
    fonts = []
    fontstype = None
    for pattern in args.fonts:
        for fullpath in glob.glob(pattern):
            ftype = fullpath.lower().rsplit(".", 1)[-1]
            if ftype == "otf": ftype = "ttf"
            if ftype not in ("ttf", "ufo"):
                logger.log("Fonts must be OpenType or UFO - " + fullpath + " invalid", "S")
            if fontstype is None:
                fontstype = ftype
            else:
                if ftype != fontstype:
                    logger.log("All fonts must be of the same type - both UFO and ttf/otf fonts supplied", "S")
            fonts.append(fullpath)

    if fonts == [] : logger.log("No files match the filespec provided for fonts: " + str(args.fonts), "S")

    # Find the main folder name for ttf files - strips "results" if present
    (path, ttfdir) = os.path.split(os.path.dirname(fonts[0]))
    if ttfdir == ("results"): ttfdir = os.path.basename(path)

    # Create the profile object
    if args.profile:
        proname = args.profile
    else:
        if fontstype == "ttf":
            proname = "silfont.fbtests.ttfchecks"
        else:
            logger.log("UFO fonts not yet supported", "S")

    try:
        module = get_module(proname)
    except Exception as e:
        logger.log("Failed to import profile: " + proname + "\n" + str(e), "S")

    profile = get_module_profile(module)
    profile.configuration_defaults = {
        "com.google.fonts/check/file_size": {
            "WARN_SIZE": 1 * 1024 * 1024,
            "FAIL_SIZE": 9 * 1024 * 1024
        }
    }

    psfcheck_list = module.psfcheck_list

    # Create the runner and reporter objects, then run the tests
    configuration = Configuration(full_lists = args.full_lists)
    runner = CheckRunner(profile, values={
        "fonts": fonts, 'ufos': [], 'designspaces': [], 'glyphs_files': [], 'readme_md': [], 'metadata_pb': []}
                         , config=configuration)

    if version == "0.8.6":
        sr = SerializeReporter(runner=runner) # This produces results from all the tests in sr.getdoc for later analysis
    else:
        sr = SerializeReporter(runner=runner, loglevels = [INFO]) # loglevels was added with 0.8.7
    reporters = [sr.receive]

    if htmlfile:
        hr = HTMLReporter(runner=runner, loglevels = [SKIP])
        reporters.append(hr.receive)

    distribute_generator(runner.run(), reporters)

    # Process the results
    results = sr.getdoc()
    sections = results["sections"]

    checks = {}
    maxname = 11
    somedebug = False
    overrides = {}
    tempoverrides = False

    for section in sections:
        secchecks = section["checks"]
        for check in secchecks:
            checkid = check["key"][1][17:-1]
            fontfile = check["filename"] if "filename" in check else "Family-wide"
            path, fontname = os.path.split(fontfile)
            if fontname not in checks:
                checks[fontname] = {"ERROR": [], "FAIL": [], "WARN": [], "INFO": [], "SKIP": [], "PASS": [], "DEBUG": []}
                if len(fontname) > maxname: maxname = len(fontname)
            status = check["result"]
            if checkid in psfcheck_list:
                # Look for status overrides
                (changetype, temp) = ("temp_change_status", True) if "temp_change_status" in psfcheck_list[checkid]\
                    else ("change_status", False)
                if changetype in psfcheck_list[checkid]:
                    change_status = psfcheck_list[checkid][changetype]
                    if status in change_status:
                        reason = change_status["reason"] if "reason" in change_status else None
                        overrides[fontname + ", " + checkid] = (status + " to " + change_status[status], temp, reason)
                        if temp: tempoverrides = True
                        status = change_status[status] ## Should validate new status is one of FAIL, WARN or PASS
            checks[fontname][status].append(check)
            if status == "DEBUG": somedebug = True

    if htmlfile:
        logger.log("Writing results to " + htmlfile, "P")
        with open(htmlfile, 'w') as hfile:
            hfile.write(hr.get_html())

    fbstats   = ["ERROR", "FAIL", "WARN", "INFO", "SKIP", "PASS"]
    psflevels = ["E",     "E",    "W",    "I",    "I",    "V"]
    if somedebug: # Only have debug column if some debug statuses are present
        fbstats.append("DEBUG")
        psflevels.append("W")
    wrapper = TextWrapper(width=120, initial_indent="   ", subsequent_indent="   ")
    errorcnt = 0
    failcnt = 0
    summarymess = "Check status summary:\n"
    summarymess += "{:{pad}}ERROR  FAIL  WARN  INFO  SKIP  PASS".format("", pad=maxname+4)
    if somedebug: summarymess += "  DEBUG"
    fontlist = list(sorted(x for x in checks if x != "Family-wide")) # Alphabetic list of fonts
    if "Family-wide" in checks: fontlist.append("Family-wide") # Add Family-wide last
    for fontname in fontlist:
        summarymess += "\n  {:{pad}}".format(fontname, pad=maxname)
        for i, status in enumerate(fbstats):
            psflevel = psflevels[i]
            checklist = checks[fontname][status]
            cnt = len(checklist)
            if cnt > 0 or status != "DEBUG": summarymess += "{:6d}".format(cnt) # Suppress 0 for DEBUG
            if cnt:
                if status == "ERROR": errorcnt += cnt
                if status == "FAIL": failcnt += cnt
                messparts = ["Checks with status {} for {}".format(status, fontname)]
                for check in checklist:
                    checkid = check["key"][1][17:-1]
                    csvline = [ttfdir, fontname, check["key"][1][17:-1], status, check["description"]]
                    messparts.append(" > {}".format(checkid))
                    for record in check["logs"]:
                        message = record["message"]
                        if record["status"] != status: message = record["status"] + " " + message
                        messparts += wrapper.wrap(message)
                        csvline.append(message)
                    if csvfile: csvlines.append(csvline)
                logger.log("\n".join(messparts) , psflevel)
    if csvfile: # Output to csv file, worted by font then checkID
        for line in sorted(csvlines, key = lambda x: (x[1],x[2])): csvwriter.writerow(line)
    if overrides != {}:
        summarymess += "\n  Note: " + str(len(overrides)) + " Fontbakery statuses were overridden - see log file for details"
        if tempoverrides: summarymess += "\n        ******** Some of the overrides were temporary overrides ********"
    logger.log(summarymess, "P")

    if overrides != {}:
        for oname in overrides:
            override = overrides[oname]
            mess = "Status override for " + oname + ": " + override[0]
            if override[1]: mess += " (Temporary override)"
            logger.log(mess, "W")
            if override[2] is not None: logger.log("Override reason: " + override[2], "I")

    if errorcnt + failcnt > 0:
        mess = str(failcnt) + " test(s) gave a status of FAIL" if failcnt > 0 else ""
        if errorcnt > 0:
            if failcnt > 0: mess += "\n                              "
            mess += str(errorcnt) + " test(s) gave a status of ERROR which means they failed to execute properly." \
                                    "\n                              " \
                                    "   ERROR probably indicates a software issue rather than font issue"
        logger.log(mess, "E")

def audit(fonts, logger):
    if len(fonts) != 1: logger.log("For audit, specify output csv file instead of list of fonts", "S")
    csvname = fonts[0]
    from silfont.fbtests.ttfchecks import all_checks_dict
    missingfromprofile=[]
    missingfromchecklist=[]
    checks = all_checks_dict()
    logger.log("Opening " + csvname + " for audit output csv", "P")
    with open(csvname, 'w', newline='') as csvfile:
        csvwriter = csv.writer(csvfile, dialect='excel')
        fields = ["id", "psfaction", "section", "description", "rationale", "conditions"]
        csvwriter.writerow(fields)

        for checkid in checks:
            check = checks[checkid]
            row = [checkid]
            for field in fields:
                if field != "id": row.append(check[field])
            if check["section"] == "Missing": missingfromprofile.append(checkid)
            if check["psfaction"] == "Not in psfcheck_list": missingfromchecklist.append(checkid)
            csvwriter.writerow(row)
    if missingfromprofile != []:
        mess = "The following checks are in psfcheck_list but not in the ttfchecks.py profile:"
        for checkid in missingfromprofile: mess += "\n                                " + checkid
        logger.log(mess, "E")
    if missingfromchecklist != []:
        mess = "The following checks are in the ttfchecks.py profile but not in psfcheck_list:"
        for checkid in missingfromchecklist: mess += "\n                                " + checkid
        logger.log(mess, "E")

    return

def cmd(): execute(None, doit, argspec)
if __name__ == "__main__": cmd()