~ubuntu-branches/ubuntu/trusty/blender/trusty

« back to all changes in this revision

Viewing changes to source/tools/codereview_upload.py

  • Committer: Package Import Robot
  • Author(s): Jeremy Bicha
  • Date: 2013-03-06 12:08:47 UTC
  • mfrom: (1.5.1) (14.1.8 experimental)
  • Revision ID: package-import@ubuntu.com-20130306120847-frjfaryb2zrotwcg
Tags: 2.66a-1ubuntu1
* Resynchronize with Debian (LP: #1076930, #1089256, #1052743, #999024,
  #1122888, #1147084)
* debian/control:
  - Lower build-depends on libavcodec-dev since we're not
    doing the libav9 transition in Ubuntu yet

Show diffs side-by-side

added added

removed removed

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