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

Side by Side Diff: src/depup.py

Issue 29599579: OffTopic: DependencyUpdater
Patch Set: Created Nov. 6, 2017, 2:04 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
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 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 """
tlucas 2017/11/06 14:15:13 This is absolutely odd - but i didn't want to copy
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 """Write a unified diff of all changes to STDOUT."""
253 print(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
266 path, filename = os.path.split(self.arguments.tmpl_path)
267
268 return jinja2.Environment(
269 loader=jinja2.FileSystemLoader(path or './')
270 ).get_template(filename).render(context)
271
272 def lookup_integration_notes(self):
273 """Search for any "Integration notes" mentions at the issue-tracker.
274
275 Cycle through the list of issue IDs and search for "Integration Notes"
276 in the associated issue on https://issues.adblockplus.org. If found,
277 write the corresponding url to STDERR.
278 """
279 integration_notes_regex = re.compile(r'Integration\s*notes', re.I)
280
281 def from_url(issue_url):
282 html = ''
283 content = urlopen(issue_url)
284
285 for line in content:
286 html += line.decode('utf-8')
287 return html
288
289 for issue_id in self.parsed_changes['issue_ids']:
290 issue_url = 'https://issues.adblockplus.org/ticket/' + issue_id
291 html = from_url(issue_url)
292 if not integration_notes_regex.search(html):
293 continue
294
295 print('Integration notes found: ' + issue_url, file=sys.stderr)
296
297 def build_changes(self):
298 """Write a descriptive list of the changes to STDOUT."""
299 return os.linesep.join(
300 [(
301 '( hg:{hg_hash} | git:{git_hash} ) : {message} (by {author})'
302 ).format(**change) for change in self.changes]
303 )
304
305 def _possible_sources(self):
306 root_conf = self._dep_config['_root']
307 config = self.dep_config[self.arguments.dependency]
308
309 # The fallback / main source paths for a repository are given in the
310 # dependencies file's _root section.
311 keys = ['hg', 'git']
312 possible_sources = {}
313 possible_sources.update({
314 key + '_root': root_conf[key]
315 for key in keys
316 })
317
318 # Any dependency may specify a custom source location.
319 possible_sources.update({
320 key: source for key, source in [
321 (key, config.get(key, (None, None))[0]) for key in keys
322 ] if source is not None
323 })
324
325 return possible_sources
326
327 def _mirror_location(self):
328 possible_sources = self._possible_sources()
329 mirror_ex = self._main_vcs._other_cls.EXECUTABLE
330
331 # If the user specified a local mirror, use it. Otherwise use the
332 # mirror, which was specified in the dependencies file.
333 if self.arguments.local_mirror:
334 mirror = self.arguments.local_mirror
335 else:
336 for key in [mirror_ex, mirror_ex + '_root']:
337 if key in possible_sources:
338 mirror = possible_sources[key]
339 break
340 return mirror
341
342 def __call__(self):
343 """Let this class's objects be callable, run all desired tasks."""
344 action_map = {
345 'diff': self.build_diff,
346 'changes': self.build_changes,
347 'issue': self.build_issue,
348 }
349
350 if len(self.changes) == 0:
351 print('NO CHANGES FOUND. You are either trying to update to a '
352 'revision, which the dependency already is at - or '
353 'something went wrong while executing the vcs.')
354 return
355
356 if self.arguments.lookup_inotes:
357 self.lookup_integration_notes()
358
359 output = action_map[self.arguments.action]()
360 if self.arguments.filename is not None:
361 with io.open(self.arguments.filename, 'w', encoding='utf-8') as fp:
362 fp.write(output)
363 print('Output writen to ' + self.arguments.filename)
364 else:
365 print(output)
OLDNEW

Powered by Google App Engine
This is Rietveld