-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2375 from Holzhaus/qsscheck
Add qsscheck to CI pipeline and improve .travis.yml config
- Loading branch information
Showing
2 changed files
with
237 additions
and
42 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
#!/usr/bin/env python3 | ||
# -*- coding: utf-8 -*- | ||
import argparse | ||
import fnmatch | ||
import os | ||
import os.path | ||
import re | ||
import sys | ||
import tinycss.css21 | ||
import PyQt5.QtWidgets | ||
|
||
|
||
RE_CPP_CLASSNAME = re.compile(r'^\s*class\s+([\w_]+)') | ||
RE_CPP_OBJNAME = re.compile(r'setObjectName\(.*"([^"]+)"') | ||
RE_UI_OBJNAME = re.compile(r'<widget[^>]+name="([^"]+)"') | ||
RE_XML_OBJNAME = re.compile(r'<ObjectName>(.*)</ObjectName>') | ||
RE_XML_OBJNAME_SETVAR = re.compile( | ||
r'<SetVariable\s+name="ObjectName">(.*)</SetVariable>') | ||
RE_CLASSNAME = re.compile(r'^[A-Z]\w+$') | ||
RE_OBJNAME_VARTAG = re.compile(r'<.*>') | ||
|
||
|
||
def get_skins(path): | ||
"""Yields (skin_name, skin_path) tuples for each skin directory in path.""" | ||
for entry in os.scandir(path): | ||
if entry.is_dir(): | ||
yield entry.name, os.path.join(path, entry.name) | ||
|
||
|
||
def get_global_names(mixxx_path): | ||
"""Returns 2 sets with all class and object names in the Mixx codebase.""" | ||
classnames = set() | ||
objectnames = set() | ||
for root, dirs, fnames in os.walk(os.path.join(mixxx_path, 'src')): | ||
for fname in fnames: | ||
ext = os.path.splitext(fname)[1] | ||
if ext in ('.h', '.cpp'): | ||
fpath = os.path.join(root, fname) | ||
with open(fpath, mode='r') as f: | ||
for line in f: | ||
classnames.update(set(RE_CPP_CLASSNAME.findall(line))) | ||
objectnames.update(set(RE_CPP_OBJNAME.findall(line))) | ||
elif ext == '.ui': | ||
fpath = os.path.join(root, fname) | ||
with open(fpath, mode='r') as f: | ||
objectnames.update(set(RE_UI_OBJNAME.findall(f.read()))) | ||
return classnames, objectnames | ||
|
||
|
||
def get_skin_objectnames(skin_path): | ||
""" | ||
Yields all object names in the skin_path. | ||
Note the names may contain one or more <Variable name="x"> tags, so it's | ||
not enough to check if a name CSS object name is in this list using "in". | ||
""" | ||
for root, dirs, fnames in os.walk(skin_path): | ||
for fname in fnames: | ||
if os.path.splitext(fname)[1] != '.xml': | ||
continue | ||
|
||
fpath = os.path.join(root, fname) | ||
with open(fpath, mode='r') as f: | ||
for line in f: | ||
yield from RE_XML_OBJNAME.findall(line) | ||
yield from RE_XML_OBJNAME_SETVAR.findall(line) | ||
|
||
|
||
def get_skin_stylesheets(skin_path): | ||
"""Yields (qss_path, stylesheet) tuples for each qss file in skin_path).""" | ||
cssparser = tinycss.css21.CSS21Parser() | ||
for filename in os.listdir(skin_path): | ||
if os.path.splitext(filename)[1] != '.qss': | ||
continue | ||
qss_path = os.path.join(skin_path, filename) | ||
stylesheet = cssparser.parse_stylesheet_file(qss_path) | ||
yield qss_path, stylesheet | ||
|
||
|
||
def check_stylesheet(stylesheet, classnames, objectnames, objectnames_fuzzy): | ||
"""Yields (token, problem) tuples for each problem found in stylesheet.""" | ||
for rule in stylesheet.rules: | ||
if not isinstance(rule, tinycss.css21.RuleSet): | ||
continue | ||
for token in rule.selector: | ||
if token.type == 'IDENT': | ||
if not RE_CLASSNAME.match(token.value): | ||
continue | ||
if token.value in classnames: | ||
continue | ||
if token.value in dir(PyQt5.QtWidgets): | ||
continue | ||
yield (token, 'Unknown widget class "%s"' % token.value) | ||
|
||
elif token.type == 'HASH': | ||
value = token.value[1:] | ||
if value in objectnames: | ||
continue | ||
|
||
if any(fnmatch.fnmatchcase(value, objname) | ||
for objname in objectnames_fuzzy): | ||
continue | ||
|
||
yield (token, 'Unknown object name "%s"' % token.value) | ||
|
||
|
||
def check_skins(mixxx_path, skins, ignore_patterns=()): | ||
""" | ||
Yields error messages for skins using class/object names from mixxx_path. | ||
By providing a list of ignore_patterns, you can ignore certain class or | ||
object names (e.g. #Test, #*Debug). | ||
""" | ||
classnames, objectnames = get_global_names(mixxx_path) | ||
for skin_name, skin_path in sorted(skins): | ||
# If the skin objectname is something like 'Deck<Variable name="i">', | ||
# then replace it with 'Deck*' and use glob-like matching | ||
skin_objectnames = objectnames.copy() | ||
skin_objectnames_fuzzy = set() | ||
for objname in get_skin_objectnames(skin_path): | ||
new_objname = RE_OBJNAME_VARTAG.sub('*', objname) | ||
if '*' in new_objname: | ||
skin_objectnames_fuzzy.add(new_objname) | ||
else: | ||
skin_objectnames.add(new_objname) | ||
|
||
for qss_path, stylesheet in get_skin_stylesheets(skin_path): | ||
for error in stylesheet.errors: | ||
yield '%s:%d:%d: %s - %s' % ( | ||
qss_path, error.line, error.column, | ||
error.__class__.__name__, error.reason, | ||
) | ||
for token, message in check_stylesheet( | ||
stylesheet, classnames, | ||
skin_objectnames, skin_objectnames_fuzzy): | ||
if any(fnmatch.fnmatchcase(token.value, pattern) | ||
for pattern in ignore_patterns): | ||
continue | ||
yield '%s:%d:%d: %s' % ( | ||
qss_path, token.line, token.column, message, | ||
) | ||
|
||
|
||
def main(argv=None): | ||
"""Main method for handling command line arguments.""" | ||
parser = argparse.ArgumentParser('qsscheck', description='Check Mixxx QSS ' | ||
'stylesheets for non-existing ' | ||
'object/class names') | ||
parser.add_argument('-p', '--extra-skins-path', | ||
help='Additonal skin path, to check (.e.g. ' | ||
'"~/.mixxx/skins")') | ||
parser.add_argument('-s', '--skin', help='Only check skin with this name') | ||
parser.add_argument('-i', '--ignore', default='', | ||
help='Glob pattern of class/object names to ignore ' | ||
'(e.g. "#Test*"), separated by commas') | ||
parser.add_argument('mixxx_path', help='Path of Mixxx sources/git repo') | ||
args = parser.parse_args(argv) | ||
|
||
mixxx_path = args.mixxx_path | ||
|
||
skins_path = os.path.join(mixxx_path, 'res', 'skins') | ||
skins = set(get_skins(skins_path)) | ||
if args.extra_skins_path: | ||
skins.update(set(get_skins(args.extra_skins_path))) | ||
|
||
if args.skin: | ||
skins = set((name, path) for name, path in skins if name == args.skin) | ||
|
||
if not skins: | ||
print('No skins to check') | ||
return 1 | ||
|
||
status = 0 | ||
for message in check_skins(mixxx_path, skins, args.ignore.split(',')): | ||
print(message) | ||
status = 2 | ||
return status | ||
|
||
|
||
if __name__ == '__main__': | ||
sys.exit(main()) |