Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(140)

Side by Side Diff: upload.py

Issue 562031: Fix licensing headers and move most third party code to third_party/... (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/depot_tools/
Patch Set: '' Created 10 years, 10 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« tests/gclient_scm_test.py ('K') | « third_party/upload.py ('k') | no next file » | 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 # Copyright 2007 Google Inc.
4 #
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
8 #
9 # http://www.apache.org/licenses/LICENSE-2.0
10 #
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
16
17 """Tool for uploading diffs from a version control system to the codereview app.
18
19 Usage summary: upload.py [options] [-- diff_options]
20
21 Diff options are passed to the diff command of the underlying system.
22
23 Supported version control systems:
24 Git
25 Mercurial
26 Subversion
27
28 It is important for Git/Mercurial users to specify a tree/node/branch to diff
29 against by using the '--rev' option.
30 """
31 # This code is derived from appcfg.py in the App Engine SDK (open source),
32 # and from ASPN recipe #146306.
33
34 import cookielib
35 import getpass
36 import logging
37 import mimetypes
38 import optparse
39 import os
40 import re
41 import socket
42 import subprocess
43 import sys
44 import urllib
45 import urllib2
46 import urlparse
47
48 # The md5 module was deprecated in Python 2.5.
49 try:
50 from hashlib import md5
51 except ImportError:
52 from md5 import md5
53
54 try:
55 import readline
56 except ImportError:
57 pass
58
59 # The logging verbosity:
60 # 0: Errors only.
61 # 1: Status messages.
62 # 2: Info logs.
63 # 3: Debug logs.
64 verbosity = 1
65
66 # Max size of patch or base file.
67 MAX_UPLOAD_SIZE = 900 * 1024
68
69 # Constants for version control names. Used by GuessVCSName.
70 VCS_GIT = "Git"
71 VCS_MERCURIAL = "Mercurial"
72 VCS_SUBVERSION = "Subversion"
73 VCS_UNKNOWN = "Unknown"
74
75 # whitelist for non-binary filetypes which do not start with "text/"
76 # .mm (Objective-C) shows up as application/x-freemind on my Linux box.
77 TEXT_MIMETYPES = ['application/javascript', 'application/x-javascript',
78 'application/x-freemind']
79
80 VCS_ABBREVIATIONS = {
81 VCS_MERCURIAL.lower(): VCS_MERCURIAL,
82 "hg": VCS_MERCURIAL,
83 VCS_SUBVERSION.lower(): VCS_SUBVERSION,
84 "svn": VCS_SUBVERSION,
85 VCS_GIT.lower(): VCS_GIT,
86 }
87
88
89 def GetEmail(prompt):
90 """Prompts the user for their email address and returns it.
91
92 The last used email address is saved to a file and offered up as a suggestion
93 to the user. If the user presses enter without typing in anything the last
94 used email address is used. If the user enters a new address, it is saved
95 for next time we prompt.
96
97 """
98 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
99 last_email = ""
100 if os.path.exists(last_email_file_name):
101 try:
102 last_email_file = open(last_email_file_name, "r")
103 last_email = last_email_file.readline().strip("\n")
104 last_email_file.close()
105 prompt += " [%s]" % last_email
106 except IOError, e:
107 pass
108 email = raw_input(prompt + ": ").strip()
109 if email:
110 try:
111 last_email_file = open(last_email_file_name, "w")
112 last_email_file.write(email)
113 last_email_file.close()
114 except IOError, e:
115 pass
116 else:
117 email = last_email
118 return email
119
120
121 def StatusUpdate(msg):
122 """Print a status message to stdout.
123
124 If 'verbosity' is greater than 0, print the message.
125
126 Args:
127 msg: The string to print.
128 """
129 if verbosity > 0:
130 print msg
131
132
133 def ErrorExit(msg):
134 """Print an error message to stderr and exit."""
135 print >>sys.stderr, msg
136 sys.exit(1)
137
138
139 class ClientLoginError(urllib2.HTTPError):
140 """Raised to indicate there was an error authenticating with ClientLogin."""
141
142 def __init__(self, url, code, msg, headers, args):
143 urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
144 self.args = args
145 self.reason = args["Error"]
146
147
148 class AbstractRpcServer(object):
149 """Provides a common interface for a simple RPC server."""
150
151 def __init__(self, host, auth_function, host_override=None, extra_headers={},
152 save_cookies=False):
153 """Creates a new HttpRpcServer.
154
155 Args:
156 host: The host to send requests to.
157 auth_function: A function that takes no arguments and returns an
158 (email, password) tuple when called. Will be called if authentication
159 is required.
160 host_override: The host header to send to the server (defaults to host).
161 extra_headers: A dict of extra headers to append to every request.
162 save_cookies: If True, save the authentication cookies to local disk.
163 If False, use an in-memory cookiejar instead. Subclasses must
164 implement this functionality. Defaults to False.
165 """
166 self.host = host
167 self.host_override = host_override
168 self.auth_function = auth_function
169 self.authenticated = False
170 self.extra_headers = extra_headers
171 self.save_cookies = save_cookies
172 self.opener = self._GetOpener()
173 if self.host_override:
174 logging.info("Server: %s; Host: %s", self.host, self.host_override)
175 else:
176 logging.info("Server: %s", self.host)
177
178 def _GetOpener(self):
179 """Returns an OpenerDirector for making HTTP requests.
180
181 Returns:
182 A urllib2.OpenerDirector object.
183 """
184 raise NotImplementedError()
185
186 def _CreateRequest(self, url, data=None):
187 """Creates a new urllib request."""
188 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
189 req = urllib2.Request(url, data=data)
190 if self.host_override:
191 req.add_header("Host", self.host_override)
192 for key, value in self.extra_headers.iteritems():
193 req.add_header(key, value)
194 return req
195
196 def _GetAuthToken(self, email, password):
197 """Uses ClientLogin to authenticate the user, returning an auth token.
198
199 Args:
200 email: The user's email address
201 password: The user's password
202
203 Raises:
204 ClientLoginError: If there was an error authenticating with ClientLogin.
205 HTTPError: If there was some other form of HTTP error.
206
207 Returns:
208 The authentication token returned by ClientLogin.
209 """
210 account_type = "GOOGLE"
211 req = self._CreateRequest(
212 url="https://www.google.com/accounts/ClientLogin",
213 data=urllib.urlencode({
214 "Email": email,
215 "Passwd": password,
216 "service": "ah",
217 "source": "rietveld-codereview-upload",
218 "accountType": account_type,
219 }),
220 )
221 try:
222 response = self.opener.open(req)
223 response_body = response.read()
224 response_dict = dict(x.split("=")
225 for x in response_body.split("\n") if x)
226 return response_dict["Auth"]
227 except urllib2.HTTPError, e:
228 if e.code == 403:
229 # Try a temporary workaround.
230 if self.host.endswith(".google.com"):
231 account_type = "HOSTED"
232 req = self._CreateRequest(
233 url="https://www.google.com/accounts/ClientLogin",
234 data=urllib.urlencode({
235 "Email": email,
236 "Passwd": password,
237 "service": "ah",
238 "source": "rietveld-codereview-upload",
239 "accountType": account_type,
240 }),
241 )
242 try:
243 response = self.opener.open(req)
244 response_body = response.read()
245 response_dict = dict(x.split("=")
246 for x in response_body.split("\n") if x)
247 return response_dict["Auth"]
248 except urllib2.HTTPError, e:
249 if e.code == 403:
250 body = e.read()
251 response_dict = dict(x.split("=", 1) for x in body.split("\n")
252 if x)
253 raise ClientLoginError(req.get_full_url(), e.code, e.msg,
254 e.headers, response_dict)
255 else:
256 raise
257
258 body = e.read()
259 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
260 raise ClientLoginError(req.get_full_url(), e.code, e.msg,
261 e.headers, response_dict)
262 else:
263 raise
264
265 def _GetAuthCookie(self, auth_token):
266 """Fetches authentication cookies for an authentication token.
267
268 Args:
269 auth_token: The authentication token returned by ClientLogin.
270
271 Raises:
272 HTTPError: If there was an error fetching the authentication cookies.
273 """
274 # This is a dummy value to allow us to identify when we're successful.
275 continue_location = "http://localhost/"
276 args = {"continue": continue_location, "auth": auth_token}
277 req = self._CreateRequest("http://%s/_ah/login?%s" %
278 (self.host, urllib.urlencode(args)))
279 try:
280 response = self.opener.open(req)
281 except urllib2.HTTPError, e:
282 response = e
283 if (response.code != 302 or
284 response.info()["location"] != continue_location):
285 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg,
286 response.headers, response.fp)
287 self.authenticated = True
288
289 def _Authenticate(self):
290 """Authenticates the user.
291
292 The authentication process works as follows:
293 1) We get a username and password from the user
294 2) We use ClientLogin to obtain an AUTH token for the user
295 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
296 3) We pass the auth token to /_ah/login on the server to obtain an
297 authentication cookie. If login was successful, it tries to redirect
298 us to the URL we provided.
299
300 If we attempt to access the upload API without first obtaining an
301 authentication cookie, it returns a 401 response (or a 302) and
302 directs us to authenticate ourselves with ClientLogin.
303 """
304 for i in range(3):
305 credentials = self.auth_function()
306 try:
307 auth_token = self._GetAuthToken(credentials[0], credentials[1])
308 except ClientLoginError, e:
309 if e.reason == "BadAuthentication":
310 print >>sys.stderr, "Invalid username or password."
311 continue
312 if e.reason == "CaptchaRequired":
313 print >>sys.stderr, (
314 "Please go to\n"
315 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
316 "and verify you are a human. Then try again.")
317 break
318 if e.reason == "NotVerified":
319 print >>sys.stderr, "Account not verified."
320 break
321 if e.reason == "TermsNotAgreed":
322 print >>sys.stderr, "User has not agreed to TOS."
323 break
324 if e.reason == "AccountDeleted":
325 print >>sys.stderr, "The user account has been deleted."
326 break
327 if e.reason == "AccountDisabled":
328 print >>sys.stderr, "The user account has been disabled."
329 break
330 if e.reason == "ServiceDisabled":
331 print >>sys.stderr, ("The user's access to the service has been "
332 "disabled.")
333 break
334 if e.reason == "ServiceUnavailable":
335 print >>sys.stderr, "The service is not available; try again later."
336 break
337 raise
338 self._GetAuthCookie(auth_token)
339 return
340
341 def Send(self, request_path, payload=None,
342 content_type="application/octet-stream",
343 timeout=None,
344 **kwargs):
345 """Sends an RPC and returns the response.
346
347 Args:
348 request_path: The path to send the request to, eg /api/appversion/create.
349 payload: The body of the request, or None to send an empty request.
350 content_type: The Content-Type header to use.
351 timeout: timeout in seconds; default None i.e. no timeout.
352 (Note: for large requests on OS X, the timeout doesn't work right.)
353 kwargs: Any keyword arguments are converted into query string parameters.
354
355 Returns:
356 The response body, as a string.
357 """
358 # TODO: Don't require authentication. Let the server say
359 # whether it is necessary.
360 if not self.authenticated:
361 self._Authenticate()
362
363 old_timeout = socket.getdefaulttimeout()
364 socket.setdefaulttimeout(timeout)
365 try:
366 tries = 0
367 while True:
368 tries += 1
369 args = dict(kwargs)
370 url = "http://%s%s" % (self.host, request_path)
371 if args:
372 url += "?" + urllib.urlencode(args)
373 req = self._CreateRequest(url=url, data=payload)
374 req.add_header("Content-Type", content_type)
375 try:
376 f = self.opener.open(req)
377 response = f.read()
378 f.close()
379 return response
380 except urllib2.HTTPError, e:
381 if tries > 3:
382 raise
383 elif e.code == 401 or e.code == 302:
384 self._Authenticate()
385 ## elif e.code >= 500 and e.code < 600:
386 ## # Server Error - try again.
387 ## continue
388 else:
389 raise
390 finally:
391 socket.setdefaulttimeout(old_timeout)
392
393
394 class HttpRpcServer(AbstractRpcServer):
395 """Provides a simplified RPC-style interface for HTTP requests."""
396
397 def _Authenticate(self):
398 """Save the cookie jar after authentication."""
399 super(HttpRpcServer, self)._Authenticate()
400 if self.save_cookies:
401 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
402 self.cookie_jar.save()
403
404 def _GetOpener(self):
405 """Returns an OpenerDirector that supports cookies and ignores redirects.
406
407 Returns:
408 A urllib2.OpenerDirector object.
409 """
410 opener = urllib2.OpenerDirector()
411 opener.add_handler(urllib2.ProxyHandler())
412 opener.add_handler(urllib2.UnknownHandler())
413 opener.add_handler(urllib2.HTTPHandler())
414 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
415 opener.add_handler(urllib2.HTTPSHandler())
416 opener.add_handler(urllib2.HTTPErrorProcessor())
417 if self.save_cookies:
418 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies")
419 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
420 if os.path.exists(self.cookie_file):
421 try:
422 self.cookie_jar.load()
423 self.authenticated = True
424 StatusUpdate("Loaded authentication cookies from %s" %
425 self.cookie_file)
426 except (cookielib.LoadError, IOError):
427 # Failed to load cookies - just ignore them.
428 pass
429 else:
430 # Create an empty cookie file with mode 600
431 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
432 os.close(fd)
433 # Always chmod the cookie file
434 os.chmod(self.cookie_file, 0600)
435 else:
436 # Don't save cookies across runs of update.py.
437 self.cookie_jar = cookielib.CookieJar()
438 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
439 return opener
440
441
442 parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]")
443 parser.add_option("-y", "--assume_yes", action="store_true",
444 dest="assume_yes", default=False,
445 help="Assume that the answer to yes/no questions is 'yes'.")
446 # Logging
447 group = parser.add_option_group("Logging options")
448 group.add_option("-q", "--quiet", action="store_const", const=0,
449 dest="verbose", help="Print errors only.")
450 group.add_option("-v", "--verbose", action="store_const", const=2,
451 dest="verbose", default=1,
452 help="Print info level logs (default).")
453 group.add_option("--noisy", action="store_const", const=3,
454 dest="verbose", help="Print all logs.")
455 # Review server
456 group = parser.add_option_group("Review server options")
457 group.add_option("-s", "--server", action="store", dest="server",
458 default="codereview.appspot.com",
459 metavar="SERVER",
460 help=("The server to upload to. The format is host[:port]. "
461 "Defaults to '%default'."))
462 group.add_option("-e", "--email", action="store", dest="email",
463 metavar="EMAIL", default=None,
464 help="The username to use. Will prompt if omitted.")
465 group.add_option("-H", "--host", action="store", dest="host",
466 metavar="HOST", default=None,
467 help="Overrides the Host header sent with all RPCs.")
468 group.add_option("--no_cookies", action="store_false",
469 dest="save_cookies", default=True,
470 help="Do not save authentication cookies to local disk.")
471 # Issue
472 group = parser.add_option_group("Issue options")
473 group.add_option("-d", "--description", action="store", dest="description",
474 metavar="DESCRIPTION", default=None,
475 help="Optional description when creating an issue.")
476 group.add_option("-f", "--description_file", action="store",
477 dest="description_file", metavar="DESCRIPTION_FILE",
478 default=None,
479 help="Optional path of a file that contains "
480 "the description when creating an issue.")
481 group.add_option("-r", "--reviewers", action="store", dest="reviewers",
482 metavar="REVIEWERS", default=None,
483 help="Add reviewers (comma separated email addresses).")
484 group.add_option("--cc", action="store", dest="cc",
485 metavar="CC", default=None,
486 help="Add CC (comma separated email addresses).")
487 group.add_option("--private", action="store_true", dest="private",
488 default=False,
489 help="Make the issue restricted to reviewers and those CCed")
490 # Upload options
491 group = parser.add_option_group("Patch options")
492 group.add_option("-m", "--message", action="store", dest="message",
493 metavar="MESSAGE", default=None,
494 help="A message to identify the patch. "
495 "Will prompt if omitted.")
496 group.add_option("-i", "--issue", type="int", action="store",
497 metavar="ISSUE", default=None,
498 help="Issue number to which to add. Defaults to new issue.")
499 group.add_option("--download_base", action="store_true",
500 dest="download_base", default=False,
501 help="Base files will be downloaded by the server "
502 "(side-by-side diffs may not work on files with CRs).")
503 group.add_option("--rev", action="store", dest="revision",
504 metavar="REV", default=None,
505 help="Branch/tree/revision to diff against (used by DVCS).")
506 group.add_option("--send_mail", action="store_true",
507 dest="send_mail", default=False,
508 help="Send notification email to reviewers.")
509 group.add_option("--vcs", action="store", dest="vcs",
510 metavar="VCS", default=None,
511 help=("Version control system (optional, usually upload.py "
512 "already guesses the right VCS)."))
513
514
515 def GetRpcServer(options):
516 """Returns an instance of an AbstractRpcServer.
517
518 Returns:
519 A new AbstractRpcServer, on which RPC calls can be made.
520 """
521
522 rpc_server_class = HttpRpcServer
523
524 def GetUserCredentials():
525 """Prompts the user for a username and password."""
526 email = options.email
527 if email is None:
528 email = GetEmail("Email (login for uploading to %s)" % options.server)
529 password = getpass.getpass("Password for %s: " % email)
530 return (email, password)
531
532 # If this is the dev_appserver, use fake authentication.
533 host = (options.host or options.server).lower()
534 if host == "localhost" or host.startswith("localhost:"):
535 email = options.email
536 if email is None:
537 email = "test@example.com"
538 logging.info("Using debug user %s. Override with --email" % email)
539 server = rpc_server_class(
540 options.server,
541 lambda: (email, "password"),
542 host_override=options.host,
543 extra_headers={"Cookie":
544 'dev_appserver_login="%s:False"' % email},
545 save_cookies=options.save_cookies)
546 # Don't try to talk to ClientLogin.
547 server.authenticated = True
548 return server
549
550 return rpc_server_class(options.server, GetUserCredentials,
551 host_override=options.host,
552 save_cookies=options.save_cookies)
553
554
555 def EncodeMultipartFormData(fields, files):
556 """Encode form fields for multipart/form-data.
557
558 Args:
559 fields: A sequence of (name, value) elements for regular form fields.
560 files: A sequence of (name, filename, value) elements for data to be
561 uploaded as files.
562 Returns:
563 (content_type, body) ready for httplib.HTTP instance.
564
565 Source:
566 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
567 """
568 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
569 CRLF = '\r\n'
570 lines = []
571 for (key, value) in fields:
572 lines.append('--' + BOUNDARY)
573 lines.append('Content-Disposition: form-data; name="%s"' % key)
574 lines.append('')
575 lines.append(value)
576 for (key, filename, value) in files:
577 lines.append('--' + BOUNDARY)
578 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' %
579 (key, filename))
580 lines.append('Content-Type: %s' % GetContentType(filename))
581 lines.append('')
582 lines.append(value)
583 lines.append('--' + BOUNDARY + '--')
584 lines.append('')
585 body = CRLF.join(lines)
586 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
587 return content_type, body
588
589
590 def GetContentType(filename):
591 """Helper to guess the content-type from the filename."""
592 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
593
594
595 # Use a shell for subcommands on Windows to get a PATH search.
596 use_shell = sys.platform.startswith("win")
597
598 def RunShellWithReturnCode(command, print_output=False,
599 universal_newlines=True,
600 env=os.environ):
601 """Executes a command and returns the output from stdout and the return code.
602
603 Args:
604 command: Command to execute.
605 print_output: If True, the output is printed to stdout.
606 If False, both stdout and stderr are ignored.
607 universal_newlines: Use universal_newlines flag (default: True).
608
609 Returns:
610 Tuple (output, return code)
611 """
612 logging.info("Running %s", command)
613 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
614 shell=use_shell, universal_newlines=universal_newlines,
615 env=env)
616 if print_output:
617 output_array = []
618 while True:
619 line = p.stdout.readline()
620 if not line:
621 break
622 print line.strip("\n")
623 output_array.append(line)
624 output = "".join(output_array)
625 else:
626 output = p.stdout.read()
627 p.wait()
628 errout = p.stderr.read()
629 if print_output and errout:
630 print >>sys.stderr, errout
631 p.stdout.close()
632 p.stderr.close()
633 return output, p.returncode
634
635
636 def RunShell(command, silent_ok=False, universal_newlines=True,
637 print_output=False, env=os.environ):
638 data, retcode = RunShellWithReturnCode(command, print_output,
639 universal_newlines, env)
640 if retcode:
641 ErrorExit("Got error status from %s:\n%s" % (command, data))
642 if not silent_ok and not data:
643 ErrorExit("No output from %s" % command)
644 return data
645
646
647 class VersionControlSystem(object):
648 """Abstract base class providing an interface to the VCS."""
649
650 def __init__(self, options):
651 """Constructor.
652
653 Args:
654 options: Command line options.
655 """
656 self.options = options
657
658 def GenerateDiff(self, args):
659 """Return the current diff as a string.
660
661 Args:
662 args: Extra arguments to pass to the diff command.
663 """
664 raise NotImplementedError(
665 "abstract method -- subclass %s must override" % self.__class__)
666
667 def GetUnknownFiles(self):
668 """Return a list of files unknown to the VCS."""
669 raise NotImplementedError(
670 "abstract method -- subclass %s must override" % self.__class__)
671
672 def CheckForUnknownFiles(self):
673 """Show an "are you sure?" prompt if there are unknown files."""
674 unknown_files = self.GetUnknownFiles()
675 if unknown_files:
676 print "The following files are not added to version control:"
677 for line in unknown_files:
678 print line
679 prompt = "Are you sure to continue?(y/N) "
680 answer = raw_input(prompt).strip()
681 if answer != "y":
682 ErrorExit("User aborted")
683
684 def GetBaseFile(self, filename):
685 """Get the content of the upstream version of a file.
686
687 Returns:
688 A tuple (base_content, new_content, is_binary, status)
689 base_content: The contents of the base file.
690 new_content: For text files, this is empty. For binary files, this is
691 the contents of the new file, since the diff output won't contain
692 information to reconstruct the current file.
693 is_binary: True iff the file is binary.
694 status: The status of the file.
695 """
696
697 raise NotImplementedError(
698 "abstract method -- subclass %s must override" % self.__class__)
699
700
701 def GetBaseFiles(self, diff):
702 """Helper that calls GetBase file for each file in the patch.
703
704 Returns:
705 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
706 are retrieved based on lines that start with "Index:" or
707 "Property changes on:".
708 """
709 files = {}
710 for line in diff.splitlines(True):
711 if line.startswith('Index:') or line.startswith('Property changes on:'):
712 unused, filename = line.split(':', 1)
713 # On Windows if a file has property changes its filename uses '\'
714 # instead of '/'.
715 filename = filename.strip().replace('\\', '/')
716 files[filename] = self.GetBaseFile(filename)
717 return files
718
719
720 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
721 files):
722 """Uploads the base files (and if necessary, the current ones as well)."""
723
724 def UploadFile(filename, file_id, content, is_binary, status, is_base):
725 """Uploads a file to the server."""
726 file_too_large = False
727 if is_base:
728 type = "base"
729 else:
730 type = "current"
731 if len(content) > MAX_UPLOAD_SIZE:
732 print ("Not uploading the %s file for %s because it's too large." %
733 (type, filename))
734 file_too_large = True
735 content = ""
736 checksum = md5(content).hexdigest()
737 if options.verbose > 0 and not file_too_large:
738 print "Uploading %s file for %s" % (type, filename)
739 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
740 form_fields = [("filename", filename),
741 ("status", status),
742 ("checksum", checksum),
743 ("is_binary", str(is_binary)),
744 ("is_current", str(not is_base)),
745 ]
746 if file_too_large:
747 form_fields.append(("file_too_large", "1"))
748 if options.email:
749 form_fields.append(("user", options.email))
750 ctype, body = EncodeMultipartFormData(form_fields,
751 [("data", filename, content)])
752 response_body = rpc_server.Send(url, body,
753 content_type=ctype)
754 if not response_body.startswith("OK"):
755 StatusUpdate(" --> %s" % response_body)
756 sys.exit(1)
757
758 patches = dict()
759 [patches.setdefault(v, k) for k, v in patch_list]
760 for filename in patches.keys():
761 base_content, new_content, is_binary, status = files[filename]
762 file_id_str = patches.get(filename)
763 if file_id_str.find("nobase") != -1:
764 base_content = None
765 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
766 file_id = int(file_id_str)
767 if base_content != None:
768 UploadFile(filename, file_id, base_content, is_binary, status, True)
769 if new_content != None:
770 UploadFile(filename, file_id, new_content, is_binary, status, False)
771
772 def IsImage(self, filename):
773 """Returns true if the filename has an image extension."""
774 mimetype = mimetypes.guess_type(filename)[0]
775 if not mimetype:
776 return False
777 return mimetype.startswith("image/")
778
779 def IsBinary(self, filename):
780 """Returns true if the guessed mimetyped isnt't in text group."""
781 mimetype = mimetypes.guess_type(filename)[0]
782 if not mimetype:
783 return False # e.g. README, "real" binaries usually have an extension
784 # special case for text files which don't start with text/
785 if mimetype in TEXT_MIMETYPES:
786 return False
787 return not mimetype.startswith("text/")
788
789
790 class SubversionVCS(VersionControlSystem):
791 """Implementation of the VersionControlSystem interface for Subversion."""
792
793 def __init__(self, options):
794 super(SubversionVCS, self).__init__(options)
795 if self.options.revision:
796 match = re.match(r"(\d+)(:(\d+))?", self.options.revision)
797 if not match:
798 ErrorExit("Invalid Subversion revision %s." % self.options.revision)
799 self.rev_start = match.group(1)
800 self.rev_end = match.group(3)
801 else:
802 self.rev_start = self.rev_end = None
803 # Cache output from "svn list -r REVNO dirname".
804 # Keys: dirname, Values: 2-tuple (ouput for start rev and end rev).
805 self.svnls_cache = {}
806 # SVN base URL is required to fetch files deleted in an older revision.
807 # Result is cached to not guess it over and over again in GetBaseFile().
808 required = self.options.download_base or self.options.revision is not None
809 self.svn_base = self._GuessBase(required)
810
811 def GuessBase(self, required):
812 """Wrapper for _GuessBase."""
813 return self.svn_base
814
815 def _GuessBase(self, required):
816 """Returns the SVN base URL.
817
818 Args:
819 required: If true, exits if the url can't be guessed, otherwise None is
820 returned.
821 """
822 info = RunShell(["svn", "info"])
823 for line in info.splitlines():
824 words = line.split()
825 if len(words) == 2 and words[0] == "URL:":
826 url = words[1]
827 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
828 username, netloc = urllib.splituser(netloc)
829 if username:
830 logging.info("Removed username from base URL")
831 if netloc.endswith("svn.python.org"):
832 if netloc == "svn.python.org":
833 if path.startswith("/projects/"):
834 path = path[9:]
835 elif netloc != "pythondev@svn.python.org":
836 ErrorExit("Unrecognized Python URL: %s" % url)
837 base = "http://svn.python.org/view/*checkout*%s/" % path
838 logging.info("Guessed Python base = %s", base)
839 elif netloc.endswith("svn.collab.net"):
840 if path.startswith("/repos/"):
841 path = path[6:]
842 base = "http://svn.collab.net/viewvc/*checkout*%s/" % path
843 logging.info("Guessed CollabNet base = %s", base)
844 elif netloc.endswith(".googlecode.com"):
845 path = path + "/"
846 base = urlparse.urlunparse(("http", netloc, path, params,
847 query, fragment))
848 logging.info("Guessed Google Code base = %s", base)
849 else:
850 path = path + "/"
851 base = urlparse.urlunparse((scheme, netloc, path, params,
852 query, fragment))
853 logging.info("Guessed base = %s", base)
854 return base
855 if required:
856 ErrorExit("Can't find URL in output from svn info")
857 return None
858
859 def GenerateDiff(self, args):
860 cmd = ["svn", "diff"]
861 if self.options.revision:
862 cmd += ["-r", self.options.revision]
863 cmd.extend(args)
864 data = RunShell(cmd)
865 count = 0
866 for line in data.splitlines():
867 if line.startswith("Index:") or line.startswith("Property changes on:"):
868 count += 1
869 logging.info(line)
870 if not count:
871 ErrorExit("No valid patches found in output from svn diff")
872 return data
873
874 def _CollapseKeywords(self, content, keyword_str):
875 """Collapses SVN keywords."""
876 # svn cat translates keywords but svn diff doesn't. As a result of this
877 # behavior patching.PatchChunks() fails with a chunk mismatch error.
878 # This part was originally written by the Review Board development team
879 # who had the same problem (http://reviews.review-board.org/r/276/).
880 # Mapping of keywords to known aliases
881 svn_keywords = {
882 # Standard keywords
883 'Date': ['Date', 'LastChangedDate'],
884 'Revision': ['Revision', 'LastChangedRevision', 'Rev'],
885 'Author': ['Author', 'LastChangedBy'],
886 'HeadURL': ['HeadURL', 'URL'],
887 'Id': ['Id'],
888
889 # Aliases
890 'LastChangedDate': ['LastChangedDate', 'Date'],
891 'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'],
892 'LastChangedBy': ['LastChangedBy', 'Author'],
893 'URL': ['URL', 'HeadURL'],
894 }
895
896 def repl(m):
897 if m.group(2):
898 return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
899 return "$%s$" % m.group(1)
900 keywords = [keyword
901 for name in keyword_str.split(" ")
902 for keyword in svn_keywords.get(name, [])]
903 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content)
904
905 def GetUnknownFiles(self):
906 status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True)
907 unknown_files = []
908 for line in status.split("\n"):
909 if line and line[0] == "?":
910 unknown_files.append(line)
911 return unknown_files
912
913 def ReadFile(self, filename):
914 """Returns the contents of a file."""
915 file = open(filename, 'rb')
916 result = ""
917 try:
918 result = file.read()
919 finally:
920 file.close()
921 return result
922
923 def GetStatus(self, filename):
924 """Returns the status of a file."""
925 if not self.options.revision:
926 status = RunShell(["svn", "status", "--ignore-externals", filename])
927 if not status:
928 ErrorExit("svn status returned no output for %s" % filename)
929 status_lines = status.splitlines()
930 # If file is in a cl, the output will begin with
931 # "\n--- Changelist 'cl_name':\n". See
932 # http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt
933 if (len(status_lines) == 3 and
934 not status_lines[0] and
935 status_lines[1].startswith("--- Changelist")):
936 status = status_lines[2]
937 else:
938 status = status_lines[0]
939 # If we have a revision to diff against we need to run "svn list"
940 # for the old and the new revision and compare the results to get
941 # the correct status for a file.
942 else:
943 dirname, relfilename = os.path.split(filename)
944 if dirname not in self.svnls_cache:
945 cmd = ["svn", "list", "-r", self.rev_start, dirname or "."]
946 out, returncode = RunShellWithReturnCode(cmd)
947 if returncode:
948 ErrorExit("Failed to get status for %s." % filename)
949 old_files = out.splitlines()
950 args = ["svn", "list"]
951 if self.rev_end:
952 args += ["-r", self.rev_end]
953 cmd = args + [dirname or "."]
954 out, returncode = RunShellWithReturnCode(cmd)
955 if returncode:
956 ErrorExit("Failed to run command %s" % cmd)
957 self.svnls_cache[dirname] = (old_files, out.splitlines())
958 old_files, new_files = self.svnls_cache[dirname]
959 if relfilename in old_files and relfilename not in new_files:
960 status = "D "
961 elif relfilename in old_files and relfilename in new_files:
962 status = "M "
963 else:
964 status = "A "
965 return status
966
967 def GetBaseFile(self, filename):
968 status = self.GetStatus(filename)
969 base_content = None
970 new_content = None
971
972 # If a file is copied its status will be "A +", which signifies
973 # "addition-with-history". See "svn st" for more information. We need to
974 # upload the original file or else diff parsing will fail if the file was
975 # edited.
976 if status[0] == "A" and status[3] != "+":
977 # We'll need to upload the new content if we're adding a binary file
978 # since diff's output won't contain it.
979 mimetype = RunShell(["svn", "propget", "svn:mime-type", filename],
980 silent_ok=True)
981 base_content = ""
982 is_binary = bool(mimetype) and not mimetype.startswith("text/")
983 if is_binary and self.IsImage(filename):
984 new_content = self.ReadFile(filename)
985 elif (status[0] in ("M", "D", "R") or
986 (status[0] == "A" and status[3] == "+") or # Copied file.
987 (status[0] == " " and status[1] == "M")): # Property change.
988 args = []
989 if self.options.revision:
990 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
991 else:
992 # Don't change filename, it's needed later.
993 url = filename
994 args += ["-r", "BASE"]
995 cmd = ["svn"] + args + ["propget", "svn:mime-type", url]
996 mimetype, returncode = RunShellWithReturnCode(cmd)
997 if returncode:
998 # File does not exist in the requested revision.
999 # Reset mimetype, it contains an error message.
1000 mimetype = ""
1001 get_base = False
1002 is_binary = bool(mimetype) and not mimetype.startswith("text/")
1003 if status[0] == " ":
1004 # Empty base content just to force an upload.
1005 base_content = ""
1006 elif is_binary:
1007 if self.IsImage(filename):
1008 get_base = True
1009 if status[0] == "M":
1010 if not self.rev_end:
1011 new_content = self.ReadFile(filename)
1012 else:
1013 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end)
1014 new_content = RunShell(["svn", "cat", url],
1015 universal_newlines=True, silent_ok=True)
1016 else:
1017 base_content = ""
1018 else:
1019 get_base = True
1020
1021 if get_base:
1022 if is_binary:
1023 universal_newlines = False
1024 else:
1025 universal_newlines = True
1026 if self.rev_start:
1027 # "svn cat -r REV delete_file.txt" doesn't work. cat requires
1028 # the full URL with "@REV" appended instead of using "-r" option.
1029 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1030 base_content = RunShell(["svn", "cat", url],
1031 universal_newlines=universal_newlines,
1032 silent_ok=True)
1033 else:
1034 base_content = RunShell(["svn", "cat", filename],
1035 universal_newlines=universal_newlines,
1036 silent_ok=True)
1037 if not is_binary:
1038 args = []
1039 if self.rev_start:
1040 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1041 else:
1042 url = filename
1043 args += ["-r", "BASE"]
1044 cmd = ["svn"] + args + ["propget", "svn:keywords", url]
1045 keywords, returncode = RunShellWithReturnCode(cmd)
1046 if keywords and not returncode:
1047 base_content = self._CollapseKeywords(base_content, keywords)
1048 else:
1049 StatusUpdate("svn status returned unexpected output: %s" % status)
1050 sys.exit(1)
1051 return base_content, new_content, is_binary, status[0:5]
1052
1053
1054 class GitVCS(VersionControlSystem):
1055 """Implementation of the VersionControlSystem interface for Git."""
1056
1057 def __init__(self, options):
1058 super(GitVCS, self).__init__(options)
1059 # Map of filename -> (hash before, hash after) of base file.
1060 # Hashes for "no such file" are represented as None.
1061 self.hashes = {}
1062 # Map of new filename -> old filename for renames.
1063 self.renames = {}
1064
1065 def GenerateDiff(self, extra_args):
1066 # This is more complicated than svn's GenerateDiff because we must convert
1067 # the diff output to include an svn-style "Index:" line as well as record
1068 # the hashes of the files, so we can upload them along with our diff.
1069
1070 # Special used by git to indicate "no such content".
1071 NULL_HASH = "0"*40
1072
1073 extra_args = extra_args[:]
1074 if self.options.revision:
1075 extra_args = [self.options.revision] + extra_args
1076
1077 # --no-ext-diff is broken in some versions of Git, so try to work around
1078 # this by overriding the environment (but there is still a problem if the
1079 # git config key "diff.external" is used).
1080 env = os.environ.copy()
1081 if 'GIT_EXTERNAL_DIFF' in env: del env['GIT_EXTERNAL_DIFF']
1082 gitdiff = RunShell(["git", "diff", "--no-ext-diff", "--full-index", "-M"]
1083 + extra_args, env=env)
1084 svndiff = []
1085 filecount = 0
1086 filename = None
1087 for line in gitdiff.splitlines():
1088 match = re.match(r"diff --git a/(.*) b/(.*)$", line)
1089 if match:
1090 filecount += 1
1091 # Intentionally use the "after" filename so we can show renames.
1092 filename = match.group(2)
1093 svndiff.append("Index: %s\n" % filename)
1094 if match.group(1) != match.group(2):
1095 self.renames[match.group(2)] = match.group(1)
1096 else:
1097 # The "index" line in a git diff looks like this (long hashes elided):
1098 # index 82c0d44..b2cee3f 100755
1099 # We want to save the left hash, as that identifies the base file.
1100 match = re.match(r"index (\w+)\.\.(\w+)", line)
1101 if match:
1102 before, after = (match.group(1), match.group(2))
1103 if before == NULL_HASH:
1104 before = None
1105 if after == NULL_HASH:
1106 after = None
1107 self.hashes[filename] = (before, after)
1108 svndiff.append(line + "\n")
1109 if not filecount:
1110 ErrorExit("No valid patches found in output from git diff")
1111 return "".join(svndiff)
1112
1113 def GetUnknownFiles(self):
1114 status = RunShell(["git", "ls-files", "--exclude-standard", "--others"],
1115 silent_ok=True)
1116 return status.splitlines()
1117
1118 def GetFileContent(self, file_hash, is_binary):
1119 """Returns the content of a file identified by its git hash."""
1120 data, retcode = RunShellWithReturnCode(["git", "show", file_hash],
1121 universal_newlines=not is_binary)
1122 if retcode:
1123 ErrorExit("Got error status from 'git show %s'" % file_hash)
1124 return data
1125
1126 def GetBaseFile(self, filename):
1127 hash_before, hash_after = self.hashes.get(filename, (None,None))
1128 base_content = None
1129 new_content = None
1130 is_binary = self.IsBinary(filename)
1131 status = None
1132
1133 if filename in self.renames:
1134 status = "A +" # Match svn attribute name for renames.
1135 if filename not in self.hashes:
1136 # If a rename doesn't change the content, we never get a hash.
1137 base_content = RunShell(["git", "show", filename])
1138 elif not hash_before:
1139 status = "A"
1140 base_content = ""
1141 elif not hash_after:
1142 status = "D"
1143 else:
1144 status = "M"
1145
1146 is_image = self.IsImage(filename)
1147
1148 # Grab the before/after content if we need it.
1149 # We should include file contents if it's text or it's an image.
1150 if not is_binary or is_image:
1151 # Grab the base content if we don't have it already.
1152 if base_content is None and hash_before:
1153 base_content = self.GetFileContent(hash_before, is_binary)
1154 # Only include the "after" file if it's an image; otherwise it
1155 # it is reconstructed from the diff.
1156 if is_image and hash_after:
1157 new_content = self.GetFileContent(hash_after, is_binary)
1158
1159 return (base_content, new_content, is_binary, status)
1160
1161
1162 class MercurialVCS(VersionControlSystem):
1163 """Implementation of the VersionControlSystem interface for Mercurial."""
1164
1165 def __init__(self, options, repo_dir):
1166 super(MercurialVCS, self).__init__(options)
1167 # Absolute path to repository (we can be in a subdir)
1168 self.repo_dir = os.path.normpath(repo_dir)
1169 # Compute the subdir
1170 cwd = os.path.normpath(os.getcwd())
1171 assert cwd.startswith(self.repo_dir)
1172 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
1173 if self.options.revision:
1174 self.base_rev = self.options.revision
1175 else:
1176 self.base_rev = RunShell(["hg", "parent", "-q"]).split(':')[1].strip()
1177
1178 def _GetRelPath(self, filename):
1179 """Get relative path of a file according to the current directory,
1180 given its logical path in the repo."""
1181 assert filename.startswith(self.subdir), (filename, self.subdir)
1182 return filename[len(self.subdir):].lstrip(r"\/")
1183
1184 def GenerateDiff(self, extra_args):
1185 # If no file specified, restrict to the current subdir
1186 extra_args = extra_args or ["."]
1187 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
1188 data = RunShell(cmd, silent_ok=True)
1189 svndiff = []
1190 filecount = 0
1191 for line in data.splitlines():
1192 m = re.match("diff --git a/(\S+) b/(\S+)", line)
1193 if m:
1194 # Modify line to make it look like as it comes from svn diff.
1195 # With this modification no changes on the server side are required
1196 # to make upload.py work with Mercurial repos.
1197 # NOTE: for proper handling of moved/copied files, we have to use
1198 # the second filename.
1199 filename = m.group(2)
1200 svndiff.append("Index: %s" % filename)
1201 svndiff.append("=" * 67)
1202 filecount += 1
1203 logging.info(line)
1204 else:
1205 svndiff.append(line)
1206 if not filecount:
1207 ErrorExit("No valid patches found in output from hg diff")
1208 return "\n".join(svndiff) + "\n"
1209
1210 def GetUnknownFiles(self):
1211 """Return a list of files unknown to the VCS."""
1212 args = []
1213 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
1214 silent_ok=True)
1215 unknown_files = []
1216 for line in status.splitlines():
1217 st, fn = line.split(" ", 1)
1218 if st == "?":
1219 unknown_files.append(fn)
1220 return unknown_files
1221
1222 def GetBaseFile(self, filename):
1223 # "hg status" and "hg cat" both take a path relative to the current subdir
1224 # rather than to the repo root, but "hg diff" has given us the full path
1225 # to the repo root.
1226 base_content = ""
1227 new_content = None
1228 is_binary = False
1229 oldrelpath = relpath = self._GetRelPath(filename)
1230 # "hg status -C" returns two lines for moved/copied files, one otherwise
1231 out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath])
1232 out = out.splitlines()
1233 # HACK: strip error message about missing file/directory if it isn't in
1234 # the working copy
1235 if out[0].startswith('%s: ' % relpath):
1236 out = out[1:]
1237 if len(out) > 1:
1238 # Moved/copied => considered as modified, use old filename to
1239 # retrieve base contents
1240 oldrelpath = out[1].strip()
1241 status = "M"
1242 else:
1243 status, _ = out[0].split(' ', 1)
1244 if ":" in self.base_rev:
1245 base_rev = self.base_rev.split(":", 1)[0]
1246 else:
1247 base_rev = self.base_rev
1248 if status != "A":
1249 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
1250 silent_ok=True)
1251 is_binary = "\0" in base_content # Mercurial's heuristic
1252 if status != "R":
1253 new_content = open(relpath, "rb").read()
1254 is_binary = is_binary or "\0" in new_content
1255 if is_binary and base_content:
1256 # Fetch again without converting newlines
1257 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
1258 silent_ok=True, universal_newlines=False)
1259 if not is_binary or not self.IsImage(relpath):
1260 new_content = None
1261 return base_content, new_content, is_binary, status
1262
1263
1264 # NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
1265 def SplitPatch(data):
1266 """Splits a patch into separate pieces for each file.
1267
1268 Args:
1269 data: A string containing the output of svn diff.
1270
1271 Returns:
1272 A list of 2-tuple (filename, text) where text is the svn diff output
1273 pertaining to filename.
1274 """
1275 patches = []
1276 filename = None
1277 diff = []
1278 for line in data.splitlines(True):
1279 new_filename = None
1280 if line.startswith('Index:'):
1281 unused, new_filename = line.split(':', 1)
1282 new_filename = new_filename.strip()
1283 elif line.startswith('Property changes on:'):
1284 unused, temp_filename = line.split(':', 1)
1285 # When a file is modified, paths use '/' between directories, however
1286 # when a property is modified '\' is used on Windows. Make them the same
1287 # otherwise the file shows up twice.
1288 temp_filename = temp_filename.strip().replace('\\', '/')
1289 if temp_filename != filename:
1290 # File has property changes but no modifications, create a new diff.
1291 new_filename = temp_filename
1292 if new_filename:
1293 if filename and diff:
1294 patches.append((filename, ''.join(diff)))
1295 filename = new_filename
1296 diff = [line]
1297 continue
1298 if diff is not None:
1299 diff.append(line)
1300 if filename and diff:
1301 patches.append((filename, ''.join(diff)))
1302 return patches
1303
1304
1305 def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
1306 """Uploads a separate patch for each file in the diff output.
1307
1308 Returns a list of [patch_key, filename] for each file.
1309 """
1310 patches = SplitPatch(data)
1311 rv = []
1312 for patch in patches:
1313 if len(patch[1]) > MAX_UPLOAD_SIZE:
1314 print ("Not uploading the patch for " + patch[0] +
1315 " because the file is too large.")
1316 continue
1317 form_fields = [("filename", patch[0])]
1318 if not options.download_base:
1319 form_fields.append(("content_upload", "1"))
1320 files = [("data", "data.diff", patch[1])]
1321 ctype, body = EncodeMultipartFormData(form_fields, files)
1322 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
1323 print "Uploading patch for " + patch[0]
1324 response_body = rpc_server.Send(url, body, content_type=ctype)
1325 lines = response_body.splitlines()
1326 if not lines or lines[0] != "OK":
1327 StatusUpdate(" --> %s" % response_body)
1328 sys.exit(1)
1329 rv.append([lines[1], patch[0]])
1330 return rv
1331
1332
1333 def GuessVCSName():
1334 """Helper to guess the version control system.
1335
1336 This examines the current directory, guesses which VersionControlSystem
1337 we're using, and returns an string indicating which VCS is detected.
1338
1339 Returns:
1340 A pair (vcs, output). vcs is a string indicating which VCS was detected
1341 and is one of VCS_GIT, VCS_MERCURIAL, VCS_SUBVERSION, or VCS_UNKNOWN.
1342 output is a string containing any interesting output from the vcs
1343 detection routine, or None if there is nothing interesting.
1344 """
1345 # Mercurial has a command to get the base directory of a repository
1346 # Try running it, but don't die if we don't have hg installed.
1347 # NOTE: we try Mercurial first as it can sit on top of an SVN working copy.
1348 try:
1349 out, returncode = RunShellWithReturnCode(["hg", "root"])
1350 if returncode == 0:
1351 return (VCS_MERCURIAL, out.strip())
1352 except OSError, (errno, message):
1353 if errno != 2: # ENOENT -- they don't have hg installed.
1354 raise
1355
1356 # Subversion has a .svn in all working directories.
1357 if os.path.isdir('.svn'):
1358 logging.info("Guessed VCS = Subversion")
1359 return (VCS_SUBVERSION, None)
1360
1361 # Git has a command to test if you're in a git tree.
1362 # Try running it, but don't die if we don't have git installed.
1363 try:
1364 out, returncode = RunShellWithReturnCode(["git", "rev-parse",
1365 "--is-inside-work-tree"])
1366 if returncode == 0:
1367 return (VCS_GIT, None)
1368 except OSError, (errno, message):
1369 if errno != 2: # ENOENT -- they don't have git installed.
1370 raise
1371
1372 return (VCS_UNKNOWN, None)
1373
1374
1375 def GuessVCS(options):
1376 """Helper to guess the version control system.
1377
1378 This verifies any user-specified VersionControlSystem (by command line
1379 or environment variable). If the user didn't specify one, this examines
1380 the current directory, guesses which VersionControlSystem we're using,
1381 and returns an instance of the appropriate class. Exit with an error
1382 if we can't figure it out.
1383
1384 Returns:
1385 A VersionControlSystem instance. Exits if the VCS can't be guessed.
1386 """
1387 vcs = options.vcs
1388 if not vcs:
1389 vcs = os.environ.get("CODEREVIEW_VCS")
1390 if vcs:
1391 v = VCS_ABBREVIATIONS.get(vcs.lower())
1392 if v is None:
1393 ErrorExit("Unknown version control system %r specified." % vcs)
1394 (vcs, extra_output) = (v, None)
1395 else:
1396 (vcs, extra_output) = GuessVCSName()
1397
1398 if vcs == VCS_MERCURIAL:
1399 if extra_output is None:
1400 extra_output = RunShell(["hg", "root"]).strip()
1401 return MercurialVCS(options, extra_output)
1402 elif vcs == VCS_SUBVERSION:
1403 return SubversionVCS(options)
1404 elif vcs == VCS_GIT:
1405 return GitVCS(options)
1406
1407 ErrorExit(("Could not guess version control system. "
1408 "Are you in a working copy directory?"))
1409
1410
1411 def CheckReviewer(reviewer):
1412 """Validate a reviewer -- either a nickname or an email addres.
1413
1414 Args:
1415 reviewer: A nickname or an email address.
1416
1417 Calls ErrorExit() if it is an invalid email address.
1418 """
1419 if "@" not in reviewer:
1420 return # Assume nickname
1421 parts = reviewer.split("@")
1422 if len(parts) > 2:
1423 ErrorExit("Invalid email address: %r" % reviewer)
1424 assert len(parts) == 2
1425 if "." not in parts[1]:
1426 ErrorExit("Invalid email address: %r" % reviewer)
1427
1428
1429 def RealMain(argv, data=None):
1430 """The real main function.
1431
1432 Args:
1433 argv: Command line arguments.
1434 data: Diff contents. If None (default) the diff is generated by
1435 the VersionControlSystem implementation returned by GuessVCS().
1436
1437 Returns:
1438 A 2-tuple (issue id, patchset id).
1439 The patchset id is None if the base files are not uploaded by this
1440 script (applies only to SVN checkouts).
1441 """
1442 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
1443 "%(lineno)s %(message)s "))
1444 os.environ['LC_ALL'] = 'C'
1445 options, args = parser.parse_args(argv[1:])
1446 global verbosity
1447 verbosity = options.verbose
1448 if verbosity >= 3:
1449 logging.getLogger().setLevel(logging.DEBUG)
1450 elif verbosity >= 2:
1451 logging.getLogger().setLevel(logging.INFO)
1452 vcs = GuessVCS(options)
1453 if isinstance(vcs, SubversionVCS):
1454 # base field is only allowed for Subversion.
1455 # Note: Fetching base files may become deprecated in future releases.
1456 base = vcs.GuessBase(options.download_base)
1457 else:
1458 base = None
1459 if not base and options.download_base:
1460 options.download_base = True
1461 logging.info("Enabled upload of base file")
1462 if not options.assume_yes:
1463 vcs.CheckForUnknownFiles()
1464 if data is None:
1465 data = vcs.GenerateDiff(args)
1466 files = vcs.GetBaseFiles(data)
1467 if verbosity >= 1:
1468 print "Upload server:", options.server, "(change with -s/--server)"
1469 if options.issue:
1470 prompt = "Message describing this patch set: "
1471 else:
1472 prompt = "New issue subject: "
1473 message = options.message or raw_input(prompt).strip()
1474 if not message:
1475 ErrorExit("A non-empty message is required")
1476 rpc_server = GetRpcServer(options)
1477 form_fields = [("subject", message)]
1478 if base:
1479 form_fields.append(("base", base))
1480 if options.issue:
1481 form_fields.append(("issue", str(options.issue)))
1482 if options.email:
1483 form_fields.append(("user", options.email))
1484 if options.reviewers:
1485 for reviewer in options.reviewers.split(','):
1486 CheckReviewer(reviewer)
1487 form_fields.append(("reviewers", options.reviewers))
1488 if options.cc:
1489 for cc in options.cc.split(','):
1490 CheckReviewer(cc)
1491 form_fields.append(("cc", options.cc))
1492 description = options.description
1493 if options.description_file:
1494 if options.description:
1495 ErrorExit("Can't specify description and description_file")
1496 file = open(options.description_file, 'r')
1497 description = file.read()
1498 file.close()
1499 if description:
1500 form_fields.append(("description", description))
1501 # Send a hash of all the base file so the server can determine if a copy
1502 # already exists in an earlier patchset.
1503 base_hashes = ""
1504 for file, info in files.iteritems():
1505 if not info[0] is None:
1506 checksum = md5(info[0]).hexdigest()
1507 if base_hashes:
1508 base_hashes += "|"
1509 base_hashes += checksum + ":" + file
1510 form_fields.append(("base_hashes", base_hashes))
1511 if options.private:
1512 if options.issue:
1513 print "Warning: Private flag ignored when updating an existing issue."
1514 else:
1515 form_fields.append(("private", "1"))
1516 # If we're uploading base files, don't send the email before the uploads, so
1517 # that it contains the file status.
1518 if options.send_mail and options.download_base:
1519 form_fields.append(("send_mail", "1"))
1520 if not options.download_base:
1521 form_fields.append(("content_upload", "1"))
1522 if len(data) > MAX_UPLOAD_SIZE:
1523 print "Patch is large, so uploading file patches separately."
1524 uploaded_diff_file = []
1525 form_fields.append(("separate_patches", "1"))
1526 else:
1527 uploaded_diff_file = [("data", "data.diff", data)]
1528 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
1529 response_body = rpc_server.Send("/upload", body, content_type=ctype)
1530 patchset = None
1531 if not options.download_base or not uploaded_diff_file:
1532 lines = response_body.splitlines()
1533 if len(lines) >= 2:
1534 msg = lines[0]
1535 patchset = lines[1].strip()
1536 patches = [x.split(" ", 1) for x in lines[2:]]
1537 else:
1538 msg = response_body
1539 else:
1540 msg = response_body
1541 StatusUpdate(msg)
1542 if not response_body.startswith("Issue created.") and \
1543 not response_body.startswith("Issue updated."):
1544 sys.exit(0)
1545 issue = msg[msg.rfind("/")+1:]
1546
1547 if not uploaded_diff_file:
1548 result = UploadSeparatePatches(issue, rpc_server, patchset, data, options)
1549 if not options.download_base:
1550 patches = result
1551
1552 if not options.download_base:
1553 vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options, files)
1554 if options.send_mail:
1555 rpc_server.Send("/" + issue + "/mail", payload="")
1556 return issue, patchset
1557
1558
1559 def main():
1560 try:
1561 RealMain(sys.argv)
1562 except KeyboardInterrupt:
1563 print
1564 StatusUpdate("Interrupted.")
1565 sys.exit(1)
1566
1567
1568 if __name__ == "__main__":
1569 main()
OLDNEW
« tests/gclient_scm_test.py ('K') | « third_party/upload.py ('k') | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698