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

Delta Between Two Patch Sets: static/upload.py

Issue 2602: git support (Closed) SVN Base: http://rietveld.googlecode.com/svn/trunk/
Left Patch Set: 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 subversion 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
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.)
20 """ 28 """
21 # 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),
22 # and from ASPN recipe #146306. 30 # and from ASPN recipe #146306.
23 31
24 import cookielib 32 import cookielib
25 import getpass 33 import getpass
26 import logging 34 import logging
27 import md5 35 import md5
28 import mimetypes 36 import mimetypes
29 import optparse 37 import optparse
30 import os 38 import os
31 import re 39 import re
32 import socket 40 import socket
41 import subprocess
33 import sys 42 import sys
34 import urllib 43 import urllib
35 import urllib2 44 import urllib2
36 import urlparse 45 import urlparse
37 46
47 try:
48 import readline
49 except ImportError:
50 pass
38 51
39 # The logging verbosity: 52 # The logging verbosity:
40 # 0: Errors only. 53 # 0: Errors only.
41 # 1: Status messages. 54 # 1: Status messages.
42 # 2: Info logs. 55 # 2: Info logs.
43 # 3: Debug logs. 56 # 3: Debug logs.
44 verbosity = 1 57 verbosity = 1
58
59 # Max size of patch or base file.
60 MAX_UPLOAD_SIZE = 900 * 1024
45 61
46 62
47 def StatusUpdate(msg): 63 def StatusUpdate(msg):
48 """Print a status message to stdout. 64 """Print a status message to stdout.
49 65
50 If 'verbosity' is greater than 0, print the message. 66 If 'verbosity' is greater than 0, print the message.
51 67
52 Args: 68 Args:
53 msg: The string to print. 69 msg: The string to print.
54 """ 70 """
(...skipping 45 matching lines...) Expand 10 before | Expand all | Expand 10 after
100 logging.info("Server: %s; Host: %s", self.host, self.host_override) 116 logging.info("Server: %s; Host: %s", self.host, self.host_override)
101 else: 117 else:
102 logging.info("Server: %s", self.host) 118 logging.info("Server: %s", self.host)
103 119
104 def _GetOpener(self): 120 def _GetOpener(self):
105 """Returns an OpenerDirector for making HTTP requests. 121 """Returns an OpenerDirector for making HTTP requests.
106 122
107 Returns: 123 Returns:
108 A urllib2.OpenerDirector object. 124 A urllib2.OpenerDirector object.
109 """ 125 """
110 raise NotImplemented() 126 raise NotImplementedError()
111 127
112 def _CreateRequest(self, url, data=None): 128 def _CreateRequest(self, url, data=None):
113 """Creates a new urllib request.""" 129 """Creates a new urllib request."""
114 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)
115 req = urllib2.Request(url, data=data) 131 req = urllib2.Request(url, data=data)
116 if self.host_override: 132 if self.host_override:
117 req.add_header("Host", self.host_override) 133 req.add_header("Host", self.host_override)
118 for key, value in self.extra_headers.iteritems(): 134 for key, value in self.extra_headers.iteritems():
119 req.add_header(key, value) 135 req.add_header(key, value)
120 return req 136 return req
(...skipping 82 matching lines...) Expand 10 before | Expand all | Expand 10 after
203 auth_token = self._GetAuthToken(credentials[0], credentials[1]) 219 auth_token = self._GetAuthToken(credentials[0], credentials[1])
204 except ClientLoginError, e: 220 except ClientLoginError, e:
205 if e.reason == "BadAuthentication": 221 if e.reason == "BadAuthentication":
206 print >>sys.stderr, "Invalid username or password." 222 print >>sys.stderr, "Invalid username or password."
207 continue 223 continue
208 if e.reason == "CaptchaRequired": 224 if e.reason == "CaptchaRequired":
209 print >>sys.stderr, ( 225 print >>sys.stderr, (
210 "Please go to\n" 226 "Please go to\n"
211 "https://www.google.com/accounts/DisplayUnlockCaptcha\n" 227 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
212 "and verify you are a human. Then try again.") 228 "and verify you are a human. Then try again.")
213 break; 229 break
214 if e.reason == "NotVerified": 230 if e.reason == "NotVerified":
215 print >>sys.stderr, "Account not verified." 231 print >>sys.stderr, "Account not verified."
216 break 232 break
217 if e.reason == "TermsNotAgreed": 233 if e.reason == "TermsNotAgreed":
218 print >>sys.stderr, "User has not agreed to TOS." 234 print >>sys.stderr, "User has not agreed to TOS."
219 break 235 break
220 if e.reason == "AccountDeleted": 236 if e.reason == "AccountDeleted":
221 print >>sys.stderr, "The user account has been deleted." 237 print >>sys.stderr, "The user account has been deleted."
222 break 238 break
223 if e.reason == "AccountDisabled": 239 if e.reason == "AccountDisabled":
224 print >>sys.stderr, "The user account has been disabled." 240 print >>sys.stderr, "The user account has been disabled."
225 break 241 break
226 if e.reason == "ServiceDisabled": 242 if e.reason == "ServiceDisabled":
227 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 "
228 "disabled.") 244 "disabled.")
229 break 245 break
230 if e.reason == "ServiceUnavailable": 246 if e.reason == "ServiceUnavailable":
231 print >>sys.stderr, "The service is not available; try again later." 247 print >>sys.stderr, "The service is not available; try again later."
232 break 248 break
233 raise 249 raise
234 self._GetAuthCookie(auth_token) 250 self._GetAuthCookie(auth_token)
235 return 251 return
236 252
237 def Send(self, request_path, payload="", 253 def Send(self, request_path, payload=None,
238 content_type="application/octet-stream", 254 content_type="application/octet-stream",
239 timeout=None, 255 timeout=None,
240 **kwargs): 256 **kwargs):
241 """Sends an RPC and returns the response. 257 """Sends an RPC and returns the response.
242 258
243 Args: 259 Args:
244 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.
245 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.
246 content_type: The Content-Type header to use. 262 content_type: The Content-Type header to use.
247 timeout: timeout in seconds; default None i.e. no timeout. 263 timeout: timeout in seconds; default None i.e. no timeout.
(...skipping 64 matching lines...) Expand 10 before | Expand all | Expand 10 after
312 opener.add_handler(urllib2.HTTPErrorProcessor()) 328 opener.add_handler(urllib2.HTTPErrorProcessor())
313 if self.save_cookies: 329 if self.save_cookies:
314 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies") 330 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies")
315 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file) 331 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
316 if os.path.exists(self.cookie_file): 332 if os.path.exists(self.cookie_file):
317 try: 333 try:
318 self.cookie_jar.load() 334 self.cookie_jar.load()
319 self.authenticated = True 335 self.authenticated = True
320 StatusUpdate("Loaded authentication cookies from %s" % 336 StatusUpdate("Loaded authentication cookies from %s" %
321 self.cookie_file) 337 self.cookie_file)
322 except cookielib.LoadError: 338 except (cookielib.LoadError, IOError):
323 # Failed to load cookies - just ignore them. 339 # Failed to load cookies - just ignore them.
324 pass 340 pass
325 else: 341 else:
326 # Create an empty cookie file with mode 600 342 # Create an empty cookie file with mode 600
327 fd = os.open(self.cookie_file, os.O_CREAT, 0600) 343 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
328 os.close(fd) 344 os.close(fd)
329 # Always chmod the cookie file 345 # Always chmod the cookie file
330 os.chmod(self.cookie_file, 0600) 346 os.chmod(self.cookie_file, 0600)
331 else: 347 else:
332 # Don't save cookies across runs of update.py. 348 # Don't save cookies across runs of update.py.
333 self.cookie_jar = cookielib.CookieJar() 349 self.cookie_jar = cookielib.CookieJar()
334 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar)) 350 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
335 return opener 351 return opener
336 352
337 353
338 parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]") 354 parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]")
339 parser.add_option("-q", "--quiet", action="store_const", const=0,
340 dest="verbose", help="Print errors only.")
341 parser.add_option("-v", "--verbose", action="store_const", const=2,
342 dest="verbose", default=1,
343 help="Print info level logs.")
344 parser.add_option("--noisy", action="store_const", const=3,
345 dest="verbose", help="Print all logs.")
346 parser.add_option("-s", "--server", action="store", dest="server",
347 default="codereview.appspot.com",
348 metavar="SERVER",
349 help="The server to upload to. The format is host[:port].")
350 parser.add_option("-e", "--email", action="store", dest="email",
351 metavar="EMAIL", default=None,
352 help="The username to use. Will prompt if omitted.")
353 parser.add_option("-H", "--host", action="store", dest="host",
354 metavar="HOST", default=None,
355 help="Overrides the Host header sent with all RPCs.")
356 parser.add_option("--no_cookies", action="store_false",
357 dest="save_cookies", default=True,
358 help="Do not save authentication cookies to local disk.")
359 parser.add_option("-m", "--message", action="store", dest="message",
360 metavar="MESSAGE", default=None,
361 help="A message to identify the patch. "
362 "Will prompt if omitted.")
363 parser.add_option("-i", "--issue", type="int", action="store",
364 metavar="ISSUE", default=None,
365 help="Issue number to which to add. Defaults to new issue.")
366 parser.add_option("-y", "--assume_yes", action="store_true", 355 parser.add_option("-y", "--assume_yes", action="store_true",
367 dest="assume_yes", default=False, 356 dest="assume_yes", default=False,
368 help="Assume that the answer to yes/no questions is 'yes'.") 357 help="Assume that the answer to yes/no questions is 'yes'.")
369 parser.add_option("-l", "--local_base", action="store_true", 358 # Logging
370 dest="local_base", default=False, 359 group = parser.add_option_group("Logging options")
371 help="Base files will be uploaded.") 360 group.add_option("-q", "--quiet", action="store_const", const=0,
372 parser.add_option("-r", "--reviewers", action="store", dest="reviewers", 361 dest="verbose", help="Print errors only.")
373 metavar="REVIEWERS", default=None, 362 group.add_option("-v", "--verbose", action="store_const", const=2,
374 help="Add reviewers (comma separated email addresses).") 363 dest="verbose", default=1,
375 parser.add_option("--cc", action="store", dest="cc", 364 help="Print info level logs (default).")
376 metavar="CC", default=None, 365 group.add_option("--noisy", action="store_const", const=3,
377 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
378 415
379 def GetRpcServer(options): 416 def GetRpcServer(options):
380 """Returns an instance of an AbstractRpcServer. 417 """Returns an instance of an AbstractRpcServer.
381 418
382 Returns: 419 Returns:
383 A new AbstractRpcServer, on which RPC calls can be made. 420 A new AbstractRpcServer, on which RPC calls can be made.
384 """ 421 """
385 422
386 rpc_server_class = HttpRpcServer 423 rpc_server_class = HttpRpcServer
387 424
(...skipping 72 matching lines...) Expand 10 before | Expand all | Expand 10 after
460 command = "%s %s" % (command, " ".join(args)) 497 command = "%s %s" % (command, " ".join(args))
461 logging.info("Running %s", command) 498 logging.info("Running %s", command)
462 stream = os.popen(command, "r") 499 stream = os.popen(command, "r")
463 data = stream.read() 500 data = stream.read()
464 if stream.close(): 501 if stream.close():
465 ErrorExit("Got error status from %s" % command) 502 ErrorExit("Got error status from %s" % command)
466 if not silent_ok and not data: 503 if not silent_ok and not data:
467 ErrorExit("No output from %s" % command) 504 ErrorExit("No output from %s" % command)
468 return data 505 return data
469 506
470 class GitVCS: 507
471 def GenerateDiff(self, extra_args): 508 class VersionControlSystem(object):
472 """Return a diff representing this change as a string.""" 509 """Abstract base class providing an interface to the VCS."""
473 cmd = "git diff master..." 510
474 data = RunShell(cmd, extra_args) 511 def GenerateDiff(self, args):
475 count = 0 512 """Return the current diff as a string.
476 for line in data.splitlines(): 513
477 if line.startswith("diff --git"): 514 Args:
478 count += 1 515 args: Extra arguments to pass to the diff command.
479 logging.info(line) 516 """
480 if not count: 517 raise NotImplementedError(
481 ErrorExit("No valid patches found in output from git diff") 518 "abstract method -- subclass %s must override" % self.__class__)
482 return data
483
484 def GuessBase(self):
485 """Return a guessed base URL for this change, or exit with an error."""
486 ErrorExit("you currently must use the --local_base flag with git")
487 519
488 def GetUnknownFiles(self): 520 def GetUnknownFiles(self):
489 """Get all "unknown" (not added with 'git add') files.""" 521 """Return a list of files unknown to the VCS."""
490 status = RunShell("git ls-files --others", silent_ok=True) 522 raise NotImplementedError(
491 return status.splitlines() 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")
492 536
493 def GetBaseFile(self, filename): 537 def GetBaseFile(self, filename):
494 """Get the base (upstream) version of a given filename.""" 538 """Get the content of the upstream version of a file.
495 cdup = RunShell("git rev-parse --show-cdup", silent_ok=True).strip() 539
496 if cdup: 540 Returns:
497 os.chdir(cdup) 541 A tuple (content, status) representing the file content and the status of
498 status = RunShell("git diff --name-status master...", [filename]) 542 the file.
499 if status[0] == "A": 543 """
500 return "" 544
501 elif status[0] in ["M", "D"]: 545 raise NotImplementedError(
502 treeline = RunShell("git ls-tree master", [filename]) 546 "abstract method -- subclass %s must override" % self.__class__)
503 # Output is of the form "mode SP type SP hash TAB filename". 547
504 mode, type, hash = treeline[:treeline.find('\t')].split(' ') 548 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options):
505 if type != "blob": 549 """Uploads the base files."""
506 ErrorExit("File '%s' is not a blob: %s" % (filename, treeline)) 550 patches = dict()
507 return RunShell("git cat-file blob", [hash]) 551 [patches.setdefault(v, k) for k, v in patch_list]
508 ErrorExit("Unexpected git diff output: %s" % status) 552 for filename in patches.keys():
509 553 content, status = self.GetBaseFile(filename)
510 class SubversionVCS: 554 if len(content) > MAX_UPLOAD_SIZE:
511 def GenerateDiff(self, extra_args): 555 print ("Not uploading the base file for " + filename +
512 """Return a diff representing this change as a string.""" 556 " because the file is too large.")
513 cmd = "svn diff" 557 continue
514 if not sys.platform.startswith("win"): 558 checksum = md5.new(content).hexdigest()
515 cmd += " --diff-cmd=diff" 559 parts = []
516 data = RunShell(cmd, extra_args) 560 while content:
517 count = 0 561 parts.append(content[:800000])
518 for line in data.splitlines(): 562 content = content[800000:]
519 if line.startswith("Index:"): 563 if not parts:
520 count += 1 564 parts = [""] # empty file
521 logging.info(line) 565 for part, data in enumerate(parts):
522 if not count: 566 if options.verbose > 0:
523 ErrorExit("No valid patches found in output from svn diff") 567 print "Uploading %s (%d/%d)" % (filename, part+1, len(parts))
524 return data 568 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset),
525 569 int(patches.get(filename)))
526 def GuessBase(self): 570 current_checksum = md5.new(data).hexdigest()
527 """Return a guessed base URL for this change, or exit with an error.""" 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."""
590
591 def GuessBase(self, required):
592 """Returns the SVN base URL.
593
594 Args:
595 required: If true, exits if the url can't be guessed, otherwise None is
596 returned.
597 """
528 info = RunShell("svn info") 598 info = RunShell("svn info")
529 for line in info.splitlines(): 599 for line in info.splitlines():
530 words = line.split() 600 words = line.split()
531 if len(words) == 2 and words[0] == "URL:": 601 if len(words) == 2 and words[0] == "URL:":
532 url = words[1] 602 url = words[1]
533 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")
534 if netloc.endswith("svn.python.org"): 607 if netloc.endswith("svn.python.org"):
535 if netloc == "svn.python.org": 608 if netloc == "svn.python.org":
536 if path.startswith("/projects/"): 609 if path.startswith("/projects/"):
537 path = path[9:] 610 path = path[9:]
538 elif netloc != "pythondev@svn.python.org": 611 elif netloc != "pythondev@svn.python.org":
539 ErrorExit("Unrecognized Python URL: %s" % url) 612 ErrorExit("Unrecognized Python URL: %s" % url)
540 base = "http://svn.python.org/view/*checkout*%s/" % path 613 base = "http://svn.python.org/view/*checkout*%s/" % path
541 logging.info("Guessed Python base = %s", base) 614 logging.info("Guessed Python base = %s", base)
542 elif netloc.endswith("svn.collab.net"): 615 elif netloc.endswith("svn.collab.net"):
543 if path.startswith("/repos/"): 616 if path.startswith("/repos/"):
544 path = path[6:] 617 path = path[6:]
545 base = "http://svn.collab.net/viewvc/*checkout*%s/" % path 618 base = "http://svn.collab.net/viewvc/*checkout*%s/" % path
546 logging.info("Guessed CollabNet base = %s", base) 619 logging.info("Guessed CollabNet base = %s", base)
547 elif netloc.endswith(".googlecode.com"): 620 elif netloc.endswith(".googlecode.com"):
548 base = url + "/" 621 path = path + "/"
549 if base.startswith("https"): 622 base = urlparse.urlunparse(("http", netloc, path, params,
550 base = "http" + base[5:] 623 query, fragment))
551 logging.info("Guessed Google Code base = %s", base) 624 logging.info("Guessed Google Code base = %s", base)
552 else: 625 else:
553 ErrorExit("Unrecognized svn project root: %s" % url) 626 path = path + "/"
627 base = urlparse.urlunparse((scheme, netloc, path, params,
628 query, fragment))
629 logging.info("Guessed base = %s", base)
554 return base 630 return base
555 ErrorExit("Can't find URL in output from svn info") 631 if required:
556 632 ErrorExit("Can't find URL in output from svn info")
557 def GetUnknownFiles(self): 633 return None
558 """Get all "unknown" (not added with 'svn add') files.""" 634
559 status = RunShell("svn status --ignore-externals", silent_ok=True) 635 def GenerateDiff(self, args):
560 unknown_files = [] 636 cmd = "svn diff"
561 for line in status.split("\n"): 637 if not sys.platform.startswith("win"):
562 if line and line[0] == "?": 638 cmd += " --diff-cmd=diff"
563 unknown_files.append(line) 639 data = RunShell(cmd, args)
564 return unknown_files 640 count = 0
641 for line in data.splitlines():
642 if line.startswith("Index:") or line.startswith("Property changes on:"):
643 count += 1
644 logging.info(line)
645 if not count:
646 ErrorExit("No valid patches found in output from svn diff")
647 return data
565 648
566 def _CollapseKeywords(self, content, keyword_str): 649 def _CollapseKeywords(self, content, keyword_str):
567 """Collapses SVN keywords.""" 650 """Collapses SVN keywords."""
568 # 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
569 # behavior patching.PatchChunks() fails with a chunk mismatch error. 652 # behavior patching.PatchChunks() fails with a chunk mismatch error.
570 # This part was originally written by the Review Board development team 653 # This part was originally written by the Review Board development team
571 # 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/).
572 # Mapping of keywords to known aliases 655 # Mapping of keywords to known aliases
573 svn_keywords = { 656 svn_keywords = {
574 # Standard keywords 657 # Standard keywords
(...skipping 11 matching lines...) Expand all
586 } 669 }
587 def repl(m): 670 def repl(m):
588 if m.group(2): 671 if m.group(2):
589 return "$%s::%s$" % (m.group(1), " " * len(m.group(3))) 672 return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
590 return "$%s$" % m.group(1) 673 return "$%s$" % m.group(1)
591 keywords = [keyword 674 keywords = [keyword
592 for name in keyword_str.split(" ") 675 for name in keyword_str.split(" ")
593 for keyword in svn_keywords.get(name, [])] 676 for keyword in svn_keywords.get(name, [])]
594 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content) 677 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content)
595 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
686
596 def GetBaseFile(self, filename): 687 def GetBaseFile(self, filename):
597 """Get the base (upstream) version of a given filename."""
598 status = RunShell("svn status --ignore-externals", [filename]) 688 status = RunShell("svn status --ignore-externals", [filename])
599 if not status: 689 if not status:
600 StatusUpdate("svn status returned no output for %s" % filename) 690 StatusUpdate("svn status returned no output for %s" % filename)
601 sys.exit(False) 691 sys.exit(False)
602 if status[0] == "A": 692 status_lines = status.splitlines()
693 # If file is in a cl, the output will begin with
694 # "\n--- Changelist 'cl_name':\n". See
695 # http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt
696 if (len(status_lines) == 3 and
697 not status_lines[0] and
698 status_lines[1].startswith("--- Changelist")):
699 status = status_lines[2]
700 else:
701 status = status_lines[0]
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
603 content = "" 708 content = ""
604 elif status[0] in ["M", "D"]: 709 elif (status[0] in ("M", "D", "R") or
605 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])
606 keywords = RunShell("svn -rBASE propget svn:keywords", [filename], 717 keywords = RunShell("svn -rBASE propget svn:keywords", [filename],
607 silent_ok=True) 718 silent_ok=True)
608 if keywords: 719 if keywords:
609 content = self._CollapseKeywords(content, keywords) 720 content = self._CollapseKeywords(content, keywords)
610 else: 721 else:
611 StatusUpdate("svn status returned unexpected output: %s" % status) 722 StatusUpdate("svn status returned unexpected output: %s" % status)
612 sys.exit(False) 723 sys.exit(False)
613 return content 724 return content, status[0:5]
614 725
615 def RealMain(argv): 726
727 class GitVCS(VersionControlSystem):
728 """Implementation of the VersionControlSystem interface for Git."""
729
730 def __init__(self):
731 # Map of filename -> hash of base file.
732 self.base_hashes = {}
733
734 def GenerateDiff(self, extra_args):
735 # This is more complicated than svn's GenerateDiff because we must convert
736 # the diff output to include an svn-style "Index:" line as well as record
737 # the hashes of the base files, so we can upload them along with our diff.
738 gitdiff = RunShell("git diff", ["--full-index"] + extra_args)
739 svndiff = []
740 filecount = 0
741 filename = None
742 for line in gitdiff.splitlines():
743 match = re.match(r"diff --git a/(.*) b/.*$", line)
744 if match:
745 filecount += 1
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)
755 svndiff.append(line + "\n")
756 if not filecount:
757 ErrorExit("No valid patches found in output from git diff")
758 return "".join(svndiff)
759
760 def GetUnknownFiles(self):
761 status = RunShell("git ls-files --others", silent_ok=True)
762 return status.splitlines()
763
764 def GetBaseFile(self, filename):
765 hash = self.base_hashes[filename]
766 if hash == "0" * 40: # All-zero hash indicates no base file.
767 return ("", "A")
768 else:
769 return (RunShell("git show", [hash]), "M")
770
771
772 # NOTE: this function is duplicated in engine.py, keep them in sync.
773 def SplitPatch(data):
774 """Splits a patch into separate pieces for each file.
775
776 Args:
777 data: A string containing the output of svn diff.
778
779 Returns:
780 A list of 2-tuple (filename, text) where text is the svn diff output
781 pertaining to filename.
782 """
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 """
851 # Subversion has a .svn in all working directories.
852 if os.path.isdir('.svn'):
853 logging.info("Guessed VCS = Subversion")
854 return SubversionVCS()
855
856 # Git has a command to test if you're in a git tree.
857 # Try running it, but don't die if we don't have git installed.
858 try:
859 subproc = subprocess.Popen(["git", "rev-parse", "--is-inside-work-tree"],
860 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
861 if subproc.wait() == 0:
862 return GitVCS()
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):
616 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:" 872 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
617 "%(lineno)s %(message)s ")) 873 "%(lineno)s %(message)s "))
618 os.environ['LC_ALL'] = 'C' 874 os.environ['LC_ALL'] = 'C'
619 options, args = parser.parse_args(sys.argv[1:]) 875 options, args = parser.parse_args(argv[1:])
620 global verbosity 876 global verbosity
621 verbosity = options.verbose 877 verbosity = options.verbose
622 if verbosity >= 3: 878 if verbosity >= 3:
623 logging.getLogger().setLevel(logging.DEBUG) 879 logging.getLogger().setLevel(logging.DEBUG)
624 elif verbosity >= 2: 880 elif verbosity >= 2:
625 logging.getLogger().setLevel(logging.INFO) 881 logging.getLogger().setLevel(logging.INFO)
626 vcs = GitVCS() 882 vcs = GuessVCS()
627 if 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:
628 base = None 888 base = None
629 else: 889 if not base and not options.local_base:
630 base = vcs.GuessBase() 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.")
631 if not options.assume_yes: 894 if not options.assume_yes:
632 CheckForUnknownFiles(vcs) 895 vcs.CheckForUnknownFiles()
633 diff = vcs.GenerateDiff(args) 896 if not data:
897 data = vcs.GenerateDiff(args)
898 if verbosity >= 1:
899 print "Upload server:", options.server, "(change with -s/--server)"
634 if options.issue: 900 if options.issue:
635 prompt = "Message describing this patch set: " 901 prompt = "Message describing this patch set: "
636 else: 902 else:
637 prompt = "New issue subject: " 903 prompt = "New issue subject: "
638 message = options.message or raw_input(prompt).strip() 904 message = options.message or raw_input(prompt).strip()
639 if not message: 905 if not message:
640 ErrorExit("A non-empty message is required") 906 ErrorExit("A non-empty message is required")
641 rpc_server = GetRpcServer(options) 907 rpc_server = GetRpcServer(options)
642 form_fields = [("subject", message)] 908 form_fields = [("subject", message)]
643 if base: 909 if base:
644 form_fields.append(("base", base)) 910 form_fields.append(("base", base))
645 if options.issue: 911 if options.issue:
646 form_fields.append(("issue", str(options.issue))) 912 form_fields.append(("issue", str(options.issue)))
647 if options.email: 913 if options.email:
648 form_fields.append(("user", options.email)) 914 form_fields.append(("user", options.email))
649 if options.reviewers: 915 if options.reviewers:
650 for reviewer in options.reviewers.split(','): 916 for reviewer in options.reviewers.split(','):
651 if reviewer.count("@") != 1 or "." not in reviewer.split("@")[1]: 917 if reviewer.count("@") != 1 or "." not in reviewer.split("@")[1]:
652 ErrorExit("Invalid email address: %s" % reviewer) 918 ErrorExit("Invalid email address: %s" % reviewer)
653 form_fields.append(("reviewers", options.reviewers)) 919 form_fields.append(("reviewers", options.reviewers))
654 if options.cc: 920 if options.cc:
655 for cc in options.cc.split(','): 921 for cc in options.cc.split(','):
656 if cc.count("@") != 1 or "." not in cc.split("@")[1]: 922 if cc.count("@") != 1 or "." not in cc.split("@")[1]:
657 ErrorExit("Invalid email address: %s" % cc) 923 ErrorExit("Invalid email address: %s" % cc)
658 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"))
659 if options.local_base: 938 if options.local_base:
660 form_fields.append(("content_upload", "1")) 939 form_fields.append(("content_upload", "1"))
661 ctype, body = EncodeMultipartFormData(form_fields, 940 if len(data) > MAX_UPLOAD_SIZE:
662 [("data", "data.diff", diff)]) 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)
663 response_body = rpc_server.Send("/upload", body, content_type=ctype) 947 response_body = rpc_server.Send("/upload", body, content_type=ctype)
664 if options.local_base: 948 if options.local_base or not files:
665 lines = response_body.splitlines() 949 lines = response_body.splitlines()
666 if len(lines) > 2: 950 if len(lines) >= 2:
667 msg = lines[0] 951 msg = lines[0]
668 patchset = lines[1].strip() 952 patchset = lines[1].strip()
669 patches = [x.split(" ", 1) for x in lines[2:]] 953 patches = [x.split(" ", 1) for x in lines[2:]]
670 else: 954 else:
671 msg = response_body 955 msg = response_body
672 else: 956 else:
673 msg = response_body 957 msg = response_body
674 StatusUpdate(msg) 958 StatusUpdate(msg)
675 if not response_body.startswith("Issue created.") and \ 959 if not response_body.startswith("Issue created.") and \
676 not response_body.startswith("Issue updated."): 960 not response_body.startswith("Issue updated."):
677 sys.exit(0) 961 sys.exit(0)
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
678 if options.local_base: 969 if options.local_base:
679 issue = msg[msg.rfind("/")+1:] 970 vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options)
680 UploadBaseFiles(issue, rpc_server, vcs, patches, patchset, options) 971 if options.send_mail:
681 972 rpc_server.Send("/" + issue + "/mail")
682 def UploadBaseFiles(issue, rpc_server, vcs, patch_list, patchset, options): 973 return issue
683 """Uploads the base files.""" 974
684 patches = dict()
685 [patches.setdefault(v, k) for k, v in patch_list]
686 for filename in patches.keys():
687 content = vcs.GetBaseFile(filename)
688 checksum = md5.new(content).hexdigest()
689 parts = []
690 while content:
691 parts.append(content[:800000])
692 content = content[800000:]
693 if not parts:
694 parts = [""] # empty file
695 for part, data in enumerate(parts):
696 if options.verbose > 0:
697 print "Uploading %s (%d/%d)" % (filename, part+1, len(parts))
698 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset),
699 int(patches.get(filename)))
700 current_checksum = md5.new(data).hexdigest()
701 form_fields = [("filename", filename),
702 ("num_parts", str(len(parts))),
703 ("checksum", checksum),
704 ("current_part", str(part)),
705 ("current_checksum", current_checksum),]
706 if options.email:
707 form_fields.append(("user", options.email))
708 ctype, body = EncodeMultipartFormData(form_fields,
709 [("data", filename, data)])
710 response_body = rpc_server.Send(url, body,
711 content_type=ctype)
712 if not response_body.startswith("OK"):
713 StatusUpdate(" --> %s" % response_body)
714 sys.exit(False)
715
716 def CheckForUnknownFiles(vcs):
717 unknown_files = vcs.GetUnknownFiles()
718 if unknown_files:
719 print "The following files are not added to version control:"
720 for line in unknown_files:
721 print line
722 prompt = "Are you sure to continue?(y/N) "
723 answer = raw_input(prompt).strip()
724 if answer != "y":
725 ErrorExit("User aborted")
726 975
727 def main(): 976 def main():
728 try: 977 try:
729 RealMain(sys.argv) 978 RealMain(sys.argv)
730 except KeyboardInterrupt: 979 except KeyboardInterrupt:
731 print 980 print
732 StatusUpdate("Interrupted.") 981 StatusUpdate("Interrupted.")
733 sys.exit(1) 982 sys.exit(1)
734 983
735 984
736 if __name__ == "__main__": 985 if __name__ == "__main__":
737 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