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