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

Side by Side Diff: git_cl/upload.py

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

Powered by Google App Engine
This is Rietveld 408576698