| [26334] | 1 | #!/usr/bin/env python3
|
|---|
| 2 | from argparse import ArgumentParser
|
|---|
| 3 | from pathlib import Path
|
|---|
| 4 | from os.path import sep, join, realpath, exists, basename, dirname
|
|---|
| 5 | from json import load, loads
|
|---|
| 6 | from re import split, match
|
|---|
| [26350] | 7 | from logging import getLogger, StreamHandler, INFO, WARNING, Filter, Formatter
|
|---|
| [26334] | 8 | import lxml.etree
|
|---|
| [26350] | 9 | import sys
|
|---|
| [26334] | 10 |
|
|---|
| [26350] | 11 | class SingleLevelFilter(Filter):
|
|---|
| 12 | def __init__(self, passlevel, reject):
|
|---|
| 13 | self.passlevel = passlevel
|
|---|
| 14 | self.reject = reject
|
|---|
| 15 |
|
|---|
| 16 | def filter(self, record):
|
|---|
| 17 | if self.reject:
|
|---|
| 18 | return (record.levelno != self.passlevel)
|
|---|
| 19 | else:
|
|---|
| 20 | return (record.levelno == self.passlevel)
|
|---|
| 21 |
|
|---|
| [26334] | 22 | class VFS_File:
|
|---|
| 23 | def __init__(self, mod_name, vfs_path):
|
|---|
| 24 | self.mod_name = mod_name
|
|---|
| 25 | self.vfs_path = vfs_path
|
|---|
| 26 |
|
|---|
| 27 | class RelaxNGValidator:
|
|---|
| 28 | def __init__(self, vfs_root, mods=None, verbose=False):
|
|---|
| 29 | self.mods = mods if mods is not None else []
|
|---|
| 30 | self.vfs_root = Path(vfs_root)
|
|---|
| 31 | self.__init_logger
|
|---|
| 32 | self.verbose = verbose
|
|---|
| 33 |
|
|---|
| 34 | @property
|
|---|
| 35 | def __init_logger(self):
|
|---|
| 36 | logger = getLogger(__name__)
|
|---|
| 37 | logger.setLevel(INFO)
|
|---|
| 38 | # create a console handler, seems nicer to Windows and for future uses
|
|---|
| [26350] | 39 | ch = StreamHandler(sys.stdout)
|
|---|
| [26334] | 40 | ch.setLevel(INFO)
|
|---|
| 41 | ch.setFormatter(Formatter('%(levelname)s - %(message)s'))
|
|---|
| [26350] | 42 | f1 = SingleLevelFilter(INFO, False)
|
|---|
| 43 | ch.addFilter(f1)
|
|---|
| [26334] | 44 | logger.addHandler(ch)
|
|---|
| [26350] | 45 | errorch = StreamHandler(sys.stderr)
|
|---|
| 46 | errorch.setLevel(WARNING)
|
|---|
| 47 | errorch.setFormatter(Formatter('%(levelname)s - %(message)s'))
|
|---|
| 48 | logger.addHandler(errorch)
|
|---|
| [26334] | 49 | self.logger = logger
|
|---|
| 50 |
|
|---|
| 51 | def run (self):
|
|---|
| 52 | self.validate_actors()
|
|---|
| 53 | self.validate_variants()
|
|---|
| 54 | self.validate_guis()
|
|---|
| 55 | self.validate_maps()
|
|---|
| 56 | self.validate_materials()
|
|---|
| 57 | self.validate_particles()
|
|---|
| 58 | self.validate_simulation()
|
|---|
| 59 | self.validate_soundgroups()
|
|---|
| 60 | self.validate_terrains()
|
|---|
| 61 | self.validate_textures()
|
|---|
| 62 |
|
|---|
| 63 | def main(self):
|
|---|
| 64 | """ Program entry point, parses command line arguments and launches the validation """
|
|---|
| 65 | # ordered uniq mods (dict maintains ordered keys from python 3.6)
|
|---|
| 66 | self.logger.info(f"Checking {'|'.join(self.mods)}'s integrity.")
|
|---|
| 67 | self.logger.info(f"The following mods will be loaded: {'|'.join(self.mods)}.")
|
|---|
| 68 | self.run()
|
|---|
| 69 |
|
|---|
| 70 | def find_files(self, vfs_root, mods, vfs_path, *ext_list):
|
|---|
| 71 | """
|
|---|
| 72 | returns a list of 2-size tuple with:
|
|---|
| 73 | - Path relative to the mod base
|
|---|
| 74 | - full Path
|
|---|
| 75 | """
|
|---|
| 76 | full_exts = ['.' + ext for ext in ext_list]
|
|---|
| 77 |
|
|---|
| 78 | def find_recursive(dp, base):
|
|---|
| 79 | """(relative Path, full Path) generator"""
|
|---|
| 80 | if dp.is_dir():
|
|---|
| 81 | if dp.name != '.svn' and dp.name != '.git' and not dp.name.endswith('~'):
|
|---|
| 82 | for fp in dp.iterdir():
|
|---|
| 83 | yield from find_recursive(fp, base)
|
|---|
| 84 | elif dp.suffix in full_exts:
|
|---|
| 85 | relative_file_path = dp.relative_to(base)
|
|---|
| 86 | yield (relative_file_path, dp.resolve())
|
|---|
| 87 | return [(rp, fp) for mod in mods for (rp, fp) in find_recursive(vfs_root / mod / vfs_path, vfs_root / mod)]
|
|---|
| 88 |
|
|---|
| 89 | def validate_actors(self):
|
|---|
| 90 | self.logger.info('Validating actors...')
|
|---|
| 91 | files = self.find_files(self.vfs_root, self.mods, 'art/actors/', 'xml')
|
|---|
| 92 | self.validate_files('actors', files, 'art/actors/actor.rng')
|
|---|
| 93 |
|
|---|
| 94 | def validate_variants(self):
|
|---|
| 95 | self.logger.info("Validating variants...")
|
|---|
| 96 | files = self.find_files(self.vfs_root, self.mods, 'art/variants/', 'xml')
|
|---|
| 97 | self.validate_files('variant', files, 'art/variants/variant.rng')
|
|---|
| 98 |
|
|---|
| 99 | def validate_guis(self):
|
|---|
| 100 | self.logger.info("Validating gui files...")
|
|---|
| 101 | pages = [file for file in self.find_files(self.vfs_root, self.mods, 'gui/', 'xml') if match(r".*[\\\/]page(_[^.\/\\]+)?\.xml$", str(file[0]))]
|
|---|
| 102 | self.validate_files('gui page', pages, 'gui/gui_page.rng')
|
|---|
| 103 | xmls = [file for file in self.find_files(self.vfs_root, self.mods, 'gui/', 'xml') if not match(r".*[\\\/]page(_[^.\/\\]+)?\.xml$", str(file[0]))]
|
|---|
| 104 | self.validate_files('gui xml', xmls, 'gui/gui.rng')
|
|---|
| 105 |
|
|---|
| 106 | def validate_maps(self):
|
|---|
| 107 | self.logger.info("Validating maps...")
|
|---|
| 108 | files = self.find_files(self.vfs_root, self.mods, 'maps/scenarios/', 'xml')
|
|---|
| 109 | self.validate_files('map', files, 'maps/scenario.rng')
|
|---|
| 110 | files = self.find_files(self.vfs_root, self.mods, 'maps/skirmishes/', 'xml')
|
|---|
| 111 | self.validate_files('map', files, 'maps/scenario.rng')
|
|---|
| 112 |
|
|---|
| 113 | def validate_materials(self):
|
|---|
| 114 | self.logger.info("Validating materials...")
|
|---|
| 115 | files = self.find_files(self.vfs_root, self.mods, 'art/materials/', 'xml')
|
|---|
| 116 | self.validate_files('material', files, 'art/materials/material.rng')
|
|---|
| 117 |
|
|---|
| 118 | def validate_particles(self):
|
|---|
| 119 | self.logger.info("Validating particles...")
|
|---|
| 120 | files = self.find_files(self.vfs_root, self.mods, 'art/particles/', 'xml')
|
|---|
| 121 | self.validate_files('particle', files, 'art/particles/particle.rng')
|
|---|
| 122 |
|
|---|
| 123 | def validate_simulation(self):
|
|---|
| 124 | self.logger.info("Validating simulation...")
|
|---|
| 125 | file = self.find_files(self.vfs_root, self.mods, 'simulation/data/pathfinder', 'xml')
|
|---|
| 126 | self.validate_files('pathfinder', file, 'simulation/data/pathfinder.rng')
|
|---|
| 127 | file = self.find_files(self.vfs_root, self.mods, 'simulation/data/territorymanager', 'xml')
|
|---|
| 128 | self.validate_files('territory manager', file, 'simulation/data/territorymanager.rng')
|
|---|
| 129 |
|
|---|
| 130 | def validate_soundgroups(self):
|
|---|
| 131 | self.logger.info("Validating soundgroups...")
|
|---|
| 132 | files = self.find_files(self.vfs_root, self.mods, 'audio/', 'xml')
|
|---|
| 133 | self.validate_files('sound group', files, 'audio/sound_group.rng')
|
|---|
| 134 |
|
|---|
| 135 | def validate_terrains(self):
|
|---|
| 136 | self.logger.info("Validating terrains...")
|
|---|
| 137 | terrains = [file for file in self.find_files(self.vfs_root, self.mods, 'art/terrains/', 'xml') if 'terrains.xml' in str(file[0])]
|
|---|
| 138 | self.validate_files('terrain', terrains, 'art/terrains/terrain.rng')
|
|---|
| 139 | terrains_textures = [file for file in self.find_files(self.vfs_root, self.mods, 'art/terrains/', 'xml') if 'terrains.xml' not in str(file[0])]
|
|---|
| 140 | self.validate_files('terrain texture', terrains_textures, 'art/terrains/terrain_texture.rng')
|
|---|
| 141 |
|
|---|
| 142 | def validate_textures(self):
|
|---|
| 143 | self.logger.info("Validating textures...")
|
|---|
| 144 | files = [file for file in self.find_files(self.vfs_root, self.mods, 'art/textures/', 'xml') if 'textures.xml' in str(file[0])]
|
|---|
| 145 | self.validate_files('texture', files, 'art/textures/texture.rng')
|
|---|
| 146 |
|
|---|
| 147 | def get_physical_path(self, mod_name, vfs_path):
|
|---|
| 148 | return realpath(join(self.vfs_root, mod_name, vfs_path))
|
|---|
| 149 |
|
|---|
| 150 | def get_relaxng_file(self, schemapath):
|
|---|
| 151 | """We look for the highest priority mod relax NG file"""
|
|---|
| 152 | for mod in self.mods:
|
|---|
| 153 | relax_ng_path = self.get_physical_path(mod, schemapath)
|
|---|
| 154 | if exists(relax_ng_path):
|
|---|
| 155 | return relax_ng_path
|
|---|
| 156 |
|
|---|
| 157 | return ""
|
|---|
| 158 |
|
|---|
| 159 | def validate_files(self, name, files, schemapath):
|
|---|
| 160 | relax_ng_path = self.get_relaxng_file(schemapath)
|
|---|
| 161 | if relax_ng_path == "":
|
|---|
| 162 | self.logger.warning(f"Could not find {schemapath}")
|
|---|
| 163 | return
|
|---|
| 164 |
|
|---|
| 165 | data = lxml.etree.parse(relax_ng_path)
|
|---|
| 166 | relaxng = lxml.etree.RelaxNG(data)
|
|---|
| 167 | error_count = 0
|
|---|
| 168 | for file in sorted(files):
|
|---|
| 169 | try:
|
|---|
| 170 | doc = lxml.etree.parse(str(file[1]))
|
|---|
| 171 | relaxng.assertValid(doc)
|
|---|
| 172 | except Exception as e:
|
|---|
| 173 | error_count = error_count + 1
|
|---|
| 174 | self.logger.error(f"{file[1]}: " + str(e))
|
|---|
| 175 |
|
|---|
| 176 | if self.verbose:
|
|---|
| 177 | self.logger.info(f"{error_count} {name} validation errors")
|
|---|
| 178 | elif error_count > 0:
|
|---|
| 179 | self.logger.error(f"{error_count} {name} validation errors")
|
|---|
| 180 |
|
|---|
| 181 |
|
|---|
| 182 | def get_mod_dependencies(vfs_root, *mods):
|
|---|
| 183 | modjsondeps = []
|
|---|
| 184 | for mod in mods:
|
|---|
| 185 | mod_json_path = Path(vfs_root) / mod / 'mod.json'
|
|---|
| 186 | if not exists(mod_json_path):
|
|---|
| 187 | continue
|
|---|
| 188 |
|
|---|
| 189 | with open(mod_json_path, encoding='utf-8') as f:
|
|---|
| 190 | modjson = load(f)
|
|---|
| 191 | # 0ad's folder isn't named like the mod.
|
|---|
| 192 | modjsondeps.extend(['public' if '0ad' in dep else dep for dep in modjson.get('dependencies', [])])
|
|---|
| 193 | return modjsondeps
|
|---|
| 194 |
|
|---|
| 195 | if __name__ == '__main__':
|
|---|
| 196 | script_dir = dirname(realpath(__file__))
|
|---|
| 197 | default_root = join(script_dir, '..', '..', '..', 'binaries', 'data', 'mods')
|
|---|
| 198 | ap = ArgumentParser(description="Validates XML files againt their Relax NG schemas")
|
|---|
| 199 | ap.add_argument('-r', '--root', action='store', dest='root', default=default_root)
|
|---|
| 200 | ap.add_argument('-v', '--verbose', action='store_true', default=True,
|
|---|
| 201 | help="Log validation errors.")
|
|---|
| 202 | ap.add_argument('-m', '--mods', metavar="MOD", dest='mods', nargs='+', default=['public'],
|
|---|
| 203 | help="specify which mods to check. Default to public and mod.")
|
|---|
| 204 | args = ap.parse_args()
|
|---|
| 205 | mods = list(dict.fromkeys([*args.mods, *get_mod_dependencies(args.root, *args.mods), 'mod']).keys())
|
|---|
| 206 | relax_ng_validator = RelaxNGValidator(args.root, mods=mods, verbose=args.verbose)
|
|---|
| 207 | relax_ng_validator.main()
|
|---|