blob: b9e584701a7e9412afc72470cc32e935d88e2b25 [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
Joe Gregorio30a08fd2011-04-11 11:33:42 -040019Usage summary: upload.py [options] [-- diff_options] [path...]
Joe Gregorioe754eb82010-08-20 10:56:32 -040020
21Diff options are passed to the diff command of the underlying system.
22
23Supported version control systems:
24 Git
25 Mercurial
26 Subversion
Joe Gregorio30a08fd2011-04-11 11:33:42 -040027 Perforce
28 CVS
Joe Gregorioe754eb82010-08-20 10:56:32 -040029
30It is important for Git/Mercurial users to specify a tree/node/branch to diff
31against by using the '--rev' option.
32"""
33# This code is derived from appcfg.py in the App Engine SDK (open source),
34# and from ASPN recipe #146306.
35
36import ConfigParser
37import cookielib
38import fnmatch
39import getpass
40import logging
Joe Gregorio30a08fd2011-04-11 11:33:42 -040041import marshal
Joe Gregorioe754eb82010-08-20 10:56:32 -040042import mimetypes
43import optparse
44import os
45import re
46import socket
47import subprocess
48import sys
49import urllib
50import urllib2
51import urlparse
52
53# The md5 module was deprecated in Python 2.5.
54try:
55 from hashlib import md5
56except ImportError:
57 from md5 import md5
58
59try:
60 import readline
61except ImportError:
62 pass
63
Joe Gregorio30a08fd2011-04-11 11:33:42 -040064try:
65 import keyring
66except ImportError:
67 keyring = None
68
Joe Gregorioe754eb82010-08-20 10:56:32 -040069# The logging verbosity:
70# 0: Errors only.
71# 1: Status messages.
72# 2: Info logs.
73# 3: Debug logs.
74verbosity = 1
75
Joe Gregorio30a08fd2011-04-11 11:33:42 -040076# The account type used for authentication.
77# This line could be changed by the review server (see handler for
78# upload.py).
79AUTH_ACCOUNT_TYPE = "GOOGLE"
80
81# URL of the default review server. As for AUTH_ACCOUNT_TYPE, this line could be
82# changed by the review server (see handler for upload.py).
83DEFAULT_REVIEW_SERVER = "codereview.appspot.com"
84
Joe Gregorioe754eb82010-08-20 10:56:32 -040085# Max size of patch or base file.
86MAX_UPLOAD_SIZE = 900 * 1024
87
88# Constants for version control names. Used by GuessVCSName.
89VCS_GIT = "Git"
90VCS_MERCURIAL = "Mercurial"
91VCS_SUBVERSION = "Subversion"
Joe Gregorio30a08fd2011-04-11 11:33:42 -040092VCS_PERFORCE = "Perforce"
93VCS_CVS = "CVS"
Joe Gregorioe754eb82010-08-20 10:56:32 -040094VCS_UNKNOWN = "Unknown"
95
96# whitelist for non-binary filetypes which do not start with "text/"
97# .mm (Objective-C) shows up as application/x-freemind on my Linux box.
Joe Gregorio30a08fd2011-04-11 11:33:42 -040098TEXT_MIMETYPES = ['application/javascript', 'application/json',
99 'application/x-javascript', 'application/xml',
100 'application/x-freemind', 'application/x-sh']
Joe Gregorioe754eb82010-08-20 10:56:32 -0400101
102VCS_ABBREVIATIONS = {
103 VCS_MERCURIAL.lower(): VCS_MERCURIAL,
104 "hg": VCS_MERCURIAL,
105 VCS_SUBVERSION.lower(): VCS_SUBVERSION,
106 "svn": VCS_SUBVERSION,
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400107 VCS_PERFORCE.lower(): VCS_PERFORCE,
108 "p4": VCS_PERFORCE,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400109 VCS_GIT.lower(): VCS_GIT,
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400110 VCS_CVS.lower(): VCS_CVS,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400111}
112
113# The result of parsing Subversion's [auto-props] setting.
114svn_auto_props_map = None
115
116def GetEmail(prompt):
117 """Prompts the user for their email address and returns it.
118
119 The last used email address is saved to a file and offered up as a suggestion
120 to the user. If the user presses enter without typing in anything the last
121 used email address is used. If the user enters a new address, it is saved
122 for next time we prompt.
123
124 """
125 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
126 last_email = ""
127 if os.path.exists(last_email_file_name):
128 try:
129 last_email_file = open(last_email_file_name, "r")
130 last_email = last_email_file.readline().strip("\n")
131 last_email_file.close()
132 prompt += " [%s]" % last_email
133 except IOError, e:
134 pass
135 email = raw_input(prompt + ": ").strip()
136 if email:
137 try:
138 last_email_file = open(last_email_file_name, "w")
139 last_email_file.write(email)
140 last_email_file.close()
141 except IOError, e:
142 pass
143 else:
144 email = last_email
145 return email
146
147
148def StatusUpdate(msg):
149 """Print a status message to stdout.
150
151 If 'verbosity' is greater than 0, print the message.
152
153 Args:
154 msg: The string to print.
155 """
156 if verbosity > 0:
157 print msg
158
159
160def ErrorExit(msg):
161 """Print an error message to stderr and exit."""
162 print >>sys.stderr, msg
163 sys.exit(1)
164
165
166class ClientLoginError(urllib2.HTTPError):
167 """Raised to indicate there was an error authenticating with ClientLogin."""
168
169 def __init__(self, url, code, msg, headers, args):
170 urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
171 self.args = args
172 self.reason = args["Error"]
173
174
175class AbstractRpcServer(object):
176 """Provides a common interface for a simple RPC server."""
177
178 def __init__(self, host, auth_function, host_override=None, extra_headers={},
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400179 save_cookies=False, account_type=AUTH_ACCOUNT_TYPE):
Joe Gregorioe754eb82010-08-20 10:56:32 -0400180 """Creates a new HttpRpcServer.
181
182 Args:
183 host: The host to send requests to.
184 auth_function: A function that takes no arguments and returns an
185 (email, password) tuple when called. Will be called if authentication
186 is required.
187 host_override: The host header to send to the server (defaults to host).
188 extra_headers: A dict of extra headers to append to every request.
189 save_cookies: If True, save the authentication cookies to local disk.
190 If False, use an in-memory cookiejar instead. Subclasses must
191 implement this functionality. Defaults to False.
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400192 account_type: Account type used for authentication. Defaults to
193 AUTH_ACCOUNT_TYPE.
Joe Gregorioe754eb82010-08-20 10:56:32 -0400194 """
195 self.host = host
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400196 if (not self.host.startswith("http://") and
197 not self.host.startswith("https://")):
198 self.host = "http://" + self.host
Joe Gregorioe754eb82010-08-20 10:56:32 -0400199 self.host_override = host_override
200 self.auth_function = auth_function
201 self.authenticated = False
202 self.extra_headers = extra_headers
203 self.save_cookies = save_cookies
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400204 self.account_type = account_type
Joe Gregorioe754eb82010-08-20 10:56:32 -0400205 self.opener = self._GetOpener()
206 if self.host_override:
207 logging.info("Server: %s; Host: %s", self.host, self.host_override)
208 else:
209 logging.info("Server: %s", self.host)
210
211 def _GetOpener(self):
212 """Returns an OpenerDirector for making HTTP requests.
213
214 Returns:
215 A urllib2.OpenerDirector object.
216 """
217 raise NotImplementedError()
218
219 def _CreateRequest(self, url, data=None):
220 """Creates a new urllib request."""
221 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
222 req = urllib2.Request(url, data=data)
223 if self.host_override:
224 req.add_header("Host", self.host_override)
225 for key, value in self.extra_headers.iteritems():
226 req.add_header(key, value)
227 return req
228
229 def _GetAuthToken(self, email, password):
230 """Uses ClientLogin to authenticate the user, returning an auth token.
231
232 Args:
233 email: The user's email address
234 password: The user's password
235
236 Raises:
237 ClientLoginError: If there was an error authenticating with ClientLogin.
238 HTTPError: If there was some other form of HTTP error.
239
240 Returns:
241 The authentication token returned by ClientLogin.
242 """
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400243 account_type = self.account_type
Joe Gregorioe754eb82010-08-20 10:56:32 -0400244 if self.host.endswith(".google.com"):
245 # Needed for use inside Google.
246 account_type = "HOSTED"
247 req = self._CreateRequest(
248 url="https://www.google.com/accounts/ClientLogin",
249 data=urllib.urlencode({
250 "Email": email,
251 "Passwd": password,
252 "service": "ah",
253 "source": "rietveld-codereview-upload",
254 "accountType": account_type,
255 }),
256 )
257 try:
258 response = self.opener.open(req)
259 response_body = response.read()
260 response_dict = dict(x.split("=")
261 for x in response_body.split("\n") if x)
262 return response_dict["Auth"]
263 except urllib2.HTTPError, e:
264 if e.code == 403:
265 body = e.read()
266 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
267 raise ClientLoginError(req.get_full_url(), e.code, e.msg,
268 e.headers, response_dict)
269 else:
270 raise
271
272 def _GetAuthCookie(self, auth_token):
273 """Fetches authentication cookies for an authentication token.
274
275 Args:
276 auth_token: The authentication token returned by ClientLogin.
277
278 Raises:
279 HTTPError: If there was an error fetching the authentication cookies.
280 """
281 # This is a dummy value to allow us to identify when we're successful.
282 continue_location = "http://localhost/"
283 args = {"continue": continue_location, "auth": auth_token}
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400284 req = self._CreateRequest("%s/_ah/login?%s" %
Joe Gregorioe754eb82010-08-20 10:56:32 -0400285 (self.host, urllib.urlencode(args)))
286 try:
287 response = self.opener.open(req)
288 except urllib2.HTTPError, e:
289 response = e
290 if (response.code != 302 or
291 response.info()["location"] != continue_location):
292 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg,
293 response.headers, response.fp)
294 self.authenticated = True
295
296 def _Authenticate(self):
297 """Authenticates the user.
298
299 The authentication process works as follows:
300 1) We get a username and password from the user
301 2) We use ClientLogin to obtain an AUTH token for the user
302 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
303 3) We pass the auth token to /_ah/login on the server to obtain an
304 authentication cookie. If login was successful, it tries to redirect
305 us to the URL we provided.
306
307 If we attempt to access the upload API without first obtaining an
308 authentication cookie, it returns a 401 response (or a 302) and
309 directs us to authenticate ourselves with ClientLogin.
310 """
311 for i in range(3):
312 credentials = self.auth_function()
313 try:
314 auth_token = self._GetAuthToken(credentials[0], credentials[1])
315 except ClientLoginError, e:
316 if e.reason == "BadAuthentication":
317 print >>sys.stderr, "Invalid username or password."
318 continue
319 if e.reason == "CaptchaRequired":
320 print >>sys.stderr, (
321 "Please go to\n"
322 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400323 "and verify you are a human. Then try again.\n"
324 "If you are using a Google Apps account the URL is:\n"
325 "https://www.google.com/a/yourdomain.com/UnlockCaptcha")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400326 break
327 if e.reason == "NotVerified":
328 print >>sys.stderr, "Account not verified."
329 break
330 if e.reason == "TermsNotAgreed":
331 print >>sys.stderr, "User has not agreed to TOS."
332 break
333 if e.reason == "AccountDeleted":
334 print >>sys.stderr, "The user account has been deleted."
335 break
336 if e.reason == "AccountDisabled":
337 print >>sys.stderr, "The user account has been disabled."
338 break
339 if e.reason == "ServiceDisabled":
340 print >>sys.stderr, ("The user's access to the service has been "
341 "disabled.")
342 break
343 if e.reason == "ServiceUnavailable":
344 print >>sys.stderr, "The service is not available; try again later."
345 break
346 raise
347 self._GetAuthCookie(auth_token)
348 return
349
350 def Send(self, request_path, payload=None,
351 content_type="application/octet-stream",
352 timeout=None,
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400353 extra_headers=None,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400354 **kwargs):
355 """Sends an RPC and returns the response.
356
357 Args:
358 request_path: The path to send the request to, eg /api/appversion/create.
359 payload: The body of the request, or None to send an empty request.
360 content_type: The Content-Type header to use.
361 timeout: timeout in seconds; default None i.e. no timeout.
362 (Note: for large requests on OS X, the timeout doesn't work right.)
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400363 extra_headers: Dict containing additional HTTP headers that should be
364 included in the request (string header names mapped to their values),
365 or None to not include any additional headers.
Joe Gregorioe754eb82010-08-20 10:56:32 -0400366 kwargs: Any keyword arguments are converted into query string parameters.
367
368 Returns:
369 The response body, as a string.
370 """
371 # TODO: Don't require authentication. Let the server say
372 # whether it is necessary.
373 if not self.authenticated:
374 self._Authenticate()
375
376 old_timeout = socket.getdefaulttimeout()
377 socket.setdefaulttimeout(timeout)
378 try:
379 tries = 0
380 while True:
381 tries += 1
382 args = dict(kwargs)
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400383 url = "%s%s" % (self.host, request_path)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400384 if args:
385 url += "?" + urllib.urlencode(args)
386 req = self._CreateRequest(url=url, data=payload)
387 req.add_header("Content-Type", content_type)
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400388 if extra_headers:
389 for header, value in extra_headers.items():
390 req.add_header(header, value)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400391 try:
392 f = self.opener.open(req)
393 response = f.read()
394 f.close()
395 return response
396 except urllib2.HTTPError, e:
397 if tries > 3:
398 raise
399 elif e.code == 401 or e.code == 302:
400 self._Authenticate()
401## elif e.code >= 500 and e.code < 600:
402## # Server Error - try again.
403## continue
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400404 elif e.code == 301:
405 # Handle permanent redirect manually.
406 url = e.info()["location"]
407 url_loc = urlparse.urlparse(url)
408 self.host = '%s://%s' % (url_loc[0], url_loc[1])
Joe Gregorioe754eb82010-08-20 10:56:32 -0400409 else:
410 raise
411 finally:
412 socket.setdefaulttimeout(old_timeout)
413
414
415class HttpRpcServer(AbstractRpcServer):
416 """Provides a simplified RPC-style interface for HTTP requests."""
417
418 def _Authenticate(self):
419 """Save the cookie jar after authentication."""
420 super(HttpRpcServer, self)._Authenticate()
421 if self.save_cookies:
422 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
423 self.cookie_jar.save()
424
425 def _GetOpener(self):
426 """Returns an OpenerDirector that supports cookies and ignores redirects.
427
428 Returns:
429 A urllib2.OpenerDirector object.
430 """
431 opener = urllib2.OpenerDirector()
432 opener.add_handler(urllib2.ProxyHandler())
433 opener.add_handler(urllib2.UnknownHandler())
434 opener.add_handler(urllib2.HTTPHandler())
435 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
436 opener.add_handler(urllib2.HTTPSHandler())
437 opener.add_handler(urllib2.HTTPErrorProcessor())
438 if self.save_cookies:
439 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies")
440 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
441 if os.path.exists(self.cookie_file):
442 try:
443 self.cookie_jar.load()
444 self.authenticated = True
445 StatusUpdate("Loaded authentication cookies from %s" %
446 self.cookie_file)
447 except (cookielib.LoadError, IOError):
448 # Failed to load cookies - just ignore them.
449 pass
450 else:
451 # Create an empty cookie file with mode 600
452 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
453 os.close(fd)
454 # Always chmod the cookie file
455 os.chmod(self.cookie_file, 0600)
456 else:
457 # Don't save cookies across runs of update.py.
458 self.cookie_jar = cookielib.CookieJar()
459 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
460 return opener
461
462
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400463parser = optparse.OptionParser(
464 usage="%prog [options] [-- diff_options] [path...]")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400465parser.add_option("-y", "--assume_yes", action="store_true",
466 dest="assume_yes", default=False,
467 help="Assume that the answer to yes/no questions is 'yes'.")
468# Logging
469group = parser.add_option_group("Logging options")
470group.add_option("-q", "--quiet", action="store_const", const=0,
471 dest="verbose", help="Print errors only.")
472group.add_option("-v", "--verbose", action="store_const", const=2,
473 dest="verbose", default=1,
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400474 help="Print info level logs.")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400475group.add_option("--noisy", action="store_const", const=3,
476 dest="verbose", help="Print all logs.")
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400477group.add_option("--print_diffs", dest="print_diffs", action="store_true",
478 help="Print full diffs.")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400479# Review server
480group = parser.add_option_group("Review server options")
481group.add_option("-s", "--server", action="store", dest="server",
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400482 default=DEFAULT_REVIEW_SERVER,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400483 metavar="SERVER",
484 help=("The server to upload to. The format is host[:port]. "
485 "Defaults to '%default'."))
486group.add_option("-e", "--email", action="store", dest="email",
487 metavar="EMAIL", default=None,
488 help="The username to use. Will prompt if omitted.")
489group.add_option("-H", "--host", action="store", dest="host",
490 metavar="HOST", default=None,
491 help="Overrides the Host header sent with all RPCs.")
492group.add_option("--no_cookies", action="store_false",
493 dest="save_cookies", default=True,
494 help="Do not save authentication cookies to local disk.")
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400495group.add_option("--account_type", action="store", dest="account_type",
496 metavar="TYPE", default=AUTH_ACCOUNT_TYPE,
497 choices=["GOOGLE", "HOSTED"],
498 help=("Override the default account type "
499 "(defaults to '%default', "
500 "valid choices are 'GOOGLE' and 'HOSTED')."))
Joe Gregorioe754eb82010-08-20 10:56:32 -0400501# Issue
502group = parser.add_option_group("Issue options")
503group.add_option("-d", "--description", action="store", dest="description",
504 metavar="DESCRIPTION", default=None,
505 help="Optional description when creating an issue.")
506group.add_option("-f", "--description_file", action="store",
507 dest="description_file", metavar="DESCRIPTION_FILE",
508 default=None,
509 help="Optional path of a file that contains "
510 "the description when creating an issue.")
511group.add_option("-r", "--reviewers", action="store", dest="reviewers",
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400512 metavar="REVIEWERS", default="jcgregorio@google.com",
Joe Gregorioe754eb82010-08-20 10:56:32 -0400513 help="Add reviewers (comma separated email addresses).")
514group.add_option("--cc", action="store", dest="cc",
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400515 metavar="CC",
516 default="google-api-python-client@googlegroups.com",
Joe Gregorioe754eb82010-08-20 10:56:32 -0400517 help="Add CC (comma separated email addresses).")
518group.add_option("--private", action="store_true", dest="private",
519 default=False,
520 help="Make the issue restricted to reviewers and those CCed")
521# Upload options
522group = parser.add_option_group("Patch options")
523group.add_option("-m", "--message", action="store", dest="message",
524 metavar="MESSAGE", default=None,
525 help="A message to identify the patch. "
526 "Will prompt if omitted.")
527group.add_option("-i", "--issue", type="int", action="store",
528 metavar="ISSUE", default=None,
529 help="Issue number to which to add. Defaults to new issue.")
530group.add_option("--base_url", action="store", dest="base_url", default=None,
531 help="Base repository URL (listed as \"Base URL\" when "
532 "viewing issue). If omitted, will be guessed automatically "
533 "for SVN repos and left blank for others.")
534group.add_option("--download_base", action="store_true",
535 dest="download_base", default=False,
536 help="Base files will be downloaded by the server "
537 "(side-by-side diffs may not work on files with CRs).")
538group.add_option("--rev", action="store", dest="revision",
539 metavar="REV", default=None,
540 help="Base revision/branch/tree to diff against. Use "
541 "rev1:rev2 range to review already committed changeset.")
542group.add_option("--send_mail", action="store_true",
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400543 dest="send_mail", default=False,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400544 help="Send notification email to reviewers.")
545group.add_option("--vcs", action="store", dest="vcs",
546 metavar="VCS", default=None,
547 help=("Version control system (optional, usually upload.py "
548 "already guesses the right VCS)."))
549group.add_option("--emulate_svn_auto_props", action="store_true",
550 dest="emulate_svn_auto_props", default=False,
551 help=("Emulate Subversion's auto properties feature."))
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400552# Perforce-specific
553group = parser.add_option_group("Perforce-specific options "
554 "(overrides P4 environment variables)")
555group.add_option("--p4_port", action="store", dest="p4_port",
556 metavar="P4_PORT", default=None,
557 help=("Perforce server and port (optional)"))
558group.add_option("--p4_changelist", action="store", dest="p4_changelist",
559 metavar="P4_CHANGELIST", default=None,
560 help=("Perforce changelist id"))
561group.add_option("--p4_client", action="store", dest="p4_client",
562 metavar="P4_CLIENT", default=None,
563 help=("Perforce client/workspace"))
564group.add_option("--p4_user", action="store", dest="p4_user",
565 metavar="P4_USER", default=None,
566 help=("Perforce user"))
Joe Gregorioe754eb82010-08-20 10:56:32 -0400567
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400568def GetRpcServer(server, email=None, host_override=None, save_cookies=True,
569 account_type=AUTH_ACCOUNT_TYPE):
Joe Gregorioe754eb82010-08-20 10:56:32 -0400570 """Returns an instance of an AbstractRpcServer.
571
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400572 Args:
573 server: String containing the review server URL.
574 email: String containing user's email address.
575 host_override: If not None, string containing an alternate hostname to use
576 in the host header.
577 save_cookies: Whether authentication cookies should be saved to disk.
578 account_type: Account type for authentication, either 'GOOGLE'
579 or 'HOSTED'. Defaults to AUTH_ACCOUNT_TYPE.
580
Joe Gregorioe754eb82010-08-20 10:56:32 -0400581 Returns:
582 A new AbstractRpcServer, on which RPC calls can be made.
583 """
584
585 rpc_server_class = HttpRpcServer
586
Joe Gregorioe754eb82010-08-20 10:56:32 -0400587 # If this is the dev_appserver, use fake authentication.
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400588 host = (host_override or server).lower()
589 if re.match(r'(http://)?localhost([:/]|$)', host):
Joe Gregorioe754eb82010-08-20 10:56:32 -0400590 if email is None:
591 email = "test@example.com"
592 logging.info("Using debug user %s. Override with --email" % email)
593 server = rpc_server_class(
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400594 server,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400595 lambda: (email, "password"),
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400596 host_override=host_override,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400597 extra_headers={"Cookie":
598 'dev_appserver_login="%s:False"' % email},
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400599 save_cookies=save_cookies,
600 account_type=account_type)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400601 # Don't try to talk to ClientLogin.
602 server.authenticated = True
603 return server
604
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400605 def GetUserCredentials():
606 """Prompts the user for a username and password."""
607 # Create a local alias to the email variable to avoid Python's crazy
608 # scoping rules.
609 local_email = email
610 if local_email is None:
611 local_email = GetEmail("Email (login for uploading to %s)" % server)
612 password = None
613 if keyring:
614 password = keyring.get_password(host, local_email)
615 if password is not None:
616 print "Using password from system keyring."
617 else:
618 password = getpass.getpass("Password for %s: " % local_email)
619 if keyring:
620 answer = raw_input("Store password in system keyring?(y/N) ").strip()
621 if answer == "y":
622 keyring.set_password(host, local_email, password)
623 return (local_email, password)
624
625 return rpc_server_class(server,
626 GetUserCredentials,
627 host_override=host_override,
628 save_cookies=save_cookies)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400629
630
631def EncodeMultipartFormData(fields, files):
632 """Encode form fields for multipart/form-data.
633
634 Args:
635 fields: A sequence of (name, value) elements for regular form fields.
636 files: A sequence of (name, filename, value) elements for data to be
637 uploaded as files.
638 Returns:
639 (content_type, body) ready for httplib.HTTP instance.
640
641 Source:
642 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
643 """
644 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
645 CRLF = '\r\n'
646 lines = []
647 for (key, value) in fields:
648 lines.append('--' + BOUNDARY)
649 lines.append('Content-Disposition: form-data; name="%s"' % key)
650 lines.append('')
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400651 if isinstance(value, unicode):
652 value = value.encode('utf-8')
Joe Gregorioe754eb82010-08-20 10:56:32 -0400653 lines.append(value)
654 for (key, filename, value) in files:
655 lines.append('--' + BOUNDARY)
656 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' %
657 (key, filename))
658 lines.append('Content-Type: %s' % GetContentType(filename))
659 lines.append('')
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400660 if isinstance(value, unicode):
661 value = value.encode('utf-8')
Joe Gregorioe754eb82010-08-20 10:56:32 -0400662 lines.append(value)
663 lines.append('--' + BOUNDARY + '--')
664 lines.append('')
665 body = CRLF.join(lines)
666 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
667 return content_type, body
668
669
670def GetContentType(filename):
671 """Helper to guess the content-type from the filename."""
672 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
673
674
675# Use a shell for subcommands on Windows to get a PATH search.
676use_shell = sys.platform.startswith("win")
677
678def RunShellWithReturnCode(command, print_output=False,
679 universal_newlines=True,
680 env=os.environ):
681 """Executes a command and returns the output from stdout and the return code.
682
683 Args:
684 command: Command to execute.
685 print_output: If True, the output is printed to stdout.
686 If False, both stdout and stderr are ignored.
687 universal_newlines: Use universal_newlines flag (default: True).
688
689 Returns:
690 Tuple (output, return code)
691 """
692 logging.info("Running %s", command)
693 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
694 shell=use_shell, universal_newlines=universal_newlines,
695 env=env)
696 if print_output:
697 output_array = []
698 while True:
699 line = p.stdout.readline()
700 if not line:
701 break
702 print line.strip("\n")
703 output_array.append(line)
704 output = "".join(output_array)
705 else:
706 output = p.stdout.read()
707 p.wait()
708 errout = p.stderr.read()
709 if print_output and errout:
710 print >>sys.stderr, errout
711 p.stdout.close()
712 p.stderr.close()
713 return output, p.returncode
714
715
716def RunShell(command, silent_ok=False, universal_newlines=True,
717 print_output=False, env=os.environ):
718 data, retcode = RunShellWithReturnCode(command, print_output,
719 universal_newlines, env)
720 if retcode:
721 ErrorExit("Got error status from %s:\n%s" % (command, data))
722 if not silent_ok and not data:
723 ErrorExit("No output from %s" % command)
724 return data
725
726
727class VersionControlSystem(object):
728 """Abstract base class providing an interface to the VCS."""
729
730 def __init__(self, options):
731 """Constructor.
732
733 Args:
734 options: Command line options.
735 """
736 self.options = options
737
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400738 def PostProcessDiff(self, diff):
739 """Return the diff with any special post processing this VCS needs, e.g.
740 to include an svn-style "Index:"."""
741 return diff
742
Joe Gregorioe754eb82010-08-20 10:56:32 -0400743 def GenerateDiff(self, args):
744 """Return the current diff as a string.
745
746 Args:
747 args: Extra arguments to pass to the diff command.
748 """
749 raise NotImplementedError(
750 "abstract method -- subclass %s must override" % self.__class__)
751
752 def GetUnknownFiles(self):
753 """Return a list of files unknown to the VCS."""
754 raise NotImplementedError(
755 "abstract method -- subclass %s must override" % self.__class__)
756
757 def CheckForUnknownFiles(self):
758 """Show an "are you sure?" prompt if there are unknown files."""
759 unknown_files = self.GetUnknownFiles()
760 if unknown_files:
761 print "The following files are not added to version control:"
762 for line in unknown_files:
763 print line
764 prompt = "Are you sure to continue?(y/N) "
765 answer = raw_input(prompt).strip()
766 if answer != "y":
767 ErrorExit("User aborted")
768
769 def GetBaseFile(self, filename):
770 """Get the content of the upstream version of a file.
771
772 Returns:
773 A tuple (base_content, new_content, is_binary, status)
774 base_content: The contents of the base file.
775 new_content: For text files, this is empty. For binary files, this is
776 the contents of the new file, since the diff output won't contain
777 information to reconstruct the current file.
778 is_binary: True iff the file is binary.
779 status: The status of the file.
780 """
781
782 raise NotImplementedError(
783 "abstract method -- subclass %s must override" % self.__class__)
784
785
786 def GetBaseFiles(self, diff):
787 """Helper that calls GetBase file for each file in the patch.
788
789 Returns:
790 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
791 are retrieved based on lines that start with "Index:" or
792 "Property changes on:".
793 """
794 files = {}
795 for line in diff.splitlines(True):
796 if line.startswith('Index:') or line.startswith('Property changes on:'):
797 unused, filename = line.split(':', 1)
798 # On Windows if a file has property changes its filename uses '\'
799 # instead of '/'.
800 filename = filename.strip().replace('\\', '/')
801 files[filename] = self.GetBaseFile(filename)
802 return files
803
804
805 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
806 files):
807 """Uploads the base files (and if necessary, the current ones as well)."""
808
809 def UploadFile(filename, file_id, content, is_binary, status, is_base):
810 """Uploads a file to the server."""
811 file_too_large = False
812 if is_base:
813 type = "base"
814 else:
815 type = "current"
816 if len(content) > MAX_UPLOAD_SIZE:
817 print ("Not uploading the %s file for %s because it's too large." %
818 (type, filename))
819 file_too_large = True
820 content = ""
821 checksum = md5(content).hexdigest()
822 if options.verbose > 0 and not file_too_large:
823 print "Uploading %s file for %s" % (type, filename)
824 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
825 form_fields = [("filename", filename),
826 ("status", status),
827 ("checksum", checksum),
828 ("is_binary", str(is_binary)),
829 ("is_current", str(not is_base)),
830 ]
831 if file_too_large:
832 form_fields.append(("file_too_large", "1"))
833 if options.email:
834 form_fields.append(("user", options.email))
835 ctype, body = EncodeMultipartFormData(form_fields,
836 [("data", filename, content)])
837 response_body = rpc_server.Send(url, body,
838 content_type=ctype)
839 if not response_body.startswith("OK"):
840 StatusUpdate(" --> %s" % response_body)
841 sys.exit(1)
842
843 patches = dict()
844 [patches.setdefault(v, k) for k, v in patch_list]
845 for filename in patches.keys():
846 base_content, new_content, is_binary, status = files[filename]
847 file_id_str = patches.get(filename)
848 if file_id_str.find("nobase") != -1:
849 base_content = None
850 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
851 file_id = int(file_id_str)
852 if base_content != None:
853 UploadFile(filename, file_id, base_content, is_binary, status, True)
854 if new_content != None:
855 UploadFile(filename, file_id, new_content, is_binary, status, False)
856
857 def IsImage(self, filename):
858 """Returns true if the filename has an image extension."""
859 mimetype = mimetypes.guess_type(filename)[0]
860 if not mimetype:
861 return False
862 return mimetype.startswith("image/")
863
864 def IsBinary(self, filename):
865 """Returns true if the guessed mimetyped isnt't in text group."""
866 mimetype = mimetypes.guess_type(filename)[0]
867 if not mimetype:
868 return False # e.g. README, "real" binaries usually have an extension
869 # special case for text files which don't start with text/
870 if mimetype in TEXT_MIMETYPES:
871 return False
872 return not mimetype.startswith("text/")
873
874
875class SubversionVCS(VersionControlSystem):
876 """Implementation of the VersionControlSystem interface for Subversion."""
877
878 def __init__(self, options):
879 super(SubversionVCS, self).__init__(options)
880 if self.options.revision:
881 match = re.match(r"(\d+)(:(\d+))?", self.options.revision)
882 if not match:
883 ErrorExit("Invalid Subversion revision %s." % self.options.revision)
884 self.rev_start = match.group(1)
885 self.rev_end = match.group(3)
886 else:
887 self.rev_start = self.rev_end = None
888 # Cache output from "svn list -r REVNO dirname".
889 # Keys: dirname, Values: 2-tuple (ouput for start rev and end rev).
890 self.svnls_cache = {}
891 # Base URL is required to fetch files deleted in an older revision.
892 # Result is cached to not guess it over and over again in GetBaseFile().
893 required = self.options.download_base or self.options.revision is not None
894 self.svn_base = self._GuessBase(required)
895
896 def GuessBase(self, required):
897 """Wrapper for _GuessBase."""
898 return self.svn_base
899
900 def _GuessBase(self, required):
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400901 """Returns base URL for current diff.
Joe Gregorioe754eb82010-08-20 10:56:32 -0400902
903 Args:
904 required: If true, exits if the url can't be guessed, otherwise None is
905 returned.
906 """
907 info = RunShell(["svn", "info"])
908 for line in info.splitlines():
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400909 if line.startswith("URL: "):
910 url = line.split()[1]
Joe Gregorioe754eb82010-08-20 10:56:32 -0400911 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400912 guess = ""
913 if netloc == "svn.python.org" and scheme == "svn+ssh":
914 path = "projects" + path
915 scheme = "http"
916 guess = "Python "
Joe Gregorioe754eb82010-08-20 10:56:32 -0400917 elif netloc.endswith(".googlecode.com"):
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400918 scheme = "http"
919 guess = "Google Code "
920 path = path + "/"
921 base = urlparse.urlunparse((scheme, netloc, path, params,
922 query, fragment))
923 logging.info("Guessed %sbase = %s", guess, base)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400924 return base
925 if required:
926 ErrorExit("Can't find URL in output from svn info")
927 return None
928
929 def GenerateDiff(self, args):
930 cmd = ["svn", "diff"]
931 if self.options.revision:
932 cmd += ["-r", self.options.revision]
933 cmd.extend(args)
934 data = RunShell(cmd)
935 count = 0
936 for line in data.splitlines():
937 if line.startswith("Index:") or line.startswith("Property changes on:"):
938 count += 1
939 logging.info(line)
940 if not count:
941 ErrorExit("No valid patches found in output from svn diff")
942 return data
943
944 def _CollapseKeywords(self, content, keyword_str):
945 """Collapses SVN keywords."""
946 # svn cat translates keywords but svn diff doesn't. As a result of this
947 # behavior patching.PatchChunks() fails with a chunk mismatch error.
948 # This part was originally written by the Review Board development team
949 # who had the same problem (http://reviews.review-board.org/r/276/).
950 # Mapping of keywords to known aliases
951 svn_keywords = {
952 # Standard keywords
953 'Date': ['Date', 'LastChangedDate'],
954 'Revision': ['Revision', 'LastChangedRevision', 'Rev'],
955 'Author': ['Author', 'LastChangedBy'],
956 'HeadURL': ['HeadURL', 'URL'],
957 'Id': ['Id'],
958
959 # Aliases
960 'LastChangedDate': ['LastChangedDate', 'Date'],
961 'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'],
962 'LastChangedBy': ['LastChangedBy', 'Author'],
963 'URL': ['URL', 'HeadURL'],
964 }
965
966 def repl(m):
967 if m.group(2):
968 return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
969 return "$%s$" % m.group(1)
970 keywords = [keyword
971 for name in keyword_str.split(" ")
972 for keyword in svn_keywords.get(name, [])]
973 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content)
974
975 def GetUnknownFiles(self):
976 status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True)
977 unknown_files = []
978 for line in status.split("\n"):
979 if line and line[0] == "?":
980 unknown_files.append(line)
981 return unknown_files
982
983 def ReadFile(self, filename):
984 """Returns the contents of a file."""
985 file = open(filename, 'rb')
986 result = ""
987 try:
988 result = file.read()
989 finally:
990 file.close()
991 return result
992
993 def GetStatus(self, filename):
994 """Returns the status of a file."""
995 if not self.options.revision:
996 status = RunShell(["svn", "status", "--ignore-externals", filename])
997 if not status:
998 ErrorExit("svn status returned no output for %s" % filename)
999 status_lines = status.splitlines()
1000 # If file is in a cl, the output will begin with
1001 # "\n--- Changelist 'cl_name':\n". See
1002 # http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt
1003 if (len(status_lines) == 3 and
1004 not status_lines[0] and
1005 status_lines[1].startswith("--- Changelist")):
1006 status = status_lines[2]
1007 else:
1008 status = status_lines[0]
1009 # If we have a revision to diff against we need to run "svn list"
1010 # for the old and the new revision and compare the results to get
1011 # the correct status for a file.
1012 else:
1013 dirname, relfilename = os.path.split(filename)
1014 if dirname not in self.svnls_cache:
1015 cmd = ["svn", "list", "-r", self.rev_start, dirname or "."]
1016 out, returncode = RunShellWithReturnCode(cmd)
1017 if returncode:
1018 ErrorExit("Failed to get status for %s." % filename)
1019 old_files = out.splitlines()
1020 args = ["svn", "list"]
1021 if self.rev_end:
1022 args += ["-r", self.rev_end]
1023 cmd = args + [dirname or "."]
1024 out, returncode = RunShellWithReturnCode(cmd)
1025 if returncode:
1026 ErrorExit("Failed to run command %s" % cmd)
1027 self.svnls_cache[dirname] = (old_files, out.splitlines())
1028 old_files, new_files = self.svnls_cache[dirname]
1029 if relfilename in old_files and relfilename not in new_files:
1030 status = "D "
1031 elif relfilename in old_files and relfilename in new_files:
1032 status = "M "
1033 else:
1034 status = "A "
1035 return status
1036
1037 def GetBaseFile(self, filename):
1038 status = self.GetStatus(filename)
1039 base_content = None
1040 new_content = None
1041
1042 # If a file is copied its status will be "A +", which signifies
1043 # "addition-with-history". See "svn st" for more information. We need to
1044 # upload the original file or else diff parsing will fail if the file was
1045 # edited.
1046 if status[0] == "A" and status[3] != "+":
1047 # We'll need to upload the new content if we're adding a binary file
1048 # since diff's output won't contain it.
1049 mimetype = RunShell(["svn", "propget", "svn:mime-type", filename],
1050 silent_ok=True)
1051 base_content = ""
1052 is_binary = bool(mimetype) and not mimetype.startswith("text/")
1053 if is_binary and self.IsImage(filename):
1054 new_content = self.ReadFile(filename)
1055 elif (status[0] in ("M", "D", "R") or
1056 (status[0] == "A" and status[3] == "+") or # Copied file.
1057 (status[0] == " " and status[1] == "M")): # Property change.
1058 args = []
1059 if self.options.revision:
1060 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1061 else:
1062 # Don't change filename, it's needed later.
1063 url = filename
1064 args += ["-r", "BASE"]
1065 cmd = ["svn"] + args + ["propget", "svn:mime-type", url]
1066 mimetype, returncode = RunShellWithReturnCode(cmd)
1067 if returncode:
1068 # File does not exist in the requested revision.
1069 # Reset mimetype, it contains an error message.
1070 mimetype = ""
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001071 else:
1072 mimetype = mimetype.strip()
Joe Gregorioe754eb82010-08-20 10:56:32 -04001073 get_base = False
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001074 is_binary = (bool(mimetype) and
1075 not mimetype.startswith("text/") and
1076 not mimetype in TEXT_MIMETYPES)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001077 if status[0] == " ":
1078 # Empty base content just to force an upload.
1079 base_content = ""
1080 elif is_binary:
1081 if self.IsImage(filename):
1082 get_base = True
1083 if status[0] == "M":
1084 if not self.rev_end:
1085 new_content = self.ReadFile(filename)
1086 else:
1087 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end)
1088 new_content = RunShell(["svn", "cat", url],
1089 universal_newlines=True, silent_ok=True)
1090 else:
1091 base_content = ""
1092 else:
1093 get_base = True
1094
1095 if get_base:
1096 if is_binary:
1097 universal_newlines = False
1098 else:
1099 universal_newlines = True
1100 if self.rev_start:
1101 # "svn cat -r REV delete_file.txt" doesn't work. cat requires
1102 # the full URL with "@REV" appended instead of using "-r" option.
1103 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1104 base_content = RunShell(["svn", "cat", url],
1105 universal_newlines=universal_newlines,
1106 silent_ok=True)
1107 else:
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001108 base_content, ret_code = RunShellWithReturnCode(
1109 ["svn", "cat", filename], universal_newlines=universal_newlines)
1110 if ret_code and status[0] == "R":
1111 # It's a replaced file without local history (see issue208).
1112 # The base file needs to be fetched from the server.
1113 url = "%s/%s" % (self.svn_base, filename)
1114 base_content = RunShell(["svn", "cat", url],
1115 universal_newlines=universal_newlines,
1116 silent_ok=True)
1117 elif ret_code:
1118 ErrorExit("Got error status from 'svn cat %s'" % filename)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001119 if not is_binary:
1120 args = []
1121 if self.rev_start:
1122 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1123 else:
1124 url = filename
1125 args += ["-r", "BASE"]
1126 cmd = ["svn"] + args + ["propget", "svn:keywords", url]
1127 keywords, returncode = RunShellWithReturnCode(cmd)
1128 if keywords and not returncode:
1129 base_content = self._CollapseKeywords(base_content, keywords)
1130 else:
1131 StatusUpdate("svn status returned unexpected output: %s" % status)
1132 sys.exit(1)
1133 return base_content, new_content, is_binary, status[0:5]
1134
1135
1136class GitVCS(VersionControlSystem):
1137 """Implementation of the VersionControlSystem interface for Git."""
1138
1139 def __init__(self, options):
1140 super(GitVCS, self).__init__(options)
1141 # Map of filename -> (hash before, hash after) of base file.
1142 # Hashes for "no such file" are represented as None.
1143 self.hashes = {}
1144 # Map of new filename -> old filename for renames.
1145 self.renames = {}
1146
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001147 def PostProcessDiff(self, gitdiff):
1148 """Converts the diff output to include an svn-style "Index:" line as well
1149 as record the hashes of the files, so we can upload them along with our
1150 diff."""
Joe Gregorioe754eb82010-08-20 10:56:32 -04001151 # Special used by git to indicate "no such content".
1152 NULL_HASH = "0"*40
1153
Joe Gregorioe754eb82010-08-20 10:56:32 -04001154 def IsFileNew(filename):
1155 return filename in self.hashes and self.hashes[filename][0] is None
1156
1157 def AddSubversionPropertyChange(filename):
1158 """Add svn's property change information into the patch if given file is
1159 new file.
1160
1161 We use Subversion's auto-props setting to retrieve its property.
1162 See http://svnbook.red-bean.com/en/1.1/ch07.html#svn-ch-7-sect-1.3.2 for
1163 Subversion's [auto-props] setting.
1164 """
1165 if self.options.emulate_svn_auto_props and IsFileNew(filename):
1166 svnprops = GetSubversionPropertyChanges(filename)
1167 if svnprops:
1168 svndiff.append("\n" + svnprops + "\n")
1169
1170 svndiff = []
1171 filecount = 0
1172 filename = None
1173 for line in gitdiff.splitlines():
1174 match = re.match(r"diff --git a/(.*) b/(.*)$", line)
1175 if match:
1176 # Add auto property here for previously seen file.
1177 if filename is not None:
1178 AddSubversionPropertyChange(filename)
1179 filecount += 1
1180 # Intentionally use the "after" filename so we can show renames.
1181 filename = match.group(2)
1182 svndiff.append("Index: %s\n" % filename)
1183 if match.group(1) != match.group(2):
1184 self.renames[match.group(2)] = match.group(1)
1185 else:
1186 # The "index" line in a git diff looks like this (long hashes elided):
1187 # index 82c0d44..b2cee3f 100755
1188 # We want to save the left hash, as that identifies the base file.
1189 match = re.match(r"index (\w+)\.\.(\w+)", line)
1190 if match:
1191 before, after = (match.group(1), match.group(2))
1192 if before == NULL_HASH:
1193 before = None
1194 if after == NULL_HASH:
1195 after = None
1196 self.hashes[filename] = (before, after)
1197 svndiff.append(line + "\n")
1198 if not filecount:
1199 ErrorExit("No valid patches found in output from git diff")
1200 # Add auto property for the last seen file.
1201 assert filename is not None
1202 AddSubversionPropertyChange(filename)
1203 return "".join(svndiff)
1204
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001205 def GenerateDiff(self, extra_args):
1206 extra_args = extra_args[:]
1207 if self.options.revision:
1208 if ":" in self.options.revision:
1209 extra_args = self.options.revision.split(":", 1) + extra_args
1210 else:
1211 extra_args = [self.options.revision] + extra_args
1212
1213 # --no-ext-diff is broken in some versions of Git, so try to work around
1214 # this by overriding the environment (but there is still a problem if the
1215 # git config key "diff.external" is used).
1216 env = os.environ.copy()
1217 if 'GIT_EXTERNAL_DIFF' in env: del env['GIT_EXTERNAL_DIFF']
1218 return RunShell(["git", "diff", "--no-ext-diff", "--full-index", "-M"]
1219 + extra_args, env=env)
1220
Joe Gregorioe754eb82010-08-20 10:56:32 -04001221 def GetUnknownFiles(self):
1222 status = RunShell(["git", "ls-files", "--exclude-standard", "--others"],
1223 silent_ok=True)
1224 return status.splitlines()
1225
1226 def GetFileContent(self, file_hash, is_binary):
1227 """Returns the content of a file identified by its git hash."""
1228 data, retcode = RunShellWithReturnCode(["git", "show", file_hash],
1229 universal_newlines=not is_binary)
1230 if retcode:
1231 ErrorExit("Got error status from 'git show %s'" % file_hash)
1232 return data
1233
1234 def GetBaseFile(self, filename):
1235 hash_before, hash_after = self.hashes.get(filename, (None,None))
1236 base_content = None
1237 new_content = None
1238 is_binary = self.IsBinary(filename)
1239 status = None
1240
1241 if filename in self.renames:
1242 status = "A +" # Match svn attribute name for renames.
1243 if filename not in self.hashes:
1244 # If a rename doesn't change the content, we never get a hash.
1245 base_content = RunShell(["git", "show", "HEAD:" + filename])
1246 elif not hash_before:
1247 status = "A"
1248 base_content = ""
1249 elif not hash_after:
1250 status = "D"
1251 else:
1252 status = "M"
1253
1254 is_image = self.IsImage(filename)
1255
1256 # Grab the before/after content if we need it.
1257 # We should include file contents if it's text or it's an image.
1258 if not is_binary or is_image:
1259 # Grab the base content if we don't have it already.
1260 if base_content is None and hash_before:
1261 base_content = self.GetFileContent(hash_before, is_binary)
1262 # Only include the "after" file if it's an image; otherwise it
1263 # it is reconstructed from the diff.
1264 if is_image and hash_after:
1265 new_content = self.GetFileContent(hash_after, is_binary)
1266
1267 return (base_content, new_content, is_binary, status)
1268
1269
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001270class CVSVCS(VersionControlSystem):
1271 """Implementation of the VersionControlSystem interface for CVS."""
1272
1273 def __init__(self, options):
1274 super(CVSVCS, self).__init__(options)
1275
1276 def GetOriginalContent_(self, filename):
1277 RunShell(["cvs", "up", filename], silent_ok=True)
1278 # TODO need detect file content encoding
1279 content = open(filename).read()
1280 return content.replace("\r\n", "\n")
1281
1282 def GetBaseFile(self, filename):
1283 base_content = None
1284 new_content = None
1285 is_binary = False
1286 status = "A"
1287
1288 output, retcode = RunShellWithReturnCode(["cvs", "status", filename])
1289 if retcode:
1290 ErrorExit("Got error status from 'cvs status %s'" % filename)
1291
1292 if output.find("Status: Locally Modified") != -1:
1293 status = "M"
1294 temp_filename = "%s.tmp123" % filename
1295 os.rename(filename, temp_filename)
1296 base_content = self.GetOriginalContent_(filename)
1297 os.rename(temp_filename, filename)
1298 elif output.find("Status: Locally Added"):
1299 status = "A"
1300 base_content = ""
1301 elif output.find("Status: Needs Checkout"):
1302 status = "D"
1303 base_content = self.GetOriginalContent_(filename)
1304
1305 return (base_content, new_content, is_binary, status)
1306
1307 def GenerateDiff(self, extra_args):
1308 cmd = ["cvs", "diff", "-u", "-N"]
1309 if self.options.revision:
1310 cmd += ["-r", self.options.revision]
1311
1312 cmd.extend(extra_args)
1313 data, retcode = RunShellWithReturnCode(cmd)
1314 count = 0
1315 if retcode == 0:
1316 for line in data.splitlines():
1317 if line.startswith("Index:"):
1318 count += 1
1319 logging.info(line)
1320
1321 if not count:
1322 ErrorExit("No valid patches found in output from cvs diff")
1323
1324 return data
1325
1326 def GetUnknownFiles(self):
1327 status = RunShell(["cvs", "diff"],
1328 silent_ok=True)
1329 unknown_files = []
1330 for line in status.split("\n"):
1331 if line and line[0] == "?":
1332 unknown_files.append(line)
1333 return unknown_files
1334
Joe Gregorioe754eb82010-08-20 10:56:32 -04001335class MercurialVCS(VersionControlSystem):
1336 """Implementation of the VersionControlSystem interface for Mercurial."""
1337
1338 def __init__(self, options, repo_dir):
1339 super(MercurialVCS, self).__init__(options)
1340 # Absolute path to repository (we can be in a subdir)
1341 self.repo_dir = os.path.normpath(repo_dir)
1342 # Compute the subdir
1343 cwd = os.path.normpath(os.getcwd())
1344 assert cwd.startswith(self.repo_dir)
1345 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
1346 if self.options.revision:
1347 self.base_rev = self.options.revision
1348 else:
1349 self.base_rev = RunShell(["hg", "parent", "-q"]).split(':')[1].strip()
1350
1351 def _GetRelPath(self, filename):
1352 """Get relative path of a file according to the current directory,
1353 given its logical path in the repo."""
1354 assert filename.startswith(self.subdir), (filename, self.subdir)
1355 return filename[len(self.subdir):].lstrip(r"\/")
1356
1357 def GenerateDiff(self, extra_args):
Joe Gregorioe754eb82010-08-20 10:56:32 -04001358 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
1359 data = RunShell(cmd, silent_ok=True)
1360 svndiff = []
1361 filecount = 0
1362 for line in data.splitlines():
1363 m = re.match("diff --git a/(\S+) b/(\S+)", line)
1364 if m:
1365 # Modify line to make it look like as it comes from svn diff.
1366 # With this modification no changes on the server side are required
1367 # to make upload.py work with Mercurial repos.
1368 # NOTE: for proper handling of moved/copied files, we have to use
1369 # the second filename.
1370 filename = m.group(2)
1371 svndiff.append("Index: %s" % filename)
1372 svndiff.append("=" * 67)
1373 filecount += 1
1374 logging.info(line)
1375 else:
1376 svndiff.append(line)
1377 if not filecount:
1378 ErrorExit("No valid patches found in output from hg diff")
1379 return "\n".join(svndiff) + "\n"
1380
1381 def GetUnknownFiles(self):
1382 """Return a list of files unknown to the VCS."""
1383 args = []
1384 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
1385 silent_ok=True)
1386 unknown_files = []
1387 for line in status.splitlines():
1388 st, fn = line.split(" ", 1)
1389 if st == "?":
1390 unknown_files.append(fn)
1391 return unknown_files
1392
1393 def GetBaseFile(self, filename):
1394 # "hg status" and "hg cat" both take a path relative to the current subdir
1395 # rather than to the repo root, but "hg diff" has given us the full path
1396 # to the repo root.
1397 base_content = ""
1398 new_content = None
1399 is_binary = False
1400 oldrelpath = relpath = self._GetRelPath(filename)
1401 # "hg status -C" returns two lines for moved/copied files, one otherwise
1402 out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath])
1403 out = out.splitlines()
1404 # HACK: strip error message about missing file/directory if it isn't in
1405 # the working copy
1406 if out[0].startswith('%s: ' % relpath):
1407 out = out[1:]
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001408 status, _ = out[0].split(' ', 1)
1409 if len(out) > 1 and status == "A":
Joe Gregorioe754eb82010-08-20 10:56:32 -04001410 # Moved/copied => considered as modified, use old filename to
1411 # retrieve base contents
1412 oldrelpath = out[1].strip()
1413 status = "M"
Joe Gregorioe754eb82010-08-20 10:56:32 -04001414 if ":" in self.base_rev:
1415 base_rev = self.base_rev.split(":", 1)[0]
1416 else:
1417 base_rev = self.base_rev
1418 if status != "A":
1419 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
1420 silent_ok=True)
1421 is_binary = "\0" in base_content # Mercurial's heuristic
1422 if status != "R":
1423 new_content = open(relpath, "rb").read()
1424 is_binary = is_binary or "\0" in new_content
1425 if is_binary and base_content:
1426 # Fetch again without converting newlines
1427 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
1428 silent_ok=True, universal_newlines=False)
1429 if not is_binary or not self.IsImage(relpath):
1430 new_content = None
1431 return base_content, new_content, is_binary, status
1432
1433
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001434class PerforceVCS(VersionControlSystem):
1435 """Implementation of the VersionControlSystem interface for Perforce."""
1436
1437 def __init__(self, options):
1438
1439 def ConfirmLogin():
1440 # Make sure we have a valid perforce session
1441 while True:
1442 data, retcode = self.RunPerforceCommandWithReturnCode(
1443 ["login", "-s"], marshal_output=True)
1444 if not data:
1445 ErrorExit("Error checking perforce login")
1446 if not retcode and (not "code" in data or data["code"] != "error"):
1447 break
1448 print "Enter perforce password: "
1449 self.RunPerforceCommandWithReturnCode(["login"])
1450
1451 super(PerforceVCS, self).__init__(options)
1452
1453 self.p4_changelist = options.p4_changelist
1454 if not self.p4_changelist:
1455 ErrorExit("A changelist id is required")
1456 if (options.revision):
1457 ErrorExit("--rev is not supported for perforce")
1458
1459 self.p4_port = options.p4_port
1460 self.p4_client = options.p4_client
1461 self.p4_user = options.p4_user
1462
1463 ConfirmLogin()
1464
1465 if not options.message:
1466 description = self.RunPerforceCommand(["describe", self.p4_changelist],
1467 marshal_output=True)
1468 if description and "desc" in description:
1469 # Rietveld doesn't support multi-line descriptions
1470 raw_message = description["desc"].strip()
1471 lines = raw_message.splitlines()
1472 if len(lines):
1473 options.message = lines[0]
1474
1475 def RunPerforceCommandWithReturnCode(self, extra_args, marshal_output=False,
1476 universal_newlines=True):
1477 args = ["p4"]
1478 if marshal_output:
1479 # -G makes perforce format its output as marshalled python objects
1480 args.extend(["-G"])
1481 if self.p4_port:
1482 args.extend(["-p", self.p4_port])
1483 if self.p4_client:
1484 args.extend(["-c", self.p4_client])
1485 if self.p4_user:
1486 args.extend(["-u", self.p4_user])
1487 args.extend(extra_args)
1488
1489 data, retcode = RunShellWithReturnCode(
1490 args, print_output=False, universal_newlines=universal_newlines)
1491 if marshal_output and data:
1492 data = marshal.loads(data)
1493 return data, retcode
1494
1495 def RunPerforceCommand(self, extra_args, marshal_output=False,
1496 universal_newlines=True):
1497 # This might be a good place to cache call results, since things like
1498 # describe or fstat might get called repeatedly.
1499 data, retcode = self.RunPerforceCommandWithReturnCode(
1500 extra_args, marshal_output, universal_newlines)
1501 if retcode:
1502 ErrorExit("Got error status from %s:\n%s" % (extra_args, data))
1503 return data
1504
1505 def GetFileProperties(self, property_key_prefix = "", command = "describe"):
1506 description = self.RunPerforceCommand(["describe", self.p4_changelist],
1507 marshal_output=True)
1508
1509 changed_files = {}
1510 file_index = 0
1511 # Try depotFile0, depotFile1, ... until we don't find a match
1512 while True:
1513 file_key = "depotFile%d" % file_index
1514 if file_key in description:
1515 filename = description[file_key]
1516 change_type = description[property_key_prefix + str(file_index)]
1517 changed_files[filename] = change_type
1518 file_index += 1
1519 else:
1520 break
1521 return changed_files
1522
1523 def GetChangedFiles(self):
1524 return self.GetFileProperties("action")
1525
1526 def GetUnknownFiles(self):
1527 # Perforce doesn't detect new files, they have to be explicitly added
1528 return []
1529
1530 def IsBaseBinary(self, filename):
1531 base_filename = self.GetBaseFilename(filename)
1532 return self.IsBinaryHelper(base_filename, "files")
1533
1534 def IsPendingBinary(self, filename):
1535 return self.IsBinaryHelper(filename, "describe")
1536
1537 def IsBinary(self, filename):
1538 ErrorExit("IsBinary is not safe: call IsBaseBinary or IsPendingBinary")
1539
1540 def IsBinaryHelper(self, filename, command):
1541 file_types = self.GetFileProperties("type", command)
1542 if not filename in file_types:
1543 ErrorExit("Trying to check binary status of unknown file %s." % filename)
1544 # This treats symlinks, macintosh resource files, temporary objects, and
1545 # unicode as binary. See the Perforce docs for more details:
1546 # http://www.perforce.com/perforce/doc.current/manuals/cmdref/o.ftypes.html
1547 return not file_types[filename].endswith("text")
1548
1549 def GetFileContent(self, filename, revision, is_binary):
1550 file_arg = filename
1551 if revision:
1552 file_arg += "#" + revision
1553 # -q suppresses the initial line that displays the filename and revision
1554 return self.RunPerforceCommand(["print", "-q", file_arg],
1555 universal_newlines=not is_binary)
1556
1557 def GetBaseFilename(self, filename):
1558 actionsWithDifferentBases = [
1559 "move/add", # p4 move
1560 "branch", # p4 integrate (to a new file), similar to hg "add"
1561 "add", # p4 integrate (to a new file), after modifying the new file
1562 ]
1563
1564 # We only see a different base for "add" if this is a downgraded branch
1565 # after a file was branched (integrated), then edited.
1566 if self.GetAction(filename) in actionsWithDifferentBases:
1567 # -Or shows information about pending integrations/moves
1568 fstat_result = self.RunPerforceCommand(["fstat", "-Or", filename],
1569 marshal_output=True)
1570
1571 baseFileKey = "resolveFromFile0" # I think it's safe to use only file0
1572 if baseFileKey in fstat_result:
1573 return fstat_result[baseFileKey]
1574
1575 return filename
1576
1577 def GetBaseRevision(self, filename):
1578 base_filename = self.GetBaseFilename(filename)
1579
1580 have_result = self.RunPerforceCommand(["have", base_filename],
1581 marshal_output=True)
1582 if "haveRev" in have_result:
1583 return have_result["haveRev"]
1584
1585 def GetLocalFilename(self, filename):
1586 where = self.RunPerforceCommand(["where", filename], marshal_output=True)
1587 if "path" in where:
1588 return where["path"]
1589
1590 def GenerateDiff(self, args):
1591 class DiffData:
1592 def __init__(self, perforceVCS, filename, action):
1593 self.perforceVCS = perforceVCS
1594 self.filename = filename
1595 self.action = action
1596 self.base_filename = perforceVCS.GetBaseFilename(filename)
1597
1598 self.file_body = None
1599 self.base_rev = None
1600 self.prefix = None
1601 self.working_copy = True
1602 self.change_summary = None
1603
1604 def GenerateDiffHeader(diffData):
1605 header = []
1606 header.append("Index: %s" % diffData.filename)
1607 header.append("=" * 67)
1608
1609 if diffData.base_filename != diffData.filename:
1610 if diffData.action.startswith("move"):
1611 verb = "rename"
1612 else:
1613 verb = "copy"
1614 header.append("%s from %s" % (verb, diffData.base_filename))
1615 header.append("%s to %s" % (verb, diffData.filename))
1616
1617 suffix = "\t(revision %s)" % diffData.base_rev
1618 header.append("--- " + diffData.base_filename + suffix)
1619 if diffData.working_copy:
1620 suffix = "\t(working copy)"
1621 header.append("+++ " + diffData.filename + suffix)
1622 if diffData.change_summary:
1623 header.append(diffData.change_summary)
1624 return header
1625
1626 def GenerateMergeDiff(diffData, args):
1627 # -du generates a unified diff, which is nearly svn format
1628 diffData.file_body = self.RunPerforceCommand(
1629 ["diff", "-du", diffData.filename] + args)
1630 diffData.base_rev = self.GetBaseRevision(diffData.filename)
1631 diffData.prefix = ""
1632
1633 # We have to replace p4's file status output (the lines starting
1634 # with +++ or ---) to match svn's diff format
1635 lines = diffData.file_body.splitlines()
1636 first_good_line = 0
1637 while (first_good_line < len(lines) and
1638 not lines[first_good_line].startswith("@@")):
1639 first_good_line += 1
1640 diffData.file_body = "\n".join(lines[first_good_line:])
1641 return diffData
1642
1643 def GenerateAddDiff(diffData):
1644 fstat = self.RunPerforceCommand(["fstat", diffData.filename],
1645 marshal_output=True)
1646 if "headRev" in fstat:
1647 diffData.base_rev = fstat["headRev"] # Re-adding a deleted file
1648 else:
1649 diffData.base_rev = "0" # Brand new file
1650 diffData.working_copy = False
1651 rel_path = self.GetLocalFilename(diffData.filename)
1652 diffData.file_body = open(rel_path, 'r').read()
1653 # Replicate svn's list of changed lines
1654 line_count = len(diffData.file_body.splitlines())
1655 diffData.change_summary = "@@ -0,0 +1"
1656 if line_count > 1:
1657 diffData.change_summary += ",%d" % line_count
1658 diffData.change_summary += " @@"
1659 diffData.prefix = "+"
1660 return diffData
1661
1662 def GenerateDeleteDiff(diffData):
1663 diffData.base_rev = self.GetBaseRevision(diffData.filename)
1664 is_base_binary = self.IsBaseBinary(diffData.filename)
1665 # For deletes, base_filename == filename
1666 diffData.file_body = self.GetFileContent(diffData.base_filename,
1667 None,
1668 is_base_binary)
1669 # Replicate svn's list of changed lines
1670 line_count = len(diffData.file_body.splitlines())
1671 diffData.change_summary = "@@ -1"
1672 if line_count > 1:
1673 diffData.change_summary += ",%d" % line_count
1674 diffData.change_summary += " +0,0 @@"
1675 diffData.prefix = "-"
1676 return diffData
1677
1678 changed_files = self.GetChangedFiles()
1679
1680 svndiff = []
1681 filecount = 0
1682 for (filename, action) in changed_files.items():
1683 svn_status = self.PerforceActionToSvnStatus(action)
1684 if svn_status == "SKIP":
1685 continue
1686
1687 diffData = DiffData(self, filename, action)
1688 # Is it possible to diff a branched file? Stackoverflow says no:
1689 # http://stackoverflow.com/questions/1771314/in-perforce-command-line-how-to-diff-a-file-reopened-for-add
1690 if svn_status == "M":
1691 diffData = GenerateMergeDiff(diffData, args)
1692 elif svn_status == "A":
1693 diffData = GenerateAddDiff(diffData)
1694 elif svn_status == "D":
1695 diffData = GenerateDeleteDiff(diffData)
1696 else:
1697 ErrorExit("Unknown file action %s (svn action %s)." % \
1698 (action, svn_status))
1699
1700 svndiff += GenerateDiffHeader(diffData)
1701
1702 for line in diffData.file_body.splitlines():
1703 svndiff.append(diffData.prefix + line)
1704 filecount += 1
1705 if not filecount:
1706 ErrorExit("No valid patches found in output from p4 diff")
1707 return "\n".join(svndiff) + "\n"
1708
1709 def PerforceActionToSvnStatus(self, status):
1710 # Mirroring the list at http://permalink.gmane.org/gmane.comp.version-control.mercurial.devel/28717
1711 # Is there something more official?
1712 return {
1713 "add" : "A",
1714 "branch" : "A",
1715 "delete" : "D",
1716 "edit" : "M", # Also includes changing file types.
1717 "integrate" : "M",
1718 "move/add" : "M",
1719 "move/delete": "SKIP",
1720 "purge" : "D", # How does a file's status become "purge"?
1721 }[status]
1722
1723 def GetAction(self, filename):
1724 changed_files = self.GetChangedFiles()
1725 if not filename in changed_files:
1726 ErrorExit("Trying to get base version of unknown file %s." % filename)
1727
1728 return changed_files[filename]
1729
1730 def GetBaseFile(self, filename):
1731 base_filename = self.GetBaseFilename(filename)
1732 base_content = ""
1733 new_content = None
1734
1735 status = self.PerforceActionToSvnStatus(self.GetAction(filename))
1736
1737 if status != "A":
1738 revision = self.GetBaseRevision(base_filename)
1739 if not revision:
1740 ErrorExit("Couldn't find base revision for file %s" % filename)
1741 is_base_binary = self.IsBaseBinary(base_filename)
1742 base_content = self.GetFileContent(base_filename,
1743 revision,
1744 is_base_binary)
1745
1746 is_binary = self.IsPendingBinary(filename)
1747 if status != "D" and status != "SKIP":
1748 relpath = self.GetLocalFilename(filename)
1749 if is_binary and self.IsImage(relpath):
1750 new_content = open(relpath, "rb").read()
1751
1752 return base_content, new_content, is_binary, status
1753
Joe Gregorioe754eb82010-08-20 10:56:32 -04001754# NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
1755def SplitPatch(data):
1756 """Splits a patch into separate pieces for each file.
1757
1758 Args:
1759 data: A string containing the output of svn diff.
1760
1761 Returns:
1762 A list of 2-tuple (filename, text) where text is the svn diff output
1763 pertaining to filename.
1764 """
1765 patches = []
1766 filename = None
1767 diff = []
1768 for line in data.splitlines(True):
1769 new_filename = None
1770 if line.startswith('Index:'):
1771 unused, new_filename = line.split(':', 1)
1772 new_filename = new_filename.strip()
1773 elif line.startswith('Property changes on:'):
1774 unused, temp_filename = line.split(':', 1)
1775 # When a file is modified, paths use '/' between directories, however
1776 # when a property is modified '\' is used on Windows. Make them the same
1777 # otherwise the file shows up twice.
1778 temp_filename = temp_filename.strip().replace('\\', '/')
1779 if temp_filename != filename:
1780 # File has property changes but no modifications, create a new diff.
1781 new_filename = temp_filename
1782 if new_filename:
1783 if filename and diff:
1784 patches.append((filename, ''.join(diff)))
1785 filename = new_filename
1786 diff = [line]
1787 continue
1788 if diff is not None:
1789 diff.append(line)
1790 if filename and diff:
1791 patches.append((filename, ''.join(diff)))
1792 return patches
1793
1794
1795def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
1796 """Uploads a separate patch for each file in the diff output.
1797
1798 Returns a list of [patch_key, filename] for each file.
1799 """
1800 patches = SplitPatch(data)
1801 rv = []
1802 for patch in patches:
1803 if len(patch[1]) > MAX_UPLOAD_SIZE:
1804 print ("Not uploading the patch for " + patch[0] +
1805 " because the file is too large.")
1806 continue
1807 form_fields = [("filename", patch[0])]
1808 if not options.download_base:
1809 form_fields.append(("content_upload", "1"))
1810 files = [("data", "data.diff", patch[1])]
1811 ctype, body = EncodeMultipartFormData(form_fields, files)
1812 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
1813 print "Uploading patch for " + patch[0]
1814 response_body = rpc_server.Send(url, body, content_type=ctype)
1815 lines = response_body.splitlines()
1816 if not lines or lines[0] != "OK":
1817 StatusUpdate(" --> %s" % response_body)
1818 sys.exit(1)
1819 rv.append([lines[1], patch[0]])
1820 return rv
1821
1822
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001823def GuessVCSName(options):
Joe Gregorioe754eb82010-08-20 10:56:32 -04001824 """Helper to guess the version control system.
1825
1826 This examines the current directory, guesses which VersionControlSystem
1827 we're using, and returns an string indicating which VCS is detected.
1828
1829 Returns:
1830 A pair (vcs, output). vcs is a string indicating which VCS was detected
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001831 and is one of VCS_GIT, VCS_MERCURIAL, VCS_SUBVERSION, VCS_PERFORCE,
1832 VCS_CVS, or VCS_UNKNOWN.
1833 Since local perforce repositories can't be easily detected, this method
1834 will only guess VCS_PERFORCE if any perforce options have been specified.
Joe Gregorioe754eb82010-08-20 10:56:32 -04001835 output is a string containing any interesting output from the vcs
1836 detection routine, or None if there is nothing interesting.
1837 """
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001838 for attribute, value in options.__dict__.iteritems():
1839 if attribute.startswith("p4") and value != None:
1840 return (VCS_PERFORCE, None)
1841
Joe Gregorioe754eb82010-08-20 10:56:32 -04001842 # Mercurial has a command to get the base directory of a repository
1843 # Try running it, but don't die if we don't have hg installed.
1844 # NOTE: we try Mercurial first as it can sit on top of an SVN working copy.
1845 try:
1846 out, returncode = RunShellWithReturnCode(["hg", "root"])
1847 if returncode == 0:
1848 return (VCS_MERCURIAL, out.strip())
1849 except OSError, (errno, message):
1850 if errno != 2: # ENOENT -- they don't have hg installed.
1851 raise
1852
1853 # Subversion has a .svn in all working directories.
1854 if os.path.isdir('.svn'):
1855 logging.info("Guessed VCS = Subversion")
1856 return (VCS_SUBVERSION, None)
1857
1858 # Git has a command to test if you're in a git tree.
1859 # Try running it, but don't die if we don't have git installed.
1860 try:
1861 out, returncode = RunShellWithReturnCode(["git", "rev-parse",
1862 "--is-inside-work-tree"])
1863 if returncode == 0:
1864 return (VCS_GIT, None)
1865 except OSError, (errno, message):
1866 if errno != 2: # ENOENT -- they don't have git installed.
1867 raise
1868
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001869 # detect CVS repos use `cvs status && $? == 0` rules
1870 try:
1871 out, returncode = RunShellWithReturnCode(["cvs", "status"])
1872 if returncode == 0:
1873 return (VCS_CVS, None)
1874 except OSError, (errno, message):
1875 if errno != 2:
1876 raise
1877
Joe Gregorioe754eb82010-08-20 10:56:32 -04001878 return (VCS_UNKNOWN, None)
1879
1880
1881def GuessVCS(options):
1882 """Helper to guess the version control system.
1883
1884 This verifies any user-specified VersionControlSystem (by command line
1885 or environment variable). If the user didn't specify one, this examines
1886 the current directory, guesses which VersionControlSystem we're using,
1887 and returns an instance of the appropriate class. Exit with an error
1888 if we can't figure it out.
1889
1890 Returns:
1891 A VersionControlSystem instance. Exits if the VCS can't be guessed.
1892 """
1893 vcs = options.vcs
1894 if not vcs:
1895 vcs = os.environ.get("CODEREVIEW_VCS")
1896 if vcs:
1897 v = VCS_ABBREVIATIONS.get(vcs.lower())
1898 if v is None:
1899 ErrorExit("Unknown version control system %r specified." % vcs)
1900 (vcs, extra_output) = (v, None)
1901 else:
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001902 (vcs, extra_output) = GuessVCSName(options)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001903
1904 if vcs == VCS_MERCURIAL:
1905 if extra_output is None:
1906 extra_output = RunShell(["hg", "root"]).strip()
1907 return MercurialVCS(options, extra_output)
1908 elif vcs == VCS_SUBVERSION:
1909 return SubversionVCS(options)
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001910 elif vcs == VCS_PERFORCE:
1911 return PerforceVCS(options)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001912 elif vcs == VCS_GIT:
1913 return GitVCS(options)
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001914 elif vcs == VCS_CVS:
1915 return CVSVCS(options)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001916
1917 ErrorExit(("Could not guess version control system. "
1918 "Are you in a working copy directory?"))
1919
1920
1921def CheckReviewer(reviewer):
1922 """Validate a reviewer -- either a nickname or an email addres.
1923
1924 Args:
1925 reviewer: A nickname or an email address.
1926
1927 Calls ErrorExit() if it is an invalid email address.
1928 """
1929 if "@" not in reviewer:
1930 return # Assume nickname
1931 parts = reviewer.split("@")
1932 if len(parts) > 2:
1933 ErrorExit("Invalid email address: %r" % reviewer)
1934 assert len(parts) == 2
1935 if "." not in parts[1]:
1936 ErrorExit("Invalid email address: %r" % reviewer)
1937
1938
1939def LoadSubversionAutoProperties():
1940 """Returns the content of [auto-props] section of Subversion's config file as
1941 a dictionary.
1942
1943 Returns:
1944 A dictionary whose key-value pair corresponds the [auto-props] section's
1945 key-value pair.
1946 In following cases, returns empty dictionary:
1947 - config file doesn't exist, or
1948 - 'enable-auto-props' is not set to 'true-like-value' in [miscellany].
1949 """
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001950 if os.name == 'nt':
1951 subversion_config = os.environ.get("APPDATA") + "\\Subversion\\config"
1952 else:
1953 subversion_config = os.path.expanduser("~/.subversion/config")
Joe Gregorioe754eb82010-08-20 10:56:32 -04001954 if not os.path.exists(subversion_config):
1955 return {}
1956 config = ConfigParser.ConfigParser()
1957 config.read(subversion_config)
1958 if (config.has_section("miscellany") and
1959 config.has_option("miscellany", "enable-auto-props") and
1960 config.getboolean("miscellany", "enable-auto-props") and
1961 config.has_section("auto-props")):
1962 props = {}
1963 for file_pattern in config.options("auto-props"):
1964 props[file_pattern] = ParseSubversionPropertyValues(
1965 config.get("auto-props", file_pattern))
1966 return props
1967 else:
1968 return {}
1969
1970def ParseSubversionPropertyValues(props):
1971 """Parse the given property value which comes from [auto-props] section and
1972 returns a list whose element is a (svn_prop_key, svn_prop_value) pair.
1973
1974 See the following doctest for example.
1975
1976 >>> ParseSubversionPropertyValues('svn:eol-style=LF')
1977 [('svn:eol-style', 'LF')]
1978 >>> ParseSubversionPropertyValues('svn:mime-type=image/jpeg')
1979 [('svn:mime-type', 'image/jpeg')]
1980 >>> ParseSubversionPropertyValues('svn:eol-style=LF;svn:executable')
1981 [('svn:eol-style', 'LF'), ('svn:executable', '*')]
1982 """
1983 key_value_pairs = []
1984 for prop in props.split(";"):
1985 key_value = prop.split("=")
1986 assert len(key_value) <= 2
1987 if len(key_value) == 1:
1988 # If value is not given, use '*' as a Subversion's convention.
1989 key_value_pairs.append((key_value[0], "*"))
1990 else:
1991 key_value_pairs.append((key_value[0], key_value[1]))
1992 return key_value_pairs
1993
1994
1995def GetSubversionPropertyChanges(filename):
1996 """Return a Subversion's 'Property changes on ...' string, which is used in
1997 the patch file.
1998
1999 Args:
2000 filename: filename whose property might be set by [auto-props] config.
2001
2002 Returns:
2003 A string like 'Property changes on |filename| ...' if given |filename|
2004 matches any entries in [auto-props] section. None, otherwise.
2005 """
2006 global svn_auto_props_map
2007 if svn_auto_props_map is None:
2008 svn_auto_props_map = LoadSubversionAutoProperties()
2009
2010 all_props = []
2011 for file_pattern, props in svn_auto_props_map.items():
2012 if fnmatch.fnmatch(filename, file_pattern):
2013 all_props.extend(props)
2014 if all_props:
2015 return FormatSubversionPropertyChanges(filename, all_props)
2016 return None
2017
2018
2019def FormatSubversionPropertyChanges(filename, props):
2020 """Returns Subversion's 'Property changes on ...' strings using given filename
2021 and properties.
2022
2023 Args:
2024 filename: filename
2025 props: A list whose element is a (svn_prop_key, svn_prop_value) pair.
2026
2027 Returns:
2028 A string which can be used in the patch file for Subversion.
2029
2030 See the following doctest for example.
2031
2032 >>> print FormatSubversionPropertyChanges('foo.cc', [('svn:eol-style', 'LF')])
2033 Property changes on: foo.cc
2034 ___________________________________________________________________
2035 Added: svn:eol-style
2036 + LF
2037 <BLANKLINE>
2038 """
2039 prop_changes_lines = [
2040 "Property changes on: %s" % filename,
2041 "___________________________________________________________________"]
2042 for key, value in props:
2043 prop_changes_lines.append("Added: " + key)
2044 prop_changes_lines.append(" + " + value)
2045 return "\n".join(prop_changes_lines) + "\n"
2046
2047
2048def RealMain(argv, data=None):
2049 """The real main function.
2050
2051 Args:
2052 argv: Command line arguments.
2053 data: Diff contents. If None (default) the diff is generated by
2054 the VersionControlSystem implementation returned by GuessVCS().
2055
2056 Returns:
2057 A 2-tuple (issue id, patchset id).
2058 The patchset id is None if the base files are not uploaded by this
2059 script (applies only to SVN checkouts).
2060 """
2061 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
2062 "%(lineno)s %(message)s "))
2063 os.environ['LC_ALL'] = 'C'
2064 options, args = parser.parse_args(argv[1:])
2065 global verbosity
2066 verbosity = options.verbose
2067 if verbosity >= 3:
2068 logging.getLogger().setLevel(logging.DEBUG)
2069 elif verbosity >= 2:
2070 logging.getLogger().setLevel(logging.INFO)
2071
2072 vcs = GuessVCS(options)
2073
2074 base = options.base_url
2075 if isinstance(vcs, SubversionVCS):
2076 # Guessing the base field is only supported for Subversion.
2077 # Note: Fetching base files may become deprecated in future releases.
2078 guessed_base = vcs.GuessBase(options.download_base)
2079 if base:
2080 if guessed_base and base != guessed_base:
2081 print "Using base URL \"%s\" from --base_url instead of \"%s\"" % \
2082 (base, guessed_base)
2083 else:
2084 base = guessed_base
2085
2086 if not base and options.download_base:
2087 options.download_base = True
2088 logging.info("Enabled upload of base file")
2089 if not options.assume_yes:
2090 vcs.CheckForUnknownFiles()
2091 if data is None:
2092 data = vcs.GenerateDiff(args)
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002093 data = vcs.PostProcessDiff(data)
2094 if options.print_diffs:
2095 print "Rietveld diff start:*****"
2096 print data
2097 print "Rietveld diff end:*****"
Joe Gregorioe754eb82010-08-20 10:56:32 -04002098 files = vcs.GetBaseFiles(data)
2099 if verbosity >= 1:
2100 print "Upload server:", options.server, "(change with -s/--server)"
2101 if options.issue:
2102 prompt = "Message describing this patch set: "
2103 else:
2104 prompt = "New issue subject: "
2105 message = options.message or raw_input(prompt).strip()
2106 if not message:
2107 ErrorExit("A non-empty message is required")
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002108 rpc_server = GetRpcServer(options.server,
2109 options.email,
2110 options.host,
2111 options.save_cookies,
2112 options.account_type)
Joe Gregorioe754eb82010-08-20 10:56:32 -04002113 form_fields = [("subject", message)]
2114 if base:
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002115 b = urlparse.urlparse(base)
2116 username, netloc = urllib.splituser(b.netloc)
2117 if username:
2118 logging.info("Removed username from base URL")
2119 base = urlparse.urlunparse((b.scheme, netloc, b.path, b.params,
2120 b.query, b.fragment))
Joe Gregorioe754eb82010-08-20 10:56:32 -04002121 form_fields.append(("base", base))
2122 if options.issue:
2123 form_fields.append(("issue", str(options.issue)))
2124 if options.email:
2125 form_fields.append(("user", options.email))
2126 if options.reviewers:
2127 for reviewer in options.reviewers.split(','):
2128 CheckReviewer(reviewer)
2129 form_fields.append(("reviewers", options.reviewers))
2130 if options.cc:
2131 for cc in options.cc.split(','):
2132 CheckReviewer(cc)
2133 form_fields.append(("cc", options.cc))
2134 description = options.description
2135 if options.description_file:
2136 if options.description:
2137 ErrorExit("Can't specify description and description_file")
2138 file = open(options.description_file, 'r')
2139 description = file.read()
2140 file.close()
2141 if description:
2142 form_fields.append(("description", description))
2143 # Send a hash of all the base file so the server can determine if a copy
2144 # already exists in an earlier patchset.
2145 base_hashes = ""
2146 for file, info in files.iteritems():
2147 if not info[0] is None:
2148 checksum = md5(info[0]).hexdigest()
2149 if base_hashes:
2150 base_hashes += "|"
2151 base_hashes += checksum + ":" + file
2152 form_fields.append(("base_hashes", base_hashes))
2153 if options.private:
2154 if options.issue:
2155 print "Warning: Private flag ignored when updating an existing issue."
2156 else:
2157 form_fields.append(("private", "1"))
2158 # If we're uploading base files, don't send the email before the uploads, so
2159 # that it contains the file status.
2160 if options.send_mail and options.download_base:
2161 form_fields.append(("send_mail", "1"))
2162 if not options.download_base:
2163 form_fields.append(("content_upload", "1"))
2164 if len(data) > MAX_UPLOAD_SIZE:
2165 print "Patch is large, so uploading file patches separately."
2166 uploaded_diff_file = []
2167 form_fields.append(("separate_patches", "1"))
2168 else:
2169 uploaded_diff_file = [("data", "data.diff", data)]
2170 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
2171 response_body = rpc_server.Send("/upload", body, content_type=ctype)
2172 patchset = None
2173 if not options.download_base or not uploaded_diff_file:
2174 lines = response_body.splitlines()
2175 if len(lines) >= 2:
2176 msg = lines[0]
2177 patchset = lines[1].strip()
2178 patches = [x.split(" ", 1) for x in lines[2:]]
2179 else:
2180 msg = response_body
2181 else:
2182 msg = response_body
2183 StatusUpdate(msg)
2184 if not response_body.startswith("Issue created.") and \
2185 not response_body.startswith("Issue updated."):
2186 sys.exit(0)
2187 issue = msg[msg.rfind("/")+1:]
2188
2189 if not uploaded_diff_file:
2190 result = UploadSeparatePatches(issue, rpc_server, patchset, data, options)
2191 if not options.download_base:
2192 patches = result
2193
2194 if not options.download_base:
2195 vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options, files)
2196 if options.send_mail:
2197 rpc_server.Send("/" + issue + "/mail", payload="")
2198 return issue, patchset
2199
2200
2201def main():
2202 try:
2203 RealMain(sys.argv)
2204 except KeyboardInterrupt:
2205 print
2206 StatusUpdate("Interrupted.")
2207 sys.exit(1)
2208
2209
2210if __name__ == "__main__":
2211 main()