Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code | Sign in
(141)

Delta Between Two Patch Sets: static/upload.py

Issue 2602: git support (Closed) SVN Base: http://rietveld.googlecode.com/svn/trunk/
Left Patch Set: trailing newline fix Created 1 year, 4 months ago
Right Patch Set: try two Created 1 year, 3 months ago
Use n/p to move between diff chunks; N/P to move between comments. Please Sign in to add in-line comments.
Jump to:
Left: Side by side diff | Download
Right: Side by side diff | Download
« no previous file | no next file » | Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
LEFTRIGHT
1 #!/usr/bin/env python 1 #!/usr/bin/env python
2 # 2 #
3 # Copyright 2007 Google Inc. 3 # Copyright 2007 Google Inc.
4 # 4 #
5 # Licensed under the Apache License, Version 2.0 (the "License"); 5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License. 6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at 7 # You may obtain a copy of the License at
8 # 8 #
9 # http://www.apache.org/licenses/LICENSE-2.0 9 # http://www.apache.org/licenses/LICENSE-2.0
10 # 10 #
11 # Unless required by applicable law or agreed to in writing, software 11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS, 12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and 14 # See the License for the specific language governing permissions and
15 # limitations under the License. 15 # limitations under the License.
16 16
17 """Tool for uploading diffs to the codereview app. 17 """Tool for uploading diffs from a version control system to the codereview app.
18 18
19 Usage summary: upload.py [options] [-- diff_options] 19 Usage summary: upload.py [options] [-- diff_options]
20 With Subversion, diff_options are passed to "svn diff". 20
21 With Git, diff_options is the tree-ish to diff against, defaulting to master. 21 Diff options are passed to the diff command of the underlying system.
22
23 Supported version control systems:
24 Git
25 Subversion
26
27 (It is important for Git users to specify a tree-ish to diff against.)
22 """ 28 """
23 # This code is derived from appcfg.py in the App Engine SDK (open source), 29 # This code is derived from appcfg.py in the App Engine SDK (open source),
24 # and from ASPN recipe #146306. 30 # and from ASPN recipe #146306.
25 31
26 import cookielib 32 import cookielib
27 import getpass 33 import getpass
28 import logging 34 import logging
29 import md5 35 import md5
30 import mimetypes 36 import mimetypes
31 import optparse 37 import optparse
32 import os 38 import os
33 import re 39 import re
34 import socket 40 import socket
41 import subprocess
35 import sys 42 import sys
36 import urllib 43 import urllib
37 import urllib2 44 import urllib2
38 import urlparse 45 import urlparse
39 46
40 try: 47 try:
41 import readline 48 import readline
42 except ImportError: 49 except ImportError:
43 pass 50 pass
44 51
45 # The logging verbosity: 52 # The logging verbosity:
46 # 0: Errors only. 53 # 0: Errors only.
47 # 1: Status messages. 54 # 1: Status messages.
48 # 2: Info logs. 55 # 2: Info logs.
49 # 3: Debug logs. 56 # 3: Debug logs.
50 verbosity = 1 57 verbosity = 1
58
59 # Max size of patch or base file.
60 MAX_UPLOAD_SIZE = 900 * 1024
51 61
52 62
53 def StatusUpdate(msg): 63 def StatusUpdate(msg):
54 """Print a status message to stdout. 64 """Print a status message to stdout.
55 65
56 If 'verbosity' is greater than 0, print the message. 66 If 'verbosity' is greater than 0, print the message.
57 67
58 Args: 68 Args:
59 msg: The string to print. 69 msg: The string to print.
60 """ 70 """
(...skipping 45 matching lines...) Expand 10 before | Expand all | Expand 10 after
106 logging.info("Server: %s; Host: %s", self.host, self.host_override) 116 logging.info("Server: %s; Host: %s", self.host, self.host_override)
107 else: 117 else:
108 logging.info("Server: %s", self.host) 118 logging.info("Server: %s", self.host)
109 119
110 def _GetOpener(self): 120 def _GetOpener(self):
111 """Returns an OpenerDirector for making HTTP requests. 121 """Returns an OpenerDirector for making HTTP requests.
112 122
113 Returns: 123 Returns:
114 A urllib2.OpenerDirector object. 124 A urllib2.OpenerDirector object.
115 """ 125 """
116 raise NotImplemented() 126 raise NotImplementedError()
117 127
118 def _CreateRequest(self, url, data=None): 128 def _CreateRequest(self, url, data=None):
119 """Creates a new urllib request.""" 129 """Creates a new urllib request."""
120 logging.debug("Creating request for: '%s' with payload:\n%s", url, data) 130 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
121 req = urllib2.Request(url, data=data) 131 req = urllib2.Request(url, data=data)
122 if self.host_override: 132 if self.host_override:
123 req.add_header("Host", self.host_override) 133 req.add_header("Host", self.host_override)
124 for key, value in self.extra_headers.iteritems(): 134 for key, value in self.extra_headers.iteritems():
125 req.add_header(key, value) 135 req.add_header(key, value)
126 return req 136 return req
(...skipping 82 matching lines...) Expand 10 before | Expand all | Expand 10 after
209 auth_token = self._GetAuthToken(credentials[0], credentials[1]) 219 auth_token = self._GetAuthToken(credentials[0], credentials[1])
210 except ClientLoginError, e: 220 except ClientLoginError, e:
211 if e.reason == "BadAuthentication": 221 if e.reason == "BadAuthentication":
212 print >>sys.stderr, "Invalid username or password." 222 print >>sys.stderr, "Invalid username or password."
213 continue 223 continue
214 if e.reason == "CaptchaRequired": 224 if e.reason == "CaptchaRequired":
215 print >>sys.stderr, ( 225 print >>sys.stderr, (
216 "Please go to\n" 226 "Please go to\n"
217 "https://www.google.com/accounts/DisplayUnlockCaptcha\n" 227 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
218 "and verify you are a human. Then try again.") 228 "and verify you are a human. Then try again.")
219 break; 229 break
220 if e.reason == "NotVerified": 230 if e.reason == "NotVerified":
221 print >>sys.stderr, "Account not verified." 231 print >>sys.stderr, "Account not verified."
222 break 232 break
223 if e.reason == "TermsNotAgreed": 233 if e.reason == "TermsNotAgreed":
224 print >>sys.stderr, "User has not agreed to TOS." 234 print >>sys.stderr, "User has not agreed to TOS."
225 break 235 break
226 if e.reason == "AccountDeleted": 236 if e.reason == "AccountDeleted":
227 print >>sys.stderr, "The user account has been deleted." 237 print >>sys.stderr, "The user account has been deleted."
228 break 238 break
229 if e.reason == "AccountDisabled": 239 if e.reason == "AccountDisabled":
230 print >>sys.stderr, "The user account has been disabled." 240 print >>sys.stderr, "The user account has been disabled."
231 break 241 break
232 if e.reason == "ServiceDisabled": 242 if e.reason == "ServiceDisabled":
233 print >>sys.stderr, ("The user's access to the service has been " 243 print >>sys.stderr, ("The user's access to the service has been "
234 "disabled.") 244 "disabled.")
235 break 245 break
236 if e.reason == "ServiceUnavailable": 246 if e.reason == "ServiceUnavailable":
237 print >>sys.stderr, "The service is not available; try again later." 247 print >>sys.stderr, "The service is not available; try again later."
238 break 248 break
239 raise 249 raise
240 self._GetAuthCookie(auth_token) 250 self._GetAuthCookie(auth_token)
241 return 251 return
242 252
243 def Send(self, request_path, payload="", 253 def Send(self, request_path, payload=None,
244 content_type="application/octet-stream", 254 content_type="application/octet-stream",
245 timeout=None, 255 timeout=None,
246 **kwargs): 256 **kwargs):
247 """Sends an RPC and returns the response. 257 """Sends an RPC and returns the response.
248 258
249 Args: 259 Args:
250 request_path: The path to send the request to, eg /api/appversion/create. 260 request_path: The path to send the request to, eg /api/appversion/create.
251 payload: The body of the request, or None to send an empty request. 261 payload: The body of the request, or None to send an empty request.
252 content_type: The Content-Type header to use. 262 content_type: The Content-Type header to use.
253 timeout: timeout in seconds; default None i.e. no timeout. 263 timeout: timeout in seconds; default None i.e. no timeout.
(...skipping 81 matching lines...) Expand 10 before | Expand all | Expand 10 after
335 # Always chmod the cookie file 345 # Always chmod the cookie file
336 os.chmod(self.cookie_file, 0600) 346 os.chmod(self.cookie_file, 0600)
337 else: 347 else:
338 # Don't save cookies across runs of update.py. 348 # Don't save cookies across runs of update.py.
339 self.cookie_jar = cookielib.CookieJar() 349 self.cookie_jar = cookielib.CookieJar()
340 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar)) 350 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
341 return opener 351 return opener
342 352
343 353
344 parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]") 354 parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]")
345 parser.add_option("-q", "--quiet", action="store_const", const=0,
346 dest="verbose", help="Print errors only.")
347 parser.add_option("-v", "--verbose", action="store_const", const=2,
348 dest="verbose", default=1,
349 help="Print info level logs.")
350 parser.add_option("--noisy", action="store_const", const=3,
351 dest="verbose", help="Print all logs.")
352 parser.add_option("-s", "--server", action="store", dest="server",
353 default="codereview.appspot.com",
354 metavar="SERVER",
355 help="The server to upload to. The format is host[:port].")
356 parser.add_option("-e", "--email", action="store", dest="email",
357 metavar="EMAIL", default=None,
358 help="The username to use. Will prompt if omitted.")
359 parser.add_option("-H", "--host", action="store", dest="host",
360 metavar="HOST", default=None,
361 help="Overrides the Host header sent with all RPCs.")
362 parser.add_option("--no_cookies", action="store_false",
363 dest="save_cookies", default=True,
364 help="Do not save authentication cookies to local disk.")
365 parser.add_option("-m", "--message", action="store", dest="message",
366 metavar="MESSAGE", default=None,
367 help="A message to identify the patch. "
368 "Will prompt if omitted.")
369 parser.add_option("-i", "--issue", type="int", action="store",
370 metavar="ISSUE", default=None,
371 help="Issue number to which to add. Defaults to new issue.")
372 parser.add_option("-y", "--assume_yes", action="store_true", 355 parser.add_option("-y", "--assume_yes", action="store_true",
373 dest="assume_yes", default=False, 356 dest="assume_yes", default=False,
374 help="Assume that the answer to yes/no questions is 'yes'.") 357 help="Assume that the answer to yes/no questions is 'yes'.")
375 parser.add_option("-l", "--local_base", action="store_true", 358 # Logging
376 dest="local_base", default=False, 359 group = parser.add_option_group("Logging options")
377 help="Base files will be uploaded.") 360 group.add_option("-q", "--quiet", action="store_const", const=0,
378 parser.add_option("-r", "--reviewers", action="store", dest="reviewers", 361 dest="verbose", help="Print errors only.")
379 metavar="REVIEWERS", default=None, 362 group.add_option("-v", "--verbose", action="store_const", const=2,
380 help="Add reviewers (comma separated email addresses).") 363 dest="verbose", default=1,
381 parser.add_option("--cc", action="store", dest="cc", 364 help="Print info level logs (default).")
382 metavar="CC", default=None, 365 group.add_option("--noisy", action="store_const", const=3,
383 help="Add CC (comma separated email addresses).") 366 dest="verbose", help="Print all logs.")
367 # Review server
368 group = parser.add_option_group("Review server options")
369 group.add_option("-s", "--server", action="store", dest="server",
370 default="codereview.appspot.com",
371 metavar="SERVER",
372 help=("The server to upload to. The format is host[:port]. "
373 "Defaults to 'codereview.appspot.com'."))
374 group.add_option("-e", "--email", action="store", dest="email",
375 metavar="EMAIL", default=None,
376 help="The username to use. Will prompt if omitted.")
377 group.add_option("-H", "--host", action="store", dest="host",
378 metavar="HOST", default=None,
379 help="Overrides the Host header sent with all RPCs.")
380 group.add_option("--no_cookies", action="store_false",
381 dest="save_cookies", default=True,
382 help="Do not save authentication cookies to local disk.")
383 # Issue
384 group = parser.add_option_group("Issue options")
385 group.add_option("-d", "--description", action="store", dest="description",
386 metavar="DESCRIPTION", default=None,
387 help="Optional description when creating an issue.")
388 group.add_option("-f", "--description_file", action="store",
389 dest="description_file", metavar="DESCRIPTION_FILE",
390 default=None,
391 help="Optional path of a file that contains "
392 "the description when creating an issue.")
393 group.add_option("-r", "--reviewers", action="store", dest="reviewers",
394 metavar="REVIEWERS", default=None,
395 help="Add reviewers (comma separated email addresses).")
396 group.add_option("--cc", action="store", dest="cc",
397 metavar="CC", default=None,
398 help="Add CC (comma separated email addresses).")
399 # Upload options
400 group = parser.add_option_group("Patch options")
401 group.add_option("-m", "--message", action="store", dest="message",
402 metavar="MESSAGE", default=None,
403 help="A message to identify the patch. "
404 "Will prompt if omitted.")
405 group.add_option("-i", "--issue", type="int", action="store",
406 metavar="ISSUE", default=None,
407 help="Issue number to which to add. Defaults to new issue.")
408 group.add_option("-l", "--local_base", action="store_true",
409 dest="local_base", default=False,
410 help="Base files will be uploaded.")
411 group.add_option("--send_mail", action="store_true",
412 dest="send_mail", default=False,
413 help="Send notification email to reviewers.")
414
384 415
385 def GetRpcServer(options): 416 def GetRpcServer(options):
386 """Returns an instance of an AbstractRpcServer. 417 """Returns an instance of an AbstractRpcServer.
387 418
388 Returns: 419 Returns:
389 A new AbstractRpcServer, on which RPC calls can be made. 420 A new AbstractRpcServer, on which RPC calls can be made.
390 """ 421 """
391 422
392 rpc_server_class = HttpRpcServer 423 rpc_server_class = HttpRpcServer
393 424
(...skipping 73 matching lines...) Expand 10 before | Expand all | Expand 10 after
467 logging.info("Running %s", command) 498 logging.info("Running %s", command)
468 stream = os.popen(command, "r") 499 stream = os.popen(command, "r")
469 data = stream.read() 500 data = stream.read()
470 if stream.close(): 501 if stream.close():
471 ErrorExit("Got error status from %s" % command) 502 ErrorExit("Got error status from %s" % command)
472 if not silent_ok and not data: 503 if not silent_ok and not data:
473 ErrorExit("No output from %s" % command) 504 ErrorExit("No output from %s" % command)
474 return data 505 return data
475 506
476 507
477 class SubversionVCS: 508 class VersionControlSystem(object):
478 """Wrapper for Subversion-specific operations.""" 509 """Abstract base class providing an interface to the VCS."""
479 def __init__(self, extra_args): 510
480 self.diff_args = extra_args 511 def GenerateDiff(self, args):
512 """Return the current diff as a string.
513
514 Args:
515 args: Extra arguments to pass to the diff command.
516 """
517 raise NotImplementedError(
518 "abstract method -- subclass %s must override" % self.__class__)
519
520 def GetUnknownFiles(self):
521 """Return a list of files unknown to the VCS."""
522 raise NotImplementedError(
523 "abstract method -- subclass %s must override" % self.__class__)
524
525 def CheckForUnknownFiles(self):
526 """Show an "are you sure?" prompt if there are unknown files."""
527 unknown_files = self.GetUnknownFiles()
528 if unknown_files:
529 print "The following files are not added to version control:"
530 for line in unknown_files:
531 print line
532 prompt = "Are you sure to continue?(y/N) "
533 answer = raw_input(prompt).strip()
534 if answer != "y":
535 ErrorExit("User aborted")
536
537 def GetBaseFile(self, filename):
538 """Get the content of the upstream version of a file.
539
540 Returns:
541 A tuple (content, status) representing the file content and the status of
542 the file.
543 """
544
545 raise NotImplementedError(
546 "abstract method -- subclass %s must override" % self.__class__)
547
548 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options):
549 """Uploads the base files."""
550 patches = dict()
551 [patches.setdefault(v, k) for k, v in patch_list]
552 for filename in patches.keys():
553 content, status = self.GetBaseFile(filename)
554 if len(content) > MAX_UPLOAD_SIZE:
555 print ("Not uploading the base file for " + filename +
556 " because the file is too large.")
557 continue
558 checksum = md5.new(content).hexdigest()
559 parts = []
560 while content:
561 parts.append(content[:800000])
562 content = content[800000:]
563 if not parts:
564 parts = [""] # empty file
565 for part, data in enumerate(parts):
566 if options.verbose > 0:
567 print "Uploading %s (%d/%d)" % (filename, part+1, len(parts))
568 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset),
569 int(patches.get(filename)))
570 current_checksum = md5.new(data).hexdigest()
571 form_fields = [("filename", filename),
572 ("status", status),
573 ("num_parts", str(len(parts))),
574 ("checksum", checksum),
575 ("current_part", str(part)),
576 ("current_checksum", current_checksum),]
577 if options.email:
578 form_fields.append(("user", options.email))
579 ctype, body = EncodeMultipartFormData(form_fields,
580 [("data", filename, data)])
581 response_body = rpc_server.Send(url, body,
582 content_type=ctype)
583 if not response_body.startswith("OK"):
584 StatusUpdate(" --> %s" % response_body)
585 sys.exit(False)
586
587
588 class SubversionVCS(VersionControlSystem):
589 """Implementation of the VersionControlSystem interface for Subversion."""
481 590
482 def GuessBase(self, required): 591 def GuessBase(self, required):
483 """Returns the SVN base URL. 592 """Returns the SVN base URL.
484 593
485 Args: 594 Args:
486 required: If true, exits if the url can't be guessed, otherwise None is 595 required: If true, exits if the url can't be guessed, otherwise None is
487 returned. 596 returned.
488 """ 597 """
489 info = RunShell("svn info") 598 info = RunShell("svn info")
490 for line in info.splitlines(): 599 for line in info.splitlines():
491 words = line.split() 600 words = line.split()
492 if len(words) == 2 and words[0] == "URL:": 601 if len(words) == 2 and words[0] == "URL:":
493 url = words[1] 602 url = words[1]
494 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url) 603 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
604 username, netloc = urllib.splituser(netloc)
605 if username:
606 logging.info("Removed username from base URL")
495 if netloc.endswith("svn.python.org"): 607 if netloc.endswith("svn.python.org"):
496 if netloc == "svn.python.org": 608 if netloc == "svn.python.org":
497 if path.startswith("/projects/"): 609 if path.startswith("/projects/"):
498 path = path[9:] 610 path = path[9:]
499 elif netloc != "pythondev@svn.python.org": 611 elif netloc != "pythondev@svn.python.org":
500 ErrorExit("Unrecognized Python URL: %s" % url) 612 ErrorExit("Unrecognized Python URL: %s" % url)
501 base = "http://svn.python.org/view/*checkout*%s/" % path 613 base = "http://svn.python.org/view/*checkout*%s/" % path
502 logging.info("Guessed Python base = %s", base) 614 logging.info("Guessed Python base = %s", base)
503 elif netloc.endswith("svn.collab.net"): 615 elif netloc.endswith("svn.collab.net"):
504 if path.startswith("/repos/"): 616 if path.startswith("/repos/"):
505 path = path[6:] 617 path = path[6:]
506 base = "http://svn.collab.net/viewvc/*checkout*%s/" % path 618 base = "http://svn.collab.net/viewvc/*checkout*%s/" % path
507 logging.info("Guessed CollabNet base = %s", base) 619 logging.info("Guessed CollabNet base = %s", base)
508 elif netloc.endswith(".googlecode.com"): 620 elif netloc.endswith(".googlecode.com"):
509 base = url + "/" 621 path = path + "/"
510 if base.startswith("https"): 622 base = urlparse.urlunparse(("http", netloc, path, params,
511 base = "http" + base[5:] 623 query, fragment))
512 logging.info("Guessed Google Code base = %s", base) 624 logging.info("Guessed Google Code base = %s", base)
513 else: 625 else:
514 base = url + "/" 626 path = path + "/"
627 base = urlparse.urlunparse((scheme, netloc, path, params,
628 query, fragment))
515 logging.info("Guessed base = %s", base) 629 logging.info("Guessed base = %s", base)
516 return base 630 return base
517 if required: 631 if required:
518 ErrorExit("Can't find URL in output from svn info") 632 ErrorExit("Can't find URL in output from svn info")
519 return None 633 return None
520 634
521 635 def GenerateDiff(self, args):
522 def GenerateDiff(self):
523 """Return the current diff as a string.
524
525 Args:
526 args: Extra arguments to pass to 'svn diff'.
527 """
528 cmd = "svn diff" 636 cmd = "svn diff"
529 if not sys.platform.startswith("win"): 637 if not sys.platform.startswith("win"):
530 cmd += " --diff-cmd=diff" 638 cmd += " --diff-cmd=diff"
531 data = RunShell(cmd, self.diff_args) 639 data = RunShell(cmd, args)
532 count = 0 640 count = 0
533 for line in data.splitlines(): 641 for line in data.splitlines():
534 if line.startswith("Index:"): 642 if line.startswith("Index:") or line.startswith("Property changes on:"):
535 count += 1 643 count += 1
536 logging.info(line) 644 logging.info(line)
537 if not count: 645 if not count:
538 ErrorExit("No valid patches found in output from svn diff") 646 ErrorExit("No valid patches found in output from svn diff")
539 return data 647 return data
540
541
542 def GetUnknownFiles(self):
543 """Get a list of files unknown to Subversion."""
544 status = RunShell("svn status --ignore-externals", silent_ok=True)
545 unknown_files = []
546 for line in status.split("\n"):
547 if line and line[0] == "?":
548 unknown_files.append(line)
549 return unknown_files
550
551 648
552 def _CollapseKeywords(self, content, keyword_str): 649 def _CollapseKeywords(self, content, keyword_str):
553 """Collapses SVN keywords.""" 650 """Collapses SVN keywords."""
554 # svn cat translates keywords but svn diff doesn't. As a result of this 651 # svn cat translates keywords but svn diff doesn't. As a result of this
555 # behavior patching.PatchChunks() fails with a chunk mismatch error. 652 # behavior patching.PatchChunks() fails with a chunk mismatch error.
556 # This part was originally written by the Review Board development team 653 # This part was originally written by the Review Board development team
557 # who had the same problem (http://reviews.review-board.org/r/276/). 654 # who had the same problem (http://reviews.review-board.org/r/276/).
558 # Mapping of keywords to known aliases 655 # Mapping of keywords to known aliases
559 svn_keywords = { 656 svn_keywords = {
560 # Standard keywords 657 # Standard keywords
(...skipping 11 matching lines...) Expand all
572 } 669 }
573 def repl(m): 670 def repl(m):
574 if m.group(2): 671 if m.group(2):
575 return "$%s::%s$" % (m.group(1), " " * len(m.group(3))) 672 return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
576 return "$%s$" % m.group(1) 673 return "$%s$" % m.group(1)
577 keywords = [keyword 674 keywords = [keyword
578 for name in keyword_str.split(" ") 675 for name in keyword_str.split(" ")
579 for keyword in svn_keywords.get(name, [])] 676 for keyword in svn_keywords.get(name, [])]
580 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content) 677 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content)
581 678
679 def GetUnknownFiles(self):
680 status = RunShell("svn status --ignore-externals", silent_ok=True)
681 unknown_files = []
682 for line in status.split("\n"):
683 if line and line[0] == "?":
684 unknown_files.append(line)
685 return unknown_files
582 686
583 def GetBaseFile(self, filename): 687 def GetBaseFile(self, filename):
584 """Get the upstream version of a file."""
585 status = RunShell("svn status --ignore-externals", [filename]) 688 status = RunShell("svn status --ignore-externals", [filename])
586 if not status: 689 if not status:
587 StatusUpdate("svn status returned no output for %s" % filename) 690 StatusUpdate("svn status returned no output for %s" % filename)
588 sys.exit(False) 691 sys.exit(False)
589 status_lines = status.splitlines() 692 status_lines = status.splitlines()
590 # If file is in a cl, the output will begin with 693 # If file is in a cl, the output will begin with
591 # "\n--- Changelist 'cl_name':\n". See 694 # "\n--- Changelist 'cl_name':\n". See
592 # http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt 695 # http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt
593 if (len(status_lines) == 3 and 696 if (len(status_lines) == 3 and
594 not status_lines[0] and 697 not status_lines[0] and
595 status_lines[1].startswith("--- Changelist")): 698 status_lines[1].startswith("--- Changelist")):
596 status = status_lines[2] 699 status = status_lines[2]
597 else: 700 else:
598 status = status_lines[0] 701 status = status_lines[0]
599 if status[0] == "A": 702 # If a file is copied its status will be "A +", which signifies
703 # "addition-with-history". See "svn st" for more information. We need to
704 # upload the original file or else diff parsing will fail if the file was
705 # edited.
706 if ((status[0] == "A" and status[3] != "+") or
707 (status[0] == " " and status[1] == "M")): # property changed
600 content = "" 708 content = ""
601 elif status[0] in ["M", "D"]: 709 elif (status[0] in ("M", "D", "R") or
602 content = RunShell("svn cat", [filename]) 710 (status[0] == "A" and status[3] == "+")):
711 mimetype = RunShell("svn -rBASE propget svn:mime-type", [filename],
712 silent_ok=True)
713 if mimetype.startswith("application/octet-stream"):
714 content = ""
715 else:
716 content = RunShell("svn cat", [filename])
603 keywords = RunShell("svn -rBASE propget svn:keywords", [filename], 717 keywords = RunShell("svn -rBASE propget svn:keywords", [filename],
604 silent_ok=True) 718 silent_ok=True)
605 if keywords: 719 if keywords:
606 content = self._CollapseKeywords(content, keywords) 720 content = self._CollapseKeywords(content, keywords)
607 else: 721 else:
608 StatusUpdate("svn status returned unexpected output: %s" % status) 722 StatusUpdate("svn status returned unexpected output: %s" % status)
609 sys.exit(False) 723 sys.exit(False)
610 return content 724 return content, status[0:5]
611 725
612 class GitVCS: 726
613 def __init__(self, extra_args): 727 class GitVCS(VersionControlSystem):
614 if len(extra_args) == 0: 728 """Implementation of the VersionControlSystem interface for Git."""
615 self.diff_from = 'master' 729
616 elif len(extra_args) == 1: 730 def __init__(self):
617 self.diff_from = extra_args[0] 731 # Map of filename -> hash of base file.
618 else: 732 self.base_hashes = {}
619 ErrorExit("git-based upload takes at most one argument") 733
620 734 def GenerateDiff(self, extra_args):
621 735 # This is more complicated than svn's GenerateDiff because we must convert
622 def GenerateDiff(self): 736 # the diff output to include an svn-style "Index:" line as well as record
623 """Return a Subversion-style diff representing this change as a string.""" 737 # the hashes of the base files, so we can upload them along with our diff.
624 gitdiff = RunShell("git diff", [self.diff_from]) 738 gitdiff = RunShell("git diff", ["--full-index"] + extra_args)
625 svndiff = [] 739 svndiff = []
626 filecount = 0 740 filecount = 0
741 filename = None
627 for line in gitdiff.splitlines(): 742 for line in gitdiff.splitlines():
628 match = re.match(r"diff --git a/(.*) b/.*", line) 743 match = re.match(r"diff --git a/(.*) b/.*$", line)
629 if match: 744 if match:
630 filecount += 1 745 filecount += 1
631 svndiff.append("Index: " + match.group(1) + "\n") 746 filename = match.group(1)
747 svndiff.append("Index: %s\n" % filename)
748 else:
749 # The "index" line in a git diff looks like this (long hashes elided):
750 # index 82c0d44..b2cee3f 100755
751 # We want to save the left hash, as that identifies the base file.
752 match = re.match(r"index (\w+)\.\.", line)
753 if match:
754 self.base_hashes[filename] = match.group(1)
632 svndiff.append(line + "\n") 755 svndiff.append(line + "\n")
633 if not filecount: 756 if not filecount:
634 ErrorExit("No valid patches found in output from svn diff") 757 ErrorExit("No valid patches found in output from git diff")
635 return ''.join(svndiff) 758 return "".join(svndiff)
636
637
638 def GuessBase(self, required):
639 """Return a guessed base URL for this change, or exit with an error."""
640 if required:
641 ErrorExit("you currently must use the --local_base flag with git")
642 return None
643
644 759
645 def GetUnknownFiles(self): 760 def GetUnknownFiles(self):
646 """Get all "unknown" (not added with 'git add') files."""
647 status = RunShell("git ls-files --others", silent_ok=True) 761 status = RunShell("git ls-files --others", silent_ok=True)
648 return status.splitlines() 762 return status.splitlines()
649 763
650
651 def GetBaseFile(self, filename): 764 def GetBaseFile(self, filename):
652 """Get the base (upstream) version of a given filename.""" 765 hash = self.base_hashes[filename]
653 # We must first directories to the top of the tree. This is only necssary 766 if hash == "0" * 40: # All-zero hash indicates no base file.
654 # so the tree-root-relative path passed to "git diff" works. 767 return ("", "A")
655 cdup = RunShell("git rev-parse --show-cdup", silent_ok=True).strip() 768 else:
656 if cdup: 769 return (RunShell("git show", [hash]), "M")
657 os.chdir(cdup) 770
658 status = RunShell("git diff --name-status", [self.diff_from, filename]) 771
659 if status[0] == "A": 772 # NOTE: this function is duplicated in engine.py, keep them in sync.
660 return "" 773 def SplitPatch(data):
661 elif status[0] in ["M", "D"]: 774 """Splits a patch into separate pieces for each file.
662 return RunShell("git show %s:%s" % (self.diff_from, filename)) 775
663 ErrorExit("Unexpected git diff output: %s" % status) 776 Args:
664 777 data: A string containing the output of svn diff.
665 778
666 779 Returns:
667 def GuessVCS(args): 780 A list of 2-tuple (filename, text) where text is the svn diff output
668 """Guess what version control system we're using, and return a new instance 781 pertaining to filename.
669 of our wrapper for that system. Exit with an error if we can't figure it
670 out.
671 """ 782 """
672 783 patches = []
784 filename = None
785 diff = []
786 for line in data.splitlines(True):
787 new_filename = None
788 if line.startswith('Index:'):
789 unused, new_filename = line.split(':', 1)
790 new_filename = new_filename.strip()
791 elif line.startswith('Property changes on:'):
792 unused, temp_filename = line.split(':', 1)
793 # When a file is modified, paths use '/' between directories, however
794 # when a property is modified '\' is used on Windows. Make them the same
795 # otherwise the file shows up twice.
796 temp_filename = temp_filename.strip().replace('\\', '/')
797 if temp_filename != filename:
798 # File has property changes but no modifications, create a new diff.
799 new_filename = temp_filename
800 if new_filename:
801 if filename and diff:
802 patches.append((filename, ''.join(diff)))
803 filename = new_filename
804 diff = [line]
805 continue
806 if diff is not None:
807 diff.append(line)
808 if filename and diff:
809 patches.append((filename, ''.join(diff)))
810 return patches
811
812
813 def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
814 """Uploads a separate patch for each file in the diff output.
815
816 Returns a list of [patch_key, filename] for each file.
817 """
818 patches = SplitPatch(data)
819 rv = []
820 for patch in patches:
821 if len(patch[1]) > MAX_UPLOAD_SIZE:
822 print ("Not uploading the patch for " + patch[0] +
823 " because the file is too large.")
824 continue
825 form_fields = [("filename", patch[0])]
826 if options.local_base:
827 form_fields.append(("content_upload", "1"))
828 files = [("data", "data.diff", patch[1])]
829 ctype, body = EncodeMultipartFormData(form_fields, files)
830 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
831 print "Uploading patch for " + patch[0]
832 response_body = rpc_server.Send(url, body, content_type=ctype)
833 lines = response_body.splitlines()
834 if not lines or lines[0] != "OK":
835 StatusUpdate(" --> %s" % response_body)
836 sys.exit(False)
837 rv.append([lines[1], patch[0]])
838 return rv
839
840
841 def GuessVCS():
842 """Helper to guess the version control system.
843
844 This examines the current directory, guesses which VersionControlSystem
845 we're using, and returns an instance of the appropriate class. Exit with an
846 error if we can't figure it out.
Andi Albrecht 2008/08/19 19:48:34 Thanks for adding some more docstrings and comment
847
848 Returns:
849 A VersionControlSystem instance. Exits if the VCS can't be guessed.
850 """
673 # Subversion has a .svn in all working directories. 851 # Subversion has a .svn in all working directories.
674 if os.path.exists('.svn'): 852 if os.path.isdir('.svn'):
675 return SubversionVCS(args) 853 logging.info("Guessed VCS = Subversion")
854 return SubversionVCS()
676 855
677 # Git has a command to test if you're in a git tree. 856 # Git has a command to test if you're in a git tree.
678 if os.system("git rev-parse --is-inside-work-tree >/dev/null 2>&1") == 0: 857 # Try running it, but don't die if we don't have git installed.
679 return GitVCS(args) 858 try:
680 859 subproc = subprocess.Popen(["git", "rev-parse", "--is-inside-work-tree"],
681 ErrorExit("can't identify whether you're using svn or git") 860 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
682 861 if subproc.wait() == 0:
683 862 return GitVCS()
684 def RealMain(argv): 863 except OSError, (errno, message):
864 if errno != 2: # ENOENT -- they don't have git installed.
865 raise
866
867 ErrorExit(("Could not guess version control system. "
868 "Are you in a working copy directory?"))
869
870
871 def RealMain(argv, data=None):
685 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:" 872 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
686 "%(lineno)s %(message)s ")) 873 "%(lineno)s %(message)s "))
687 os.environ['LC_ALL'] = 'C' 874 os.environ['LC_ALL'] = 'C'
688 options, args = parser.parse_args(argv[1:]) 875 options, args = parser.parse_args(argv[1:])
689 global verbosity 876 global verbosity
690 verbosity = options.verbose 877 verbosity = options.verbose
691 if verbosity >= 3: 878 if verbosity >= 3:
692 logging.getLogger().setLevel(logging.DEBUG) 879 logging.getLogger().setLevel(logging.DEBUG)
693 elif verbosity >= 2: 880 elif verbosity >= 2:
694 logging.getLogger().setLevel(logging.INFO) 881 logging.getLogger().setLevel(logging.INFO)
695 vcs = GuessVCS(args) 882 vcs = GuessVCS()
696 base = vcs.GuessBase(not options.local_base) 883 if isinstance(vcs, SubversionVCS):
884 # base field is only allowed for Subversion.
885 # Note: Fetching base files may become deprecated in future releases.
886 base = vcs.GuessBase(not options.local_base)
887 else:
888 base = None
889 if not base and not options.local_base:
890 # TODO(andi): Enable local_base for other VCS by default.
891 # For future use. SubversionVCS.GuessBase() already checks this
Andi Albrecht 2008/08/19 19:48:34 I think we can enable local_base here now for all
892 # condition.
893 ErrorExit("Use '--local_base' to upload base files.")
697 if not options.assume_yes: 894 if not options.assume_yes:
698 CheckForUnknownFiles(vcs) 895 vcs.CheckForUnknownFiles()
699 data = vcs.GenerateDiff() 896 if not data:
897 data = vcs.GenerateDiff(args)
700 if verbosity >= 1: 898 if verbosity >= 1:
701 print "Upload server:", options.server, "(change with -s/--server)" 899 print "Upload server:", options.server, "(change with -s/--server)"
702 if options.issue: 900 if options.issue:
703 prompt = "Message describing this patch set: " 901 prompt = "Message describing this patch set: "
704 else: 902 else:
705 prompt = "New issue subject: " 903 prompt = "New issue subject: "
706 message = options.message or raw_input(prompt).strip() 904 message = options.message or raw_input(prompt).strip()
707 if not message: 905 if not message:
708 ErrorExit("A non-empty message is required") 906 ErrorExit("A non-empty message is required")
709 rpc_server = GetRpcServer(options) 907 rpc_server = GetRpcServer(options)
710 form_fields = [("subject", message)] 908 form_fields = [("subject", message)]
711 if base: 909 if base:
712 form_fields.append(("base", base)) 910 form_fields.append(("base", base))
713 if options.issue: 911 if options.issue:
714 form_fields.append(("issue", str(options.issue))) 912 form_fields.append(("issue", str(options.issue)))
715 if options.email: 913 if options.email:
716 form_fields.append(("user", options.email)) 914 form_fields.append(("user", options.email))
717 if options.reviewers: 915 if options.reviewers:
718 for reviewer in options.reviewers.split(','): 916 for reviewer in options.reviewers.split(','):
719 if reviewer.count("@") != 1 or "." not in reviewer.split("@")[1]: 917 if reviewer.count("@") != 1 or "." not in reviewer.split("@")[1]:
720 ErrorExit("Invalid email address: %s" % reviewer) 918 ErrorExit("Invalid email address: %s" % reviewer)
721 form_fields.append(("reviewers", options.reviewers)) 919 form_fields.append(("reviewers", options.reviewers))
722 if options.cc: 920 if options.cc:
723 for cc in options.cc.split(','): 921 for cc in options.cc.split(','):
724 if cc.count("@") != 1 or "." not in cc.split("@")[1]: 922 if cc.count("@") != 1 or "." not in cc.split("@")[1]:
725 ErrorExit("Invalid email address: %s" % cc) 923 ErrorExit("Invalid email address: %s" % cc)
726 form_fields.append(("cc", options.cc)) 924 form_fields.append(("cc", options.cc))
925 description = options.description
926 if options.description_file:
927 if options.description:
928 ErrorExit("Can't specify description and description_file")
929 file = open(options.description_file, 'r')
930 description = file.read()
931 file.close()
932 if description:
933 form_fields.append(("description", description))
934 # If we're uploading base files, don't send the email before the uploads, so
935 # that it contains the file status.
936 if options.send_mail and not options.local_base:
937 form_fields.append(("send_mail", "1"))
727 if options.local_base: 938 if options.local_base:
728 form_fields.append(("content_upload", "1")) 939 form_fields.append(("content_upload", "1"))
729 ctype, body = EncodeMultipartFormData(form_fields, 940 if len(data) > MAX_UPLOAD_SIZE:
730 [("data", "data.diff", data)]) 941 print "Patch is large, so uploading file patches separately."
942 files = []
943 form_fields.append(("separate_patches", "1"))
944 else:
945 files = [("data", "data.diff", data)]
946 ctype, body = EncodeMultipartFormData(form_fields, files)
731 response_body = rpc_server.Send("/upload", body, content_type=ctype) 947 response_body = rpc_server.Send("/upload", body, content_type=ctype)
732 if options.local_base: 948 if options.local_base or not files:
733 lines = response_body.splitlines() 949 lines = response_body.splitlines()
734 if len(lines) > 2: 950 if len(lines) >= 2:
735 msg = lines[0] 951 msg = lines[0]
736 patchset = lines[1].strip() 952 patchset = lines[1].strip()
737 patches = [x.split(" ", 1) for x in lines[2:]] 953 patches = [x.split(" ", 1) for x in lines[2:]]
738 else: 954 else:
739 msg = response_body 955 msg = response_body
740 else: 956 else:
741 msg = response_body 957 msg = response_body
742 StatusUpdate(msg) 958 StatusUpdate(msg)
743 if not response_body.startswith("Issue created.") and \ 959 if not response_body.startswith("Issue created.") and \
744 not response_body.startswith("Issue updated."): 960 not response_body.startswith("Issue updated."):
745 sys.exit(0) 961 sys.exit(0)
746 issue = msg[msg.rfind("/")+1:] 962 issue = msg[msg.rfind("/")+1:]
963
964 if not files:
965 result = UploadSeparatePatches(issue, rpc_server, patchset, data, options)
966 if options.local_base:
967 patches = result
968
747 if options.local_base: 969 if options.local_base:
748 UploadBaseFiles(issue, rpc_server, vcs, patches, patchset, options) 970 vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options)
971 if options.send_mail:
972 rpc_server.Send("/" + issue + "/mail")
749 return issue 973 return issue
750 974
751 def UploadBaseFiles(issue, rpc_server, vcs, patch_list, patchset, options):
752 """Uploads the base files."""
753 patches = dict()
754 [patches.setdefault(v, k) for k, v in patch_list]
755 for filename in patches.keys():
756 content = vcs.GetBaseFile(filename)
757 checksum = md5.new(content).hexdigest()
758 parts = []
759 while content:
760 parts.append(content[:800000])
761 content = content[800000:]
762 if not parts:
763 parts = [""] # empty file
764 for part, data in enumerate(parts):
765 if options.verbose > 0:
766 print "Uploading %s (%d/%d)" % (filename, part+1, len(parts))
767 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset),
768 int(patches.get(filename)))
769 current_checksum = md5.new(data).hexdigest()
770 form_fields = [("filename", filename),
771 ("num_parts", str(len(parts))),
772 ("checksum", checksum),
773 ("current_part", str(part)),
774 ("current_checksum", current_checksum),]
775 if options.email:
776 form_fields.append(("user", options.email))
777 ctype, body = EncodeMultipartFormData(form_fields,
778 [("data", filename, data)])
779 response_body = rpc_server.Send(url, body,
780 content_type=ctype)
781 if not response_body.startswith("OK"):
782 StatusUpdate(" --> %s" % response_body)
783 sys.exit(False)
784
785 def CheckForUnknownFiles(vcs):
786 unknown_files = vcs.GetUnknownFiles()
787 if unknown_files:
788 print "The following files are not added to version control:"
789 for line in unknown_files:
790 print line
791 prompt = "Are you sure to continue?(y/N) "
792 answer = raw_input(prompt).strip()
793 if answer != "y":
794 ErrorExit("User aborted")
795 975
796 def main(): 976 def main():
797 try: 977 try:
798 RealMain(sys.argv) 978 RealMain(sys.argv)
799 except KeyboardInterrupt: 979 except KeyboardInterrupt:
800 print 980 print
801 StatusUpdate("Interrupted.") 981 StatusUpdate("Interrupted.")
802 sys.exit(1) 982 sys.exit(1)
803 983
804 984
805 if __name__ == "__main__": 985 if __name__ == "__main__":
806 main() 986 main()
LEFTRIGHT
« no previous file | no next file » | Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Toggle Comments ('s')

Powered by Google App Engine
RSS Feeds Recent Issues | This issue
This is Rietveld r497