4
# Copyright 2007 Google Inc.
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
10
# http://www.apache.org/licenses/LICENSE-2.0
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.
18
"""Tool for uploading diffs from a version control system to the codereview app.
20
Usage summary: upload.py [options] [-- diff_options] [path...]
22
Diff options are passed to the diff command of the underlying system.
24
Supported version control systems:
31
It is important for Git/Mercurial users to specify a tree/node/branch to diff
32
against by using the '--rev' option.
34
# This code is derived from appcfg.py in the App Engine SDK (open source),
35
# and from ASPN recipe #146306.
55
# The md5 module was deprecated in Python 2.5.
57
from hashlib import md5
71
# The logging verbosity:
78
# The account type used for authentication.
79
# This line could be changed by the review server (see handler for
81
AUTH_ACCOUNT_TYPE = "GOOGLE"
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"
87
# Max size of patch or base file.
88
MAX_UPLOAD_SIZE = 900 * 1024
90
# Constants for version control names. Used by GuessVCSName.
92
VCS_MERCURIAL = "Mercurial"
93
VCS_SUBVERSION = "Subversion"
94
VCS_PERFORCE = "Perforce"
96
VCS_UNKNOWN = "Unknown"
99
VCS_MERCURIAL.lower(): VCS_MERCURIAL,
101
VCS_SUBVERSION.lower(): VCS_SUBVERSION,
102
"svn": VCS_SUBVERSION,
103
VCS_PERFORCE.lower(): VCS_PERFORCE,
105
VCS_GIT.lower(): VCS_GIT,
106
VCS_CVS.lower(): VCS_CVS,
109
# The result of parsing Subversion's [auto-props] setting.
110
svn_auto_props_map = None
112
def GetEmail(prompt):
113
"""Prompts the user for their email address and returns it.
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.
121
last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
123
if os.path.exists(last_email_file_name):
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
131
email = raw_input(prompt + ": ").strip()
134
last_email_file = open(last_email_file_name, "w")
135
last_email_file.write(email)
136
last_email_file.close()
144
def StatusUpdate(msg):
145
"""Print a status message to stdout.
147
If 'verbosity' is greater than 0, print the message.
150
msg: The string to print.
157
"""Print an error message to stderr and exit."""
158
print >>sys.stderr, msg
162
class ClientLoginError(urllib2.HTTPError):
163
"""Raised to indicate there was an error authenticating with ClientLogin."""
165
def __init__(self, url, code, msg, headers, args):
166
urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
168
self.reason = args["Error"]
169
self.info = args.get("Info", None)
172
class AbstractRpcServer(object):
173
"""Provides a common interface for a simple RPC server."""
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.
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
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
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)
206
logging.info("Server: %s", self.host)
208
def _GetOpener(self):
209
"""Returns an OpenerDirector for making HTTP requests.
212
A urllib2.OpenerDirector object.
214
raise NotImplementedError()
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)
226
def _GetAuthToken(self, email, password):
227
"""Uses ClientLogin to authenticate the user, returning an auth token.
230
email: The user's email address
231
password: The user's password
234
ClientLoginError: If there was an error authenticating with ClientLogin.
235
HTTPError: If there was some other form of HTTP error.
238
The authentication token returned by ClientLogin.
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({
250
"source": "rietveld-codereview-upload",
251
"accountType": account_type,
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:
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)
269
def _GetAuthCookie(self, auth_token):
270
"""Fetches authentication cookies for an authentication token.
273
auth_token: The authentication token returned by ClientLogin.
276
HTTPError: If there was an error fetching the authentication cookies.
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)))
284
response = self.opener.open(req)
285
except urllib2.HTTPError, 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
293
def _Authenticate(self):
294
"""Authenticates the user.
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.
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.
309
credentials = self.auth_function()
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")
322
print >>sys.stderr, "Invalid username or password."
323
elif e.reason == "CaptchaRequired":
324
print >>sys.stderr, (
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."
339
elif e.reason == "ServiceDisabled":
340
print >>sys.stderr, ("The user's access to the service has been "
342
elif e.reason == "ServiceUnavailable":
343
print >>sys.stderr, "The service is not available; try again later."
347
print >>sys.stderr, ''
349
self._GetAuthCookie(auth_token)
352
def Send(self, request_path, payload=None,
353
content_type="application/octet-stream",
357
"""Sends an RPC and returns the response.
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.
371
The response body, as a string.
373
# TODO: Don't require authentication. Let the server say
374
# whether it is necessary.
375
if not self.authenticated:
378
old_timeout = socket.getdefaulttimeout()
379
socket.setdefaulttimeout(timeout)
385
url = "%s%s" % (self.host, request_path)
387
url += "?" + urllib.urlencode(args)
388
req = self._CreateRequest(url=url, data=payload)
389
req.add_header("Content-Type", content_type)
391
for header, value in extra_headers.items():
392
req.add_header(header, value)
394
f = self.opener.open(req)
398
except urllib2.HTTPError, e:
401
elif e.code == 401 or e.code == 302:
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])
413
socket.setdefaulttimeout(old_timeout)
416
class HttpRpcServer(AbstractRpcServer):
417
"""Provides a simplified RPC-style interface for HTTP requests."""
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()
426
def _GetOpener(self):
427
"""Returns an OpenerDirector that supports cookies and ignores redirects.
430
A urllib2.OpenerDirector object.
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):
444
self.cookie_jar.load()
445
self.authenticated = True
446
StatusUpdate("Loaded authentication cookies from %s" %
448
except (cookielib.LoadError, IOError):
449
# Failed to load cookies - just ignore them.
452
# Create an empty cookie file with mode 600
453
fd = os.open(self.cookie_file, os.O_CREAT, 0600)
455
# Always chmod the cookie file
456
os.chmod(self.cookie_file, 0600)
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))
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"""
469
def format_heading(self, heading):
470
return "%s:\n" % heading
472
def format_option(self, option):
474
res = optparse.HelpFormatter.format_option(self, option)
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(", ")
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)
490
parser = optparse.OptionParser(
491
usage="%prog [options] [-- diff_options] [path...]",
492
add_help_option=False,
493
formatter=CondensedHelpFormatter()
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'.")
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.")
512
group = parser.add_option_group("Review server options")
513
group.add_option("-s", "--server", action="store", dest="server",
514
default=DEFAULT_REVIEW_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')."))
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",
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",
550
help="Make the issue restricted to reviewers and those CCed")
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."))
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"))
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.
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
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.
612
A new AbstractRpcServer, on which RPC calls can be made.
615
rpc_server_class = HttpRpcServer
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):
621
email = "test@example.com"
622
logging.info("Using debug user %s. Override with --email" % email)
623
server = rpc_server_class(
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
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
641
if local_email is None:
642
local_email = GetEmail("Email (login for uploading to %s)" % server)
646
password = keyring.get_password(host, local_email)
648
# Sadly, we have to trap all errors here as
649
# gnomekeyring.IOError inherits from object. :/
650
print "Failed to get password from keyring"
652
if password is not None:
653
print "Using password from system keyring."
655
password = getpass.getpass("Password for %s: " % local_email)
657
answer = raw_input("Store password in system keyring?(y/N) ").strip()
659
keyring.set_password(host, local_email, password)
660
return (local_email, password)
662
return rpc_server_class(server,
664
host_override=host_override,
665
save_cookies=save_cookies)
668
def EncodeMultipartFormData(fields, files):
669
"""Encode form fields for multipart/form-data.
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
676
(content_type, body) ready for httplib.HTTP instance.
679
http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
681
BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
684
for (key, value) in fields:
685
lines.append('--' + BOUNDARY)
686
lines.append('Content-Disposition: form-data; name="%s"' % key)
688
if isinstance(value, unicode):
689
value = value.encode('utf-8')
691
for (key, filename, value) in files:
692
lines.append('--' + BOUNDARY)
693
lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' %
695
lines.append('Content-Type: %s' % GetContentType(filename))
697
if isinstance(value, unicode):
698
value = value.encode('utf-8')
700
lines.append('--' + BOUNDARY + '--')
702
body = CRLF.join(lines)
703
content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
704
return content_type, body
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'
712
# Use a shell for subcommands on Windows to get a PATH search.
713
use_shell = sys.platform.startswith("win")
715
def RunShellWithReturnCodeAndStderr(command, print_output=False,
716
universal_newlines=True,
718
"""Executes a command and returns the output from stdout, stderr and the return code.
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).
727
Tuple (stdout, stderr, return code)
729
logging.info("Running %s", command)
731
env['LC_MESSAGES'] = 'C'
732
p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
733
shell=use_shell, universal_newlines=universal_newlines,
738
line = p.stdout.readline()
741
print line.strip("\n")
742
output_array.append(line)
743
output = "".join(output_array)
745
output = p.stdout.read()
747
errout = p.stderr.read()
748
if print_output and errout:
749
print >>sys.stderr, errout
752
return output, errout, p.returncode
754
def RunShellWithReturnCode(command, print_output=False,
755
universal_newlines=True,
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)
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)
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)
773
class VersionControlSystem(object):
774
"""Abstract base class providing an interface to the VCS."""
776
def __init__(self, options):
780
options: Command line options.
782
self.options = options
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__)
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:"."""
795
def GenerateDiff(self, args):
796
"""Return the current diff as a string.
799
args: Extra arguments to pass to the diff command.
801
raise NotImplementedError(
802
"abstract method -- subclass %s must override" % self.__class__)
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__)
809
def CheckForUnknownFiles(self):
810
"""Show an "are you sure?" prompt if there are unknown files."""
811
unknown_files = self.GetUnknownFiles()
813
print "The following files are not added to version control:"
814
for line in unknown_files:
816
prompt = "Are you sure to continue?(y/N) "
817
answer = raw_input(prompt).strip()
819
ErrorExit("User aborted")
821
def GetBaseFile(self, filename):
822
"""Get the content of the upstream version of a file.
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.
834
raise NotImplementedError(
835
"abstract method -- subclass %s must override" % self.__class__)
838
def GetBaseFiles(self, diff):
839
"""Helper that calls GetBase file for each file in the patch.
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:".
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 '\'
852
filename = filename.strip().replace('\\', '/')
853
files[filename] = self.GetBaseFile(filename)
857
def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
859
"""Uploads the base files (and if necessary, the current ones as well)."""
861
def UploadFile(filename, file_id, content, is_binary, status, is_base):
862
"""Uploads a file to the server."""
863
file_too_large = False
868
if len(content) > MAX_UPLOAD_SIZE:
869
print ("Not uploading the %s file for %s because it's too large." %
871
file_too_large = True
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),
879
("checksum", checksum),
880
("is_binary", str(is_binary)),
881
("is_current", str(not is_base)),
884
form_fields.append(("file_too_large", "1"))
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,
891
if not response_body.startswith("OK"):
892
StatusUpdate(" --> %s" % response_body)
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:
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)
909
def IsImage(self, filename):
910
"""Returns true if the filename has an image extension."""
911
mimetype = mimetypes.guess_type(filename)[0]
914
return mimetype.startswith("image/")
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)
923
class SubversionVCS(VersionControlSystem):
924
"""Implementation of the VersionControlSystem interface for Subversion."""
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)
931
ErrorExit("Invalid Subversion revision %s." % self.options.revision)
932
self.rev_start = match.group(1)
933
self.rev_end = match.group(3)
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)
945
return self._GetInfo("Repository UUID")
947
def GuessBase(self, required):
948
"""Wrapper for _GuessBase."""
951
def _GuessBase(self, required):
952
"""Returns base URL for current diff.
955
required: If true, exits if the url can't be guessed, otherwise None is
958
url = self._GetInfo("URL")
960
scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
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
967
elif netloc.endswith(".googlecode.com"):
969
guess = "Google Code "
971
base = urlparse.urlunparse((scheme, netloc, path, params,
973
logging.info("Guessed %sbase = %s", guess, base)
976
ErrorExit("Can't find URL in output from svn info")
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()
985
def _EscapeFilename(self, filename):
986
"""Escapes filename for SVN commands."""
987
if "@" in filename and not filename.endswith("@"):
988
filename = "%s@" % filename
991
def GenerateDiff(self, args):
992
cmd = ["svn", "diff"]
993
if self.options.revision:
994
cmd += ["-r", self.options.revision]
998
for line in data.splitlines():
999
if line.startswith(("Index:", "Property changes on:")):
1003
ErrorExit("No valid patches found in output from svn diff")
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
1015
'Date': ['Date', 'LastChangedDate'],
1016
'Revision': ['Revision', 'LastChangedRevision', 'Rev'],
1017
'Author': ['Author', 'LastChangedBy'],
1018
'HeadURL': ['HeadURL', 'URL'],
1022
'LastChangedDate': ['LastChangedDate', 'Date'],
1023
'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'],
1024
'LastChangedBy': ['LastChangedBy', 'Author'],
1025
'URL': ['URL', 'HeadURL'],
1030
return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
1031
return "$%s$" % m.group(1)
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)
1037
def GetUnknownFiles(self):
1038
status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True)
1040
for line in status.split("\n"):
1041
if line and line[0] == "?":
1042
unknown_files.append(line)
1043
return unknown_files
1045
def ReadFile(self, filename):
1046
"""Returns the contents of a file."""
1047
file = open(filename, 'rb')
1050
result = file.read()
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)])
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]
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.
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)
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):
1087
ErrorExit("Failed to get status for %s:\n%s" % (filename, err))
1089
old_files = out.splitlines()
1090
args = ["svn", "list"]
1092
args += ["-r", self.rev_end]
1093
cmd = args + [self._EscapeFilename(dirname) or "."]
1094
out, returncode = RunShellWithReturnCode(cmd)
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:
1101
elif relfilename in old_files and relfilename in new_files:
1107
def GetBaseFile(self, filename):
1108
status = self.GetStatus(filename)
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
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)
1122
is_binary = bool(mimetype) and not mimetype.startswith("text/")
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.
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)
1133
# Don't change filename, it's needed later.
1135
args += ["-r", "BASE"]
1136
cmd = ["svn"] + args + ["propget", "svn:mime-type", url]
1137
mimetype, returncode = RunShellWithReturnCode(cmd)
1139
# File does not exist in the requested revision.
1140
# Reset mimetype, it contains an error message.
1143
mimetype = mimetype.strip()
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.
1156
if status[0] == "M":
1157
if not self.rev_end:
1158
new_content = self.ReadFile(filename)
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)
1168
universal_newlines = False
1170
universal_newlines = True
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,
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,
1190
ErrorExit("Got error status from 'svn cat %s'" % filename)
1194
url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
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)
1203
StatusUpdate("svn status returned unexpected output: %s" % status)
1205
return base_content, new_content, is_binary, status[0:5]
1208
class GitVCS(VersionControlSystem):
1209
"""Implementation of the VersionControlSystem interface for Git."""
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.
1216
# Map of new filename -> old filename for renames.
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'.
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
1232
# Special used by git to indicate "no such content".
1235
def IsFileNew(filename):
1236
return filename in self.hashes and self.hashes[filename][0] is None
1238
def AddSubversionPropertyChange(filename):
1239
"""Add svn's property change information into the patch if given file is
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.
1246
if self.options.emulate_svn_auto_props and IsFileNew(filename):
1247
svnprops = GetSubversionPropertyChanges(filename)
1249
svndiff.append("\n" + svnprops + "\n")
1254
for line in gitdiff.splitlines():
1255
match = re.match(r"diff --git a/(.*) b/(.*)$", line)
1257
# Add auto property here for previously seen file.
1258
if filename is not None:
1259
AddSubversionPropertyChange(filename)
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)
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)
1272
before, after = (match.group(1), match.group(2))
1273
if before == NULL_HASH:
1275
if after == NULL_HASH:
1277
self.hashes[filename] = (before, after)
1278
svndiff.append(line + "\n")
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)
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
1292
extra_args = [self.options.revision] + extra_args
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']
1300
[ "git", "diff", "--no-color", "--no-ext-diff", "--full-index",
1301
"--ignore-submodules", "-M"] + extra_args,
1304
def GetUnknownFiles(self):
1305
status = RunShell(["git", "ls-files", "--exclude-standard", "--others"],
1307
return status.splitlines()
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)
1314
ErrorExit("Got error status from 'git show %s'" % file_hash)
1317
def GetBaseFile(self, filename):
1318
hash_before, hash_after = self.hashes.get(filename, (None,None))
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:
1332
elif not hash_after:
1337
is_binary = self.IsBinaryData(base_content)
1338
is_image = self.IsImage(filename)
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)
1349
return (base_content, new_content, is_binary, status)
1352
class CVSVCS(VersionControlSystem):
1353
"""Implementation of the VersionControlSystem interface for CVS."""
1355
def __init__(self, options):
1356
super(CVSVCS, self).__init__(options)
1359
"""For now we don't know how to get repository ID for CVS"""
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")
1368
def GetBaseFile(self, filename):
1373
output, retcode = RunShellWithReturnCode(["cvs", "status", filename])
1375
ErrorExit("Got error status from 'cvs status %s'" % filename)
1377
if output.find("Status: Locally Modified") != -1:
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"):
1386
elif output.find("Status: Needs Checkout"):
1388
base_content = self.GetOriginalContent_(filename)
1390
return (base_content, new_content, self.IsBinaryData(base_content), status)
1392
def GenerateDiff(self, extra_args):
1393
cmd = ["cvs", "diff", "-u", "-N"]
1394
if self.options.revision:
1395
cmd += ["-r", self.options.revision]
1397
cmd.extend(extra_args)
1398
data, retcode = RunShellWithReturnCode(cmd)
1400
if retcode in [0, 1]:
1401
for line in data.splitlines():
1402
if line.startswith("Index:"):
1407
ErrorExit("No valid patches found in output from cvs diff")
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,))
1416
for line in data.split("\n"):
1417
if line and line[0] == "?":
1418
unknown_files.append(line)
1419
return unknown_files
1421
class MercurialVCS(VersionControlSystem):
1422
"""Implementation of the VersionControlSystem interface for Mercurial."""
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
1435
self.base_rev = RunShell(["hg", "parent", "-q"]).split(':')[1].strip()
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())
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)
1449
def GenerateDiff(self, extra_args):
1450
cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
1451
data = RunShell(cmd, silent_ok=True)
1454
for line in data.splitlines():
1455
m = re.match("diff --git a/(\S+) b/(\S+)", line)
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)
1468
svndiff.append(line)
1470
ErrorExit("No valid patches found in output from hg diff")
1471
return "\n".join(svndiff) + "\n"
1473
def GetUnknownFiles(self):
1474
"""Return a list of files unknown to the VCS."""
1476
status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
1479
for line in status.splitlines():
1480
st, fn = line.split(" ", 1)
1482
unknown_files.append(fn)
1483
return unknown_files
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.
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
1497
if out[0].startswith('%s: ' % relpath):
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()
1505
if ":" in self.base_rev:
1506
base_rev = self.base_rev.split(":", 1)[0]
1508
base_rev = self.base_rev
1510
base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
1512
is_binary = self.IsBinaryData(base_content)
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)
1522
return base_content, new_content, is_binary, status
1525
class PerforceVCS(VersionControlSystem):
1526
"""Implementation of the VersionControlSystem interface for Perforce."""
1528
def __init__(self, options):
1531
# Make sure we have a valid perforce session
1533
data, retcode = self.RunPerforceCommandWithReturnCode(
1534
["login", "-s"], marshal_output=True)
1536
ErrorExit("Error checking perforce login")
1537
if not retcode and (not "code" in data or data["code"] != "error"):
1539
print "Enter perforce password: "
1540
self.RunPerforceCommandWithReturnCode(["login"])
1542
super(PerforceVCS, self).__init__(options)
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")
1550
self.p4_port = options.p4_port
1551
self.p4_client = options.p4_client
1552
self.p4_user = options.p4_user
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()
1564
options.title = lines[0]
1567
"""For now we don't know how to get repository ID for Perforce"""
1570
def RunPerforceCommandWithReturnCode(self, extra_args, marshal_output=False,
1571
universal_newlines=True):
1574
# -G makes perforce format its output as marshalled python objects
1577
args.extend(["-p", self.p4_port])
1579
args.extend(["-c", self.p4_client])
1581
args.extend(["-u", self.p4_user])
1582
args.extend(extra_args)
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
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)
1597
ErrorExit("Got error status from %s:\n%s" % (extra_args, data))
1600
def GetFileProperties(self, property_key_prefix = "", command = "describe"):
1601
description = self.RunPerforceCommand(["describe", self.p4_changelist],
1602
marshal_output=True)
1606
# Try depotFile0, depotFile1, ... until we don't find a match
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
1616
return changed_files
1618
def GetChangedFiles(self):
1619
return self.GetFileProperties("action")
1621
def GetUnknownFiles(self):
1622
# Perforce doesn't detect new files, they have to be explicitly added
1625
def IsBaseBinary(self, filename):
1626
base_filename = self.GetBaseFilename(filename)
1627
return self.IsBinaryHelper(base_filename, "files")
1629
def IsPendingBinary(self, filename):
1630
return self.IsBinaryHelper(filename, "describe")
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")
1641
def GetFileContent(self, filename, revision, is_binary):
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)
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
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)
1663
baseFileKey = "resolveFromFile0" # I think it's safe to use only file0
1664
if baseFileKey in fstat_result:
1665
return fstat_result[baseFileKey]
1669
def GetBaseRevision(self, filename):
1670
base_filename = self.GetBaseFilename(filename)
1672
have_result = self.RunPerforceCommand(["have", base_filename],
1673
marshal_output=True)
1674
if "haveRev" in have_result:
1675
return have_result["haveRev"]
1677
def GetLocalFilename(self, filename):
1678
where = self.RunPerforceCommand(["where", filename], marshal_output=True)
1680
return where["path"]
1682
def GenerateDiff(self, args):
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)
1690
self.file_body = None
1691
self.base_rev = None
1693
self.working_copy = True
1694
self.change_summary = None
1696
def GenerateDiffHeader(diffData):
1698
header.append("Index: %s" % diffData.filename)
1699
header.append("=" * 67)
1701
if diffData.base_filename != diffData.filename:
1702
if diffData.action.startswith("move"):
1706
header.append("%s from %s" % (verb, diffData.base_filename))
1707
header.append("%s to %s" % (verb, diffData.filename))
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)
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 = ""
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()
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:])
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
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"
1749
diffData.change_summary += ",%d" % line_count
1750
diffData.change_summary += " @@"
1751
diffData.prefix = "+"
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,
1761
# Replicate svn's list of changed lines
1762
line_count = len(diffData.file_body.splitlines())
1763
diffData.change_summary = "@@ -1"
1765
diffData.change_summary += ",%d" % line_count
1766
diffData.change_summary += " +0,0 @@"
1767
diffData.prefix = "-"
1770
changed_files = self.GetChangedFiles()
1774
for (filename, action) in changed_files.items():
1775
svn_status = self.PerforceActionToSvnStatus(action)
1776
if svn_status == "SKIP":
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)
1789
ErrorExit("Unknown file action %s (svn action %s)." % \
1790
(action, svn_status))
1792
svndiff += GenerateDiffHeader(diffData)
1794
for line in diffData.file_body.splitlines():
1795
svndiff.append(diffData.prefix + line)
1798
ErrorExit("No valid patches found in output from p4 diff")
1799
return "\n".join(svndiff) + "\n"
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?
1808
"edit" : "M", # Also includes changing file types.
1811
"move/delete": "SKIP",
1812
"purge" : "D", # How does a file's status become "purge"?
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)
1820
return changed_files[filename]
1822
def GetBaseFile(self, filename):
1823
base_filename = self.GetBaseFilename(filename)
1827
status = self.PerforceActionToSvnStatus(self.GetAction(filename))
1830
revision = self.GetBaseRevision(base_filename)
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,
1838
is_binary = self.IsPendingBinary(filename)
1839
if status != "D" and status != "SKIP":
1840
relpath = self.GetLocalFilename(filename)
1842
new_content = open(relpath, "rb").read()
1844
return base_content, new_content, is_binary, status
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.
1851
data: A string containing the output of svn diff.
1854
A list of 2-tuple (filename, text) where text is the svn diff output
1855
pertaining to filename.
1860
for line in data.splitlines(True):
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
1875
if filename and diff:
1876
patches.append((filename, ''.join(diff)))
1877
filename = new_filename
1880
if diff is not None:
1882
if filename and diff:
1883
patches.append((filename, ''.join(diff)))
1887
def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
1888
"""Uploads a separate patch for each file in the diff output.
1890
Returns a list of [patch_key, filename] for each file.
1892
patches = SplitPatch(data)
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.")
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)
1911
rv.append([lines[1], patch[0]])
1915
def GuessVCSName(options):
1916
"""Helper to guess the version control system.
1918
This examines the current directory, guesses which VersionControlSystem
1919
we're using, and returns an string indicating which VCS is detected.
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.
1930
for attribute, value in options.__dict__.iteritems():
1931
if attribute.startswith("p4") and value != None:
1932
return (VCS_PERFORCE, None)
1934
def RunDetectCommand(vcs_type, command):
1935
"""Helper to detect VCS by executing command.
1938
A pair (vcs, output) or None. Throws exception on error.
1941
out, returncode = RunShellWithReturnCode(command)
1943
return (vcs_type, out.strip())
1944
except OSError, (errcode, message):
1945
if errcode != errno.ENOENT: # command not found code
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"])
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"])
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"])
1969
# detect CVS repos use `cvs status && $? == 0` rules
1970
res = RunDetectCommand(VCS_CVS, ["cvs", "status"])
1974
return (VCS_UNKNOWN, None)
1977
def GuessVCS(options):
1978
"""Helper to guess the version control system.
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.
1987
A VersionControlSystem instance. Exits if the VCS can't be guessed.
1991
vcs = os.environ.get("CODEREVIEW_VCS")
1993
v = VCS_ABBREVIATIONS.get(vcs.lower())
1995
ErrorExit("Unknown version control system %r specified." % vcs)
1996
(vcs, extra_output) = (v, None)
1998
(vcs, extra_output) = GuessVCSName(options)
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)
2013
ErrorExit(("Could not guess version control system. "
2014
"Are you in a working copy directory?"))
2017
def CheckReviewer(reviewer):
2018
"""Validate a reviewer -- either a nickname or an email addres.
2021
reviewer: A nickname or an email address.
2023
Calls ErrorExit() if it is an invalid email address.
2025
if "@" not in reviewer:
2026
return # Assume nickname
2027
parts = reviewer.split("@")
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)
2035
def LoadSubversionAutoProperties():
2036
"""Returns the content of [auto-props] section of Subversion's config file as
2040
A dictionary whose key-value pair corresponds the [auto-props] section's
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].
2047
subversion_config = os.environ.get("APPDATA") + "\\Subversion\\config"
2049
subversion_config = os.path.expanduser("~/.subversion/config")
2050
if not os.path.exists(subversion_config):
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")):
2059
for file_pattern in config.options("auto-props"):
2060
props[file_pattern] = ParseSubversionPropertyValues(
2061
config.get("auto-props", file_pattern))
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.
2070
See the following doctest for example.
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', '*')]
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], "*"))
2087
key_value_pairs.append((key_value[0], key_value[1]))
2088
return key_value_pairs
2091
def GetSubversionPropertyChanges(filename):
2092
"""Return a Subversion's 'Property changes on ...' string, which is used in
2096
filename: filename whose property might be set by [auto-props] config.
2099
A string like 'Property changes on |filename| ...' if given |filename|
2100
matches any entries in [auto-props] section. None, otherwise.
2102
global svn_auto_props_map
2103
if svn_auto_props_map is None:
2104
svn_auto_props_map = LoadSubversionAutoProperties()
2107
for file_pattern, props in svn_auto_props_map.items():
2108
if fnmatch.fnmatch(filename, file_pattern):
2109
all_props.extend(props)
2111
return FormatSubversionPropertyChanges(filename, all_props)
2115
def FormatSubversionPropertyChanges(filename, props):
2116
"""Returns Subversion's 'Property changes on ...' strings using given filename
2121
props: A list whose element is a (svn_prop_key, svn_prop_value) pair.
2124
A string which can be used in the patch file for Subversion.
2126
See the following doctest for example.
2128
>>> print FormatSubversionPropertyChanges('foo.cc', [('svn:eol-style', 'LF')])
2129
Property changes on: foo.cc
2130
___________________________________________________________________
2131
Added: svn:eol-style
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"
2144
def RealMain(argv, data=None):
2145
"""The real main function.
2148
argv: Command line arguments.
2149
data: Diff contents. If None (default) the diff is generated by
2150
the VersionControlSystem implementation returned by GuessVCS().
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).
2157
options, args = parser.parse_args(argv[1:])
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'))
2167
verbosity = options.verbose
2169
logging.getLogger().setLevel(logging.DEBUG)
2170
elif verbosity >= 2:
2171
logging.getLogger().setLevel(logging.INFO)
2173
vcs = GuessVCS(options)
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)
2181
if guessed_base and base != guessed_base:
2182
print "Using base URL \"%s\" from --base_url instead of \"%s\"" % \
2183
(base, guessed_base)
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()
2193
data = vcs.GenerateDiff(args)
2194
data = vcs.PostProcessDiff(data)
2195
if options.print_diffs:
2196
print "Rietveld diff start:*****"
2198
print "Rietveld diff end:*****"
2199
files = vcs.GetBaseFiles(data)
2201
print "Upload server:", options.server, "(change with -s/--server)"
2202
rpc_server = GetRpcServer(options.server,
2205
options.save_cookies,
2206
options.account_type)
2209
repo_guid = vcs.GetGUID()
2211
form_fields.append(("repo_guid", repo_guid))
2213
b = urlparse.urlparse(base)
2214
username, netloc = urllib.splituser(b.netloc)
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))
2221
form_fields.append(("issue", str(options.issue)))
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))
2229
for cc in options.cc.split(','):
2231
form_fields.append(("cc", options.cc))
2233
# Process --message, --title and --file.
2234
message = options.message or ""
2235
title = options.title or ""
2238
ErrorExit("Can't specify both message and message file options")
2239
file = open(options.file, 'r')
2240
message = file.read()
2243
prompt = "Title describing this patch set: "
2245
prompt = "New issue subject: "
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
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))
2264
# Send a hash of all the base file so the server can determine if a copy
2265
# already exists in an earlier patchset.
2267
for file, info in files.iteritems():
2268
if not info[0] is None:
2269
checksum = md5(info[0]).hexdigest()
2272
base_hashes += checksum + ":" + file
2273
form_fields.append(("base_hashes", base_hashes))
2276
print "Warning: Private flag ignored when updating an existing issue."
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"))
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)
2292
if not options.download_base or not uploaded_diff_file:
2293
lines = response_body.splitlines()
2296
patchset = lines[1].strip()
2297
patches = [x.split(" ", 1) for x in lines[2:]]
2303
if not response_body.startswith("Issue created.") and \
2304
not response_body.startswith("Issue updated."):
2306
issue = msg[msg.rfind("/")+1:]
2308
if not uploaded_diff_file:
2309
result = UploadSeparatePatches(issue, rpc_server, patchset, data, options)
2310
if not options.download_base:
2313
if not options.download_base:
2314
vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options, files)
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 ""),
2326
return issue, patchset
2331
logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
2332
"%(lineno)s %(message)s "))
2333
os.environ['LC_ALL'] = 'C'
2335
except KeyboardInterrupt:
2337
StatusUpdate("Interrupted.")
2341
if __name__ == "__main__":