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

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

Powered by Google App Engine
This is Rietveld 408576698