| OLD | NEW | 
|---|
| (Empty) |  | 
|  | 1 #!/usr/bin/env python | 
|  | 2 | 
|  | 3 # This file is part of Adblock Plus <https://adblockplus.org/>, | 
|  | 4 # Copyright (C) 2006-present eyeo GmbH | 
|  | 5 # | 
|  | 6 # Adblock Plus is free software: you can redistribute it and/or modify | 
|  | 7 # it under the terms of the GNU General Public License version 3 as | 
|  | 8 # published by the Free Software Foundation. | 
|  | 9 # | 
|  | 10 # Adblock Plus is distributed in the hope that it will be useful, | 
|  | 11 # but WITHOUT ANY WARRANTY; without even the implied warranty of | 
|  | 12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | 
|  | 13 # GNU General Public License for more details. | 
|  | 14 # | 
|  | 15 # You should have received a copy of the GNU General Public License | 
|  | 16 # along with Adblock Plus.  If not, see <http://www.gnu.org/licenses/>. | 
|  | 17 | 
|  | 18 """Prepare a dependency update. | 
|  | 19 | 
|  | 20 This script executes the automatable work which needs to be done for a | 
|  | 21 dependency update and provides additional information, i.e. a complete | 
|  | 22 diff of imported changes, as well as related integration notes. | 
|  | 23 """ | 
|  | 24 | 
|  | 25 from __future__ import print_function, unicode_literals | 
|  | 26 | 
|  | 27 import argparse | 
|  | 28 import io | 
|  | 29 import json | 
|  | 30 import os | 
|  | 31 import re | 
|  | 32 import subprocess | 
|  | 33 import sys | 
|  | 34 try: | 
|  | 35     from urllib import urlopen | 
|  | 36 except ImportError: | 
|  | 37     from urllib.request import urlopen | 
|  | 38 | 
|  | 39 import jinja2 | 
|  | 40 | 
|  | 41 from src.vcs import Vcs | 
|  | 42 | 
|  | 43 | 
|  | 44 class DepUpdate(object): | 
|  | 45     """The main class used to process dependency updates. | 
|  | 46 | 
|  | 47     TODO: CLARIFY ME! | 
|  | 48 | 
|  | 49     """ | 
|  | 50 | 
|  | 51     VCS_EXECUTABLE = ('hg', '--config', 'defaults.log=', '--config', | 
|  | 52                       'defaults.pull=') | 
|  | 53     DEFAULT_NEW_REVISION = 'master' | 
|  | 54 | 
|  | 55     ISSUE_NUMBER_REGEX = re.compile(r'\b(issue|fixes)\s+(\d+)\b', re.I) | 
|  | 56     NOISSUE_REGEX = re.compile(r'^noissue\b', re.I) | 
|  | 57 | 
|  | 58     def __init__(self, *args): | 
|  | 59         """Construct a DepUpdate object. | 
|  | 60 | 
|  | 61         During initialization, DepUpdate will invoke the appropriate VCS to | 
|  | 62         fetch a list of changes, parse them and (if not otherwise specified) | 
|  | 63         get the matching revisions from the mirrored repository. | 
|  | 64 | 
|  | 65         Parameters: *args - Passed down to the argparse.ArgumentParser instance | 
|  | 66 | 
|  | 67         """ | 
|  | 68         self._cwd = os.getcwd() | 
|  | 69 | 
|  | 70         self._base_revision = None | 
|  | 71         self._parsed_changes = None | 
|  | 72         self.arguments = None | 
|  | 73 | 
|  | 74         self._dep_config = None | 
|  | 75 | 
|  | 76         default_template = os.path.join( | 
|  | 77             os.path.dirname(os.path.realpath(__file__)), 'templates', | 
|  | 78             'default.trac') | 
|  | 79 | 
|  | 80         # Initialize and run the internal argument parser | 
|  | 81         self._make_arguments(default_template, *args) | 
|  | 82 | 
|  | 83         # Initialize the main VCS and the list of changes | 
|  | 84         self._main_vcs = Vcs.factory(os.path.join(self._cwd, | 
|  | 85                                      self.arguments.dependency)) | 
|  | 86         self.changes = self._main_vcs.change_list(self.base_revision, | 
|  | 87                                                   self.arguments.new_revision) | 
|  | 88         if len(self.changes) == 0: | 
|  | 89             self.changes = self._main_vcs.change_list( | 
|  | 90                     self.arguments.new_revision, | 
|  | 91                     self.base_revision | 
|  | 92             ) | 
|  | 93             if len(self.changes) > 0: | 
|  | 94                 # reverse mode. Uh-oh. | 
|  | 95                 print('WARNING: you are trying to downgrade the dependency!', | 
|  | 96                       file=sys.stderr) | 
|  | 97 | 
|  | 98         self._main_vcs.enhance_changes_information( | 
|  | 99             self.changes, | 
|  | 100             os.path.join(self._mirror_location(), | 
|  | 101                          self.arguments.dependency), | 
|  | 102             self.arguments.skip_mirror, | 
|  | 103         ) | 
|  | 104 | 
|  | 105     def _make_arguments(self, default_template, *args): | 
|  | 106         """Initialize the argument parser and store the arguments.""" | 
|  | 107         parser = argparse.ArgumentParser( | 
|  | 108             description=__doc__, | 
|  | 109             formatter_class=argparse.RawDescriptionHelpFormatter) | 
|  | 110 | 
|  | 111         # First prepare all shared options | 
|  | 112         options_parser = argparse.ArgumentParser(add_help=False) | 
|  | 113         shared = options_parser.add_argument_group(title='Shared options') | 
|  | 114         shared.add_argument( | 
|  | 115                 'dependency', | 
|  | 116                 help=('The dependency to be updated, as specified in the ' | 
|  | 117                       'dependencies file.') | 
|  | 118         ) | 
|  | 119         shared.add_argument( | 
|  | 120                 '-r', '--revision', dest='new_revision', | 
|  | 121                 default=self.DEFAULT_NEW_REVISION, | 
|  | 122                 help=('The revision to update to. Defaults to the remote ' | 
|  | 123                       'master bookmark/branch. Must be accessible by the ' | 
|  | 124                       "dependency's vcs.") | 
|  | 125         ) | 
|  | 126         shared.add_argument( | 
|  | 127                 '-f', '--filename', dest='filename', default=None, | 
|  | 128                 help=("When specified, write the subcommand's output to the " | 
|  | 129                       'given file, rather than to STDOUT.') | 
|  | 130         ) | 
|  | 131         shared.add_argument( | 
|  | 132                 '-l', '--lookup-integration-notes', action='store_true', | 
|  | 133                 dest='lookup_inotes', default=False, | 
|  | 134                 help=('Search https://issues.adblockplus.org for integration ' | 
|  | 135                       'notes associated with the included issue IDs. The ' | 
|  | 136                       'results are written to STDERR. CAUTION: This is a very ' | 
|  | 137                       'network heavy operation.') | 
|  | 138         ) | 
|  | 139         shared.add_argument( | 
|  | 140                 '-s', '--skip-mirror', action='store_true', dest='skip_mirror', | 
|  | 141                 help='Do not use any mirror.' | 
|  | 142         ) | 
|  | 143         shared.add_argument( | 
|  | 144                 '-m', '--mirrored-repository', dest='local_mirror', | 
|  | 145                 help=('Path to the local copy of a mirrored repository. ' | 
|  | 146                       'Used to fetch the corresponding hash. If not ' | 
|  | 147                       'given, the source parsed from the dependencies file is ' | 
|  | 148                       'used.') | 
|  | 149         ) | 
|  | 150 | 
|  | 151         subs = parser.add_subparsers( | 
|  | 152                 title='Subcommands', dest='action', | 
|  | 153                 help=('Required, the actual command to be executed. Execute ' | 
|  | 154                       'run "<subcommand> -h" for more information.') | 
|  | 155         ) | 
|  | 156 | 
|  | 157         # Add the command and options for creating a diff | 
|  | 158         diff_parser = subs.add_parser('diff', parents=[options_parser]) | 
|  | 159         diff_parser.add_argument( | 
|  | 160                 '-n', '--n-context-lines', dest='unified_lines', type=int, | 
|  | 161                 default=16, | 
|  | 162                 help=('Number of unified context lines to be added to the ' | 
|  | 163                       'diff. Defaults to 16 (Used only with -d/--diff).') | 
|  | 164         ) | 
|  | 165 | 
|  | 166         # Add the command and options for creating an issue body | 
|  | 167         issue_parser = subs.add_parser('issue', parents=[options_parser]) | 
|  | 168         issue_parser.add_argument( | 
|  | 169                 '-t', '--template', dest='tmpl_path', | 
|  | 170                 default=default_template, | 
|  | 171                 help=('The template to use. Defaults to the provided ' | 
|  | 172                       'default.trac (Used only with -i/--issue).') | 
|  | 173         ) | 
|  | 174 | 
|  | 175         # Add the command for printing a list of changes | 
|  | 176         subs.add_parser('changes', parents=[options_parser]) | 
|  | 177 | 
|  | 178         self.arguments = parser.parse_args(args if len(args) > 0 else None) | 
|  | 179 | 
|  | 180     @property | 
|  | 181     def dep_config(self): | 
|  | 182         """Provide the dependencies by using ensure_dependencies.read_dep(). | 
|  | 183 | 
|  | 184         Since this program is meant to be run inside a repository which uses | 
|  | 185         the buildtools' dependency functionalities, we are sure that | 
|  | 186         ensure_dependencies.py and dependencies exist. | 
|  | 187 | 
|  | 188         However, ensure_dependencies is currently only compatible with python2. | 
|  | 189         Due to this we explicitly invoke a python2 interpreter to run our | 
|  | 190         dependencies.py, which runs ensure_dependencies.read_deps() and returns | 
|  | 191         the output as JSON data. | 
|  | 192         """ | 
|  | 193         if self._dep_config is None: | 
|  | 194             dependencies_script = os.path.join( | 
|  | 195                 os.path.dirname(os.path.realpath(__file__)), 'dependencies.py') | 
|  | 196 | 
|  | 197             dep_json = subprocess.check_output( | 
|  | 198                 ['python2', dependencies_script] | 
|  | 199             ).decode('utf-8') | 
|  | 200             self._dep_config = json.loads(dep_json) | 
|  | 201         return self._dep_config | 
|  | 202 | 
|  | 203     @property | 
|  | 204     def base_revision(self): | 
|  | 205         """Provide the current revision of the dependency to be processed.""" | 
|  | 206         if self._base_revision is None: | 
|  | 207             for key in ['*', self._main_vcs.EXECUTABLE]: | 
|  | 208                 rev = self.dep_config[self.arguments.dependency][key][1] | 
|  | 209                 if rev is not None: | 
|  | 210                     self._base_revision = rev | 
|  | 211                     break | 
|  | 212         return self._base_revision | 
|  | 213 | 
|  | 214     def _parse_changes(self, changes): | 
|  | 215         """Parse the changelist to issues / noissues.""" | 
|  | 216         issue_ids = set() | 
|  | 217         noissues = [] | 
|  | 218         for change in changes: | 
|  | 219             match = self.ISSUE_NUMBER_REGEX.search(change['message']) | 
|  | 220             if match: | 
|  | 221                 issue_ids.add(match.group(2)) | 
|  | 222             else: | 
|  | 223                 noissues.append(change) | 
|  | 224                 if not self.NOISSUE_REGEX.search(change['message']): | 
|  | 225                     msg = ( | 
|  | 226                         'warning: no issue reference in commit message: ' | 
|  | 227                         '"{message}" (commit {hg_hash} | {git_hash})\n' | 
|  | 228                     ).format(**change) | 
|  | 229                     print(msg, file=sys.stderr) | 
|  | 230 | 
|  | 231         return issue_ids, noissues | 
|  | 232 | 
|  | 233     @property | 
|  | 234     def parsed_changes(self): | 
|  | 235         """Provide the list of changes, separated by issues and noissues. | 
|  | 236 | 
|  | 237         Returns a dictionary, containing the following two key/value pairs: | 
|  | 238         'issue_ids': a list of issue IDs (as seen on | 
|  | 239                      https://issues.adblockplus.org/) | 
|  | 240         'noissues': The remaining changes, with all original information (see | 
|  | 241                     DepUpdate.changes) which could not be associated with any | 
|  | 242                     issue. | 
|  | 243         """ | 
|  | 244         if self._parsed_changes is None: | 
|  | 245             self._parsed_changes = {} | 
|  | 246             issue_ids, noissues = self._parse_changes(self.changes) | 
|  | 247             self._parsed_changes['issue_ids'] = issue_ids | 
|  | 248             self._parsed_changes['noissues'] = noissues | 
|  | 249         return self._parsed_changes | 
|  | 250 | 
|  | 251     def build_diff(self): | 
|  | 252         """Generate a unified diff of all changes.""" | 
|  | 253         return self._main_vcs.merged_diff(self.base_revision, | 
|  | 254                                           self.arguments.new_revision, | 
|  | 255                                           self.arguments.unified_lines) | 
|  | 256 | 
|  | 257     def build_issue(self): | 
|  | 258         """Process all changes and render an issue.""" | 
|  | 259         context = {} | 
|  | 260         context['repository'] = self.arguments.dependency | 
|  | 261         context['issue_ids'] = self.parsed_changes['issue_ids'] | 
|  | 262         context['noissues'] = self.parsed_changes['noissues'] | 
|  | 263         context['hg_hash'] = self.changes[0]['hg_hash'] | 
|  | 264         context['git_hash'] = self.changes[0]['git_hash'] | 
|  | 265         context['raw_changes'] = self.changes | 
|  | 266 | 
|  | 267         path, filename = os.path.split(self.arguments.tmpl_path) | 
|  | 268 | 
|  | 269         return jinja2.Environment( | 
|  | 270             loader=jinja2.FileSystemLoader(path or './') | 
|  | 271         ).get_template(filename).render(context) | 
|  | 272 | 
|  | 273     def lookup_integration_notes(self): | 
|  | 274         """Search for any "Integration notes" mentions at the issue-tracker. | 
|  | 275 | 
|  | 276         Cycle through the list of issue IDs and search for "Integration Notes" | 
|  | 277         in the associated issue on https://issues.adblockplus.org. If found, | 
|  | 278         write the corresponding url to STDERR. | 
|  | 279         """ | 
|  | 280         integration_notes_regex = re.compile(r'Integration\s*notes', re.I) | 
|  | 281 | 
|  | 282         def from_url(issue_url): | 
|  | 283             html = '' | 
|  | 284             content = urlopen(issue_url) | 
|  | 285 | 
|  | 286             for line in content: | 
|  | 287                 html += line.decode('utf-8') | 
|  | 288             return html | 
|  | 289 | 
|  | 290         for issue_id in self.parsed_changes['issue_ids']: | 
|  | 291             issue_url = 'https://issues.adblockplus.org/ticket/' + issue_id | 
|  | 292             html = from_url(issue_url) | 
|  | 293             if not integration_notes_regex.search(html): | 
|  | 294                 continue | 
|  | 295 | 
|  | 296             print('Integration notes found: ' + issue_url, file=sys.stderr) | 
|  | 297 | 
|  | 298     def build_changes(self): | 
|  | 299         """Write a descriptive list of the changes to STDOUT.""" | 
|  | 300         return os.linesep.join( | 
|  | 301             [( | 
|  | 302                 '( hg:{hg_hash} | git:{git_hash} ) : {message} (by {author})' | 
|  | 303              ).format(**change) for change in self.changes] | 
|  | 304         ) | 
|  | 305 | 
|  | 306     def _possible_sources(self): | 
|  | 307         root_conf = self._dep_config['_root'] | 
|  | 308         config = self.dep_config[self.arguments.dependency] | 
|  | 309 | 
|  | 310         # The fallback / main source paths for a repository are given in the | 
|  | 311         # dependencies file's _root section. | 
|  | 312         keys = ['hg', 'git'] | 
|  | 313         possible_sources = {} | 
|  | 314         possible_sources.update({ | 
|  | 315             key + '_root': root_conf[key] | 
|  | 316             for key in keys | 
|  | 317         }) | 
|  | 318 | 
|  | 319         # Any dependency may specify a custom source location. | 
|  | 320         possible_sources.update({ | 
|  | 321             key: source for key, source in [ | 
|  | 322                 (key, config.get(key, (None, None))[0]) for key in keys | 
|  | 323             ] if source is not None | 
|  | 324         }) | 
|  | 325 | 
|  | 326         return possible_sources | 
|  | 327 | 
|  | 328     def _mirror_location(self): | 
|  | 329         possible_sources = self._possible_sources() | 
|  | 330         mirror_ex = self._main_vcs._other_cls.EXECUTABLE | 
|  | 331 | 
|  | 332         # If the user specified a local mirror, use it. Otherwise use the | 
|  | 333         # mirror, which was specified in the dependencies file. | 
|  | 334         if self.arguments.local_mirror: | 
|  | 335             mirror = self.arguments.local_mirror | 
|  | 336         else: | 
|  | 337             for key in [mirror_ex, mirror_ex + '_root']: | 
|  | 338                 if key in possible_sources: | 
|  | 339                     mirror = possible_sources[key] | 
|  | 340                     break | 
|  | 341         return mirror | 
|  | 342 | 
|  | 343     def __call__(self): | 
|  | 344         """Let this class's objects be callable, run all desired tasks.""" | 
|  | 345         action_map = { | 
|  | 346             'diff': self.build_diff, | 
|  | 347             'changes': self.build_changes, | 
|  | 348             'issue': self.build_issue, | 
|  | 349         } | 
|  | 350 | 
|  | 351         if len(self.changes) == 0: | 
|  | 352             print('NO CHANGES FOUND. You are either trying to update to a ' | 
|  | 353                   'revision, which the dependency already is at - or ' | 
|  | 354                   'something went wrong while executing the vcs.') | 
|  | 355             return | 
|  | 356 | 
|  | 357         if self.arguments.lookup_inotes: | 
|  | 358             self.lookup_integration_notes() | 
|  | 359 | 
|  | 360         output = action_map[self.arguments.action]() | 
|  | 361         if self.arguments.filename is not None: | 
|  | 362             with io.open(self.arguments.filename, 'w', encoding='utf-8') as fp: | 
|  | 363                 fp.write(output) | 
|  | 364             print('Output writen to ' + self.arguments.filename) | 
|  | 365         else: | 
|  | 366             print(output) | 
| OLD | NEW | 
|---|