Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Side by Side Diff: eyeo-depup/src/depup.py

Issue 29599579: OffTopic: DependencyUpdater
Patch Set: Integrating into codingtools Created Nov. 20, 2017, 2:58 p.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
View unified diff | Download patch
« no previous file with comments | « eyeo-depup/src/dependencies.py ('k') | eyeo-depup/src/templates/default.trac » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(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 logging
31 import os
32 import re
33 import subprocess
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 logging.basicConfig()
44 logger = logging.getLogger('eyeo-depup')
45
46
47 class DepUpdate(object):
48 """The main class used to process dependency updates.
49
50 TODO: CLARIFY ME!
51
52 """
53
54 VCS_EXECUTABLE = ('hg', '--config', 'defaults.log=', '--config',
55 'defaults.pull=')
56 ISSUE_NUMBER_REGEX = re.compile(r'\b(issue|fixes)\s+(\d+)\b', re.I)
57 NOISSUE_REGEX = re.compile(r'^noissue\b', re.I)
58
59 def __init__(self, *args):
60 """Construct a DepUpdate object.
61
62 During initialization, DepUpdate will invoke the appropriate VCS to
63 fetch a list of changes, parse them and (if not otherwise specified)
64 get the matching revisions from the mirrored repository.
65
66 Parameters: *args - Passed down to the argparse.ArgumentParser instance
67
68 """
69 self._cwd = os.getcwd()
70
71 self.root_repo = Vcs.factory(self._cwd)
72
73 self._base_revision = None
74 self._parsed_changes = None
75 self.arguments = None
76
77 self._dep_config = None
78
79 default_template = os.path.join(
80 os.path.dirname(os.path.realpath(__file__)), 'templates',
81 'default.trac')
82
83 # Initialize and run the internal argument parser
84 self._make_arguments(default_template, *args)
85
86 # Check if root repository is dirty
87 if not self.root_repo.repo_is_clean():
88 logger.error('Your repository is dirty')
89 exit(1)
90
91 # Initialize the main VCS and the list of changes
92 self._main_vcs = Vcs.factory(os.path.join(self._cwd,
93 self.arguments.dependency))
94 self.changes = self._main_vcs.change_list(self.base_revision,
95 self.arguments.new_revision)
96 if len(self.changes) == 0:
97 self.changes = self._main_vcs.change_list(
98 self.arguments.new_revision,
99 self.base_revision
100 )
101 if len(self.changes) > 0:
102 # reverse mode. Uh-oh.
103 logger.warn('You are trying to downgrade the dependency!')
104
105 self._main_vcs.enhance_changes_information(
106 self.changes,
107 os.path.join(self._mirror_location(),
108 self.arguments.dependency),
109 self.arguments.skip_mirror,
110 )
111
112 def _make_arguments(self, default_template, *args):
113 """Initialize the argument parser and store the arguments."""
114 parser = argparse.ArgumentParser(
115 description=__doc__,
116 formatter_class=argparse.RawDescriptionHelpFormatter)
117
118 # First prepare all basic shared options
119 options_parser = argparse.ArgumentParser(add_help=False)
120 shared = options_parser.add_argument_group()
121 shared.add_argument(
122 'dependency',
123 help=('The dependency to be updated, as specified in the '
124 'dependencies file.')
125 )
126 shared.add_argument(
127 '-r', '--revision', dest='new_revision',
128 help=('The revision to update to. Defaults to the remote '
129 'master bookmark/branch. Must be accessible by the '
130 "dependency's vcs.")
131 )
132 shared.add_argument(
133 '-m', '--mirrored-repository', dest='local_mirror',
134 help=('Path to the local copy of a mirrored repository. '
135 'Used to fetch the corresponding hash. If not '
136 'given, the source parsed from the dependencies file is '
137 'used.')
138 )
139
140 # Shared options for non-commit commands
141 advanced_parser = argparse.ArgumentParser(add_help=False)
142 advanced = advanced_parser.add_argument_group()
143 advanced.add_argument(
144 '-f', '--filename', dest='filename', default=None,
145 help=("When specified, write the subcommand's output to the "
146 'given file, rather than to STDOUT.')
147 )
148 advanced.add_argument(
149 '-s', '--skip-mirror', action='store_true', dest='skip_mirror',
150 help='Do not use any mirror.'
151 )
152 advanced.add_argument(
153 '-l', '--lookup-integration-notes', action='store_true',
154 dest='lookup_inotes', default=False,
155 help=('Search https://issues.adblockplus.org for integration '
156 'notes associated with the included issue IDs. The '
157 'results are written to STDERR. CAUTION: This is a very '
158 'network heavy operation.')
159 )
160
161 subs = parser.add_subparsers(
162 title='Subcommands', dest='action', metavar='[subcommand]',
163 help=('Required, the actual command to be executed. Execute '
164 'run "<subcommand> -h" for more information.')
165 )
166
167 # Add the command and options for creating a diff
168 diff_parser = subs.add_parser(
169 'diff', parents=[options_parser, advanced_parser],
170 help='Create a unified diff of all changes',
171 description=("Invoke the current repository's VCS to generate "
172 'a diff, containing all changes made between two '
173 'revisions.'))
174 diff_parser.add_argument(
175 '-n', '--n-context-lines', dest='unified_lines', type=int,
176 default=16,
177 help=('Number of unified context lines to be added to the '
178 'diff. Defaults to 16 (Used only with -d/--diff).')
179 )
180
181 # Add the command and options for creating an issue body
182 issue_parser = subs.add_parser(
183 'issue', parents=[options_parser, advanced_parser],
184 help='Render an issue body',
185 description=('Render an issue subject and an issue body, '
186 'according to the given template.'))
187 issue_parser.add_argument(
188 '-t', '--template', dest='tmpl_path',
189 default=default_template,
190 help=('The template to use. Defaults to the provided '
191 'default.trac (Used only with -i/--issue).')
192 )
193
194 # Add the command for printing a list of changes
195 subs.add_parser(
196 'changes', parents=[options_parser, advanced_parser],
197 help='Generate a list of commits between two revisions',
198 description=('Generate a list of commit hashes and commit '
199 "messages between the dependency's current "
200 'revision and a given new revision.'))
201
202 # Add the command for changing and committing a dependency update
203 commit_parser = subs.add_parser(
204 'commit', help='Update and commit a dependency change',
205 parents=[options_parser],
206 description=('Rewrite and commit a dependency file to the new '
207 'revision. WARNING: This actually changes your '
208 "repository's history, use with care!"))
209 commit_parser.add_argument(
210 'issue_number', help=('The issue number, filed on '
211 'https://issues.adblockplus.org'))
212 commit_parser.set_defaults(skip_mirror=False, lookup_inotes=False,
213 filename=None)
214
215 self.arguments = parser.parse_args(args if len(args) > 0 else None)
216
217 @property
218 def dep_config(self):
219 """Provide the dependencies by using ensure_dependencies.read_dep().
220
221 Since this program is meant to be run inside a repository which uses
222 the buildtools' dependency functionalities, we are sure that
223 ensure_dependencies.py and dependencies exist.
224
225 However, ensure_dependencies is currently only compatible with python2.
226 Due to this we explicitly invoke a python2 interpreter to run our
227 dependencies.py, which runs ensure_dependencies.read_deps() and returns
228 the output as JSON data.
229 """
230 if self._dep_config is None:
231 dependencies_script = os.path.join(
232 os.path.dirname(os.path.realpath(__file__)), 'dependencies.py')
233
234 dep_json = subprocess.check_output(
235 ['python2', dependencies_script]
236 ).decode('utf-8')
237 self._dep_config = json.loads(dep_json)
238 return self._dep_config
239
240 @property
241 def base_revision(self):
242 """Provide the current revision of the dependency to be processed."""
243 if self._base_revision is None:
244 for key in ['*', self._main_vcs.EXECUTABLE]:
245 rev = self.dep_config[self.arguments.dependency][key][1]
246 if rev is not None:
247 self._base_revision = rev
248 break
249 return self._base_revision
250
251 def _parse_changes(self, changes):
252 """Parse the changelist to issues / noissues."""
253 issue_ids = set()
254 noissues = []
255 for change in changes:
256 match = self.ISSUE_NUMBER_REGEX.search(change['message'])
257 if match:
258 issue_ids.add(match.group(2))
259 else:
260 noissues.append(change)
261 if not self.NOISSUE_REGEX.search(change['message']):
262 msg = (
263 'warning: no issue reference in commit message: '
264 '"{message}" (commit {hg_hash} | {git_hash})\n'
265 ).format(**change)
266 logger.warn(msg)
267
268 return issue_ids, noissues
269
270 @property
271 def parsed_changes(self):
272 """Provide the list of changes, separated by issues and noissues.
273
274 Returns a dictionary, containing the following two key/value pairs:
275 'issue_ids': a list of issue IDs (as seen on
276 https://issues.adblockplus.org/)
277 'noissues': The remaining changes, with all original information (see
278 DepUpdate.changes) which could not be associated with any
279 issue.
280 """
281 if self._parsed_changes is None:
282 self._parsed_changes = {}
283 issue_ids, noissues = self._parse_changes(self.changes)
284 self._parsed_changes['issue_ids'] = issue_ids
285 self._parsed_changes['noissues'] = noissues
286 return self._parsed_changes
287
288 def _possible_sources(self):
289 root_conf = self._dep_config['_root']
290 config = self.dep_config[self.arguments.dependency]
291
292 # The fallback / main source paths for a repository are given in the
293 # dependencies file's _root section.
294 keys = ['hg', 'git']
295 possible_sources = {}
296 possible_sources.update({
297 key + '_root': root_conf[key]
298 for key in keys
299 })
300
301 # Any dependency may specify a custom source location.
302 possible_sources.update({
303 key: source for key, source in [
304 (key, config.get(key, (None, None))[0]) for key in keys
305 ] if source is not None
306 })
307
308 return possible_sources
309
310 def _mirror_location(self):
311 possible_sources = self._possible_sources()
312 mirror_ex = self._main_vcs._other_cls.EXECUTABLE
313
314 # If the user specified a local mirror, use it. Otherwise use the
315 # mirror, which was specified in the dependencies file.
316 if self.arguments.local_mirror:
317 mirror = self.arguments.local_mirror
318 else:
319 for key in [mirror_ex, mirror_ex + '_root']:
320 if key in possible_sources:
321 mirror = possible_sources[key]
322 break
323 return mirror
324
325 def _make_dependencies_string(self, hg_source=None, hg_rev=None,
326 git_source=None, git_rev=None,
327 remote_name=None):
328 dependency = '{} = {}'.format(self.arguments.dependency,
329 remote_name or self.arguments.dependency)
330
331 for prefix, rev, source in [(' hg:', hg_rev, hg_source),
332 (' git:', git_rev, git_source)]:
333 if rev is not None:
334 dependency += prefix
335 if source is not None:
336 dependency += source + '@'
337 dependency += rev
338
339 return dependency
340
341 def _update_dependencies_file(self):
342 config = self.dep_config[self.arguments.dependency]
343
344 remote_repository_name, none_hash_rev = config.get('*', (None, None))
345 hg_source, hg_rev = config.get('hg', (None, None))
346 git_source, git_rev = config.get('git', (None, None))
347
348 current_entry = self._make_dependencies_string(
349 hg_source, hg_rev, git_source, git_rev, remote_repository_name
350 )
351
352 new_entry = self._make_dependencies_string(
353 hg_source, self.changes[0]['hg_hash'], git_source,
354 self.changes[0]['git_hash'], remote_repository_name
355 )
356
357 dependency_path = os.path.join(self._cwd, 'dependencies')
358 with io.open(dependency_path, 'r', encoding='utf-8') as fp:
359 current_deps = fp.read()
360 with io.open(dependency_path, 'w', encoding='utf-8') as fp:
361 fp.write(current_deps.replace(current_entry, new_entry))
362
363 def _update_copied_code(self):
364 subprocess.check_output(
365 ['python2', 'ensure_dependencies.py'],
366 cwd=self._cwd
367 )
368
369 def build_diff(self):
370 """Generate a unified diff of all changes."""
371 return self._main_vcs.merged_diff(self.base_revision,
372 self.arguments.new_revision,
373 self.arguments.unified_lines)
374
375 def build_issue(self):
376 """Process all changes and render an issue."""
377 context = {}
378 context['repository'] = self.arguments.dependency
379 context['issue_ids'] = self.parsed_changes['issue_ids']
380 context['noissues'] = self.parsed_changes['noissues']
381 context['hg_hash'] = self.changes[0]['hg_hash']
382 context['git_hash'] = self.changes[0]['git_hash']
383 context['raw_changes'] = self.changes
384
385 path, filename = os.path.split(self.arguments.tmpl_path)
386
387 return jinja2.Environment(
388 loader=jinja2.FileSystemLoader(path or './')
389 ).get_template(filename).render(context)
390
391 def lookup_integration_notes(self):
392 """Search for any "Integration notes" mentions at the issue-tracker.
393
394 Cycle through the list of issue IDs and search for "Integration Notes"
395 in the associated issue on https://issues.adblockplus.org. If found,
396 write the corresponding url to STDERR.
397 """
398 # Let logger show INFO-level messages
399 logger.setLevel(logging.INFO)
400 integration_notes_regex = re.compile(r'Integration\s*notes', re.I)
401
402 def from_url(issue_url):
403 html = ''
404 content = urlopen(issue_url)
405
406 for line in content:
407 html += line.decode('utf-8')
408 return html
409
410 for issue_id in self.parsed_changes['issue_ids']:
411 issue_url = 'https://issues.adblockplus.org/ticket/' + issue_id
412 html = from_url(issue_url)
413 if not integration_notes_regex.search(html):
414 continue
415
416 logger.info('Integration notes found: ' + issue_url)
417
418 def build_changes(self):
419 """Write a descriptive list of the changes to STDOUT."""
420 return os.linesep.join(
421 [(
422 '( hg:{hg_hash} | git:{git_hash} ) : {message} (by {author})'
423 ).format(**change) for change in self.changes]
424 )
425
426 def commit_update(self):
427 """Commit the new dependency and potentially updated files."""
428 commit_msg = 'Issue {} - Update {} to {}'.format(
429 self.arguments.issue_number, self.arguments.dependency,
430 ' / '.join((self.changes[0]['hg_hash'],
431 self.changes[0]['git_hash'])))
432 try:
433 self._update_dependencies_file()
434 self._update_copied_code()
435 self.root_repo.commit_changes(commit_msg)
436
437 return commit_msg
438 except subprocess.CalledProcessError:
439 self._main_vcs.undo_changes()
440 logger.error('Could not safely commit the changes. Reverting.')
441
442 def __call__(self):
443 """Let this class's objects be callable, run all desired tasks."""
444 action_map = {
445 'diff': self.build_diff,
446 'changes': self.build_changes,
447 'issue': self.build_issue,
448 'commit': self.commit_update,
449 }
450
451 if len(self.changes) == 0:
452 print('NO CHANGES FOUND. You are either trying to update to a '
453 'revision, which the dependency already is at - or '
454 'something went wrong while executing the vcs.')
455 return
456
457 if self.arguments.lookup_inotes:
458 self.lookup_integration_notes()
459
460 output = action_map[self.arguments.action]()
461 if self.arguments.filename is not None:
462 with io.open(self.arguments.filename, 'w', encoding='utf-8') as fp:
463 fp.write(output)
464 print('Output writen to ' + self.arguments.filename)
465 else:
466 print(output)
OLDNEW
« no previous file with comments | « eyeo-depup/src/dependencies.py ('k') | eyeo-depup/src/templates/default.trac » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld