| Index: src/depup.py |
| diff --git a/src/depup.py b/src/depup.py |
| new file mode 100644 |
| index 0000000000000000000000000000000000000000..e36f989e81c4418c38b46a59b46caafe686050c0 |
| --- /dev/null |
| +++ b/src/depup.py |
| @@ -0,0 +1,365 @@ |
| +#!/usr/bin/env python |
| + |
| +# This file is part of Adblock Plus <https://adblockplus.org/>, |
| +# Copyright (C) 2006-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/>. |
| + |
| +"""Prepare a dependency update. |
| + |
| +This script executes the automatable work which needs to be done for a |
| +dependency update and provides additional information, i.e. a complete |
| +diff of imported changes, as well as related integration notes. |
| +""" |
| + |
| +from __future__ import print_function, unicode_literals |
| + |
| +import argparse |
| +import io |
| +import json |
| +import os |
| +import re |
| +import subprocess |
| +import sys |
| +try: |
| + from urllib import urlopen |
| +except ImportError: |
| + from urllib.request import urlopen |
| + |
| +import jinja2 |
| + |
| +from src.vcs import Vcs |
| + |
| + |
| +class DepUpdate(object): |
| + """The main class used to process dependency updates. |
| + |
| + TODO: CLARIFY ME! |
| + |
| + """ |
| + |
| + VCS_EXECUTABLE = ('hg', '--config', 'defaults.log=', '--config', |
| + 'defaults.pull=') |
| + DEFAULT_NEW_REVISION = 'master' |
| + |
| + ISSUE_NUMBER_REGEX = re.compile(r'\b(issue|fixes)\s+(\d+)\b', re.I) |
| + NOISSUE_REGEX = re.compile(r'^noissue\b', re.I) |
| + |
| + def __init__(self, *args): |
| + """Construct a DepUpdate object. |
| + |
| + During initialization, DepUpdate will invoke the appropriate VCS to |
| + fetch a list of changes, parse them and (if not otherwise specified) |
| + get the matching revisions from the mirrored repository. |
| + |
| + Parameters: *args - Passed down to the argparse.ArgumentParser instance |
| + |
| + """ |
| + self._cwd = os.getcwd() |
| + |
| + self._base_revision = None |
| + self._parsed_changes = None |
| + self.arguments = None |
| + |
| + self._dep_config = None |
| + |
| + default_template = os.path.join( |
| + os.path.dirname(os.path.realpath(__file__)), 'templates', |
| + 'default.trac') |
| + |
| + # Initialize and run the internal argument parser |
| + self._make_arguments(default_template, *args) |
| + |
| + # Initialize the main VCS and the list of changes |
| + self._main_vcs = Vcs.factory(os.path.join(self._cwd, |
| + self.arguments.dependency)) |
| + self.changes = self._main_vcs.change_list(self.base_revision, |
| + self.arguments.new_revision) |
| + if len(self.changes) == 0: |
| + self.changes = self._main_vcs.change_list( |
| + self.arguments.new_revision, |
| + self.base_revision |
| + ) |
| + if len(self.changes) > 0: |
| + # reverse mode. Uh-oh. |
| + print('WARNING: you are trying to downgrade the dependency!', |
| + file=sys.stderr) |
| + |
| + self._main_vcs.enhance_changes_information( |
| + self.changes, |
| + os.path.join(self._mirror_location(), |
| + self.arguments.dependency), |
| + self.arguments.skip_mirror, |
| + ) |
| + |
| + def _make_arguments(self, default_template, *args): |
| + """Initialize the argument parser and store the arguments.""" |
| + parser = argparse.ArgumentParser( |
| + description=__doc__, |
| + formatter_class=argparse.RawDescriptionHelpFormatter) |
| + |
| + # First prepare all shared options |
| + options_parser = argparse.ArgumentParser(add_help=False) |
| + shared = options_parser.add_argument_group(title='Shared options') |
| + shared.add_argument( |
| + 'dependency', |
| + help=('The dependency to be updated, as specified in the ' |
| + 'dependencies file.') |
| + ) |
| + shared.add_argument( |
| + '-r', '--revision', dest='new_revision', |
| + default=self.DEFAULT_NEW_REVISION, |
| + help=('The revision to update to. Defaults to the remote ' |
| + 'master bookmark/branch. Must be accessible by the ' |
| + "dependency's vcs.") |
| + ) |
| + shared.add_argument( |
| + '-f', '--filename', dest='filename', default=None, |
| + help=("When specified, write the subcommand's output to the " |
| + 'given file, rather than to STDOUT.') |
| + ) |
| + shared.add_argument( |
| + '-l', '--lookup-integration-notes', action='store_true', |
| + dest='lookup_inotes', default=False, |
| + help=('Search https://issues.adblockplus.org for integration ' |
| + 'notes associated with the included issue IDs. The ' |
| + 'results are written to STDERR. CAUTION: This is a very ' |
| + 'network heavy operation.') |
| + ) |
| + shared.add_argument( |
| + '-s', '--skip-mirror', action='store_true', dest='skip_mirror', |
| + help='Do not use any mirror.' |
| + ) |
| + shared.add_argument( |
| + '-m', '--mirrored-repository', dest='local_mirror', |
| + help=('Path to the local copy of a mirrored repository. ' |
| + 'Used to fetch the corresponding hash. If not ' |
| + 'given, the source parsed from the dependencies file is ' |
| + 'used.') |
| + ) |
| + |
| + subs = parser.add_subparsers( |
| + title='Subcommands', dest='action', |
| + help=('Required, the actual command to be executed. Execute ' |
| + 'run "<subcommand> -h" for more information.') |
| + ) |
| + |
| + # Add the command and options for creating a diff |
| + diff_parser = subs.add_parser('diff', parents=[options_parser]) |
| + diff_parser.add_argument( |
| + '-n', '--n-context-lines', dest='unified_lines', type=int, |
| + default=16, |
| + help=('Number of unified context lines to be added to the ' |
| + 'diff. Defaults to 16 (Used only with -d/--diff).') |
| + ) |
| + |
| + # Add the command and options for creating an issue body |
| + issue_parser = subs.add_parser('issue', parents=[options_parser]) |
| + issue_parser.add_argument( |
| + '-t', '--template', dest='tmpl_path', |
| + default=default_template, |
| + help=('The template to use. Defaults to the provided ' |
| + 'default.trac (Used only with -i/--issue).') |
| + ) |
| + |
| + # Add the command for printing a list of changes |
| + subs.add_parser('changes', parents=[options_parser]) |
| + |
| + self.arguments = parser.parse_args(args if len(args) > 0 else None) |
| + |
| + @property |
| + def dep_config(self): |
| + """Provide the dependencies by using ensure_dependencies.read_dep(). |
| + |
| + Since this program is meant to be run inside a repository which uses |
| + the buildtools' dependency functionalities, we are sure that |
| + ensure_dependencies.py and dependencies exist. |
| + |
| + However, ensure_dependencies is currently only compatible with python2. |
| + Due to this we explicitly invoke a python2 interpreter to run our |
| + dependencies.py, which runs ensure_dependencies.read_deps() and returns |
| + the output as JSON data. |
| + """ |
|
tlucas
2017/11/06 14:15:13
This is absolutely odd - but i didn't want to copy
|
| + if self._dep_config is None: |
| + dependencies_script = os.path.join( |
| + os.path.dirname(os.path.realpath(__file__)), 'dependencies.py') |
| + |
| + dep_json = subprocess.check_output( |
| + ['python2', dependencies_script] |
| + ).decode('utf-8') |
| + self._dep_config = json.loads(dep_json) |
| + return self._dep_config |
| + |
| + @property |
| + def base_revision(self): |
| + """Provide the current revision of the dependency to be processed.""" |
| + if self._base_revision is None: |
| + for key in ['*', self._main_vcs.EXECUTABLE]: |
| + rev = self.dep_config[self.arguments.dependency][key][1] |
| + if rev is not None: |
| + self._base_revision = rev |
| + break |
| + return self._base_revision |
| + |
| + def _parse_changes(self, changes): |
| + """Parse the changelist to issues / noissues.""" |
| + issue_ids = set() |
| + noissues = [] |
| + for change in changes: |
| + match = self.ISSUE_NUMBER_REGEX.search(change['message']) |
| + if match: |
| + issue_ids.add(match.group(2)) |
| + else: |
| + noissues.append(change) |
| + if not self.NOISSUE_REGEX.search(change['message']): |
| + msg = ( |
| + 'warning: no issue reference in commit message: ' |
| + '"{message}" (commit {hg_hash} | {git_hash})\n' |
| + ).format(**change) |
| + print(msg, file=sys.stderr) |
| + |
| + return issue_ids, noissues |
| + |
| + @property |
| + def parsed_changes(self): |
| + """Provide the list of changes, separated by issues and noissues. |
| + |
| + Returns a dictionary, containing the following two key/value pairs: |
| + 'issue_ids': a list of issue IDs (as seen on |
| + https://issues.adblockplus.org/) |
| + 'noissues': The remaining changes, with all original information (see |
| + DepUpdate.changes) which could not be associated with any |
| + issue. |
| + """ |
| + if self._parsed_changes is None: |
| + self._parsed_changes = {} |
| + issue_ids, noissues = self._parse_changes(self.changes) |
| + self._parsed_changes['issue_ids'] = issue_ids |
| + self._parsed_changes['noissues'] = noissues |
| + return self._parsed_changes |
| + |
| + def build_diff(self): |
| + """Write a unified diff of all changes to STDOUT.""" |
| + print(self._main_vcs.merged_diff(self.base_revision, |
| + self.arguments.new_revision, |
| + self.arguments.unified_lines)) |
| + |
| + def build_issue(self): |
| + """Process all changes and render an issue.""" |
| + context = {} |
| + context['repository'] = self.arguments.dependency |
| + context['issue_ids'] = self.parsed_changes['issue_ids'] |
| + context['noissues'] = self.parsed_changes['noissues'] |
| + context['hg_hash'] = self.changes[0]['hg_hash'] |
| + context['git_hash'] = self.changes[0]['git_hash'] |
| + |
| + path, filename = os.path.split(self.arguments.tmpl_path) |
| + |
| + return jinja2.Environment( |
| + loader=jinja2.FileSystemLoader(path or './') |
| + ).get_template(filename).render(context) |
| + |
| + def lookup_integration_notes(self): |
| + """Search for any "Integration notes" mentions at the issue-tracker. |
| + |
| + Cycle through the list of issue IDs and search for "Integration Notes" |
| + in the associated issue on https://issues.adblockplus.org. If found, |
| + write the corresponding url to STDERR. |
| + """ |
| + integration_notes_regex = re.compile(r'Integration\s*notes', re.I) |
| + |
| + def from_url(issue_url): |
| + html = '' |
| + content = urlopen(issue_url) |
| + |
| + for line in content: |
| + html += line.decode('utf-8') |
| + return html |
| + |
| + for issue_id in self.parsed_changes['issue_ids']: |
| + issue_url = 'https://issues.adblockplus.org/ticket/' + issue_id |
| + html = from_url(issue_url) |
| + if not integration_notes_regex.search(html): |
| + continue |
| + |
| + print('Integration notes found: ' + issue_url, file=sys.stderr) |
| + |
| + def build_changes(self): |
| + """Write a descriptive list of the changes to STDOUT.""" |
| + return os.linesep.join( |
| + [( |
| + '( hg:{hg_hash} | git:{git_hash} ) : {message} (by {author})' |
| + ).format(**change) for change in self.changes] |
| + ) |
| + |
| + def _possible_sources(self): |
| + root_conf = self._dep_config['_root'] |
| + config = self.dep_config[self.arguments.dependency] |
| + |
| + # The fallback / main source paths for a repository are given in the |
| + # dependencies file's _root section. |
| + keys = ['hg', 'git'] |
| + possible_sources = {} |
| + possible_sources.update({ |
| + key + '_root': root_conf[key] |
| + for key in keys |
| + }) |
| + |
| + # Any dependency may specify a custom source location. |
| + possible_sources.update({ |
| + key: source for key, source in [ |
| + (key, config.get(key, (None, None))[0]) for key in keys |
| + ] if source is not None |
| + }) |
| + |
| + return possible_sources |
| + |
| + def _mirror_location(self): |
| + possible_sources = self._possible_sources() |
| + mirror_ex = self._main_vcs._other_cls.EXECUTABLE |
| + |
| + # If the user specified a local mirror, use it. Otherwise use the |
| + # mirror, which was specified in the dependencies file. |
| + if self.arguments.local_mirror: |
| + mirror = self.arguments.local_mirror |
| + else: |
| + for key in [mirror_ex, mirror_ex + '_root']: |
| + if key in possible_sources: |
| + mirror = possible_sources[key] |
| + break |
| + return mirror |
| + |
| + def __call__(self): |
| + """Let this class's objects be callable, run all desired tasks.""" |
| + action_map = { |
| + 'diff': self.build_diff, |
| + 'changes': self.build_changes, |
| + 'issue': self.build_issue, |
| + } |
| + |
| + if len(self.changes) == 0: |
| + print('NO CHANGES FOUND. You are either trying to update to a ' |
| + 'revision, which the dependency already is at - or ' |
| + 'something went wrong while executing the vcs.') |
| + return |
| + |
| + if self.arguments.lookup_inotes: |
| + self.lookup_integration_notes() |
| + |
| + output = action_map[self.arguments.action]() |
| + if self.arguments.filename is not None: |
| + with io.open(self.arguments.filename, 'w', encoding='utf-8') as fp: |
| + fp.write(output) |
| + print('Output writen to ' + self.arguments.filename) |
| + else: |
| + print(output) |