| Index: cms-dev/cms_cmp.py | 
| =================================================================== | 
| new file mode 100644 | 
| --- /dev/null | 
| +++ b/cms-dev/cms_cmp.py | 
| @@ -0,0 +1,211 @@ | 
| +#!/bin/env python | 
| +# This file is part of Adblock Plus <https://adblockplus.org/>, | 
| +# Copyright (C) 2017-present eyeo GmbH | 
| +# | 
| +# Adblock Plus is free software: you can redistribute it and/or modify | 
| +# it under the terms of the GNU General Public License version 3 as | 
| +# published by the Free Software Foundation. | 
| +# | 
| +# Adblock Plus 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 Adblock Plus. If not, see <http://www.gnu.org/licenses/>. | 
| + | 
| +"""Test CMS by comparing the output of its different revisions.""" | 
| + | 
| +from __future__ import print_function, unicode_literals | 
| + | 
| +import argparse | 
| +import difflib | 
| +import filecmp | 
| +import io | 
| +import os | 
| +import shutil | 
| +import subprocess | 
| +import sys | 
| + | 
| +# Path to static generation script. | 
| +GENERATE_PATH = 'cms/bin/generate_static_pages.py' | 
| + | 
| +# Fake revision that indicates "use current working copy". | 
| +WORKING_COPY = '[working-copy]' | 
| + | 
| + | 
| +def run_cmd(*cmd, **kw): | 
| + """Run a command, print and return its output.""" | 
| + silent = kw.get('silent', False) | 
| + if 'silent' in kw: | 
| + del kw['silent'] | 
| + if not silent: | 
| + print('$', *cmd) | 
| + try: | 
| + output = subprocess.check_output(cmd, **kw).decode('utf-8') | 
| + except subprocess.CalledProcessError: | 
| + sys.exit('Command invocation failed: {}'.format(' '.join(cmd))) | 
| + if not silent: | 
| + for line in output.splitlines(): | 
| + print('>', line) | 
| + return output | 
| + | 
| + | 
| +def hg(*args, **kw): | 
| + """Run Mercurial and return its output.""" | 
| + cmd = ['hg'] | 
| + if 'repo' in kw: | 
| + cmd += ['-R', kw['repo']] | 
| + del kw['repo'] | 
| + # Disable default options from local user config. | 
| + cmd += ['--config', 'defaults.{}='.format(args[0])] | 
| + return run_cmd(*(cmd + list(args)), **kw) | 
| + | 
| + | 
| +def get_current_rev(repo): | 
| + """Get the revision ids of the working copy in repository.""" | 
| + return hg('id', repo=repo, silent=True).split() | 
| + | 
| + | 
| +def read_file(path): | 
| + """Read file, return list of strings.""" | 
| + with io.open(path, encoding='utf-8') as f: | 
| + return f.read().splitlines() | 
| + | 
| + | 
| +def print_diff(one, two): | 
| + """Print unified diff between two files.""" | 
| + for line in difflib.unified_diff(read_file(one), read_file(two), | 
| + fromfile=one, tofile=two): | 
| + print(line) | 
| + | 
| + | 
| +def compare_dirs(one, two, ignore=[]): | 
| + """Compare two directories, return True if same, False if not.""" | 
| + | 
| + def recursive_compare(c): | 
| + if c.left_only: | 
| + print('The following file(s)/dir(s) are only in base', c.left) | 
| + for f in c.left_only: | 
| + print('-', f) | 
| + return False | 
| + if c.right_only: | 
| + print('The following file(s)/dir(s) are only in test', c.right) | 
| + for f in c.right_only: | 
| + print('-', f) | 
| + return False | 
| + if c.diff_files: | 
| + print('The following file(s) are different between', c.left, | 
| + 'and', c.right) | 
| + for f in c.diff_files: | 
| + print('-', f) | 
| + base = os.path.join(c.left, f) | 
| + test = os.path.join(c.right, f) | 
| + print_diff(base, test) | 
| + return False | 
| + return all(recursive_compare(sub) for sub in c.subdirs.values()) | 
| + | 
| + print('Comparing', one, 'and', two) | 
| + comparator = filecmp.dircmp(one, two, ignore=ignore) | 
| + return recursive_compare(comparator) | 
| + | 
| + | 
| +class Tester(object): | 
| + """Test runner.""" | 
| + | 
| + def __init__(self, config): | 
| + self.__dict__.update(config.__dict__) | 
| + | 
| + def clone_cms(self): | 
| + """Clone CMS repository for to use for tests.""" | 
| + self.cms_clone = os.path.join(self.dest, 'cms-cmp.cms-clone') | 
| + print('Cloning CMS to', self.cms_clone) | 
| + if os.path.exists(self.cms_clone): | 
| + shutil.rmtree(self.cms_clone) | 
| + hg('clone', self.cms_repo, self.cms_clone) | 
| + | 
| + def cms_checkout(self, rev): | 
| + """Checkout specified revision of CMS. | 
| + | 
| + Returns revision hash (or working copy marker) and path to where it is. | 
| + """ | 
| + if rev is WORKING_COPY: | 
| + print('Using CMS working copy') | 
| + return WORKING_COPY, self.cms_repo | 
| + print('Switching CMS to revision:', rev) | 
| + hg('co', rev, repo=self.cms_clone) | 
| + return get_current_rev(self.cms_clone)[0], self.cms_clone | 
| + | 
| + def generate(self, cms_rev, website_path): | 
| + """Generate the website using specified revision of CMS.""" | 
| + name = os.path.basename(website_path) | 
| + website_rev = get_current_rev(website_path)[0] | 
| + cms_rev, cms_path = self.cms_checkout(cms_rev) | 
| + print('Generating', website_path, 'with CMS revision:', cms_rev) | 
| + unique_id = '{}-rev-{}-cms-{}'.format(name, website_rev, cms_rev) | 
| + dst = os.path.join(self.dest, unique_id) | 
| + if os.path.exists(dst): | 
| + if self.remove_old or cms_rev == WORKING_COPY: | 
| + shutil.rmtree(dst) | 
| + else: | 
| + print(dst, 'exists, assuming it was generated earlier') | 
| + return dst | 
| + env = dict(os.environ) | 
| + env['PYTHONPATH'] = cms_path | 
| + generate = os.path.join(cms_path, GENERATE_PATH) | 
| + run_cmd(self.python, generate, website_path, dst, env=env) | 
| + return dst | 
| + | 
| + def run(self): | 
| + if not os.path.exists(os.path.join(self.cms_repo, GENERATE_PATH)): | 
| + sys.exit('No cms source found in ' + self.cms_repo) | 
| + print('Using CMS repository at', self.cms_repo) | 
| + self.clone_cms() | 
| + for website_path in self.websites: | 
| + base = self.generate(self.base_rev, website_path) | 
| + test = self.generate(self.test_rev, website_path) | 
| + if not compare_dirs(base, test, ignore=self.ignore): | 
| + print('Differences found for', website_path) | 
| + sys.exit(1) | 
| + | 
| + | 
| +def configure(): | 
| 
 
tlucas
2017/11/24 10:57:46
Since configure()'s only purpose is to specify par
 
Vasily Kuznetsov
2017/11/28 16:53:37
I would like to keep `Tester` independent from par
 
tlucas
2017/12/14 10:30:42
ACK, your argumentation seems valid :)
 
 | 
| + """Configure the script from arguments.""" | 
| + parser = argparse.ArgumentParser(description=__doc__) | 
| + parser.add_argument('websites', metavar='WEBSITE', nargs='+', | 
| + help='path of website source to use for comparison') | 
| + parser.add_argument('-b', '--base-rev', metavar='REVISION', | 
| + default='master', | 
| + help='base revision of CMS to use as baseline') | 
| + parser.add_argument('-c', '--cms-repo', metavar='CMS_REPO', | 
| + default=os.getcwd(), | 
| + help='Location of CMS repository') | 
| + parser.add_argument('-d', '--dest', metavar='OUT_DIR', | 
| + default=os.environ.get('TMPDIR', '/tmp'), | 
| + help='directory for storing CMS output') | 
| + parser.add_argument('-i', '--ignore', metavar='FILENAME', action='append', | 
| + default=[], | 
| + help='file names to ignore in output comparison') | 
| + parser.add_argument('-p', '--python', metavar='PYTHON_EXE', | 
| + default=sys.executable, | 
| + help='python interpreter to run CMS') | 
| + parser.add_argument('-r', '--remove-old', action='store_true', | 
| + help='remove previously generated CMS outputs instead' | 
| + 'of reusing them') | 
| + parser.add_argument('-t', '--test-rev', metavar='REVISION', | 
| + default=WORKING_COPY, | 
| + help='revision of CMS to use for testing (by default ' | 
| + 'currently checked out working copy including ' | 
| + 'uncommited changes will be used)') | 
| + return parser.parse_args() | 
| + | 
| + | 
| +def main(): | 
| + """Main entry point for the script.""" | 
| + config = configure() | 
| + tester = Tester(config) | 
| + tester.run() | 
| + | 
| + | 
| +if __name__ == '__main__': | 
| + main() |