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

Side by Side Diff: git_cl/upload.py

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

Powered by Google App Engine
This is Rietveld 408576698