Merge branch 'main' into avm99963-monorail

Merged commit 34d8229ae2b51fb1a15bd208e6fe6185c94f6266

GitOrigin-RevId: 7ee0917f93a577e475f8e09526dd144d245593f4
diff --git a/framework/alerts.py b/framework/alerts.py
index 1d24f77..ca2ab2d 100644
--- a/framework/alerts.py
+++ b/framework/alerts.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Helpers for showing alerts at the top of the page.
 
diff --git a/framework/authdata.py b/framework/authdata.py
index 3c1bee9..34e1ca3 100644
--- a/framework/authdata.py
+++ b/framework/authdata.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Classes to hold information parsed from a request.
 """
@@ -11,7 +10,7 @@
 
 from google.appengine.api import users
 
-from proto import user_pb2
+from mrproto import user_pb2
 from framework import framework_bizobj
 from framework import framework_views
 
diff --git a/framework/banned.py b/framework/banned.py
index 209a715..5a4cd07 100644
--- a/framework/banned.py
+++ b/framework/banned.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """A class to display the a message explaining that the user has been banned.
 
@@ -18,11 +17,11 @@
 
 import ezt
 
-from framework import flaskservlet, permissions
+from framework import permissions
 from framework import servlet
 
 
-class Banned(flaskservlet.FlaskServlet):
+class Banned(servlet.Servlet):
   """The Banned page shows a message explaining that the user is banned."""
 
   _PAGE_TEMPLATE = 'framework/banned-page.ezt'
diff --git a/framework/clientmon.py b/framework/clientmon.py
index fd10684..06e0266 100644
--- a/framework/clientmon.py
+++ b/framework/clientmon.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """A class to log client-side javascript error reports.
 
@@ -19,7 +18,7 @@
 from infra_libs import ts_mon
 
 
-class ClientMonitor(jsonfeed.FlaskJsonFeed):
+class ClientMonitor(jsonfeed.JsonFeed):
   """JSON feed to track client side js errors in ts_mon."""
 
   js_errors = ts_mon.CounterMetric('frontend/js_errors',
diff --git a/framework/cloud_tasks_helpers.py b/framework/cloud_tasks_helpers.py
index bd9b7f9..89954e3 100644
--- a/framework/cloud_tasks_helpers.py
+++ b/framework/cloud_tasks_helpers.py
@@ -1,4 +1,4 @@
-# Copyright 2020 The Chromium Authors. All rights reserved.
+# Copyright 2020 The Chromium Authors
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 """A helper module for interfacing with google cloud tasks.
@@ -12,6 +12,7 @@
 from __future__ import print_function
 
 import logging
+import six
 from six.moves import urllib
 
 from google.api_core import exceptions
@@ -21,7 +22,8 @@
 
 if not settings.unit_test_mode:
   import grpc
-  from google.cloud import tasks
+  from google.cloud import tasks_v2
+  from google.cloud.tasks_v2.services import cloud_tasks
 
 _client = None
 # Default exponential backoff retry config for enqueueing, not to be confused
@@ -35,10 +37,11 @@
   global _client
   if not _client:
     if settings.local_mode:
-      _client = tasks.CloudTasksClient(
+      transport = cloud_tasks.transports.CloudTasksGrpcTransport(
           channel=grpc.insecure_channel(settings.CLOUD_TASKS_EMULATOR_ADDRESS))
+      _client = tasks_v2.CloudTasksClient(transport=transport)
     else:
-      _client = tasks.CloudTasksClient()
+      _client = tasks_v2.CloudTasksClient()
   return _client
 
 
@@ -74,7 +77,7 @@
   target = task.get('app_engine_http_request').get('relative_uri')
   kwargs.setdefault('retry', _DEFAULT_RETRY)
   logging.info('Enqueueing %s task to %s', target, parent)
-  return client.create_task(parent, task, **kwargs)
+  return client.create_task(parent=parent, task=task, **kwargs)
 
 
 def generate_simple_task(url, params):
@@ -91,7 +94,7 @@
       'app_engine_http_request':
           {
               'relative_uri': url,
-              'body': urllib.parse.urlencode(params),
+              'body': six.ensure_binary(urllib.parse.urlencode(params)),
               'headers': {
                   'Content-type': 'application/x-www-form-urlencoded'
               }
diff --git a/framework/csp_report.py b/framework/csp_report.py
index 4b6f29e..08c061d 100644
--- a/framework/csp_report.py
+++ b/framework/csp_report.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Servlet for Content Security Policy violation reporting.
 See http://www.html5rocks.com/en/tutorials/security/content-security-policy/
@@ -18,3 +17,4 @@
 def postCsp():
   """CSPReportPage serves CSP violation reports."""
   logging.error('CSP Violation: %s' % flask.request.get_data(as_text=True))
+  return ''
diff --git a/framework/csv_helpers.py b/framework/csv_helpers.py
index 3dd10c7..ac6509f 100644
--- a/framework/csv_helpers.py
+++ b/framework/csv_helpers.py
@@ -1,13 +1,13 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Helper functions for creating CSV pagedata."""
 from __future__ import print_function
 from __future__ import division
 from __future__ import absolute_import
 
+import six
 import types
 
 from framework import framework_helpers
@@ -63,7 +63,7 @@
   """Return a version of string S that is safe as part of a CSV file."""
   if s is None:
     return ''
-  if isinstance(s, types.StringTypes):
+  if isinstance(s, six.string_types):
     s = s.strip().replace('"', '""')
     # Prefix any formula cells because some spreadsheets have built-in
     # formila functions that can actually have side-effects on the user's
diff --git a/framework/deleteusers.py b/framework/deleteusers.py
index 015fad4..5c16189 100644
--- a/framework/deleteusers.py
+++ b/framework/deleteusers.py
@@ -1,6 +1,6 @@
-# Copyright 2019 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file.
+# Copyright 2019 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Cron and task handlers for syncing with wipeoute-lite and deleting users."""
 
@@ -32,7 +32,7 @@
   return credentials.authorize(httplib2.Http(timeout=60))
 
 
-class WipeoutSyncCron(jsonfeed.FlaskInternalTask):
+class WipeoutSyncCron(jsonfeed.InternalTask):
   """Enqueue tasks for sending user lists to wipeout-lite and deleting deleted
      users fetched from wipeout-lite."""
 
@@ -65,7 +65,7 @@
     return self.handler(**kwargs)
 
 
-class SendWipeoutUserListsTask(jsonfeed.FlaskInternalTask):
+class SendWipeoutUserListsTask(jsonfeed.InternalTask):
   """Sends a batch of monorail users to wipeout-lite."""
 
   def HandleRequest(self, mr):
@@ -94,7 +94,7 @@
     return self.handler(**kwargs)
 
 
-class DeleteWipeoutUsersTask(jsonfeed.FlaskInternalTask):
+class DeleteWipeoutUsersTask(jsonfeed.InternalTask):
   """Fetches deleted users from wipeout-lite and enqueues tasks to delete
      those users from Monorail's DB."""
 
@@ -132,7 +132,7 @@
     return self.handler(**kwargs)
 
 
-class DeleteUsersTask(jsonfeed.FlaskInternalTask):
+class DeleteUsersTask(jsonfeed.InternalTask):
   """Deletes users from Monorail's DB."""
 
   def HandleRequest(self, mr):
diff --git a/framework/emailfmt.py b/framework/emailfmt.py
index 2933fea..e14075c 100644
--- a/framework/emailfmt.py
+++ b/framework/emailfmt.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Functions that format or parse email messages in Monorail.
 
@@ -13,13 +12,18 @@
 from __future__ import division
 from __future__ import absolute_import
 
+import hashlib
 import hmac
 import logging
 import re
-import rfc822
 
 import six
 
+if six.PY2:
+  import rfc822
+else:
+  import email.utils
+
 from google.appengine.api import app_identity
 
 import settings
@@ -116,7 +120,10 @@
 
 def _ExtractAddrs(header_value):
   """Given a message header value, return email address found there."""
-  friendly_addr_pairs = list(rfc822.AddressList(header_value))
+  if six.PY2:
+    friendly_addr_pairs = list(rfc822.AddressList(header_value))
+  else:
+    friendly_addr_pairs = email.utils.getaddresses([header_value])
   return [addr for _friendly, addr in friendly_addr_pairs]
 
 
@@ -230,11 +237,15 @@
   if isinstance(normalized_subject, six.text_type):
     normalized_subject = normalized_subject.encode('utf-8')
   mail_hmac_key = secrets_svc.GetEmailKey()
+  to_addr_hash = hmac.new(
+      mail_hmac_key, six.ensure_binary(to_addr),
+      digestmod=hashlib.md5).hexdigest()
+  subject_hash = hmac.new(
+      mail_hmac_key,
+      six.ensure_binary(normalized_subject),
+      digestmod=hashlib.md5).hexdigest()
   return '<0=%s=%s=%s@%s>' % (
-      hmac.new(mail_hmac_key, to_addr).hexdigest(),
-      hmac.new(mail_hmac_key, normalized_subject).hexdigest(),
-      from_addr.split('@')[0],
-      MailDomain())
+      to_addr_hash, subject_hash, from_addr.split('@')[0], MailDomain())
 
 
 def GetReferences(to_addr, subject, seq_num, project_from_addr):
diff --git a/framework/exceptions.py b/framework/exceptions.py
index 51c9951..2024f51 100644
--- a/framework/exceptions.py
+++ b/framework/exceptions.py
@@ -1,7 +1,6 @@
-# Copyright 2017 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2017 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Exception classes used throughout monorail.
 """
@@ -182,3 +181,8 @@
 class OverAttachmentQuota(Error):
   """Project will exceed quota if the current operation is allowed."""
   pass
+
+
+class RedirectException(Error):
+  """Page need to Redirect to new url."""
+  pass
diff --git a/framework/excessiveactivity.py b/framework/excessiveactivity.py
index 5506de3..87477b8 100644
--- a/framework/excessiveactivity.py
+++ b/framework/excessiveactivity.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """A class to display the an error page for excessive activity.
 
@@ -12,10 +11,10 @@
 from __future__ import division
 from __future__ import absolute_import
 
-from framework import flaskservlet
+from framework import servlet
 
 
-class ExcessiveActivity(flaskservlet.FlaskServlet):
+class ExcessiveActivity(servlet.Servlet):
   """ExcessiveActivity page shows an error message."""
 
   _PAGE_TEMPLATE = 'framework/excessive-activity-page.ezt'
diff --git a/framework/filecontent.py b/framework/filecontent.py
index 15d2940..7e79643 100644
--- a/framework/filecontent.py
+++ b/framework/filecontent.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Utility routines for dealing with MIME types and decoding text files."""
 
@@ -11,6 +10,7 @@
 
 import itertools
 import logging
+import six
 
 from framework import framework_constants
 
@@ -160,7 +160,7 @@
 
   # If the string can be decoded as utf-8, we treat it as textual.
   try:
-    u_str = file_contents.decode('utf-8', 'strict')
+    u_str = six.ensure_text(file_contents)
     is_long = len(u_str.split('\n')) > SOURCE_FILE_MAX_LINES
     return u_str, False, is_long
   except UnicodeDecodeError:
@@ -168,7 +168,7 @@
 
   # Fall back on latin-1. This will always succeed, since every byte maps to
   # something in latin-1, even if that something is gibberish.
-  u_str = file_contents.decode('latin-1', 'strict')
+  u_str = six.ensure_text(file_contents, encoding='latin-1')
 
   lines = u_str.split('\n')
   is_long = len(lines) > SOURCE_FILE_MAX_LINES
diff --git a/framework/flaskservlet.py b/framework/flaskservlet.py
deleted file mode 100644
index bc543d8..0000000
--- a/framework/flaskservlet.py
+++ /dev/null
@@ -1,881 +0,0 @@
-# Copyright 2022 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
-"""Base classes for Monorail Flask servlets.
-
-This is derived from  servlet.py
-This base class provides handler methods that conveniently drive
-the process of parsing the request, checking base permisssion,
-gathering common page information, gathering page-specific information,
-and adding on-page debugging information (when appropriate).
-Subclasses can simply implement the page-specific logic.
-
-Summary of page classes:
-  FlaskServlet: abstract base class for all Monorail flask servlets.
-"""
-
-import gc
-import os
-import httplib
-import logging
-import time
-from businesslogic import work_env
-
-import ezt
-from features import features_bizobj, hotlist_views
-import flask
-import httpagentparser
-from project import project_constants
-from proto import project_pb2
-from search import query2ast
-
-import settings
-from framework import alerts, exceptions, framework_helpers, urls
-from framework import framework_views, servlet_helpers
-from framework import framework_constants
-from framework import monorailrequest
-from framework import permissions
-from framework import ratelimiter
-from framework import template_helpers
-from framework import xsrf
-
-from google.appengine.api import app_identity
-from google.appengine.api import modules
-from google.appengine.api import users
-from tracker import tracker_views
-
-NONCE_LENGTH = 32
-
-if not settings.unit_test_mode:
-  import MySQLdb
-
-class FlaskServlet(object):
-  """Base class for all Monorail flask servlets.
-
-  Defines a framework of methods that build up parts of the EZT page data.
-
-  Subclasses should override GatherPageData and/or ProcessFormData to
-  handle requests.
-  """
-  _MAIN_TAB_MODE = None  # Normally overridden in subclasses to be one of these:
-
-  MAIN_TAB_ISSUES = 't2'
-  MAIN_TAB_PEOPLE = 't3'
-  IN_TAB_PEOPLE = 't3'
-  MAIN_TAB_PROCESS = 't4'
-  MAIN_TAB_UPDATES = 't5'
-  MAIN_TAB_ADMIN = 't6'
-  PROCESS_TAB_SUMMARY = 'st1'
-  PROCESS_TAB_STATUSES = 'st3'
-  PROCESS_TAB_LABELS = 'st4'
-  PROCESS_TAB_RULES = 'st5'
-  PROCESS_TAB_TEMPLATES = 'st6'
-  PROCESS_TAB_COMPONENTS = 'st7'
-  PROCESS_TAB_VIEWS = 'st8'
-  ADMIN_TAB_META = 'st1'
-  ADMIN_TAB_ADVANCED = 'st9'
-  HOTLIST_TAB_ISSUES = 'ht2'
-  HOTLIST_TAB_PEOPLE = 'ht3'
-  HOTLIST_TAB_DETAILS = 'ht4'
-
-  # Most forms require a security token, however if a form is really
-  # just redirecting to a search GET request without writing any data,
-  # subclass can override this to allow anonymous use.
-  CHECK_SECURITY_TOKEN = True
-
-  # Some pages might be posted to by clients outside of Monorail.
-  # ie: The issue entry page, by the issue filing wizard. In these cases,
-  # we can allow an xhr-scoped XSRF token to be used to post to the page.
-  ALLOW_XHR = False
-
-  # This value should not typically be overridden.
-  _TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
-
-  _PAGE_TEMPLATE = None  # Normally overridden in subclasses.
-  _ELIMINATE_BLANK_LINES = False
-
-  _MISSING_PERMISSIONS_TEMPLATE = 'sitewide/403-page.ezt'
-
-  def __init__(self, services=None, content_type='text/html; charset=UTF-8'):
-    """Load and parse the template, saving it for later use."""
-    if self._PAGE_TEMPLATE:  # specified in subclasses
-      template_path = self._TEMPLATE_PATH + self._PAGE_TEMPLATE
-      self.template = template_helpers.GetTemplate(
-          template_path, eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
-    else:
-      self.template = None
-
-    self._missing_permissions_template = template_helpers.MonorailTemplate(
-        self._TEMPLATE_PATH + self._MISSING_PERMISSIONS_TEMPLATE)
-    self.services = services or flask.current_app.config['services']
-    self.content_type = content_type
-    self.mr = None
-    self.request = flask.request
-    self.request_path = None
-    self.response = None
-    self.ratelimiter = ratelimiter.RateLimiter()
-    self.redirect_url = None
-
-  # pylint: disable=unused-argument
-  def handler(self, **kwargs):
-    """Do common stuff then dispatch the request to get() or put() methods."""
-    self.response = flask.make_response()
-    handler_start_time = time.time()
-    logging.info('\n\n\n Flask Request handler: %r', self)
-
-    #TODO: add the ts_mon.NonCumulativeDistributionMetric
-    # count0, count1, count2 = gc.get_count()
-    # logging.info('gc counts: %d %d %d', count0, count1, count2)
-    # GC_COUNT.add(count0, {'generation': 0})
-    # GC_COUNT.add(count1, {'generation': 1})
-    # GC_COUNT.add(count2, {'generation': 2})
-
-    self.mr = monorailrequest.MonorailRequest(self.services)
-    self.request_path = self.request.base_url[len(self.request.host_url) - 1:]
-    self.response.headers.add(
-        'Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
-
-    if 'X-Cloud-Trace-Context' in self.request.headers:
-      self.mr.profiler.trace_context = (
-          self.request.headers.get('X-Cloud-Trace-Context'))
-
-    if self.services.cache_manager:
-      try:
-        with self.mr.profiler.Phase('distributed invalidation'):
-          self.services.cache_manager.DoDistributedInvalidation(self.mr.cnxn)
-
-      except MySQLdb.OperationalError as e:
-        logging.exception(e)
-        page_data = {
-            'http_response_code': httplib.SERVICE_UNAVAILABLE,
-            'requested_url': self.request.url,
-        }
-        self.template = template_helpers.GetTemplate(
-            'templates/framework/database-maintenance.ezt',
-            eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
-        self.template.WriteFlaskResponse(
-            self.response, page_data, content_type='text/html')
-        return self.response
-
-    try:
-      self.ratelimiter.CheckStart(self.request)
-
-      with self.mr.profiler.Phase('parsing request and doing lookups'):
-        self.mr.ParseFlaskRequest(self.request, self.services)
-
-      self.response.headers['X-Frame-Options'] = 'SAMEORIGIN'
-
-      if self.request.method == 'POST':
-        self.post()
-        if self.redirect_url:
-          return self.redirect(self.redirect_url)
-      elif self.request.method == 'GET':
-        self.get()
-    except exceptions.NoSuchUserException as e:
-      logging.info('Trapped NoSuchUserException %s', e)
-      flask.abort(404, 'user not found')
-
-    except exceptions.NoSuchGroupException as e:
-      logging.warning('Trapped NoSuchGroupException %s', e)
-      flask.abort(404, 'user group not found')
-
-    except exceptions.InputException as e:
-      logging.info('Rejecting invalid input: %r', e)
-      self.response.status_code = httplib.BAD_REQUEST
-
-    except exceptions.NoSuchProjectException as e:
-      logging.info('Rejecting invalid request: %r', e)
-      self.response.status_code = httplib.NOT_FOUND
-
-    except xsrf.TokenIncorrect as e:
-      logging.info('Bad XSRF token: %r', e.message)
-      self.response.status_code = httplib.BAD_REQUEST
-
-    except permissions.BannedUserException as e:
-      logging.warning('The user has been banned')
-      url = framework_helpers.FormatAbsoluteURL(
-          self.mr, urls.BANNED, include_project=False, copy_params=False)
-      self.redirect(url, abort=True)
-
-    except ratelimiter.RateLimitExceeded as e:
-      logging.info('RateLimitExceeded Exception %s', e)
-      self.response.status_code = httplib.BAD_REQUEST
-      self.response.set_data('Slow your roll.')
-
-    finally:
-      self.mr.CleanUp()
-      self.ratelimiter.CheckEnd(self.request, time.time(), handler_start_time)
-
-    total_processing_time = time.time() - handler_start_time
-    logging.info(
-        'Processed request in %d ms', int(total_processing_time * 1000))
-
-    end_count0, end_count1, end_count2 = gc.get_count()
-    logging.info('gc counts: %d %d %d', end_count0, end_count1, end_count2)
-    # TODO: get the GC event back
-    # if (end_count0 < count0) or (end_count1 < count1) or(end_count2 < count2):
-    #   GC_EVENT_REQUEST.increment()
-
-    if settings.enable_profiler_logging:
-      self.mr.profiler.LogStats()
-
-    return self.response
-
-  def get(self):
-    """Collect page-specific and generic info, then render the page.
-
-    Args:
-      Any path components parsed by webapp2 will be in kwargs, but we do
-        our own parsing later anyway, so ignore them for now.
-    """
-    page_data = {}
-    nonce = framework_helpers.MakeRandomKey(length=NONCE_LENGTH)
-    try:
-      csp_header = 'Content-Security-Policy'
-      csp_scheme = 'https:'
-      if settings.local_mode:
-        csp_header = 'Content-Security-Policy-Report-Only'
-        csp_scheme = 'http:'
-      user_agent_str = self.mr.request.headers.get('User-Agent', '')
-      ua = httpagentparser.detect(user_agent_str)
-      browser, browser_major_version = 'Unknown browser', 0
-      if ua.has_key('browser'):
-        browser = ua['browser']['name']
-        try:
-          browser_major_version = int(ua['browser']['version'].split('.')[0])
-        except ValueError:
-          logging.warn('Could not parse version: %r', ua['browser']['version'])
-      csp_supports_report_sample = (
-          (browser == 'Chrome' and browser_major_version >= 59) or
-          (browser == 'Opera' and browser_major_version >= 46))
-      version_base = servlet_helpers.VersionBaseURL(self.mr.request)
-      self.response.headers.add(
-          csp_header,
-          (
-              "default-src %(scheme)s ; "
-              "script-src"
-              " %(rep_samp)s"  # Report 40 chars of any inline violation.
-              " 'unsafe-inline'"  # Only counts in browsers that lack CSP2.
-              " 'strict-dynamic'"  # Allows <script nonce> to load more.
-              " %(version_base)s/static/dist/"
-              " 'self' 'nonce-%(nonce)s'; "
-              "child-src 'none'; "
-              "frame-src accounts.google.com"  # All used by gapi.js auth.
-              " content-issuetracker.corp.googleapis.com"
-              " login.corp.google.com up.corp.googleapis.com"
-              # Used by Google Feedback
-              " feedback.googleusercontent.com"
-              " www.google.com; "
-              "img-src %(scheme)s data: blob: ; "
-              "style-src %(scheme)s 'unsafe-inline'; "
-              "object-src 'none'; "
-              "base-uri 'self'; "  # Used by Google Feedback
-              "report-uri /csp.do" % {
-                  'nonce':
-                      nonce,
-                  'scheme':
-                      csp_scheme,
-                  'rep_samp':
-                      "'report-sample'" if csp_supports_report_sample else '',
-                  'version_base':
-                      version_base,
-              }))
-
-      # add the function to get data and render page
-      page_data.update(self._GatherFlagData(self.mr))
-
-      # Page-specific work happens in this call.
-      page_data.update(self._DoPageProcessing(self.mr, nonce))
-
-      self._AddHelpDebugPageData(page_data)
-
-      with self.mr.profiler.Phase('rendering template'):
-        self._RenderResponse(page_data)
-
-    except (servlet_helpers.MethodNotSupportedError, NotImplementedError) as e:
-      # Instead of these pages throwing 500s display the 404 message and log.
-      # The motivation of this is to minimize 500s on the site to keep alerts
-      # meaningful during fuzzing. For more context see
-      # https://bugs.chromium.org/p/monorail/issues/detail?id=659
-      logging.warning('Trapped NotImplementedError %s', e)
-      flask.abort(404, 'invalid page')
-    except query2ast.InvalidQueryError as e:
-      logging.warning('Trapped InvalidQueryError: %s', e)
-      logging.exception(e)
-      msg = e.message if e.message else 'invalid query'
-      flask.abort(400, msg)
-    except permissions.PermissionException as e:
-      logging.warning('Trapped PermissionException %s', e)
-      logging.warning('mr.auth.user_id is %s', self.mr.auth.user_id)
-      logging.warning('mr.auth.effective_ids is %s', self.mr.auth.effective_ids)
-      logging.warning('mr.perms is %s', self.mr.perms)
-      if not self.mr.auth.user_id:
-        # If not logged in, let them log in
-        url = servlet_helpers.SafeCreateLoginURL(self.mr)
-        self.redirect(url, abort=True)
-      else:
-        # Display the missing permissions template.
-        page_data = {
-            'reason': e.message,
-            'http_response_code': httplib.FORBIDDEN,
-        }
-        with self.mr.profiler.Phase('gather base data'):
-          page_data.update(self.GatherBaseData(self.mr, nonce))
-        self._AddHelpDebugPageData(page_data)
-        self._missing_permissions_template.WriteFlaskResponse(
-            self.response, page_data, content_type=self.content_type)
-
-  def post(self):
-    logging.info('process post request')
-    try:
-      # Page-specific work happens in this call.
-      self._DoFormProcessing(self.request, self.mr)
-
-    except permissions.PermissionException as e:
-      logging.warning('Trapped permission-related exception "%s".', e)
-      self.response.status_code = httplib.BAD_REQUEST
-
-  def _RenderResponse(self, page_data):
-    logging.info('rendering response len(page_data) is %r', len(page_data))
-    self.template.WriteFlaskResponse(
-        self.response, page_data, content_type=self.content_type)
-
-  def _GatherFlagData(self, mr):
-    page_data = {
-        'project_stars_enabled':
-            ezt.boolean(settings.enable_project_stars),
-        'user_stars_enabled':
-            ezt.boolean(settings.enable_user_stars),
-        'can_create_project':
-            ezt.boolean(permissions.CanCreateProject(mr.perms)),
-        'can_create_group':
-            ezt.boolean(permissions.CanCreateGroup(mr.perms)),
-    }
-
-    return page_data
-
-  def _DoCommonRequestProcessing(self, request, mr):
-    """Do common processing dependent on having the user and project pbs."""
-    with mr.profiler.Phase('basic processing'):
-      self._CheckForMovedProject(mr, request)
-      self.AssertBasePermission(mr)
-
-  # pylint: disable=unused-argument
-  def _DoPageProcessing(self, mr, nonce):
-    """Do user lookups and gather page-specific ezt data."""
-    with mr.profiler.Phase('common request data'):
-
-      self._DoCommonRequestProcessing(self.request, mr)
-
-      self._MaybeRedirectToBrandedDomain(self.request, mr.project_name)
-
-      page_data = self.GatherBaseData(mr, nonce)
-
-    with mr.profiler.Phase('page processing'):
-      page_data.update(self.GatherPageData(mr))
-      page_data.update(mr.form_overrides)
-      template_helpers.ExpandLabels(page_data)
-      self._RecordVisitTime(mr)
-
-    return page_data
-
-  def _DoFormProcessing(self, request, mr):
-    """Do user lookups and handle form data."""
-    self._DoCommonRequestProcessing(request, mr)
-
-    if self.CHECK_SECURITY_TOKEN:
-      try:
-        xsrf.ValidateToken(
-            request.values.get('token'), mr.auth.user_id, self.request_path)
-      except xsrf.TokenIncorrect as err:
-        if self.ALLOW_XHR:
-          xsrf.ValidateToken(
-              request.values.get('token'), mr.auth.user_id, 'xhr')
-        else:
-          raise err
-
-    self.redirect_url = self.ProcessFormData(mr, request.values)
-
-    # Most forms redirect the user to a new URL on success.  If no
-    # redirect_url was returned, the form handler must have already
-    # sent a response.  E.g., bounced the user back to the form with
-    # invalid form fields highlighted.
-    if self.redirect_url:
-      return self.redirect(self.redirect_url, abort=True)
-    else:
-      assert self.response.response
-
-  def ProcessFormData(self, mr, post_data):
-    """Handle form data and redirect appropriately.
-
-    Args:
-      mr: commonly used info parsed from the request.
-      post_data: HTML form data from the request.
-
-    Returns:
-      String URL to redirect the user to, or None if response was already sent.
-    """
-    raise servlet_helpers.MethodNotSupportedError()
-
-  def _FormHandlerURL(self, path):
-    """Return the form handler for the main form on a page."""
-    if path.endswith('/'):
-      return path + 'edit.do'
-    elif path.endswith('.do'):
-      return path  # This happens as part of PleaseCorrect().
-    else:
-      return path + '.do'
-
-  # pylint: disable=unused-argument
-  def GatherPageData(self, mr):
-    """Return a dict of page-specific ezt data."""
-    raise servlet_helpers.MethodNotSupportedError()
-
-  def GatherBaseData(self, mr, nonce):
-    """Return a dict of info used on almost all pages."""
-    project = mr.project
-
-    project_summary = ''
-    project_alert = None
-    project_read_only = False
-    project_home_page = ''
-    project_thumbnail_url = ''
-    if project:
-      project_summary = project.summary
-      project_alert = servlet_helpers.CalcProjectAlert(project)
-      project_read_only = project.read_only_reason
-      project_home_page = project.home_page
-      project_thumbnail_url = tracker_views.LogoView(project).thumbnail_url
-
-    with work_env.WorkEnv(mr, self.services) as we:
-      is_project_starred = False
-      project_view = None
-      if mr.project:
-        if permissions.UserCanViewProject(mr.auth.user_pb,
-                                          mr.auth.effective_ids, mr.project):
-          is_project_starred = we.IsProjectStarred(mr.project_id)
-          project_view = template_helpers.PBProxy(mr.project)
-
-    grid_x_attr = None
-    grid_y_attr = None
-    hotlist_view = None
-    if mr.hotlist:
-      users_by_id = framework_views.MakeAllUserViews(
-          mr.cnxn, self.services.user,
-          features_bizobj.UsersInvolvedInHotlists([mr.hotlist]))
-      hotlist_view = hotlist_views.HotlistView(
-          mr.hotlist, mr.perms, mr.auth, mr.viewed_user_auth.user_id,
-          users_by_id,
-          self.services.hotlist_star.IsItemStarredBy(
-              mr.cnxn, mr.hotlist.hotlist_id, mr.auth.user_id))
-      grid_x_attr = mr.x.lower()
-      grid_y_attr = mr.y.lower()
-
-    app_version = os.environ.get('CURRENT_VERSION_ID')
-
-    viewed_username = None
-    if mr.viewed_user_auth.user_view:
-      viewed_username = mr.viewed_user_auth.user_view.username
-
-    config = None
-    if mr.project_id and self.services.config:
-      with mr.profiler.Phase('getting config'):
-        config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
-      grid_x_attr = (mr.x or config.default_x_attr).lower()
-      grid_y_attr = (mr.y or config.default_y_attr).lower()
-
-    viewing_self = mr.auth.user_id == mr.viewed_user_auth.user_id
-    offer_saved_queries_subtab = (
-        viewing_self or mr.auth.user_pb and mr.auth.user_pb.is_site_admin)
-
-    login_url = servlet_helpers.SafeCreateLoginURL(mr)
-    logout_url = servlet_helpers.SafeCreateLogoutURL(mr)
-    logout_url_goto_home = users.create_logout_url('/')
-    version_base = servlet_helpers.VersionBaseURL(mr.request)
-
-    base_data = {
-        # EZT does not have constants for True and False, so we pass them in.
-        'True':
-            ezt.boolean(True),
-        'False':
-            ezt.boolean(False),
-        'local_mode':
-            ezt.boolean(settings.local_mode),
-        'site_name':
-            settings.site_name,
-        'show_search_metadata':
-            ezt.boolean(False),
-        'page_template':
-            self._PAGE_TEMPLATE,
-        'main_tab_mode':
-            self._MAIN_TAB_MODE,
-        'project_summary':
-            project_summary,
-        'project_home_page':
-            project_home_page,
-        'project_thumbnail_url':
-            project_thumbnail_url,
-        'hotlist_id':
-            mr.hotlist_id,
-        'hotlist':
-            hotlist_view,
-        'hostport':
-            mr.request.host,
-        'absolute_base_url':
-            '%s://%s' % (mr.request.scheme, mr.request.host),
-        'project_home_url':
-            None,
-        'link_rel_canonical':
-            None,  # For specifying <link rel="canonical">
-        'projectname':
-            mr.project_name,
-        'project':
-            project_view,
-        'project_is_restricted':
-            ezt.boolean(servlet_helpers.ProjectIsRestricted(mr)),
-        'offer_contributor_list':
-            ezt.boolean(permissions.CanViewContributorList(mr, mr.project)),
-        'logged_in_user':
-            mr.auth.user_view,
-        'form_token':
-            None,  # Set to a value below iff the user is logged in.
-        'form_token_path':
-            None,
-        'token_expires_sec':
-            None,
-        'xhr_token':
-            None,  # Set to a value below iff the user is logged in.
-        'flag_spam_token':
-            None,
-        'nonce':
-            nonce,
-        'perms':
-            mr.perms,
-        'warnings':
-            mr.warnings,
-        'errors':
-            mr.errors,
-        'viewed_username':
-            viewed_username,
-        'viewed_user':
-            mr.viewed_user_auth.user_view,
-        'viewed_user_pb':
-            template_helpers.PBProxy(mr.viewed_user_auth.user_pb),
-        'viewing_self':
-            ezt.boolean(viewing_self),
-        'viewed_user_id':
-            mr.viewed_user_auth.user_id,
-        'offer_saved_queries_subtab':
-            ezt.boolean(offer_saved_queries_subtab),
-        'currentPageURL':
-            mr.current_page_url,
-        'currentPageURLEncoded':
-            mr.current_page_url_encoded,
-        'login_url':
-            login_url,
-        'logout_url':
-            logout_url,
-        'logout_url_goto_home':
-            logout_url_goto_home,
-        'continue_issue_id':
-            mr.continue_issue_id,
-        'feedback_email':
-            settings.feedback_email,
-        'category_css':
-            None,  # Used to specify a category of stylesheet
-        'category2_css':
-            None,  # specify a 2nd category of stylesheet if needed.
-        'page_css':
-            None,  # Used to add a stylesheet to a specific page.
-        'can':
-            mr.can,
-        'query':
-            mr.query,
-        'colspec':
-            None,
-        'sortspec':
-            mr.sort_spec,
-
-        # Options for issuelist display
-        'grid_x_attr':
-            grid_x_attr,
-        'grid_y_attr':
-            grid_y_attr,
-        'grid_cell_mode':
-            mr.cells,
-        'grid_mode':
-            None,
-        'list_mode':
-            None,
-        'chart_mode':
-            None,
-        'is_cross_project':
-            ezt.boolean(False),
-
-        # for project search (some also used in issue search)
-        'start':
-            mr.start,
-        'num':
-            mr.num,
-        'groupby':
-            mr.group_by_spec,
-        'q_field_size':
-            (
-                min(
-                    framework_constants.MAX_ARTIFACT_SEARCH_FIELD_SIZE,
-                    max(
-                        framework_constants.MIN_ARTIFACT_SEARCH_FIELD_SIZE,
-                        len(mr.query) + framework_constants.AUTOSIZE_STEP))),
-        'mode':
-            None,  # Display mode, e.g., grid mode.
-        'ajah':
-            mr.ajah,
-        'table_title':
-            mr.table_title,
-        'alerts':
-            alerts.AlertsView(mr),  # For alert.ezt
-        'project_alert':
-            project_alert,
-        'title':
-            None,  # First part of page title
-        'title_summary':
-            None,  # Appended to title on artifact detail pages
-        'project_read_only':
-            ezt.boolean(project_read_only),
-        'site_read_only':
-            ezt.boolean(settings.read_only),
-        'banner_time':
-            servlet_helpers.GetBannerTime(settings.banner_time),
-        'read_only':
-            ezt.boolean(settings.read_only or project_read_only),
-        'site_banner_message':
-            settings.banner_message,
-        'robots_no_index':
-            None,
-        'analytics_id':
-            settings.analytics_id,
-        'is_project_starred':
-            ezt.boolean(is_project_starred),
-        'version_base':
-            version_base,
-        'app_version':
-            app_version,
-        'gapi_client_id':
-            settings.gapi_client_id,
-        'viewing_user_page':
-            ezt.boolean(False),
-        'old_ui_url':
-            None,
-        'new_ui_url':
-            None,
-        'is_member':
-            ezt.boolean(False),
-    }
-
-    if mr.project:
-      base_data['project_home_url'] = '/p/%s' % mr.project_name
-
-    # Always add xhr-xsrf token because even anon users need some
-    # pRPC methods, e.g., autocomplete, flipper, and charts.
-    base_data['token_expires_sec'] = xsrf.TokenExpiresSec()
-    base_data['xhr_token'] = xsrf.GenerateToken(
-        mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
-    # Always add other anti-xsrf tokens when the user is logged in.
-    if mr.auth.user_id:
-      form_token_path = self._FormHandlerURL(mr.request_path)
-      base_data['form_token'] = xsrf.GenerateToken(
-          mr.auth.user_id, form_token_path)
-      base_data['form_token_path'] = form_token_path
-
-    return base_data
-
-  def _AddHelpDebugPageData(self, page_data):
-    with self.mr.profiler.Phase('help and debug data'):
-      page_data.update(self.GatherHelpData(self.mr, page_data))
-      page_data.update(self.GatherDebugData(self.mr, page_data))
-
-  # pylint: disable=unused-argument
-  def GatherHelpData(self, mr, page_data):
-    """Return a dict of values to drive on-page user help.
-       Subclasses can override this function
-    Args:
-      mr: common information parsed from the HTTP request.
-      page_data: Dictionary of base and page template data.
-
-    Returns:
-      A dict of values to drive on-page user help, to be added to page_data.
-    """
-    help_data = {
-        'cue': None,  # for cues.ezt
-        'account_cue': None,  # for cues.ezt
-    }
-    dismissed = []
-    if mr.auth.user_pb:
-      with work_env.WorkEnv(mr, self.services) as we:
-        userprefs = we.GetUserPrefs(mr.auth.user_id)
-      dismissed = [pv.name for pv in userprefs.prefs if pv.value == 'true']
-      if (mr.auth.user_pb.vacation_message and
-          'you_are_on_vacation' not in dismissed):
-        help_data['cue'] = 'you_are_on_vacation'
-      if (mr.auth.user_pb.email_bounce_timestamp and
-          'your_email_bounced' not in dismissed):
-        help_data['cue'] = 'your_email_bounced'
-      if mr.auth.user_pb.linked_parent_id:
-        # This one is not dismissable.
-        help_data['account_cue'] = 'switch_to_parent_account'
-        parent_email = self.services.user.LookupUserEmail(
-            mr.cnxn, mr.auth.user_pb.linked_parent_id)
-        help_data['parent_email'] = parent_email
-
-    return help_data
-
-  def GatherDebugData(self, mr, page_data):
-    """Return debugging info for display at the very bottom of the page."""
-    if mr.debug_enabled:
-      debug = [servlet_helpers.ContextDebugCollection('Page data', page_data)]
-      debug = [('none', 'recorded')]
-      return {
-          'dbg': 'on',
-          'debug': debug,
-          'profiler': mr.profiler,
-      }
-    else:
-      if '?' in mr.current_page_url:
-        debug_url = mr.current_page_url + '&debug=1'
-      else:
-        debug_url = mr.current_page_url + '?debug=1'
-
-      return {
-          'debug_uri': debug_url,
-          'dbg': 'off',
-          'debug': [('none', 'recorded')],
-      }
-
-  def _CheckForMovedProject(self, mr, request):
-    """If the project moved, redirect there or to an informational page."""
-    if not mr.project:
-      return  # We are on a site-wide or user page.
-    if not mr.project.moved_to:
-      return  # This project has not moved.
-    admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
-    if self.request_path.startswith(admin_url):
-      return  # It moved, but we are near the page that can un-move it.
-
-    logging.info(
-        'project %s has moved: %s', mr.project.project_name,
-        mr.project.moved_to)
-
-    moved_to = mr.project.moved_to
-    if project_constants.RE_PROJECT_NAME.match(moved_to):
-      # Use the redir query parameter to avoid redirect loops.
-      if mr.redir is None:
-        url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
-        if '?' in url:
-          url += '&redir=1'
-        else:
-          url += '?redir=1'
-        logging.info('trusted move to a new project on our site')
-        self.redirect(url, abort=True)
-
-    logging.info('not a trusted move, will display link to user to click')
-    # Attach the project name as a url param instead of generating a /p/
-    # link to the destination project.
-    url = framework_helpers.FormatAbsoluteURL(
-        mr,
-        urls.PROJECT_MOVED,
-        include_project=False,
-        copy_params=False,
-        project=mr.project_name)
-    self.redirect(url, abort=True)
-
-  def _MaybeRedirectToBrandedDomain(self, request, project_name):
-    """If we are live and the project should be branded, check request host."""
-    if request.values.get('redir'):
-      return  # Avoid any chance of a redirect loop.
-    if not project_name:
-      return
-    needed_domain = framework_helpers.GetNeededDomain(
-        project_name, request.host)
-    if not needed_domain:
-      return
-
-    url = 'https://%s%s' % (needed_domain, request.full_path)
-    if '?' in url:
-      url += '&redir=1'
-    else:
-      url += '?redir=1'
-    logging.info('branding redirect to url %r', url)
-    self.redirect(url, abort=True)
-
-  def AssertBasePermission(self, mr):
-    """Make sure that the logged in user has permission to view this page.
-
-    Subclasses should call super, then check additional permissions
-    and raise a PermissionException if the user is not authorized to
-    do something.
-
-    Args:
-      mr: commonly used info parsed from the request.
-
-    Raises:
-      PermissionException: If the user does not have permisssion to view
-        the current page.
-    """
-    servlet_helpers.AssertBasePermission(mr)
-
-  def CheckPerm(self, mr, perm, art=None, granted_perms=None):
-    """Return True if the user can use the requested permission."""
-    return servlet_helpers.CheckPerm(
-        mr, perm, art=art, granted_perms=granted_perms)
-
-  def MakePagePerms(self, mr, art, *perm_list, **kwargs):
-    """Make an EZTItem with a set of permissions needed in a given template.
-
-    Args:
-      mr: commonly used info parsed from the request.
-      art: a project artifact, such as an issue.
-      *perm_list: any number of permission names that are referenced
-          in the EZT template.
-      **kwargs: dictionary that may include 'granted_perms' list of permissions
-          granted to the current user specifically on the current page.
-
-    Returns:
-      An EZTItem with one attribute for each permission and the value
-      of each attribute being an ezt.boolean().  True if the user
-      is permitted to do that action on the given artifact, or
-      False if not.
-    """
-    granted_perms = kwargs.get('granted_perms')
-    page_perms = template_helpers.EZTItem()
-    for perm in perm_list:
-      setattr(
-          page_perms, perm,
-          ezt.boolean(
-              self.CheckPerm(mr, perm, art=art, granted_perms=granted_perms)))
-
-    return page_perms
-
-  def redirect(self, url, abort=False):
-    if abort:
-      return flask.redirect(url, code=302)
-    else:
-      return flask.redirect(url)
-
-  def PleaseCorrect(self, mr, **echo_data):
-    """Show the same form again so that the user can correct their input."""
-    mr.PrepareForReentry(echo_data)
-    self.get()
-
-  def _RecordVisitTime(self, mr, now=None):
-    """Record the signed in user's last visit time, if possible."""
-    now = now or int(time.time())
-    if not settings.read_only and mr.auth.user_id:
-      user_pb = mr.auth.user_pb
-      if (user_pb.last_visit_timestamp <
-          now - framework_constants.VISIT_RESOLUTION):
-        user_pb.last_visit_timestamp = now
-        self.services.user.UpdateUser(mr.cnxn, user_pb.user_id, user_pb)
-
-  def abort(self, code, context=""):
-    return flask.abort(code, context)
diff --git a/framework/framework_bizobj.py b/framework/framework_bizobj.py
index bacaec5..77a70ac 100644
--- a/framework/framework_bizobj.py
+++ b/framework/framework_bizobj.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Business objects for Monorail's framework.
 
@@ -22,7 +21,7 @@
 import settings
 from framework import exceptions
 from framework import framework_constants
-from proto import tracker_pb2
+from mrproto import tracker_pb2
 from services import client_config_svc
 
 
@@ -30,7 +29,7 @@
 RE_COLUMN_NAME = r'\w+[\w+-.]*\w+'
 
 # Compiled regexp to match a valid column specification.
-RE_COLUMN_SPEC = re.compile('(%s(\s%s)*)*$' % (RE_COLUMN_NAME, RE_COLUMN_NAME))
+RE_COLUMN_SPEC = re.compile(r'(%s(\s%s)*)*$' % (RE_COLUMN_NAME, RE_COLUMN_NAME))
 
 
 def WhichUsersShareAProject(cnxn, services, user_effective_ids, other_users):
diff --git a/framework/framework_constants.py b/framework/framework_constants.py
index 1490135..7609a96 100644
--- a/framework/framework_constants.py
+++ b/framework/framework_constants.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Some constants used throughout Monorail."""
 from __future__ import print_function
diff --git a/framework/framework_helpers.py b/framework/framework_helpers.py
index 47c34ef..cb13931 100644
--- a/framework/framework_helpers.py
+++ b/framework/framework_helpers.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Helper functions and classes used throughout Monorail."""
 
@@ -30,7 +29,7 @@
 from framework import template_helpers
 from framework import timestr
 from framework import urls
-from proto import user_pb2
+from mrproto import user_pb2
 from services import client_config_svc
 
 # AttachmentUpload holds the information of an incoming uploaded
diff --git a/framework/framework_views.py b/framework/framework_views.py
index 17dead8..ca91a56 100644
--- a/framework/framework_views.py
+++ b/framework/framework_views.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """View classes to make it easy to display framework objects in EZT."""
 from __future__ import print_function
@@ -19,7 +18,7 @@
 from framework import permissions
 from framework import template_helpers
 from framework import timestr
-from proto import user_pb2
+from mrproto import user_pb2
 from services import client_config_svc
 import settings
 
diff --git a/framework/gcs_helpers.py b/framework/gcs_helpers.py
index 9da5b11..fd282e0 100644
--- a/framework/gcs_helpers.py
+++ b/framework/gcs_helpers.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Set of helpers for interacting with Google Cloud Storage."""
 from __future__ import print_function
@@ -10,6 +9,7 @@
 
 import logging
 import os
+import six
 from six.moves import urllib
 import uuid
 
@@ -96,7 +96,7 @@
       # Don't log the whole exception because we don't need to see
       # this on the Cloud Error Reporting page.
       logging.info('Got LargeImageError on image with %d bytes', len(content))
-    except Exception, e:
+    except Exception as e:
       # Do not raise exception for incorrectly formed images.
       # See https://bugs.chromium.org/p/monorail/issues/detail?id=597 for more
       # detail.
@@ -132,7 +132,7 @@
   """Request that devstorage API signs a GCS content URL."""
   resp = urlfetch.fetch(url, follow_redirects=False)
   redir = resp.headers["Location"]
-  return redir
+  return six.ensure_str(redir)
 
 
 def SignUrl(bucket, object_id):
@@ -149,7 +149,7 @@
     cache_key = 'gcs-object-url-%s' % object_id
     cached = memcache.get(key=cache_key)
     if cached is not None:
-      return cached
+      return six.ensure_str(cached)
 
     if IS_DEV_APPSERVER:
       attachment_url = '/_ah/gcs/%s%s' % (bucket, object_id)
diff --git a/framework/grid_view_helpers.py b/framework/grid_view_helpers.py
index 44af6b7..2ed4e70 100644
--- a/framework/grid_view_helpers.py
+++ b/framework/grid_view_helpers.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Classes and functions for displaying grids of project artifacts.
 
@@ -24,7 +23,7 @@
 from framework import table_view_helpers
 from framework import template_helpers
 from framework import urls
-from proto import tracker_pb2
+from mrproto import tracker_pb2
 from tracker import tracker_bizobj
 from tracker import tracker_constants
 from tracker import tracker_helpers
@@ -87,7 +86,7 @@
     return sorting.MAX_STRING  # Undefined values sort last.
   try:
     # well-known values sort by index
-    return well_known_list.index(value)
+    return '%09d' % well_known_list.index(value)
   except ValueError:
     return value  # odd-ball values lexicographically after all well-known ones
 
diff --git a/framework/jsonfeed.py b/framework/jsonfeed.py
index 1eff87d..b357fd8 100644
--- a/framework/jsonfeed.py
+++ b/framework/jsonfeed.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """This file defines a subclass of Servlet for JSON feeds.
 
@@ -21,10 +20,9 @@
 import settings
 
 from framework import framework_constants
-from framework import flaskservlet
-from framework import servlet_helpers
 from framework import permissions
 from framework import servlet
+from framework import servlet_helpers
 from framework import xsrf
 from search import query2ast
 
@@ -56,110 +54,6 @@
   def _DoRequestHandling(self, request, mr):
     """Do permission checking, page processing, and response formatting."""
     try:
-      # TODO(jrobbins): check the XSRF token even for anon users
-      # after the next deployment.
-      if self.CHECK_SECURITY_TOKEN and mr.auth.user_id:
-        # Validate the XSRF token with the specific request path for this
-        # servlet.  But, not every XHR request has a distinct token, so just
-        # use 'xhr' for ones that don't.
-        # TODO(jrobbins): make specific tokens for:
-        # user and project stars, issue options, check names.
-        try:
-          logging.info('request in jsonfeed is %r', request)
-          xsrf.ValidateToken(mr.token, mr.auth.user_id, request.path)
-        except xsrf.TokenIncorrect:
-          logging.info('using token path "xhr"')
-          xsrf.ValidateToken(mr.token, mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
-
-      if self.CHECK_SAME_APP and not settings.local_mode:
-        calling_app_id = request.headers.get('X-Appengine-Inbound-Appid')
-        if calling_app_id != app_identity.get_application_id():
-          self.response.status = http_client.FORBIDDEN
-          return
-
-      self._CheckForMovedProject(mr, request)
-      self.AssertBasePermission(mr)
-
-      json_data = self.HandleRequest(mr)
-
-      self._RenderJsonResponse(json_data)
-
-    except query2ast.InvalidQueryError as e:
-      logging.warning('Trapped InvalidQueryError: %s', e)
-      logging.exception(e)
-      msg = e.message if e.message else 'invalid query'
-      self.abort(400, msg)
-    except permissions.PermissionException as e:
-      logging.info('Trapped PermissionException %s', e)
-      self.response.status = http_client.FORBIDDEN
-
-  # pylint: disable=unused-argument
-  # pylint: disable=arguments-differ
-  # Note: unused arguments necessary because they are specified in
-  # registerpages.py as an extra URL validation step even though we
-  # do our own URL parsing in monorailrequest.py
-  def get(self, project_name=None, viewed_username=None, hotlist_id=None):
-    """Collect page-specific and generic info, then render the page.
-
-    Args:
-      project_name: string project name parsed from the URL by webapp2,
-        but we also parse it out in our code.
-      viewed_username: string user email parsed from the URL by webapp2,
-        but we also parse it out in our code.
-      hotlist_id: string hotlist id parsed from the URL by webapp2,
-        but we also parse it out in our code.
-    """
-    self._DoRequestHandling(self.mr.request, self.mr)
-
-  # pylint: disable=unused-argument
-  # pylint: disable=arguments-differ
-  def post(self, project_name=None, viewed_username=None, hotlist_id=None):
-    """Parse the request, check base perms, and call form-specific code."""
-    self._DoRequestHandling(self.mr.request, self.mr)
-
-  def _RenderJsonResponse(self, json_data):
-    """Serialize the data as JSON so that it can be sent to the browser."""
-    json_str = json.dumps(json_data, indent=self.JSON_INDENT)
-    logging.debug(
-      'Sending JSON response: %r length: %r',
-      json_str[:framework_constants.LOGGING_MAX_LENGTH], len(json_str))
-    self.response.content_type = framework_constants.CONTENT_TYPE_JSON
-    self.response.headers['X-Content-Type-Options'] = (
-        framework_constants.CONTENT_TYPE_JSON_OPTIONS)
-    self.response.write(XSSI_PREFIX)
-    self.response.write(json_str)
-
-
-class InternalTask(JsonFeed):
-  """Internal tasks are JSON feeds that can only be reached by our own code."""
-
-  CHECK_SECURITY_TOKEN = False
-
-
-class FlaskJsonFeed(flaskservlet.FlaskServlet):
-  """A convenient base class for JSON feeds."""
-
-  # By default, JSON output is compact.  Subclasses can set this to
-  # an integer, like 4, for pretty-printed output.
-  JSON_INDENT = None
-
-  # Some JSON handlers can only be accessed from our own app.
-  CHECK_SAME_APP = False
-
-  def HandleRequest(self, _mr):
-    """Override this method to implement handling of the request.
-
-    Args:
-      mr: common information parsed from the HTTP request.
-
-    Returns:
-      A dictionary of json data.
-    """
-    raise servlet_helpers.MethodNotSupportedError()
-
-  def _DoRequestHandling(self, request, mr):
-    """Do permission checking, page processing, and response formatting."""
-    try:
       if self.CHECK_SECURITY_TOKEN and mr.auth.user_id:
         try:
           logging.info('request in jsonfeed is %r', request)
@@ -184,7 +78,7 @@
     except query2ast.InvalidQueryError as e:
       logging.warning('Trapped InvalidQueryError: %s', e)
       logging.exception(e)
-      msg = e.message if e.message else 'invalid query'
+      msg = str(e) if str(e) else 'invalid query'
       self.abort(400, msg)
     except permissions.PermissionException as e:
       logging.info('Trapped PermissionException %s', e)
@@ -199,11 +93,11 @@
     """Collect page-specific and generic info, then render the page.
 
     Args:
-      project_name: string project name parsed from the URL by webapp2,
+      project_name: string project name parsed from the URL by Flask,
         but we also parse it out in our code.
-      viewed_username: string user email parsed from the URL by webapp2,
+      viewed_username: string user email parsed from the URL by Flask,
         but we also parse it out in our code.
-      hotlist_id: string hotlist id parsed from the URL by webapp2,
+      hotlist_id: string hotlist id parsed from the URL by Flask,
         but we also parse it out in our code.
     """
     self._DoRequestHandling(self.mr.request, self.mr)
@@ -226,7 +120,7 @@
     self.response.set_data(XSSI_PREFIX + json_str)
 
 
-class FlaskInternalTask(FlaskJsonFeed):
+class InternalTask(JsonFeed):
   """Internal tasks are JSON feeds that can only be reached by our own code."""
 
   CHECK_SECURITY_TOKEN = False
diff --git a/framework/logger.py b/framework/logger.py
index d2a8a0d..2a4da8e 100644
--- a/framework/logger.py
+++ b/framework/logger.py
@@ -1,4 +1,4 @@
-# Copyright 2022 The Chromium Authors. All rights reserved.
+# Copyright 2022 The Chromium Authors
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 """"Helper methods for structured logging."""
diff --git a/framework/monitoring.py b/framework/monitoring.py
index 08a1e23..c7062f9 100644
--- a/framework/monitoring.py
+++ b/framework/monitoring.py
@@ -1,6 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file.
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Monitoring ts_mon custom to monorail."""
 
diff --git a/framework/monorailcontext.py b/framework/monorailcontext.py
index 76ecff4..45e4637 100644
--- a/framework/monorailcontext.py
+++ b/framework/monorailcontext.py
@@ -1,7 +1,6 @@
-# Copyright 2018 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2018 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Context object to hold utility objects used during request processing.
 """
diff --git a/framework/monorailrequest.py b/framework/monorailrequest.py
index 7fe2918..52f3e52 100644
--- a/framework/monorailrequest.py
+++ b/framework/monorailrequest.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Classes to hold information parsed from a request.
 
@@ -19,13 +18,12 @@
 from six.moves import urllib
 
 import ezt
+import flask
 import six
 
 from google.appengine.api import app_identity
 from google.appengine.api import oauth
 
-import webapp2
-
 import settings
 from businesslogic import work_env
 from features import features_constants
@@ -39,12 +37,12 @@
 from framework import profiler
 from framework import sql
 from framework import template_helpers
-from proto import api_pb2_v1
+from mrproto import api_pb2_v1
 from tracker import tracker_bizobj
 from tracker import tracker_constants
 
 
-_HOSTPORT_RE = re.compile('^[-a-z0-9.]+(:\d+)?$', re.I)
+_HOSTPORT_RE = re.compile(r'^[-a-z0-9.]+(:\d+)?$', re.I)
 
 
 # TODO(jrobbins): Stop extending MonorailContext and change whole servlet
@@ -231,63 +229,6 @@
     self.viewed_user_auth = authdata.AuthData()
 
   def ParseRequest(self, request, services, do_user_lookups=True):
-    """Parse tons of useful info from the given request object.
-
-    Args:
-      request: webapp2 Request object w/ path and query params.
-      services: connections to backend servers including DB.
-      do_user_lookups: Set to False to disable lookups during testing.
-    """
-    with self.profiler.Phase('basic parsing'):
-      self.request = request
-      self.request_path = request.path
-      self.current_page_url = request.url
-      self.current_page_url_encoded = urllib.parse.quote_plus(
-          self.current_page_url)
-
-      # Only accept a hostport from the request that looks valid.
-      if not _HOSTPORT_RE.match(request.host):
-        raise exceptions.InputException(
-            'request.host looks funny: %r', request.host)
-
-      logging.info('Request: %s', self.current_page_url)
-
-    with self.profiler.Phase('path parsing'):
-      (viewed_user_val, self.project_name, self.hotlist_id,
-       self.hotlist_name) = _ParsePathIdentifiers(self.request_path)
-      self.viewed_username = _GetViewedEmail(
-          viewed_user_val, self.cnxn, services)
-    with self.profiler.Phase('qs parsing'):
-      self._ParseQueryParameters()
-    with self.profiler.Phase('overrides parsing'):
-      self._ParseFormOverrides()
-
-    if not self.project:  # It can be already set in unit tests.
-      self._LookupProject(services)
-    if self.project_id and services.config:
-      self.config = services.config.GetProjectConfig(self.cnxn, self.project_id)
-
-    if do_user_lookups:
-      if self.viewed_username:
-        self._LookupViewedUser(services)
-      self._LookupLoggedInUser(services)
-
-    if not self.hotlist:
-      self._LookupHotlist(services)
-
-    if self.query is None:
-      self.query = self._CalcDefaultQuery()
-
-    prod_debug_allowed = self.perms.HasPerm(
-        permissions.VIEW_DEBUG, self.auth.user_id, None)
-    self.debug_enabled = (request.params.get('debug') and
-                          (settings.local_mode or prod_debug_allowed))
-    # temporary option for perf testing on staging instance.
-    if request.params.get('disable_cache'):
-      if settings.local_mode or 'staging' in request.host:
-        self.use_cached_searches = False
-
-  def ParseFlaskRequest(self, request, services, do_user_lookups=True):
     """Parse tons of useful info from the given flask request object.
 
     Args:
@@ -300,7 +241,7 @@
       self.request_path = request.base_url[len(request.host_url) - 1:]
       self.current_page_url = request.url
       self.current_page_url_encoded = urllib.parse.quote_plus(
-          self.current_page_url)
+          six.ensure_str(self.current_page_url))
 
       # Only accept a hostport from the request that looks valid.
       if not _HOSTPORT_RE.match(request.host):
@@ -502,10 +443,10 @@
             self.cnxn, self.viewed_username, services, autocreate=False)
     except exceptions.NoSuchUserException:
       logging.info('could not find user %r', self.viewed_username)
-      webapp2.abort(404, 'user not found')
+      flask.abort(404, 'user not found')
 
     if not self.viewed_user_auth.user_id:
-      webapp2.abort(404, 'user not found')
+      flask.abort(404, 'user not found')
 
   def _LookupProject(self, services):
     """Get information about the current project (if any) from the request.
@@ -530,7 +471,7 @@
           self.hotlist_id = hotlist_id_dict[(
               self.hotlist_name, self.viewed_user_auth.user_id)]
         except KeyError:
-          webapp2.abort(404, 'invalid hotlist')
+          flask.abort(404, 'invalid hotlist')
 
       if not self.hotlist_id:
         logging.info('no hotlist_id or bad hotlist_name, so no hotlist')
@@ -540,7 +481,7 @@
         if not self.hotlist or (
             self.viewed_user_auth.user_id and
             self.viewed_user_auth.user_id not in self.hotlist.owner_ids):
-          webapp2.abort(404, 'invalid hotlist')
+          flask.abort(404, 'invalid hotlist')
 
   def _LookupLoggedInUser(self, services):
     """Get information about the signed-in user (if any) from the request."""
@@ -601,7 +542,7 @@
       value = self.request.params.get(query_param_name)
     else:
       value = self.request.values.get(query_param_name)
-    assert value is None or isinstance(value, six.text_type)
+    assert value is None or isinstance(value, six.string_types)
     using_default = value is None
     if using_default:
       value = default_value
@@ -635,8 +576,10 @@
 
   def GetPositiveIntParam(self, query_param_name, default_value=None):
     """Returns 0 if the user-provided value is less than 0."""
-    return max(self.GetIntParam(query_param_name, default_value=default_value),
-               0)
+    value = self.GetIntParam(query_param_name, default_value=default_value)
+    if value is None:
+      return 0
+    return max(value, 0)
 
   def GetListParam(self, query_param_name, default_value=None):
     """Get a list of strings from the URL or default."""
@@ -742,7 +685,7 @@
     viewed_email = services.user.LookupUserEmail(cnxn, viewed_userid)
     if not viewed_email:
       logging.info('userID %s not found', viewed_userid)
-      webapp2.abort(404, 'user not found')
+      flask.abort(404, 'user not found')
   except ValueError:
     viewed_email = viewed_user_val
 
diff --git a/framework/paginate.py b/framework/paginate.py
index bbe0998..817475f 100644
--- a/framework/paginate.py
+++ b/framework/paginate.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Classes that help display pagination widgets for result sets."""
 from __future__ import print_function
@@ -9,8 +8,10 @@
 from __future__ import absolute_import
 
 import base64
-import logging
+import binascii
+import hashlib
 import hmac
+import six
 
 import ezt
 from google.protobuf import message
@@ -19,7 +20,7 @@
 from framework import exceptions
 from framework import framework_helpers
 from services import secrets_svc
-from proto import secrets_pb2
+from mrproto import secrets_pb2
 
 
 def GeneratePageToken(request_contents, start):
@@ -34,12 +35,12 @@
   Returns:
     String next_page_token that is a serialized PageTokenContents object.
   """
-  digester = hmac.new(secrets_svc.GetPaginationKey())
-  digester.update(request_contents.SerializeToString())
+  digester = hmac.new(secrets_svc.GetPaginationKey(), digestmod=hashlib.md5)
+  digester.update(six.ensure_binary(request_contents.SerializeToString()))
   token_contents = secrets_pb2.PageTokenContents(
       start=start,
       encrypted_list_request_contents=digester.digest())
-  serialized_token = token_contents.SerializeToString()
+  serialized_token = six.ensure_binary(token_contents.SerializeToString())
   # Page tokens must be URL-safe strings (see aip.dev/158)
   # and proto string fields must be utf-8 strings while
   # `SerializeToString()` returns binary bytes contained in a str type.
@@ -48,7 +49,7 @@
 
 
 def ValidateAndParsePageToken(token, request_contents):
-  # type: (str, secrets_pb2.ListRequestContents) -> int
+  # type: (bytes, secrets_pb2.ListRequestContents) -> int
   """Returns the start index of the page if the token is valid.
 
   Args:
@@ -67,7 +68,7 @@
   try:
     decoded_serialized_token = base64.b64decode(token)
     token_contents.ParseFromString(decoded_serialized_token)
-  except (message.DecodeError, TypeError):
+  except (message.DecodeError, TypeError, binascii.Error):  # TypeError in Py2.
     raise exceptions.PageTokenException('Invalid page token.')
 
   start = token_contents.start
diff --git a/framework/pbproxy_test_pb2.py b/framework/pbproxy_test_pb2.py
index 3c47ae1..16db03d 100644
--- a/framework/pbproxy_test_pb2.py
+++ b/framework/pbproxy_test_pb2.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Message classes for use by template_helpers_test."""
 from __future__ import print_function
diff --git a/framework/permissions.py b/framework/permissions.py
index ac46af6..37f6cdc 100644
--- a/framework/permissions.py
+++ b/framework/permissions.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Classes and functions to implement permission checking.
 
@@ -32,10 +31,10 @@
 import settings
 from framework import framework_bizobj
 from framework import framework_constants
-from proto import project_pb2
-from proto import site_pb2
-from proto import tracker_pb2
-from proto import usergroup_pb2
+from mrproto import project_pb2
+from mrproto import site_pb2
+from mrproto import tracker_pb2
+from mrproto import usergroup_pb2
 from tracker import tracker_bizobj
 
 # Constants that define permissions.
@@ -333,40 +332,53 @@
 
     # Project owners can view and edit artifacts in a LIVE project.
     (OWNER_ROLE, project_pb2.ProjectState.LIVE, WILDCARD_ACCESS):
-      OWNER_ACTIVE_PERMISSIONSET,
+        OWNER_ACTIVE_PERMISSIONSET,
 
     # Project owners can view, but not edit artifacts in ARCHIVED.
     # Note: EDIT_PROJECT is not enough permission to change an ARCHIVED project
     # back to LIVE if a delete_time was set.
     (OWNER_ROLE, project_pb2.ProjectState.ARCHIVED, WILDCARD_ACCESS):
-      OWNER_INACTIVE_PERMISSIONSET,
+        OWNER_INACTIVE_PERMISSIONSET,
 
     # Project members can view their own project, regardless of state.
     (COMMITTER_ROLE, project_pb2.ProjectState.LIVE, WILDCARD_ACCESS):
-      COMMITTER_ACTIVE_PERMISSIONSET,
+        COMMITTER_ACTIVE_PERMISSIONSET,
     (COMMITTER_ROLE, project_pb2.ProjectState.ARCHIVED, WILDCARD_ACCESS):
-      COMMITTER_INACTIVE_PERMISSIONSET,
+        COMMITTER_INACTIVE_PERMISSIONSET,
 
     # Project contributors can view their own project, regardless of state.
     (CONTRIBUTOR_ROLE, project_pb2.ProjectState.LIVE, WILDCARD_ACCESS):
-      CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+        CONTRIBUTOR_ACTIVE_PERMISSIONSET,
     (CONTRIBUTOR_ROLE, project_pb2.ProjectState.ARCHIVED, WILDCARD_ACCESS):
-      CONTRIBUTOR_INACTIVE_PERMISSIONSET,
+        CONTRIBUTOR_INACTIVE_PERMISSIONSET,
 
-    # Non-members users can read and comment in projects with access == ANYONE
-    (USER_ROLE, project_pb2.ProjectState.LIVE,
-     project_pb2.ProjectAccess.ANYONE):
-      USER_PERMISSIONSET,
+    # Non-members users can read and comment in projects with access == ANYONE.
+    (
+        USER_ROLE, project_pb2.ProjectState.LIVE,
+        project_pb2.ProjectAccess.ANYONE):
+        USER_PERMISSIONSET,
 
-    # Anonymous users can only read projects with access == ANYONE.
-    (ANON_ROLE, project_pb2.ProjectState.LIVE,
-     project_pb2.ProjectAccess.ANYONE):
-      READ_ONLY_PERMISSIONSET,
+    # Non-members users can read archived projects with access == ANYONE.
+    (
+        USER_ROLE, project_pb2.ProjectState.ARCHIVED,
+        project_pb2.ProjectAccess.ANYONE):
+        READ_ONLY_PERMISSIONSET,
+
+    # Anonymous users can only read projects with access == ANYONE,
+    # regardless of state.
+    (
+        ANON_ROLE, project_pb2.ProjectState.LIVE,
+        project_pb2.ProjectAccess.ANYONE):
+        READ_ONLY_PERMISSIONSET,
+    (
+        ANON_ROLE, project_pb2.ProjectState.ARCHIVED,
+        project_pb2.ProjectAccess.ANYONE):
+        READ_ONLY_PERMISSIONSET,
 
     # Permissions for site pages, e.g., creating a new project
     (USER_ROLE, UNDEFINED_STATUS, UNDEFINED_ACCESS):
-      PermissionSet([CREATE_PROJECT, CREATE_GROUP, CREATE_HOTLIST]),
-    }
+        PermissionSet([CREATE_PROJECT, CREATE_GROUP, CREATE_HOTLIST]),
+}
 
 def GetPermissions(user, effective_ids, project):
   """Return a permission set appropriate for the user and project.
@@ -428,7 +440,7 @@
 
 def UpdateIssuePermissions(
     perms, project, issue, effective_ids, granted_perms=None, config=None):
-  """Update the PermissionSet for an specific issue.
+  """Update the PermissionSet for a specific issue.
 
   Take into account granted permissions and label restrictions to filter the
   permissions, and updates the VIEW and EDIT_ISSUE permissions depending on the
@@ -488,7 +500,7 @@
   filtered_perms.update(granted_perms)
 
   # The VIEW perm might have been removed due to restrictions, but the issue
-  # owner, reporter, cc and approvers can always be an issue.
+  # owner, reporter, cc and approvers can always view an issue.
   allowed_ids = set(
       tracker_bizobj.GetCcIds(issue)
       + tracker_bizobj.GetApproverIds(issue)
@@ -507,7 +519,8 @@
 
   # The EDIT_ISSUE permission might have been removed due to restrictions, but
   # the owner always has permission to edit it.
-  if effective_ids and tracker_bizobj.GetOwnerId(issue) in effective_ids:
+  if (effective_ids and tracker_bizobj.GetOwnerId(issue) in effective_ids and
+      project and project.state != project_pb2.ProjectState.ARCHIVED):
     filtered_perms.add(EDIT_ISSUE.lower())
 
   return PermissionSet(filtered_perms, perms.consider_restrictions)
@@ -1113,6 +1126,36 @@
   return perms.CanUsePerm(EDIT_ISSUE_APPROVAL, effective_ids, project, [])
 
 
+def CanEditProjectConfig(mr, services):
+  """ Special function to check if a user can edit a project config.
+
+  This function accounts for special edge cases pertaining only to project
+  configuration editing permissions, such as checking if a project is frozen
+  for config edits or if a user is in the allowlist of users who can override
+  a config freeze.
+
+  Args:
+    mr: MonorailRequest object.
+    services: reference to database layer.
+
+  Returns:
+    True if the user can edit the project.
+  """
+  if mr.project.project_id not in settings.config_freeze_project_ids:
+    return mr.perms.CanUsePerm(
+        EDIT_PROJECT, mr.auth.effective_ids, mr.project, [])
+
+  effective_users = services.user.GetUsersByIDs(
+      mr.cnxn, list(mr.auth.effective_ids))
+
+  for _, user in effective_users.items():
+    if user.email in settings.config_freeze_override_users.get(
+        mr.project.project_id, {}):
+      return True
+
+  return False
+
+
 def CanViewComponentDef(effective_ids, perms, project, component_def):
   """Return True if a user can view the given component definition."""
   if not effective_ids.isdisjoint(component_def.admin_ids):
@@ -1122,8 +1165,53 @@
   return perms.CanUsePerm(VIEW, effective_ids, project, [])
 
 
-def CanEditComponentDef(effective_ids, perms, project, component_def, config):
-  """Return True if a user can edit the given component definition."""
+def CanEditComponentDef(mr, services, component_def, config):
+  """ Checks if the currently logged in user can edit a component.
+
+  Args:
+    mr: MonorailRequest object.
+    services: reference to database layer.
+    component_def: the component to check permissions for.
+    config: project config of the project the component is in.
+
+  Returns:
+    True if a user can edit the given component definition."""
+  if mr.project.project_id in settings.config_freeze_project_ids:
+    return CanEditProjectConfig(mr, services)
+
+  if not mr.auth.effective_ids.isdisjoint(component_def.admin_ids):
+    return True  # Component admins can edit that component.
+
+  # Check to see if user is admin of any parent component.
+  parent_components = tracker_bizobj.FindAncestorComponents(
+      config, component_def)
+  for parent in parent_components:
+    if not mr.auth.effective_ids.isdisjoint(parent.admin_ids):
+      return True
+
+  return CanEditProjectConfig(mr, services)
+
+
+def CanEditComponentDefLegacy(
+    effective_ids, perms, project, component_def, config):
+  """ Legacy version of CanEditComponentDef for codepaths without access to mr.
+  This function is entirely used in API clients.
+
+  Args:
+    effective_ids: Set containing IDs for the user and their groups
+      linked accounts, etc.
+    perms: PermissionSet for current user.
+    project: the project the component is in.
+    component_def: the component to check permissions for.
+    config: project config of the project the component is in.
+
+  Returns:
+    True if a user can edit the given component definition."""
+  # Do not bother checking if API client users are allowlisted to override
+  # the config freeze. Only human users are currently being allowlisted.
+  if project and project.project_id in settings.config_freeze_project_ids:
+    return False
+
   if not effective_ids.isdisjoint(component_def.admin_ids):
     return True  # Component admins can edit that component.
 
diff --git a/framework/profiler.py b/framework/profiler.py
index 362585f..61e5092 100644
--- a/framework/profiler.py
+++ b/framework/profiler.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """A simple profiler object to track how time is spent on a request.
 
@@ -156,14 +155,14 @@
     self.ms = int(self.elapsed_seconds * 1000)
     for sub in self.subphases:
       if sub.elapsed_seconds is None:
-        logging.warn('issue3182: subphase is %r', sub and sub.name)
+        logging.warning('issue3182: subphase is %r', sub and sub.name)
     categorized = sum(sub.elapsed_seconds or 0.0 for sub in self.subphases)
     self.uncategorized_ms = int((self.elapsed_seconds - categorized) * 1000)
     return self.parent
 
   def AccumulateStatLines(self, total_seconds, lines, indent=''):
     # Only phases that took longer than 30ms are interesting.
-    if self.ms <= 30:
+    if self.ms == 'in_progress' or self.ms <= 30:
       return
 
     percent = self.elapsed_seconds // total_seconds * 100
diff --git a/framework/ratelimiter.py b/framework/ratelimiter.py
index b2bbb25..4e1a0b8 100644
--- a/framework/ratelimiter.py
+++ b/framework/ratelimiter.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Request rate limiting implementation.
 
@@ -174,7 +173,7 @@
         # monitor rate limit exceeded events with our standard tools.
         # We return a 400 with a custom error message to the client,
         # and this logging is so we can monitor it internally.
-        logging.info('%s, %d' % (exception_obj.message, count))
+        logging.info('%s, %d' % (str(exception_obj), count))
 
         self.limit_exceeded.increment()
 
diff --git a/framework/reap.py b/framework/reap.py
index 4654964..93f8b81 100644
--- a/framework/reap.py
+++ b/framework/reap.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """A class to handle cron requests to expunge doomed and deletable projects."""
 from __future__ import print_function
@@ -16,7 +15,7 @@
 RUN_DURATION_LIMIT = 50 * 60  # 50 minutes
 
 
-class Reap(jsonfeed.FlaskInternalTask):
+class Reap(jsonfeed.InternalTask):
   """Look for doomed and deletable projects and delete them."""
 
   def HandleRequest(self, mr):
diff --git a/framework/registerpages_helpers.py b/framework/registerpages_helpers.py
deleted file mode 100644
index 0073e57..0000000
--- a/framework/registerpages_helpers.py
+++ /dev/null
@@ -1,81 +0,0 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
-
-"""This file sets up all the urls for monorail pages."""
-from __future__ import print_function
-from __future__ import division
-from __future__ import absolute_import
-
-
-from six.moves import http_client
-import logging
-
-import webapp2
-
-
-def MakeRedirect(redirect_to_this_uri, permanent=True):
-  """Return a new request handler class that redirects to the given URL."""
-
-  class Redirect(webapp2.RequestHandler):
-    """Redirect is a response handler that issues a redirect to another URI."""
-
-    def get(self, **_kw):
-      """Send the 301/302 response code and write the Location: redirect."""
-      self.response.location = redirect_to_this_uri
-      self.response.headers.add('Strict-Transport-Security',
-          'max-age=31536000; includeSubDomains')
-      self.response.status = (
-          http_client.MOVED_PERMANENTLY if permanent else http_client.FOUND)
-
-  return Redirect
-
-
-def MakeRedirectInScope(uri_in_scope, scope, permanent=True, keep_qs=False):
-  """Redirect to a URI within a given scope, e.g., per project or user.
-
-  Args:
-    uri_in_scope: a uri within a project or user starting with a slash.
-    scope: a string indicating the uri-space scope:
-      p for project pages
-      u for user pages
-      g for group pages
-    permanent: True for a HTTP 301 permanently moved response code,
-      otherwise a HTTP 302 temporarily moved response will be used.
-    keep_qs: set to True to make the redirect retain the query string.
-      When true, permanent is ignored.
-
-  Example:
-    self._SetupProjectPage(
-      redirect.MakeRedirectInScope('/newpage', 'p'), '/oldpage')
-
-  Returns:
-    A class that can be used with webapp2.
-  """
-  assert uri_in_scope.startswith('/')
-
-  class RedirectInScope(webapp2.RequestHandler):
-    """A handler that redirects to another URI in the same scope."""
-
-    def get(self, **_kw):
-      """Send the 301/302 response code and write the Location: redirect."""
-      split_path = self.request.path.lstrip('/').split('/')
-      if len(split_path) > 1:
-        project_or_user = split_path[1]
-        url = '//%s/%s/%s%s' % (
-            self.request.host, scope, project_or_user, uri_in_scope)
-      else:
-        url = '/'
-      if keep_qs and self.request.query_string:
-        url += '?' + self.request.query_string
-      self.response.location = url
-
-      self.response.headers.add('Strict-Transport-Security',
-          'max-age=31536000; includeSubDomains')
-      if permanent and not keep_qs:
-        self.response.status = http_client.MOVED_PERMANENTLY
-      else:
-        self.response.status = http_client.FOUND
-
-  return RedirectInScope
diff --git a/framework/servlet.py b/framework/servlet.py
index b363095..15530a9 100644
--- a/framework/servlet.py
+++ b/framework/servlet.py
@@ -1,105 +1,64 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2022 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Base classes for Monorail Flask servlets.
 
-"""Base classes for Monorail servlets.
-
-This base class provides HTTP get() and post() methods that
-conveniently drive the process of parsing the request, checking base
-permissions, gathering common page information, gathering
-page-specific information, and adding on-page debugging information
-(when appropriate).  Subclasses can simply implement the page-specific
-logic.
+This is derived from  servlet.py
+This base class provides handler methods that conveniently drive
+the process of parsing the request, checking base permisssion,
+gathering common page information, gathering page-specific information,
+and adding on-page debugging information (when appropriate).
+Subclasses can simply implement the page-specific logic.
 
 Summary of page classes:
-  Servlet: abstract base class for all Monorail servlets.
-  _ContextDebugItem: displays page_data elements for on-page debugging.
+  Servlet: abstract base class for all Monorail flask servlets.
 """
-from __future__ import print_function
-from __future__ import division
-from __future__ import absolute_import
 
 import gc
-from six.moves import http_client
-import json
-import logging
 import os
-import random
+import logging
+from six.moves import http_client
 import time
-from six.moves import urllib
+from businesslogic import work_env
 
 import ezt
+from features import features_bizobj, hotlist_views
+import flask
 import httpagentparser
+from project import project_constants
+from mrproto import project_pb2
+from search import query2ast
+
+import settings
+from framework import alerts, exceptions, framework_helpers, urls
+from framework import framework_views, servlet_helpers
+from framework import framework_constants
+from framework import monorailrequest
+from framework import permissions
+from framework import ratelimiter
+from framework import template_helpers
+from framework import xsrf
 
 from google.appengine.api import app_identity
 from google.appengine.api import modules
 from google.appengine.api import users
-from oauth2client.client import GoogleCredentials
-
-import webapp2
-
-import settings
-from businesslogic import work_env
-from features import savedqueries_helpers
-from features import features_bizobj
-from features import hotlist_views
-from framework import alerts
-from framework import exceptions
-from framework import framework_constants
-from framework import framework_helpers
-from framework import framework_views
-from framework import monorailrequest
-from framework import permissions
-from framework import ratelimiter
-from framework import servlet_helpers
-from framework import template_helpers
-from framework import urls
-from framework import xsrf
-from project import project_constants
-from proto import project_pb2
-from search import query2ast
 from tracker import tracker_views
-
-from infra_libs import ts_mon
+from werkzeug import datastructures
 
 NONCE_LENGTH = 32
 
 if not settings.unit_test_mode:
   import MySQLdb
 
-GC_COUNT = ts_mon.NonCumulativeDistributionMetric(
-    'monorail/servlet/gc_count',
-    'Count of objects in each generation tracked by the GC',
-    [ts_mon.IntegerField('generation')])
-
-GC_EVENT_REQUEST = ts_mon.CounterMetric(
-    'monorail/servlet/gc_event_request',
-    'Counts of requests that triggered at least one GC event',
-    [])
-
-# TODO(crbug/monorail:7084): Find a better home for this code.
-trace_service = None
-# TOD0(crbug/monorail:7082): Re-enable this once we have a solution that doesn't
-# inur clatency, or when we're actively using Cloud Tracing data.
-# if app_identity.get_application_id() != 'testing-app':
-#   logging.warning('app id: %s', app_identity.get_application_id())
-#   try:
-#     credentials = GoogleCredentials.get_application_default()
-#     trace_service = discovery.build(
-#         'cloudtrace', 'v1', credentials=credentials)
-#   except Exception as e:
-#     logging.warning('could not get trace service: %s', e)
-class Servlet(webapp2.RequestHandler):
-  """Base class for all Monorail servlets.
+class Servlet(object):
+  """Base class for all Monorail flask servlets.
 
   Defines a framework of methods that build up parts of the EZT page data.
 
   Subclasses should override GatherPageData and/or ProcessFormData to
   handle requests.
   """
-
-  _MAIN_TAB_MODE = None  # Normally overriden in subclasses to be one of these:
+  _MAIN_TAB_MODE = None  # Normally overridden in subclasses to be one of these:
 
   MAIN_TAB_NONE = 't0'
   MAIN_TAB_DASHBOARD = 't1'
@@ -132,26 +91,16 @@
   # we can allow an xhr-scoped XSRF token to be used to post to the page.
   ALLOW_XHR = False
 
-  # Most forms just ignore fields that have value "".  Subclasses can override
-  # if needed.
-  KEEP_BLANK_FORM_VALUES = False
-
-  # Most forms use regular forms, but subclasses that accept attached files can
-  # override this to be True.
-  MULTIPART_POST_BODY = False
-
   # This value should not typically be overridden.
   _TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
 
-  _PAGE_TEMPLATE = None  # Normally overriden in subclasses.
+  _PAGE_TEMPLATE = None  # Normally overridden in subclasses.
   _ELIMINATE_BLANK_LINES = False
 
   _MISSING_PERMISSIONS_TEMPLATE = 'sitewide/403-page.ezt'
 
-  def __init__(self, request, response, services=None,
-               content_type='text/html; charset=UTF-8'):
+  def __init__(self, services=None, content_type='text/html; charset=UTF-8'):
     """Load and parse the template, saving it for later use."""
-    super(Servlet, self).__init__(request, response)
     if self._PAGE_TEMPLATE:  # specified in subclasses
       template_path = self._TEMPLATE_PATH + self._PAGE_TEMPLATE
       self.template = template_helpers.GetTemplate(
@@ -161,37 +110,40 @@
 
     self._missing_permissions_template = template_helpers.MonorailTemplate(
         self._TEMPLATE_PATH + self._MISSING_PERMISSIONS_TEMPLATE)
-    self.services = services or self.app.config.get('services')
+    self.services = services or flask.current_app.config['services']
     self.content_type = content_type
     self.mr = None
+    # TODO: convert it to use self.request.path when we merge all flask together
+    self.request = flask.request
+    self.request_path = None
+    self.response = None
     self.ratelimiter = ratelimiter.RateLimiter()
 
-  def dispatch(self):
+  # pylint: disable=unused-argument
+  def handler(self, **kwargs):
     """Do common stuff then dispatch the request to get() or put() methods."""
+    self.response = flask.make_response()
     handler_start_time = time.time()
+    logging.info('\n\n\n Flask Request handler: %r', self)
 
-    logging.info('\n\n\nRequest handler: %r', self)
-    count0, count1, count2 = gc.get_count()
-    logging.info('gc counts: %d %d %d', count0, count1, count2)
-    GC_COUNT.add(count0, {'generation': 0})
-    GC_COUNT.add(count1, {'generation': 1})
-    GC_COUNT.add(count2, {'generation': 2})
+    #TODO: add the ts_mon.NonCumulativeDistributionMetric
+    # count0, count1, count2 = gc.get_count()
+    # logging.info('gc counts: %d %d %d', count0, count1, count2)
+    # GC_COUNT.add(count0, {'generation': 0})
+    # GC_COUNT.add(count1, {'generation': 1})
+    # GC_COUNT.add(count2, {'generation': 2})
 
     self.mr = monorailrequest.MonorailRequest(self.services)
-
-    self.response.headers.add('Strict-Transport-Security',
-        'max-age=31536000; includeSubDomains')
+    # TODO: convert it to use self.request.path when we merge all flask together
+    self.request_path = self.request.base_url[len(self.request.host_url) - 1:]
+    self.response.headers.add(
+        'Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
 
     if 'X-Cloud-Trace-Context' in self.request.headers:
       self.mr.profiler.trace_context = (
           self.request.headers.get('X-Cloud-Trace-Context'))
-    # TOD0(crbug/monorail:7082): Re-enable tracing.
-    # if trace_service is not None:
-    #   self.mr.profiler.trace_service = trace_service
 
     if self.services.cache_manager:
-      # TODO(jrobbins): don't do this step if invalidation_timestep was
-      # passed via the request and matches our last timestep
       try:
         with self.mr.profiler.Phase('distributed invalidation'):
           self.services.cache_manager.DoDistributedInvalidation(self.mr.cnxn)
@@ -206,8 +158,8 @@
             'templates/framework/database-maintenance.ezt',
             eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
         self.template.WriteResponse(
-          self.response, page_data, content_type='text/html')
-        return
+            self.response, page_data, content_type='text/html')
+        return self.response
 
     try:
       self.ratelimiter.CheckStart(self.request)
@@ -216,27 +168,33 @@
         self.mr.ParseRequest(self.request, self.services)
 
       self.response.headers['X-Frame-Options'] = 'SAMEORIGIN'
-      webapp2.RequestHandler.dispatch(self)
 
+      if self.request.method == 'POST':
+        self.post()
+      elif self.request.method == 'GET':
+        self.get()
+
+    except exceptions.RedirectException as e:
+      return self.redirect(str(e))
     except exceptions.NoSuchUserException as e:
-      logging.warning('Trapped NoSuchUserException %s', e)
-      self.abort(404, 'user not found')
+      logging.info('Trapped NoSuchUserException %s', e)
+      flask.abort(404, 'user not found')
 
     except exceptions.NoSuchGroupException as e:
       logging.warning('Trapped NoSuchGroupException %s', e)
-      self.abort(404, 'user group not found')
+      flask.abort(404, 'user group not found')
 
     except exceptions.InputException as e:
       logging.info('Rejecting invalid input: %r', e)
-      self.response.status = http_client.BAD_REQUEST
+      self.response.status_code = http_client.BAD_REQUEST
 
     except exceptions.NoSuchProjectException as e:
       logging.info('Rejecting invalid request: %r', e)
-      self.response.status = http_client.NOT_FOUND
+      self.response.status_code = http_client.NOT_FOUND
 
     except xsrf.TokenIncorrect as e:
-      logging.info('Bad XSRF token: %r', e.message)
-      self.response.status = http_client.BAD_REQUEST
+      logging.info('Bad XSRF token: %r', str(e))
+      self.response.status_code = http_client.BAD_REQUEST
 
     except permissions.BannedUserException as e:
       logging.warning('The user has been banned')
@@ -246,8 +204,8 @@
 
     except ratelimiter.RateLimitExceeded as e:
       logging.info('RateLimitExceeded Exception %s', e)
-      self.response.status = http_client.BAD_REQUEST
-      self.response.body = 'Slow your roll.'
+      self.response.status_code = http_client.BAD_REQUEST
+      self.response.set_data('Slow your roll.')
 
     finally:
       self.mr.CleanUp()
@@ -259,34 +217,17 @@
 
     end_count0, end_count1, end_count2 = gc.get_count()
     logging.info('gc counts: %d %d %d', end_count0, end_count1, end_count2)
-    if (end_count0 < count0) or (end_count1 < count1) or (end_count2 < count2):
-      GC_EVENT_REQUEST.increment()
+    # TODO: get the GC event back
+    # if (end_count0 < count0) or (end_count1 < count1) or(end_count2 < count2):
+    #   GC_EVENT_REQUEST.increment()
 
     if settings.enable_profiler_logging:
       self.mr.profiler.LogStats()
 
-    # TOD0(crbug/monorail:7082, crbug/monorail:7088): Re-enable this when we
-    # have solved the latency, or when we really need the profiler data.
-    # if self.mr.profiler.trace_context is not None:
-    #   try:
-    #     self.mr.profiler.ReportTrace()
-    #   except Exception as ex:
-    #     # We never want Cloud Tracing to cause a user-facing error.
-    #     logging.warning('Ignoring exception reporting Cloud Trace %s', ex)
+    return self.response
 
-  def _AddHelpDebugPageData(self, page_data):
-    with self.mr.profiler.Phase('help and debug data'):
-      page_data.update(self.GatherHelpData(self.mr, page_data))
-      page_data.update(self.GatherDebugData(self.mr, page_data))
-
-  # pylint: disable=unused-argument
-  def get(self, **kwargs):
-    """Collect page-specific and generic info, then render the page.
-
-    Args:
-      Any path components parsed by webapp2 will be in kwargs, but we do
-        our own parsing later anyway, so igore them for now.
-    """
+  def get(self):
+    """Collect page-specific and generic info, then render the page."""
     page_data = {}
     nonce = framework_helpers.MakeRandomKey(length=NONCE_LENGTH)
     try:
@@ -298,44 +239,52 @@
       user_agent_str = self.mr.request.headers.get('User-Agent', '')
       ua = httpagentparser.detect(user_agent_str)
       browser, browser_major_version = 'Unknown browser', 0
-      if ua.has_key('browser'):
+      if 'browser' in ua:
         browser = ua['browser']['name']
         try:
           browser_major_version = int(ua['browser']['version'].split('.')[0])
         except ValueError:
-          logging.warn('Could not parse version: %r', ua['browser']['version'])
+          logging.warning(
+              'Could not parse version: %r', ua['browser']['version'])
         except KeyError:
-          logging.warn('No browser version defined in user agent.')
+          logging.warning('No browser version defined in user agent.')
       csp_supports_report_sample = (
-        (browser == 'Chrome' and browser_major_version >= 59) or
-        (browser == 'Opera' and browser_major_version >= 46))
+          (browser == 'Chrome' and browser_major_version >= 59) or
+          (browser == 'Opera' and browser_major_version >= 46))
       version_base = servlet_helpers.VersionBaseURL(self.mr.request)
-      self.response.headers.add(csp_header,
-           ("default-src %(scheme)s ; "
-            "script-src"
-            " %(rep_samp)s"  # Report 40 chars of any inline violation.
-            " 'unsafe-inline'"  # Only counts in browsers that lack CSP2.
-            " 'strict-dynamic'"  # Allows <script nonce> to load more.
-            " %(version_base)s/static/dist/"
-            " 'self' 'nonce-%(nonce)s'; "
-            "child-src 'none'; "
-            "frame-src accounts.google.com" # All used by gapi.js auth.
-            " content-issuetracker.corp.googleapis.com"
-            " login.corp.google.com up.corp.googleapis.com"
-            # Used by Google Feedback
-            " feedback.googleusercontent.com"
-            " www.google.com; "
-            "img-src %(scheme)s data: blob: ; "
-            "style-src %(scheme)s 'unsafe-inline'; "
-            "object-src 'none'; "
-            "base-uri 'self'; " # Used by Google Feedback
-            "report-uri /csp.do" % {
-            'nonce': nonce,
-            'scheme': csp_scheme,
-            'rep_samp': "'report-sample'" if csp_supports_report_sample else '',
-            'version_base': version_base,
-            }))
+      self.response.headers.add(
+          csp_header,
+          (
+              "default-src %(scheme)s ; "
+              "script-src"
+              " %(rep_samp)s"  # Report 40 chars of any inline violation.
+              " 'unsafe-inline'"  # Only counts in browsers that lack CSP2.
+              " 'strict-dynamic'"  # Allows <script nonce> to load more.
+              " %(version_base)s/static/dist/"
+              " 'self' 'nonce-%(nonce)s'; "
+              "child-src 'none'; "
+              "frame-src accounts.google.com"  # All used by gapi.js auth.
+              " content-issuetracker.corp.googleapis.com"
+              " login.corp.google.com up.corp.googleapis.com"
+              # Used by Google Feedback
+              " feedback.googleusercontent.com"
+              " www.google.com; "
+              "img-src %(scheme)s data: blob: ; "
+              "style-src %(scheme)s 'unsafe-inline'; "
+              "object-src 'none'; "
+              "base-uri 'self'; "  # Used by Google Feedback
+              "report-uri /csp.do" % {
+                  'nonce':
+                      nonce,
+                  'scheme':
+                      csp_scheme,
+                  'rep_samp':
+                      "'report-sample'" if csp_supports_report_sample else '',
+                  'version_base':
+                      version_base,
+              }))
 
+      # add the function to get data and render page
       page_data.update(self._GatherFlagData(self.mr))
 
       # Page-specific work happens in this call.
@@ -352,12 +301,12 @@
       # meaningful during fuzzing. For more context see
       # https://bugs.chromium.org/p/monorail/issues/detail?id=659
       logging.warning('Trapped NotImplementedError %s', e)
-      self.abort(404, 'invalid page')
+      flask.abort(404, 'invalid page')
     except query2ast.InvalidQueryError as e:
       logging.warning('Trapped InvalidQueryError: %s', e)
       logging.exception(e)
-      msg = e.message if e.message else 'invalid query'
-      self.abort(400, msg)
+      msg = str(e) if str(e) else 'invalid query'
+      flask.abort(400, msg)
     except permissions.PermissionException as e:
       logging.warning('Trapped PermissionException %s', e)
       logging.warning('mr.auth.user_id is %s', self.mr.auth.user_id)
@@ -365,12 +314,12 @@
       logging.warning('mr.perms is %s', self.mr.perms)
       if not self.mr.auth.user_id:
         # If not logged in, let them log in
-        url = servlet_helpers.SafeCreateLoginURL(self.mr)
-        self.redirect(url, abort=True)
+        login_url = servlet_helpers.SafeCreateLoginURL(self.mr)
+        raise exceptions.RedirectException(login_url)
       else:
         # Display the missing permissions template.
         page_data = {
-            'reason': e.message,
+            'reason': str(e),
             'http_response_code': http_client.FORBIDDEN,
         }
         with self.mr.profiler.Phase('gather base data'):
@@ -379,61 +328,34 @@
         self._missing_permissions_template.WriteResponse(
             self.response, page_data, content_type=self.content_type)
 
-  def GetTemplate(self, _page_data):
-    """Get the template to use for writing the http response.
-
-    Defaults to self.template.  This method can be overwritten in subclasses
-    to allow dynamic template selection based on page_data.
-
-    Args:
-      _page_data: A dict of data for ezt rendering, containing base ezt
-      data, page data, and debug data.
-
-    Returns:
-      The template to be used for writing the http response.
-    """
-    return self.template
-
-  def _GatherFlagData(self, mr):
-    page_data = {
-        'project_stars_enabled': ezt.boolean(
-            settings.enable_project_stars),
-        'user_stars_enabled': ezt.boolean(settings.enable_user_stars),
-        'can_create_project': ezt.boolean(
-            permissions.CanCreateProject(mr.perms)),
-        'can_create_group': ezt.boolean(
-            permissions.CanCreateGroup(mr.perms)),
-        }
-
-    return page_data
-
-  def _RenderResponse(self, page_data):
-    logging.info('rendering response len(page_data) is %r', len(page_data))
-    self.GetTemplate(page_data).WriteResponse(
-        self.response, page_data, content_type=self.content_type)
-
-  def ProcessFormData(self, mr, post_data):
-    """Handle form data and redirect appropriately.
-
-    Args:
-      mr: commonly used info parsed from the request.
-      post_data: HTML form data from the request.
-
-    Returns:
-      String URL to redirect the user to, or None if response was already sent.
-    """
-    raise servlet_helpers.MethodNotSupportedError()
-
-  def post(self, **kwargs):
-    """Parse the request, check base perms, and call form-specific code."""
+  def post(self):
+    logging.info('process post request')
     try:
       # Page-specific work happens in this call.
       self._DoFormProcessing(self.request, self.mr)
 
     except permissions.PermissionException as e:
       logging.warning('Trapped permission-related exception "%s".', e)
-      # TODO(jrobbins): can we do better than an error page? not much.
-      self.response.status = http_client.BAD_REQUEST
+      self.response.status_code = http_client.BAD_REQUEST
+
+  def _RenderResponse(self, page_data):
+    logging.info('rendering response len(page_data) is %r', len(page_data))
+    self.template.WriteResponse(
+        self.response, page_data, content_type=self.content_type)
+
+  def _GatherFlagData(self, mr):
+    page_data = {
+        'project_stars_enabled':
+            ezt.boolean(settings.enable_project_stars),
+        'user_stars_enabled':
+            ezt.boolean(settings.enable_user_stars),
+        'can_create_project':
+            ezt.boolean(permissions.CanCreateProject(mr.perms)),
+        'can_create_group':
+            ezt.boolean(permissions.CanCreateGroup(mr.perms)),
+    }
+
+    return page_data
 
   def _DoCommonRequestProcessing(self, request, mr):
     """Do common processing dependent on having the user and project pbs."""
@@ -441,11 +363,15 @@
       self._CheckForMovedProject(mr, request)
       self.AssertBasePermission(mr)
 
+  # pylint: disable=unused-argument
   def _DoPageProcessing(self, mr, nonce):
     """Do user lookups and gather page-specific ezt data."""
     with mr.profiler.Phase('common request data'):
+
       self._DoCommonRequestProcessing(self.request, mr)
+
       self._MaybeRedirectToBrandedDomain(self.request, mr.project_name)
+
       page_data = self.GatherBaseData(mr, nonce)
 
     with mr.profiler.Phase('page processing'):
@@ -463,123 +389,52 @@
     if self.CHECK_SECURITY_TOKEN:
       try:
         xsrf.ValidateToken(
-            request.POST.get('token'), mr.auth.user_id, request.path)
+            request.values.get('token'), mr.auth.user_id, self.request_path)
       except xsrf.TokenIncorrect as err:
         if self.ALLOW_XHR:
-          xsrf.ValidateToken(request.POST.get('token'), mr.auth.user_id, 'xhr')
+          xsrf.ValidateToken(
+              request.values.get('token'), mr.auth.user_id, 'xhr')
         else:
           raise err
 
-    redirect_url = self.ProcessFormData(mr, request.POST)
+    form_values = datastructures.MultiDict(request.values)
+    form_values.update(request.files)
+    redirect_url = self.ProcessFormData(mr, form_values)
 
     # Most forms redirect the user to a new URL on success.  If no
     # redirect_url was returned, the form handler must have already
     # sent a response.  E.g., bounced the user back to the form with
-    # invalid form fields higlighted.
+    # invalid form fields highlighted.
     if redirect_url:
-      self.redirect(redirect_url, abort=True)
+      raise exceptions.RedirectException(redirect_url)
     else:
-      assert self.response.body
+      assert self.response.response
 
-  def _CheckForMovedProject(self, mr, request):
-    """If the project moved, redirect there or to an informational page."""
-    if not mr.project:
-      return  # We are on a site-wide or user page.
-    if not mr.project.moved_to:
-      return  # This project has not moved.
-    admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
-    if request.path.startswith(admin_url):
-      return  # It moved, but we are near the page that can un-move it.
-
-    logging.info('project %s has moved: %s', mr.project.project_name,
-                 mr.project.moved_to)
-
-    moved_to = mr.project.moved_to
-    if project_constants.RE_PROJECT_NAME.match(moved_to):
-      # Use the redir query parameter to avoid redirect loops.
-      if mr.redir is None:
-        url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
-        if '?' in url:
-          url += '&redir=1'
-        else:
-          url += '?redir=1'
-        logging.info('trusted move to a new project on our site')
-        self.redirect(url, abort=True)
-
-    logging.info('not a trusted move, will display link to user to click')
-    # Attach the project name as a url param instead of generating a /p/
-    # link to the destination project.
-    url = framework_helpers.FormatAbsoluteURL(
-        mr, urls.PROJECT_MOVED,
-        include_project=False, copy_params=False, project=mr.project_name)
-    self.redirect(url, abort=True)
-
-  def _MaybeRedirectToBrandedDomain(self, request, project_name):
-    """If we are live and the project should be branded, check request host."""
-    if request.params.get('redir'):
-      return  # Avoid any chance of a redirect loop.
-    if not project_name:
-      return
-    needed_domain = framework_helpers.GetNeededDomain(
-        project_name, request.host)
-    if not needed_domain:
-      return
-
-    url = 'https://%s%s' % (needed_domain, request.path_qs)
-    if '?' in url:
-      url += '&redir=1'
-    else:
-      url += '?redir=1'
-    logging.info('branding redirect to url %r', url)
-    self.redirect(url, abort=True)
-
-  def CheckPerm(self, mr, perm, art=None, granted_perms=None):
-    """Return True if the user can use the requested permission."""
-    return servlet_helpers.CheckPerm(
-        mr, perm, art=art, granted_perms=granted_perms)
-
-  def MakePagePerms(self, mr, art, *perm_list, **kwargs):
-    """Make an EZTItem with a set of permissions needed in a given template.
+  def ProcessFormData(self, mr, post_data):
+    """Handle form data and redirect appropriately.
 
     Args:
       mr: commonly used info parsed from the request.
-      art: a project artifact, such as an issue.
-      *perm_list: any number of permission names that are referenced
-          in the EZT template.
-      **kwargs: dictionary that may include 'granted_perms' list of permissions
-          granted to the current user specifically on the current page.
+      post_data: HTML form data from the request.
 
     Returns:
-      An EZTItem with one attribute for each permission and the value
-      of each attribute being an ezt.boolean().  True if the user
-      is permitted to do that action on the given artifact, or
-      False if not.
+      String URL to redirect the user to, or None if response was already sent.
     """
-    granted_perms = kwargs.get('granted_perms')
-    page_perms = template_helpers.EZTItem()
-    for perm in perm_list:
-      setattr(
-          page_perms, perm,
-          ezt.boolean(self.CheckPerm(
-              mr, perm, art=art, granted_perms=granted_perms)))
+    raise servlet_helpers.MethodNotSupportedError()
 
-    return page_perms
+  def _FormHandlerURL(self, path):
+    """Return the form handler for the main form on a page."""
+    if path.endswith('/'):
+      return path + 'edit.do'
+    elif path.endswith('.do'):
+      return path  # This happens as part of PleaseCorrect().
+    else:
+      return path + '.do'
 
-  def AssertBasePermission(self, mr):
-    """Make sure that the logged in user has permission to view this page.
-
-    Subclasses should call super, then check additional permissions
-    and raise a PermissionException if the user is not authorized to
-    do something.
-
-    Args:
-      mr: commonly used info parsed from the request.
-
-    Raises:
-      PermissionException: If the user does not have permisssion to view
-        the current page.
-    """
-    servlet_helpers.AssertBasePermission(mr)
+  # pylint: disable=unused-argument
+  def GatherPageData(self, mr):
+    """Return a dict of page-specific ezt data."""
+    raise servlet_helpers.MethodNotSupportedError()
 
   def GatherBaseData(self, mr, nonce):
     """Return a dict of info used on almost all pages."""
@@ -601,10 +456,9 @@
       is_project_starred = False
       project_view = None
       if mr.project:
-        if permissions.UserCanViewProject(
-            mr.auth.user_pb, mr.auth.effective_ids, mr.project):
+        if permissions.UserCanViewProject(mr.auth.user_pb,
+                                          mr.auth.effective_ids, mr.project):
           is_project_starred = we.IsProjectStarred(mr.project_id)
-          # TODO(jrobbins): should this be a ProjectView?
           project_view = template_helpers.PBProxy(mr.project)
 
     grid_x_attr = None
@@ -616,8 +470,9 @@
           features_bizobj.UsersInvolvedInHotlists([mr.hotlist]))
       hotlist_view = hotlist_views.HotlistView(
           mr.hotlist, mr.perms, mr.auth, mr.viewed_user_auth.user_id,
-          users_by_id, self.services.hotlist_star.IsItemStarredBy(
-            mr.cnxn, mr.hotlist.hotlist_id, mr.auth.user_id))
+          users_by_id,
+          self.services.hotlist_star.IsItemStarredBy(
+              mr.cnxn, mr.hotlist.hotlist_id, mr.auth.user_id))
       grid_x_attr = mr.x.lower()
       grid_y_attr = mr.y.lower()
 
@@ -790,10 +645,6 @@
             None,  # First part of page title
         'title_summary':
             None,  # Appended to title on artifact detail pages
-
-        # TODO(jrobbins): make sure that the templates use
-        # project_read_only for project-mutative actions and if any
-        # uses of read_only remain.
         'project_read_only':
             ezt.boolean(project_read_only),
         'site_read_only':
@@ -836,30 +687,22 @@
         mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
     # Always add other anti-xsrf tokens when the user is logged in.
     if mr.auth.user_id:
-      form_token_path = self._FormHandlerURL(mr.request.path)
+      form_token_path = self._FormHandlerURL(mr.request_path)
       base_data['form_token'] = xsrf.GenerateToken(
-        mr.auth.user_id, form_token_path)
+          mr.auth.user_id, form_token_path)
       base_data['form_token_path'] = form_token_path
 
     return base_data
 
-  def _FormHandlerURL(self, path):
-    """Return the form handler for the main form on a page."""
-    if path.endswith('/'):
-      return path + 'edit.do'
-    elif path.endswith('.do'):
-      return path  # This happens as part of PleaseCorrect().
-    else:
-      return path + '.do'
-
-  def GatherPageData(self, mr):
-    """Return a dict of page-specific ezt data."""
-    raise servlet_helpers.MethodNotSupportedError()
+  def _AddHelpDebugPageData(self, page_data):
+    with self.mr.profiler.Phase('help and debug data'):
+      page_data.update(self.GatherHelpData(self.mr, page_data))
+      page_data.update(self.GatherDebugData(self.mr, page_data))
 
   # pylint: disable=unused-argument
   def GatherHelpData(self, mr, page_data):
     """Return a dict of values to drive on-page user help.
-
+       Subclasses can override this function
     Args:
       mr: common information parsed from the HTTP request.
       page_data: Dictionary of base and page template data.
@@ -870,13 +713,12 @@
     help_data = {
         'cue': None,  # for cues.ezt
         'account_cue': None,  # for cues.ezt
-        }
+    }
     dismissed = []
     if mr.auth.user_pb:
       with work_env.WorkEnv(mr, self.services) as we:
         userprefs = we.GetUserPrefs(mr.auth.user_id)
-      dismissed = [
-          pv.name for pv in userprefs.prefs if pv.value == 'true']
+      dismissed = [pv.name for pv in userprefs.prefs if pv.value == 'true']
       if (mr.auth.user_pb.vacation_message and
           'you_are_on_vacation' not in dismissed):
         help_data['cue'] = 'you_are_on_vacation'
@@ -896,11 +738,12 @@
     """Return debugging info for display at the very bottom of the page."""
     if mr.debug_enabled:
       debug = [servlet_helpers.ContextDebugCollection('Page data', page_data)]
+      debug = [('none', 'recorded')]
       return {
           'dbg': 'on',
           'debug': debug,
           'profiler': mr.profiler,
-          }
+      }
     else:
       if '?' in mr.current_page_url:
         debug_url = mr.current_page_url + '&debug=1'
@@ -911,7 +754,117 @@
           'debug_uri': debug_url,
           'dbg': 'off',
           'debug': [('none', 'recorded')],
-          }
+      }
+
+  def _CheckForMovedProject(self, mr, request):
+    """If the project moved, redirect there or to an informational page."""
+    if not mr.project:
+      return  # We are on a site-wide or user page.
+    if not mr.project.moved_to:
+      return  # This project has not moved.
+    admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
+    if self.request_path.startswith(admin_url):
+      return  # It moved, but we are near the page that can un-move it.
+
+    logging.info(
+        'project %s has moved: %s', mr.project.project_name,
+        mr.project.moved_to)
+
+    moved_to = mr.project.moved_to
+    if project_constants.RE_PROJECT_NAME.match(moved_to):
+      # Use the redir query parameter to avoid redirect loops.
+      if mr.redir is None:
+        url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
+        if '?' in url:
+          url += '&redir=1'
+        else:
+          url += '?redir=1'
+        logging.info('trusted move to a new project on our site')
+        self.redirect(url, abort=True)
+
+    logging.info('not a trusted move, will display link to user to click')
+    # Attach the project name as a url param instead of generating a /p/
+    # link to the destination project.
+    url = framework_helpers.FormatAbsoluteURL(
+        mr,
+        urls.PROJECT_MOVED,
+        include_project=False,
+        copy_params=False,
+        project=mr.project_name)
+    self.redirect(url, abort=True)
+
+  def _MaybeRedirectToBrandedDomain(self, request, project_name):
+    """If we are live and the project should be branded, check request host."""
+    if request.values.get('redir'):
+      return  # Avoid any chance of a redirect loop.
+    if not project_name:
+      return
+    needed_domain = framework_helpers.GetNeededDomain(
+        project_name, request.host)
+    if not needed_domain:
+      return
+
+    url = 'https://%s%s' % (needed_domain, request.full_path)
+    if '?' in url:
+      url += '&redir=1'
+    else:
+      url += '?redir=1'
+    logging.info('branding redirect to url %r', url)
+    self.redirect(url, abort=True)
+
+  def AssertBasePermission(self, mr):
+    """Make sure that the logged in user has permission to view this page.
+
+    Subclasses should call super, then check additional permissions
+    and raise a PermissionException if the user is not authorized to
+    do something.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Raises:
+      PermissionException: If the user does not have permisssion to view
+        the current page.
+    """
+    servlet_helpers.AssertBasePermission(mr)
+
+  def CheckPerm(self, mr, perm, art=None, granted_perms=None):
+    """Return True if the user can use the requested permission."""
+    return servlet_helpers.CheckPerm(
+        mr, perm, art=art, granted_perms=granted_perms)
+
+  def MakePagePerms(self, mr, art, *perm_list, **kwargs):
+    """Make an EZTItem with a set of permissions needed in a given template.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      art: a project artifact, such as an issue.
+      *perm_list: any number of permission names that are referenced
+          in the EZT template.
+      **kwargs: dictionary that may include 'granted_perms' list of permissions
+          granted to the current user specifically on the current page.
+
+    Returns:
+      An EZTItem with one attribute for each permission and the value
+      of each attribute being an ezt.boolean().  True if the user
+      is permitted to do that action on the given artifact, or
+      False if not.
+    """
+    granted_perms = kwargs.get('granted_perms')
+    page_perms = template_helpers.EZTItem()
+    for perm in perm_list:
+      setattr(
+          page_perms, perm,
+          ezt.boolean(
+              self.CheckPerm(mr, perm, art=art, granted_perms=granted_perms)))
+
+    return page_perms
+
+  def redirect(self, url, abort=False):
+    if abort:
+      return flask.redirect(url, code=302)
+    else:
+      return flask.redirect(url)
 
   def PleaseCorrect(self, mr, **echo_data):
     """Show the same form again so that the user can correct their input."""
@@ -927,3 +880,6 @@
           now - framework_constants.VISIT_RESOLUTION):
         user_pb.last_visit_timestamp = now
         self.services.user.UpdateUser(mr.cnxn, user_pb.user_id, user_pb)
+
+  def abort(self, code, context=""):
+    return flask.abort(code, context)
diff --git a/framework/servlet_helpers.py b/framework/servlet_helpers.py
index fddec26..4d4259c 100644
--- a/framework/servlet_helpers.py
+++ b/framework/servlet_helpers.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Helper functions used by the Monorail servlet base class."""
 from __future__ import print_function
@@ -22,7 +21,7 @@
 from framework import template_helpers
 from framework import urls
 from framework import xsrf
-from proto import project_pb2
+from mrproto import project_pb2
 
 from google.appengine.api import app_identity
 from google.appengine.api import modules
@@ -209,28 +208,37 @@
   return (mr.project and mr.project.access != project_pb2.ProjectAccess.ANYONE)
 
 
-def SafeCreateLoginURL(mr, continue_url=None):
+def SafeCreateLoginURL(mr):
   """Make a login URL w/ a detailed continue URL, otherwise use a short one."""
-  current_page_url = mr.current_page_url_encoded
+  current_url = mr.current_page_url_encoded
   if settings.local_mode:
-    current_page_url = mr.current_page_url
-  continue_url = continue_url or current_page_url
+    current_url = mr.current_page_url
   try:
     # Check the URL length
-    generated_login_url = users.create_login_url(continue_url)
+    generated_login_url = users.create_login_url(current_url)
   except users.RedirectTooLongError:
     if mr.project_name:
-      continue_url = '/p/%s' % mr.project_name
+      current_url = '/p/%s' % mr.project_name
     else:
-      continue_url = '/'
+      current_url = '/'
   if settings.local_mode:
     return generated_login_url
+
+  current_parts = urllib.parse.urlparse(current_url)
+  current_query = current_parts.query
+  # Double encode only the query so that it survives redirect parsing.
+  current_url = urllib.parse.urlunparse(
+      current_parts[:3] + ('', urllib.parse.quote_plus(current_query), ''))
   # URL to allow user to choose an account when >1 account is logged in.
-  redirect_url = (
-      'https://accounts.google.com/AccountChooser?continue='
-      'https://uc.appengine.google.com/_ah/conflogin%3Fcontinue%3D{}')
-  url = redirect_url.format(continue_url)
-  return url
+  second_redirect_url = 'https://uc.appengine.google.com/_ah/conflogin?'
+  second_redirect_query = 'continue=' + current_url
+  second_redirect_uri = second_redirect_url + second_redirect_query
+
+  first_redirect_url = 'https://accounts.google.com/AccountChooser?'
+  first_redirect_params = {'continue': second_redirect_uri}
+  first_redirect_uri = first_redirect_url + urllib.parse.urlencode(
+      first_redirect_params)
+  return first_redirect_uri
 
 
 def SafeCreateLogoutURL(mr):
@@ -275,6 +283,6 @@
       project_alert = (
           'Scheduled for deletion in %d %s.' % (delay_days, days_word))
   elif project.state == project_pb2.ProjectState.ARCHIVED:
-    project_alert = 'Project is archived: read-only by members only.'
+    project_alert = 'Project is archived and read-only.'
 
   return project_alert
diff --git a/framework/sorting.py b/framework/sorting.py
index 558044c..2ad4c1c 100644
--- a/framework/sorting.py
+++ b/framework/sorting.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Helper functions for sorting lists of project artifacts.
 
@@ -23,16 +22,16 @@
 from __future__ import division
 from __future__ import absolute_import
 
-from functools import total_ordering
+import functools
 
 import settings
-from proto import tracker_pb2
+from mrproto import tracker_pb2
 from services import caches
 from tracker import tracker_bizobj
 from tracker import tracker_constants
 
 
-@total_ordering
+@functools.total_ordering
 class DescendingValue(object):
   """A wrapper which reverses the sort order of values."""
 
@@ -149,7 +148,7 @@
     art_values_cache.CacheItem(art.issue_id, art_values)
     return sort_key
 
-  return sorted(artifacts, key=SortKey)
+  return sorted(artifacts, key=lambda x: Python2Key(SortKey(x)))
 
 
 def ComputeSortDirectives(config, group_by_spec, sort_spec, tie_breakers=None):
@@ -249,7 +248,7 @@
         key_part.extend(value)
       else:
         key_part.append(value)
-    return sorted(key_part)
+    return sorted(key_part, key=Python2Key)
 
   return _MaybeMakeDescending(CombinedAccessor, descending)
 
@@ -341,7 +340,7 @@
     else:
       values = [postprocessor(users_by_id[id_or_id_list])]
 
-    return sorted(values) or MAX_STRING
+    return sorted(values) or [MAX_STRING]
 
   return Accessor
 
@@ -427,10 +426,10 @@
     values = base_accessor(art)
     if not values:
       # Undefined values sort last.
-      return MAX_STRING
+      return [MAX_STRING]
 
     indexes = [well_known_value_indexes.get(val, MAX_STRING) for val in values]
-    return sorted(indexes)
+    return sorted(indexes, key=Python2Key)
 
   return Accessor
 
@@ -465,8 +464,8 @@
           _SortableApprovalApproverValues(art, approval_fds, users_by_id) +
           _SortableLabelValues(art, col_name, well_known_value_indexes))
       if not idx_or_lex_list:
-        return MAX_STRING  # issues with no value sort to the end of the list.
-      return sorted(idx_or_lex_list)
+        return [MAX_STRING]  # issues with no value sort to the end of the list.
+      return sorted(idx_or_lex_list, key=Python2Key)
 
     return ApproverAccessor
 
@@ -494,8 +493,8 @@
         _SortableFieldValues(art, fd_list, users_by_id, phase_name) +
         _SortableLabelValues(art, col_name, well_known_value_indexes))
     if not idx_or_lex_list:
-      return MAX_STRING  # issues with no value sort to the end of the list.
-    return sorted(idx_or_lex_list)
+      return [MAX_STRING]  # issues with no value sort to the end of the list.
+    return sorted(idx_or_lex_list, key=Python2Key)
 
   return Accessor
 
@@ -573,3 +572,37 @@
     sortable_value_list.append(idx_or_lex)
 
   return sortable_value_list
+
+
+def _Python2Cmp(a, b):
+  """Compares two objects in the Python 2 way.
+
+  In Python 3, comparing two objects of different types raises a TypeError.
+  In Python 2, when you compare two objects of different types, they are
+  generally ordered by their type names, with a few special cases carved
+  out for int/float and str/unicode.
+
+  This comparison function also looks through lists and compares them pairwise.
+  It doesn't do the same for other iterables.
+  """
+  try:
+    # First try comparing the objects directly.
+    # https://docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons
+    return (a > b) - (a < b)
+  except TypeError:
+    s1, s2 = type(a).__name__, type(b).__name__
+    if not (s1 == 'list' and s2 == 'list'):
+      # If they are different types, compare their type names.
+      return (s1 > s2) - (s1 < s2)
+
+    # If they are both lists, compare their elements pairwise.
+    for x, y in zip(a, b):
+      element_cmp = _Python2Cmp(x, y)
+      if element_cmp != 0:
+        return element_cmp
+
+    # If the lists start with the same elements, compare their lengths.
+    return (len(a) > len(b)) - (len(a) < len(b))
+
+
+Python2Key = functools.cmp_to_key(_Python2Cmp)
diff --git a/framework/sql.py b/framework/sql.py
index 0fb8043..d1c45e3 100644
--- a/framework/sql.py
+++ b/framework/sql.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """A set of classes for interacting with tables in SQL."""
 from __future__ import print_function
@@ -14,7 +13,8 @@
 import sys
 import time
 
-from six import string_types
+import six
+from six.moves import queue
 
 import settings
 
@@ -27,8 +27,6 @@
 
 from infra_libs import ts_mon
 
-from Queue import Queue
-
 
 class ConnectionPool(object):
   """Manage a set of database connections such that they may be re-used.
@@ -45,15 +43,15 @@
     key = instance + '/' + database
 
     if not key in self.queues:
-      queue = Queue(self.poolsize)
-      self.queues[key] = queue
+      q = queue.Queue(self.poolsize)
+      self.queues[key] = q
 
-    queue = self.queues[key]
+    q = self.queues[key]
 
-    if queue.empty():
+    if q.empty():
       cnxn = cnxn_ctor(instance, database)
     else:
-      cnxn = queue.get()
+      cnxn = q.get()
       # Make sure the connection is still good.
       cnxn.ping()
       cnxn.commit()
@@ -266,13 +264,14 @@
         '%d rows in %d ms: %s', cursor.rowcount, int(duration),
         formatted_statement)
     if duration >= 2000:
-      logger.log({
-          'log_type': 'database/query',
-          'statement': formatted_statement,
-          'type': formatted_statement.split(' ')[0],
-          'duration': duration / 1000,
-          'row_count': cursor.rowcount,
-      })
+      logger.log(
+          {
+              'log_type': 'database/query',
+              'statement': formatted_statement[:100000],
+              'type': formatted_statement.split(' ')[0],
+              'duration': duration / 1000,
+              'row_count': cursor.rowcount,
+          })
 
     if commit and not stmt_str.startswith('SELECT'):
       try:
@@ -670,7 +669,7 @@
     elif self.limit:
       clauses.append('LIMIT %d' % self.limit)
     elif self.offset:
-      clauses.append('LIMIT %d OFFSET %d' % (sys.maxint, self.offset))
+      clauses.append('LIMIT %d OFFSET %d' % (sys.maxsize, self.offset))
 
     if self.insert_args:
       clauses.append('VALUES (' + PlaceHolders(self.insert_args[0]) + ')')
@@ -947,16 +946,19 @@
     _MakeRE(r'^LOWER\({tab_col}\) NOT IN \({multi_placeholder}\)$'),
     _MakeRE(r'^LOWER\({tab_col}\) LIKE {placeholder}$'),
     _MakeRE(r'^LOWER\({tab_col}\) NOT LIKE {placeholder}$'),
-    _MakeRE(r'^timestep < \(SELECT MAX\(j.timestep\) FROM Invalidate AS j '
-            r'WHERE j.kind = %s '
-            r'AND j.cache_key = Invalidate.cache_key\)$'),
-    _MakeRE(r'^\({tab_col} IS NULL OR {tab_col} {compare_op} {placeholder}\) '
-             'AND \({tab_col} IS NULL OR {tab_col} {compare_op} {placeholder}'
-             '\)$'),
-    _MakeRE(r'^\({tab_col} IS NOT NULL AND {tab_col} {compare_op} '
-             '{placeholder}\) OR \({tab_col} IS NOT NULL AND {tab_col} '
-             '{compare_op} {placeholder}\)$'),
-    ]
+    _MakeRE(
+        r'^timestep < \(SELECT MAX\(j.timestep\) FROM Invalidate AS j '
+        r'WHERE j.kind = %s '
+        r'AND j.cache_key = Invalidate.cache_key\)$'),
+    _MakeRE(
+        r'^\({tab_col} IS NULL OR {tab_col} {compare_op} {placeholder}\) '
+        r'AND \({tab_col} IS NULL OR {tab_col} {compare_op} {placeholder}'
+        r'\)$'),
+    _MakeRE(
+        r'^\({tab_col} IS NOT NULL AND {tab_col} {compare_op} '
+        r'{placeholder}\) OR \({tab_col} IS NOT NULL AND {tab_col} '
+        r'{compare_op} {placeholder}\)$'),
+]
 
 # Note: We never use ';' for multiple statements, '@' for SQL variables, or
 # any quoted strings in stmt_str (quotes are put in my MySQLdb for args).
@@ -966,7 +968,7 @@
 
 
 def _IsValidDBValue(val):
-  if isinstance(val, string_types):
+  if isinstance(val, six.string_types):
     return '\x00' not in val
   return True
 
diff --git a/framework/table_view_helpers.py b/framework/table_view_helpers.py
index 3fa07c2..f81ba40 100644
--- a/framework/table_view_helpers.py
+++ b/framework/table_view_helpers.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Classes and functions for displaying lists of project artifacts.
 
@@ -24,7 +23,7 @@
 from framework import framework_constants
 from framework import template_helpers
 from framework import timestr
-from proto import tracker_pb2
+from mrproto import tracker_pb2
 from tracker import tracker_bizobj
 from tracker import tracker_constants
 
@@ -697,8 +696,8 @@
       if not fd:
         # TODO(jrobbins): This can happen if an issue with a custom
         # field value is moved to a different project.
-        logging.warn('Issue ID %r has undefined field value %r',
-                     art.issue_id, fv)
+        logging.warning(
+            'Issue ID %r has undefined field value %r', art.issue_id, fv)
       elif fd.field_name.lower() == col and (
           phase_names_by_id.get(fv.phase_id) == phase_name):
         if fd.field_type == tracker_pb2.FieldTypes.URL_TYPE:
@@ -726,8 +725,8 @@
       fd = tracker_bizobj.FindFieldDef(col, config)
       ad = tracker_bizobj.FindApprovalDef(col, config)
       if not (ad and fd):
-        logging.warn('Issue ID %r has undefined field value %r',
-                     art.issue_id, av)
+        logging.warning(
+            'Issue ID %r has undefined field value %r', art.issue_id, av)
       elif av.approval_id == fd.field_id:
         explicit_values.append(av.status.name)
         break
@@ -745,8 +744,8 @@
       fd = tracker_bizobj.FindFieldDef(approval_name, config)
       ad = tracker_bizobj.FindApprovalDef(approval_name, config)
       if not (ad and fd):
-        logging.warn('Issue ID %r has undefined field value %r',
-                     art.issue_id, av)
+        logging.warning(
+            'Issue ID %r has undefined field value %r', art.issue_id, av)
       elif av.approval_id == fd.field_id:
         explicit_values = [users_by_id.get(approver_id).display_name
                            for approver_id in av.approver_ids
diff --git a/framework/teardown.py b/framework/teardown.py
new file mode 100644
index 0000000..05ebb29
--- /dev/null
+++ b/framework/teardown.py
@@ -0,0 +1,41 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""A handler run on Flask request teardown."""
+
+import logging
+import os
+
+from google.appengine.api import runtime
+from googleapiclient import discovery
+from googleapiclient import errors
+from oauth2client import client
+
+import settings
+
+_MAXIMUM_MEMORY_USAGE = 2000
+
+
+def Teardown(_exc):
+  if settings.local_mode:
+    return
+
+  # Stop the instance if it's using too much memory.
+  memory_usage = runtime.memory_usage().average10m
+  if memory_usage < _MAXIMUM_MEMORY_USAGE:
+    return
+
+  credentials = client.GoogleCredentials.get_application_default()
+  appengine = discovery.build('appengine', 'v1', credentials=credentials)
+  delete = appengine.apps().services().versions().instances().delete(
+      appsId=os.environ.get('GAE_APPLICATION').split('~')[-1],
+      servicesId=os.environ.get('GAE_SERVICE'),
+      versionsId=os.environ.get('GAE_VERSION'),
+      instancesId=os.environ.get('GAE_INSTANCE'))
+  try:
+    delete.execute()
+  except errors.HttpError as e:
+    if e.status_code != 404:
+      raise
+  else:
+    logging.critical('Deleted instance using %d MB of memory.' % memory_usage)
diff --git a/framework/template_helpers.py b/framework/template_helpers.py
index 7ebb7e2..d360054 100644
--- a/framework/template_helpers.py
+++ b/framework/template_helpers.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Some utility classes for interacting with templates."""
 
@@ -9,12 +8,14 @@
 from __future__ import print_function
 from __future__ import absolute_import
 
-import cgi
-import cStringIO
+try:
+  import html
+except ImportError:
+  import cgi as html
 from six.moves import http_client
+from six.moves import StringIO
 import logging
 import time
-import types
 
 import ezt
 import six
@@ -119,18 +120,14 @@
   return template
 
 
-class cStringIOUnicodeWrapper(object):
-  """Wrapper on cStringIO.StringIO that encodes unicode as UTF-8 as it goes."""
+class StringIOUnicodeWrapper(object):
+  """Wrapper on io.StringIO that encodes unicode as UTF-8 as it goes."""
 
   def __init__(self):
-    self.buffer = cStringIO.StringIO()
+    self.buffer = StringIO()
 
   def write(self, s):
-    if isinstance(s, six.text_type):
-      utf8_s = s.encode('utf-8')
-    else:
-      utf8_s = s
-    self.buffer.write(utf8_s)
+    self.buffer.write(six.ensure_str(s))
 
   def getvalue(self):
     return self.buffer.getvalue()
@@ -157,20 +154,6 @@
     if content_type:
       response.content_type = content_type
 
-    response.status = data.get('http_response_code', http_client.OK)
-    whole_page = self.GetResponse(data)
-    if data.get('prevent_sniffing'):
-      for sniff_pattern, sniff_replacement in SNIFFABLE_PATTERNS.items():
-        whole_page = whole_page.replace(sniff_pattern, sniff_replacement)
-    start = time.time()
-    response.write(whole_page)
-    logging.info('wrote response in %dms', int((time.time() - start) * 1000))
-
-  def WriteFlaskResponse(self, response, data, content_type=None):
-    """Write the parsed and filled in template to http server."""
-    if content_type:
-      response.content_type = content_type
-
     response.status_code = data.get('http_response_code', http_client.OK)
     whole_page = self.GetResponse(data)
     if data.get('prevent_sniffing'):
@@ -184,7 +167,7 @@
     """Generate the text from the template and return it as a string."""
     template = self.GetTemplate()
     start = time.time()
-    buf = cStringIOUnicodeWrapper()
+    buf = StringIOUnicodeWrapper()
     template.generate(buf, data)
     whole_page = buf.getvalue()
     logging.info('rendering took %dms', int((time.time() - start) * 1000))
@@ -311,7 +294,7 @@
     page_data: Template data which may include a 'labels' field.
   """
   label_list = page_data.get('labels', [])
-  if isinstance(label_list, types.StringTypes):
+  if isinstance(label_list, six.string_types):
     label_list = [label.strip() for label in page_data['labels'].split(',')]
 
   for i in range(len(label_list)):
@@ -334,7 +317,7 @@
     """Return a string that can be used in an HTML email body."""
     if self.tag == 'a' and self.href:
       return '<a href="%s">%s</a>' % (
-          cgi.escape(self.href, quote=True),
-          cgi.escape(self.content, quote=True))
+          html.escape(self.href,
+                      quote=True), html.escape(self.content, quote=True))
 
-    return cgi.escape(self.content, quote=True)
+    return html.escape(self.content, quote=True)
diff --git a/framework/test/alerts_test.py b/framework/test/alerts_test.py
index 0c398c1..122a0fe 100644
--- a/framework/test/alerts_test.py
+++ b/framework/test/alerts_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for alert display helpers."""
 from __future__ import print_function
diff --git a/framework/test/authdata_test.py b/framework/test/authdata_test.py
index a0e7313..22a7552 100644
--- a/framework/test/authdata_test.py
+++ b/framework/test/authdata_test.py
@@ -1,7 +1,6 @@
-# Copyright 2017 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2017 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for the authdata module."""
 from __future__ import print_function
diff --git a/framework/test/banned_test.py b/framework/test/banned_test.py
index 0331cdd..f07b6e8 100644
--- a/framework/test/banned_test.py
+++ b/framework/test/banned_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unittests for monorail.framework.banned."""
 from __future__ import print_function
@@ -10,8 +9,6 @@
 
 import unittest
 
-import webapp2
-
 from framework import banned
 from framework import monorailrequest
 from services import service_manager
diff --git a/framework/test/cloud_tasks_helpers_test.py b/framework/test/cloud_tasks_helpers_test.py
index 09ad2cd..bbc52b0 100644
--- a/framework/test/cloud_tasks_helpers_test.py
+++ b/framework/test/cloud_tasks_helpers_test.py
@@ -1,4 +1,4 @@
-# Copyright 2020 The Chromium Authors. All rights reserved.
+# Copyright 2020 The Chromium Authors
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 """Tests for the cloud tasks helper module."""
@@ -34,8 +34,8 @@
     get_client_mock().queue_path.assert_called_with(
         settings.app_id, settings.CLOUD_TASKS_REGION, queue)
     get_client_mock().create_task.assert_called_once()
-    ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
-    self.assertEqual(called_task, task)
+    _, kwargs = get_client_mock().create_task.call_args
+    self.assertEqual(kwargs['task'], task)
 
   @mock.patch('framework.cloud_tasks_helpers._get_client')
   def test_create_task_raises(self, get_client_mock):
@@ -53,7 +53,7 @@
 
     cloud_tasks_helpers.create_task(task)
 
-    (_args, kwargs) = get_client_mock().create_task.call_args
+    _, kwargs = get_client_mock().create_task.call_args
     self.assertEqual(kwargs.get('retry'), cloud_tasks_helpers._DEFAULT_RETRY)
 
   def test_generate_simple_task(self):
@@ -66,7 +66,7 @@
         'app_engine_http_request':
             {
                 'relative_uri': '/alphabet/letters',
-                'body': 'a=a&b=b',
+                'body': b'a=a&b=b',
                 'headers': {
                     'Content-type': 'application/x-www-form-urlencoded'
                 }
@@ -79,7 +79,7 @@
         'app_engine_http_request':
             {
                 'relative_uri': '/alphabet/letters',
-                'body': '',
+                'body': b'',
                 'headers': {
                     'Content-type': 'application/x-www-form-urlencoded'
                 }
diff --git a/framework/test/csv_helpers_test.py b/framework/test/csv_helpers_test.py
index 19c89c5..a8726a7 100644
--- a/framework/test/csv_helpers_test.py
+++ b/framework/test/csv_helpers_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for csv_helpers functions."""
 from __future__ import print_function
diff --git a/framework/test/deleteusers_test.py b/framework/test/deleteusers_test.py
index 87ed5bc..13c6922 100644
--- a/framework/test/deleteusers_test.py
+++ b/framework/test/deleteusers_test.py
@@ -1,7 +1,6 @@
-# Copyright 2019 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2019 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for deleteusers classes."""
 from __future__ import print_function
@@ -10,6 +9,7 @@
 
 import logging
 import mock
+import six
 import unittest
 from six.moves import urllib
 
@@ -52,24 +52,24 @@
     self.assertEqual(get_client_mock().create_task.call_count, 3)
 
     expected_task = self.generate_simple_task(
-        urls.SEND_WIPEOUT_USER_LISTS_TASK + '.do', 'limit=2&offset=0')
+        urls.SEND_WIPEOUT_USER_LISTS_TASK + '.do', b'limit=2&offset=0')
     get_client_mock().create_task.assert_any_call(
-        get_client_mock().queue_path(),
-        expected_task,
+        parent=get_client_mock().queue_path(),
+        task=expected_task,
         retry=cloud_tasks_helpers._DEFAULT_RETRY)
 
     expected_task = self.generate_simple_task(
-        urls.SEND_WIPEOUT_USER_LISTS_TASK + '.do', 'limit=2&offset=2')
+        urls.SEND_WIPEOUT_USER_LISTS_TASK + '.do', b'limit=2&offset=2')
     get_client_mock().create_task.assert_any_call(
-        get_client_mock().queue_path(),
-        expected_task,
+        parent=get_client_mock().queue_path(),
+        task=expected_task,
         retry=cloud_tasks_helpers._DEFAULT_RETRY)
 
     expected_task = self.generate_simple_task(
-        urls.DELETE_WIPEOUT_USERS_TASK + '.do', '')
+        urls.DELETE_WIPEOUT_USERS_TASK + '.do', b'')
     get_client_mock().create_task.assert_any_call(
-        get_client_mock().queue_path(),
-        expected_task,
+        parent=get_client_mock().queue_path(),
+        task=expected_task,
         retry=cloud_tasks_helpers._DEFAULT_RETRY)
 
   @mock.patch('framework.cloud_tasks_helpers._get_client')
@@ -79,10 +79,10 @@
 
     expected_task = self.generate_simple_task(
         urls.SEND_WIPEOUT_USER_LISTS_TASK + '.do',
-        'limit={}&offset=0'.format(deleteusers.MAX_BATCH_SIZE))
+        b'limit=%d&offset=0' % deleteusers.MAX_BATCH_SIZE)
     get_client_mock().create_task.assert_any_call(
-        get_client_mock().queue_path(),
-        expected_task,
+        parent=get_client_mock().queue_path(),
+        task=expected_task,
         retry=cloud_tasks_helpers._DEFAULT_RETRY)
 
   @mock.patch('framework.cloud_tasks_helpers._get_client')
@@ -118,13 +118,13 @@
   def testHandleRequest_NoLimit(self):
     mr = testing_helpers.MakeMonorailRequest()
     self.services.user.users_by_id = {}
-    with self.assertRaisesRegexp(AssertionError, 'Missing param limit'):
+    with self.assertRaisesRegex(AssertionError, 'Missing param limit'):
       self.task.HandleRequest(mr)
 
   def testHandleRequest_NoOffset(self):
     mr = testing_helpers.MakeMonorailRequest(path='url/url?limit=3')
     self.services.user.users_by_id = {}
-    with self.assertRaisesRegexp(AssertionError, 'Missing param offset'):
+    with self.assertRaisesRegex(AssertionError, 'Missing param offset'):
       self.task.HandleRequest(mr)
 
   def testHandleRequest_ZeroOffset(self):
@@ -152,7 +152,7 @@
         'app_engine_http_request':
             {
                 'relative_uri': url,
-                'body': body,
+                'body': six.ensure_binary(body),
                 'headers': {
                     'Content-type': 'application/x-www-form-urlencoded'
                 }
@@ -178,8 +178,8 @@
         urls.DELETE_USERS_TASK + '.do', query)
 
     get_client_mock().create_task.assert_any_call(
-        get_client_mock().queue_path(),
-        expected_task,
+        parent=get_client_mock().queue_path(),
+        task=expected_task,
         retry=cloud_tasks_helpers._DEFAULT_RETRY)
 
     query = urllib.parse.urlencode({'emails': 'user4@gmail.com'})
@@ -187,8 +187,8 @@
         urls.DELETE_USERS_TASK + '.do', query)
 
     get_client_mock().create_task.assert_any_call(
-        get_client_mock().queue_path(),
-        expected_task,
+        parent=get_client_mock().queue_path(),
+        task=expected_task,
         retry=cloud_tasks_helpers._DEFAULT_RETRY)
 
   @mock.patch('framework.cloud_tasks_helpers._get_client')
@@ -206,6 +206,6 @@
         urls.DELETE_USERS_TASK + '.do', query)
 
     get_client_mock().create_task.assert_any_call(
-        get_client_mock().queue_path(),
-        expected_task,
+        parent=get_client_mock().queue_path(),
+        task=expected_task,
         retry=cloud_tasks_helpers._DEFAULT_RETRY)
diff --git a/framework/test/emailfmt_test.py b/framework/test/emailfmt_test.py
index dd7cca3..5445189 100644
--- a/framework/test/emailfmt_test.py
+++ b/framework/test/emailfmt_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for monorail.framework.emailfmt."""
 from __future__ import print_function
@@ -9,6 +8,7 @@
 from __future__ import absolute_import
 
 import mock
+import six
 import unittest
 
 from google.appengine.ext import testbed
@@ -16,7 +16,7 @@
 import settings
 from framework import emailfmt
 from framework import framework_views
-from proto import project_pb2
+from mrproto import project_pb2
 from testing import testing_helpers
 
 from google.appengine.api import apiproxy_stub_map
@@ -79,12 +79,11 @@
         testing_helpers.HEADER_LINES + [references_header], 'awesome!')
     (from_addr, to_addrs, cc_addrs, references, incident_id, subject,
      body) = emailfmt.ParseEmailMessage(msg)
-    self.assertItemsEqual(
-        ['<5678@bar.com>',
-         '<0=969704940193871313=13442892928193434663='
-         'proj@monorail.example.com>',
-         '<1234@foo.com>'],
-        references)
+    six.assertCountEqual(
+        self, [
+            '<5678@bar.com>', '<0=969704940193871313=13442892928193434663='
+            'proj@monorail.example.com>', '<1234@foo.com>'
+        ], references)
 
   def testParseEmailMessage_Bulk(self):
     for precedence in ['Bulk', 'Junk']:
diff --git a/framework/test/exceptions_test.py b/framework/test/exceptions_test.py
index 8fe2295..5013267 100644
--- a/framework/test/exceptions_test.py
+++ b/framework/test/exceptions_test.py
@@ -1,6 +1,6 @@
-# Copyright 2020 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file.
+# Copyright 2020 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 """Unittest for the exceptions module."""
 
 from __future__ import print_function
@@ -21,7 +21,7 @@
 
     err_aggregator.AddErrorMessage('The chickens are missing.')
     err_aggregator.AddErrorMessage('The foxes are free.')
-    with self.assertRaisesRegexp(
+    with self.assertRaisesRegex(
         exceptions.InputException,
         'The chickens are missing.\nThe foxes are free.'):
       err_aggregator.RaiseIfErrors()
@@ -34,16 +34,16 @@
   def testWithinContext_ExceptionPassedIn(self):
     """We do not suppress exceptions raised within wrapped code."""
 
-    with self.assertRaisesRegexp(exceptions.InputException,
-                                 'We should raise this'):
+    with self.assertRaisesRegex(exceptions.InputException,
+                                'We should raise this'):
       with exceptions.ErrorAggregator(exceptions.InputException) as errors:
         errors.AddErrorMessage('We should ignore this error.')
         raise exceptions.InputException('We should raise this')
 
   def testWithinContext_NoExceptionPassedIn(self):
     """We raise an exception for any errors if no exceptions are passed in."""
-    with self.assertRaisesRegexp(exceptions.InputException,
-                                 'We can raise this now.'):
+    with self.assertRaisesRegex(exceptions.InputException,
+                                'We can raise this now.'):
       with exceptions.ErrorAggregator(exceptions.InputException) as errors:
         errors.AddErrorMessage('We can raise this now.')
         return True
diff --git a/framework/test/filecontent_test.py b/framework/test/filecontent_test.py
index 4843b47..f2c8d9e 100644
--- a/framework/test/filecontent_test.py
+++ b/framework/test/filecontent_test.py
@@ -1,13 +1,13 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for the filecontent module."""
 from __future__ import print_function
 from __future__ import division
 from __future__ import absolute_import
 
+import six
 import unittest
 
 from framework import filecontent
@@ -84,10 +84,10 @@
     return is_binary
 
   def testFileIsBinaryEmpty(self):
-    self.assertFalse(self.IsBinary(''))
+    self.assertFalse(self.IsBinary(b''))
 
   def testFileIsBinaryShortText(self):
-    self.assertFalse(self.IsBinary('This is some plain text.'))
+    self.assertFalse(self.IsBinary(b'This is some plain text.'))
 
   def testLineLengthDetection(self):
     unicode_str = (
@@ -101,29 +101,35 @@
     lines.append(long_line)
 
     # High lower ratio - text
-    self.assertFalse(self.IsBinary('\n'.join(lines)))
+    self.assertFalse(self.IsBinary(b'\n'.join(lines)))
 
     lines.extend([long_line] * 99)
 
     # 50/50 lower/upper ratio - binary
-    self.assertTrue(self.IsBinary('\n'.join(lines)))
+    self.assertTrue(self.IsBinary(b'\n'.join(lines)))
 
     # Single line too long - binary
     lines = [short_line] * 100
     lines.append(short_line * 100)  # Very long line
-    self.assertTrue(self.IsBinary('\n'.join(lines)))
+    self.assertTrue(self.IsBinary(b'\n'.join(lines)))
 
   def testFileIsBinaryLongText(self):
-    self.assertFalse(self.IsBinary('This is plain text. \n' * 100))
+    self.assertFalse(self.IsBinary(b'This is plain text. \n' * 100))
     # long utf-8 lines are OK
-    self.assertFalse(self.IsBinary('This one long line. ' * 100))
+    self.assertFalse(self.IsBinary(b'This one long line. ' * 100))
 
   def testFileIsBinaryLongBinary(self):
-    bin_string = ''.join([chr(c) for c in range(122, 252)])
+    if six.PY2:
+      bin_string = ''.join([chr(c) for c in range(122, 252)])
+    else:
+      bin_string = bytes(range(122, 252))
     self.assertTrue(self.IsBinary(bin_string * 100))
 
   def testFileIsTextByPath(self):
-    bin_string = ''.join([chr(c) for c in range(122, 252)] * 100)
+    if six.PY2:
+      bin_string = ''.join([chr(c) for c in range(122, 252)] * 100)
+    else:
+      bin_string = bytes(range(122, 252)) * 100
     unicode_str = (
         u'Some non-ascii chars - '
         u'\xa2\xfa\xb6\xe7\xfc\xea\xd0\xf4\xe6\xf0\xce\xf6\xbe')
@@ -143,7 +149,7 @@
             filecontent.DecodeFileContents(contents, path=path)[1])
 
   def testFileIsBinaryByCommonExtensions(self):
-    contents = 'this is not examined'
+    contents = b'this is not examined'
     self.assertTrue(filecontent.DecodeFileContents(
         contents, path='junk.zip')[1])
     self.assertTrue(filecontent.DecodeFileContents(
@@ -175,13 +181,13 @@
         contents, path='/wiki/PageName.wiki')[1])
 
   def testUnreasonablyLongFile(self):
-    contents = '\n' * (filecontent.SOURCE_FILE_MAX_LINES + 2)
+    contents = b'\n' * (filecontent.SOURCE_FILE_MAX_LINES + 2)
     _contents, is_binary, is_long = filecontent.DecodeFileContents(
         contents)
     self.assertFalse(is_binary)
     self.assertTrue(is_long)
 
-    contents = '\n' * 100
+    contents = b'\n' * 100
     _contents, is_binary, is_long = filecontent.DecodeFileContents(
         contents)
     self.assertFalse(is_binary)
diff --git a/framework/test/flask_servlet_test.py b/framework/test/flask_servlet_test.py
deleted file mode 100644
index 4c47209..0000000
--- a/framework/test/flask_servlet_test.py
+++ /dev/null
@@ -1,110 +0,0 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
-"""Unit tests for servlet base class module."""
-from __future__ import print_function
-from __future__ import division
-from __future__ import absolute_import
-
-import time
-import mock
-import unittest
-import logging
-
-from google.appengine.ext import testbed
-
-from framework import flaskservlet, framework_constants, servlet_helpers
-from framework import xsrf
-from proto import project_pb2
-from proto import tracker_pb2
-from proto import user_pb2
-from services import service_manager
-from testing import fake
-from testing import testing_helpers
-
-
-class TestableFlaskServlet(flaskservlet.FlaskServlet):
-  """A tiny concrete subclass of abstract class Servlet."""
-
-  def __init__(self, services=None, do_post_redirect=True):
-    super(TestableFlaskServlet, self).__init__(services=services)
-    self.do_post_redirect = do_post_redirect
-    self.seen_post_data = None
-
-
-class FlaskServletTest(unittest.TestCase):
-
-  def setUp(self):
-    services = service_manager.Services(
-        project=fake.ProjectService(),
-        project_star=fake.ProjectStarService(),
-        user=fake.UserService(),
-        usergroup=fake.UserGroupService())
-    services.user.TestAddUser('user@example.com', 111)
-    self.page_class = flaskservlet.FlaskServlet(services=services)
-    self.testbed = testbed.Testbed()
-    self.testbed.activate()
-    self.testbed.init_user_stub()
-    self.testbed.init_memcache_stub()
-    self.testbed.init_datastore_v3_stub()
-
-  def tearDown(self):
-    self.testbed.deactivate()
-
-  def testDefaultValues(self):
-    self.assertEqual(None, self.page_class._MAIN_TAB_MODE)
-    self.assertTrue(self.page_class._TEMPLATE_PATH.endswith('/templates/'))
-    self.assertEqual(None, self.page_class._PAGE_TEMPLATE)
-
-  @mock.patch('flask.abort')
-  def testCheckForMovedProject_NoRedirect(self, mock_abort):
-    project = fake.Project(
-        project_name='proj', state=project_pb2.ProjectState.LIVE)
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj', project=project)
-    self.page_class._CheckForMovedProject(mr, request)
-    mock_abort.assert_not_called()
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/source/browse/p/adminAdvanced', project=project)
-    self.page_class._CheckForMovedProject(mr, request)
-    mock_abort.assert_not_called()
-
-  @mock.patch('flask.redirect')
-  def testCheckForMovedProject_Redirect(self, mock_redirect):
-    project = fake.Project(project_name='proj', moved_to='http://example.com')
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj', project=project)
-    self.page_class.request_path = '/p/test'
-    self.page_class._CheckForMovedProject(mr, request)
-    mock_redirect.assert_called_once_with(
-        'http://127.0.0.1/hosting/moved?project=proj', code=302)
-
-  def testGatherBaseData(self):
-    project = self.page_class.services.project.TestAddProject(
-        'testproj', state=project_pb2.ProjectState.LIVE)
-    project.cached_content_timestamp = 12345
-
-    (_request, mr) = testing_helpers.GetRequestObjects(
-        path='/p/testproj/feeds', project=project)
-    nonce = '1a2b3c4d5e6f7g'
-
-    base_data = self.page_class.GatherBaseData(mr, nonce)
-
-    self.assertEqual(base_data['nonce'], nonce)
-    self.assertEqual(base_data['projectname'], 'testproj')
-    self.assertEqual(base_data['project'].cached_content_timestamp, 12345)
-    self.assertEqual(base_data['project_alert'], None)
-
-    self.assertTrue(base_data['currentPageURL'].endswith('/p/testproj/feeds'))
-    self.assertTrue(
-        base_data['currentPageURLEncoded'].endswith('%2Fp%2Ftestproj%2Ffeeds'))
-
-  def testGatherHelpData_Normal(self):
-    project = fake.Project(project_name='proj')
-    _request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj', project=project)
-    help_data = self.page_class.GatherHelpData(mr, {})
-    self.assertEqual(None, help_data['cue'])
-    self.assertEqual(None, help_data['account_cue'])
diff --git a/framework/test/framework_bizobj_test.py b/framework/test/framework_bizobj_test.py
index 131ebb5..5ea05a6 100644
--- a/framework/test/framework_bizobj_test.py
+++ b/framework/test/framework_bizobj_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for monorail.framework.framework_bizobj."""
 from __future__ import print_function
@@ -15,9 +14,9 @@
 from framework import authdata
 from framework import framework_bizobj
 from framework import framework_constants
-from proto import project_pb2
-from proto import tracker_pb2
-from proto import user_pb2
+from mrproto import project_pb2
+from mrproto import tracker_pb2
+from mrproto import user_pb2
 from services import service_manager
 from services import client_config_svc
 from testing import fake
diff --git a/framework/test/framework_helpers_test.py b/framework/test/framework_helpers_test.py
index fb8810b..fe0a225 100644
--- a/framework/test/framework_helpers_test.py
+++ b/framework/test/framework_helpers_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for the framework_helpers module."""
 from __future__ import print_function
@@ -15,14 +14,15 @@
   from mox3 import mox
 except ImportError:
   import mox
+import six
 import time
 
 from businesslogic import work_env
 from framework import framework_helpers
 from framework import framework_views
-from proto import features_pb2
-from proto import project_pb2
-from proto import user_pb2
+from mrproto import features_pb2
+from mrproto import project_pb2
+from mrproto import user_pb2
 from services import service_manager
 from testing import fake
 from testing import testing_helpers
@@ -381,8 +381,8 @@
     """Run one call to the target method and check expected results."""
     actual_added, actual_removed = framework_helpers.ComputeListDeltas(
         old, new)
-    self.assertItemsEqual(added, actual_added)
-    self.assertItemsEqual(removed, actual_removed)
+    six.assertCountEqual(self, added, actual_added)
+    six.assertCountEqual(self, removed, actual_removed)
 
   def testEmptyLists(self):
     self.DoOne(old=[], new=[], added=[], removed=[])
@@ -430,7 +430,7 @@
         'preview_on_hover',
         'settings_user_prefs',
         ]
-    self.assertItemsEqual(expected_keys, list(page_data.keys()))
+    six.assertCountEqual(self, expected_keys, list(page_data.keys()))
 
     self.assertEqual('profile/url', page_data['profile_url_fragment'])
     self.assertTrue(page_data['settings_user_prefs'].public_issue_notice)
diff --git a/framework/test/framework_views_test.py b/framework/test/framework_views_test.py
index 57f9fd1..edb05cd 100644
--- a/framework/test/framework_views_test.py
+++ b/framework/test/framework_views_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for framework_views classes."""
 from __future__ import print_function
@@ -14,9 +13,9 @@
 from framework import framework_constants
 from framework import framework_views
 from framework import monorailrequest
-from proto import project_pb2
-from proto import tracker_pb2
-from proto import user_pb2
+from mrproto import project_pb2
+from mrproto import tracker_pb2
+from mrproto import user_pb2
 import settings
 from services import service_manager
 from testing import fake
diff --git a/framework/test/gcs_helpers_test.py b/framework/test/gcs_helpers_test.py
index a7c01d0..5eab073 100644
--- a/framework/test/gcs_helpers_test.py
+++ b/framework/test/gcs_helpers_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for the framework_helpers module."""
 from __future__ import print_function
@@ -27,8 +26,7 @@
     self.testbed.init_memcache_stub()
     self.testbed.init_app_identity_stub()
 
-    self.test_storage_client = mock.create_autospec(
-        storage.Client, instance=True)
+    self.test_storage_client = mock.MagicMock()
     mock.patch.object(
         storage, 'Client', return_value=self.test_storage_client).start()
 
diff --git a/framework/test/grid_view_helpers_test.py b/framework/test/grid_view_helpers_test.py
index df3ecc6..d54353a 100644
--- a/framework/test/grid_view_helpers_test.py
+++ b/framework/test/grid_view_helpers_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for grid_view_helpers classes and functions."""
 from __future__ import print_function
@@ -13,7 +12,7 @@
 from framework import framework_constants
 from framework import framework_views
 from framework import grid_view_helpers
-from proto import tracker_pb2
+from mrproto import tracker_pb2
 from testing import fake
 from tracker import tracker_bizobj
 
diff --git a/framework/test/jsonfeed_test.py b/framework/test/jsonfeed_test.py
index 4ca83fa..7c9a6b6 100644
--- a/framework/test/jsonfeed_test.py
+++ b/framework/test/jsonfeed_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for jsonfeed module."""
 from __future__ import print_function
@@ -9,13 +8,12 @@
 from __future__ import absolute_import
 
 from six.moves import http_client
-import logging
 import unittest
 
+import flask
 from google.appengine.api import app_identity
 
 from framework import jsonfeed
-from framework import servlet
 from framework import xsrf
 from services import service_manager
 from testing import testing_helpers
@@ -28,7 +26,7 @@
 
   def testGet(self):
     """Tests handling of GET requests."""
-    feed = TestableJsonFeed()
+    feed = _TestableJsonFeed()
 
     # all expected args are present + a bonus arg that should be ignored
     feed.mr = testing_helpers.MakeMonorailRequest(
@@ -41,7 +39,7 @@
 
   def testPost(self):
     """Tests handling of POST requests."""
-    feed = TestableJsonFeed()
+    feed = _TestableJsonFeed()
     feed.mr = testing_helpers.MakeMonorailRequest(
         path='/foo/bar/wee?sna=foo', method='POST',
         params={'a': '123', 'z': 'zebra'})
@@ -52,7 +50,7 @@
     self.assertEqual(1, len(feed.json_data))
 
   def testSecurityTokenChecked_BadToken(self):
-    feed = TestableJsonFeed()
+    feed = _TestableJsonFeed()
     feed.mr = testing_helpers.MakeMonorailRequest(
         user_info={'user_id': 555})
     # Note that feed.mr has no token set.
@@ -64,7 +62,7 @@
     self.assertRaises(xsrf.TokenIncorrect, feed.post)
 
   def testSecurityTokenChecked_HandlerDoesNotNeedToken(self):
-    feed = TestableJsonFeed()
+    feed = _TestableJsonFeed()
     feed.mr = testing_helpers.MakeMonorailRequest(
         user_info={'user_id': 555})
     # Note that feed.mr has no token set.
@@ -73,21 +71,21 @@
     feed.post()
 
   def testSecurityTokenChecked_AnonUserDoesNotNeedToken(self):
-    feed = TestableJsonFeed()
+    feed = _TestableJsonFeed()
     feed.mr = testing_helpers.MakeMonorailRequest()
     # Note that feed.mr has no token set, but also no auth.user_id.
     feed.get()
     feed.post()
 
   def testSameAppOnly_ExternallyAccessible(self):
-    feed = TestableJsonFeed()
+    feed = _TestableJsonFeed()
     feed.mr = testing_helpers.MakeMonorailRequest()
     # Note that request has no X-Appengine-Inbound-Appid set.
     feed.get()
     feed.post()
 
   def testSameAppOnly_InternalOnlyCalledFromSameApp(self):
-    feed = TestableJsonFeed()
+    feed = _TestableJsonFeed()
     feed.CHECK_SAME_APP = True
     feed.mr = testing_helpers.MakeMonorailRequest()
     app_id = app_identity.get_application_id()
@@ -96,36 +94,36 @@
     feed.post()
 
   def testSameAppOnly_InternalOnlyCalledExternally(self):
-    feed = TestableJsonFeed()
+    feed = _TestableJsonFeed()
     feed.CHECK_SAME_APP = True
     feed.mr = testing_helpers.MakeMonorailRequest()
     # Note that request has no X-Appengine-Inbound-Appid set.
+    feed.response = flask.Response()
     self.assertIsNone(feed.get())
     self.assertFalse(feed.handle_request_called)
-    self.assertEqual(http_client.FORBIDDEN, feed.response.status)
+    self.assertEqual(http_client.FORBIDDEN, feed.response.status_code)
     self.assertIsNone(feed.post())
     self.assertFalse(feed.handle_request_called)
-    self.assertEqual(http_client.FORBIDDEN, feed.response.status)
+    self.assertEqual(http_client.FORBIDDEN, feed.response.status_code)
 
   def testSameAppOnly_InternalOnlyCalledFromWrongApp(self):
-    feed = TestableJsonFeed()
+    feed = _TestableJsonFeed()
     feed.CHECK_SAME_APP = True
     feed.mr = testing_helpers.MakeMonorailRequest()
     feed.mr.request.headers['X-Appengine-Inbound-Appid'] = 'wrong'
+    feed.response = flask.Response()
     self.assertIsNone(feed.get())
     self.assertFalse(feed.handle_request_called)
-    self.assertEqual(http_client.FORBIDDEN, feed.response.status)
+    self.assertEqual(http_client.FORBIDDEN, feed.response.status_code)
     self.assertIsNone(feed.post())
     self.assertFalse(feed.handle_request_called)
-    self.assertEqual(http_client.FORBIDDEN, feed.response.status)
+    self.assertEqual(http_client.FORBIDDEN, feed.response.status_code)
 
 
-class TestableJsonFeed(jsonfeed.JsonFeed):
+class _TestableJsonFeed(jsonfeed.JsonFeed):
 
-  def __init__(self, request=None):
-    response = testing_helpers.Blank()
-    super(TestableJsonFeed, self).__init__(
-        request or 'req', response, services=service_manager.Services())
+  def __init__(self):
+    super(_TestableJsonFeed, self).__init__(services=service_manager.Services())
 
     self.response_data = None
     self.handle_request_called = False
diff --git a/framework/test/monitoring_test.py b/framework/test/monitoring_test.py
index edbd15d..0592336 100644
--- a/framework/test/monitoring_test.py
+++ b/framework/test/monitoring_test.py
@@ -1,6 +1,6 @@
-# Copyright 2020 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file.
+# Copyright 2020 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for the monitoring module."""
 
diff --git a/framework/test/monorailcontext_test.py b/framework/test/monorailcontext_test.py
index 2071c9e..04ef491 100644
--- a/framework/test/monorailcontext_test.py
+++ b/framework/test/monorailcontext_test.py
@@ -1,7 +1,6 @@
-# Copyright 2018 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2018 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for MonorailContext."""
 from __future__ import print_function
diff --git a/framework/test/monorailrequest_test.py b/framework/test/monorailrequest_test.py
index ef52f1e..0c81694 100644
--- a/framework/test/monorailrequest_test.py
+++ b/framework/test/monorailrequest_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for the monorailrequest module."""
 from __future__ import print_function
@@ -18,17 +17,16 @@
 except ImportError:
   import mox
 import six
+import werkzeug
 
 from google.appengine.api import oauth
 from google.appengine.api import users
 
-import webapp2
-
 from framework import exceptions
 from framework import monorailrequest
 from framework import permissions
-from proto import project_pb2
-from proto import tracker_pb2
+from mrproto import project_pb2
+from mrproto import tracker_pb2
 from services import service_manager
 from testing import fake
 from testing import testing_helpers
@@ -170,14 +168,15 @@
 
   def testGetIntListParam_NoParam(self):
     mr = monorailrequest.MonorailRequest(self.services)
-    mr.ParseRequest(webapp2.Request.blank('servlet'), self.services)
+    mr.ParseRequest(testing_helpers.RequestStub('servlet'), self.services)
     self.assertEqual(mr.GetIntListParam('ids'), None)
     self.assertEqual(mr.GetIntListParam('ids', default_value=['test']),
                       ['test'])
 
   def testGetIntListParam_OneValue(self):
     mr = monorailrequest.MonorailRequest(self.services)
-    mr.ParseRequest(webapp2.Request.blank('servlet?ids=11'), self.services)
+    request = testing_helpers.RequestStub('servlet?ids=11')
+    mr.ParseRequest(request, self.services)
     self.assertEqual(mr.GetIntListParam('ids'), [11])
     self.assertEqual(mr.GetIntListParam('ids', default_value=['test']),
                       [11])
@@ -185,7 +184,7 @@
   def testGetIntListParam_MultiValue(self):
     mr = monorailrequest.MonorailRequest(self.services)
     mr.ParseRequest(
-        webapp2.Request.blank('servlet?ids=21,22,23'), self.services)
+        testing_helpers.RequestStub('servlet?ids=21,22,23'), self.services)
     self.assertEqual(mr.GetIntListParam('ids'), [21, 22, 23])
     self.assertEqual(mr.GetIntListParam('ids', default_value=['test']),
                       [21, 22, 23])
@@ -194,18 +193,18 @@
     mr = monorailrequest.MonorailRequest(self.services)
     with self.assertRaises(exceptions.InputException):
       mr.ParseRequest(
-          webapp2.Request.blank('servlet?ids=not_an_int'), self.services)
+          testing_helpers.RequestStub('servlet?ids=not_an_int'), self.services)
 
   def testGetIntListParam_Malformed(self):
     mr = monorailrequest.MonorailRequest(self.services)
     with self.assertRaises(exceptions.InputException):
       mr.ParseRequest(
-          webapp2.Request.blank('servlet?ids=31,32,,'), self.services)
+          testing_helpers.RequestStub('servlet?ids=31,32,,'), self.services)
 
   def testDefaultValuesNoUrl(self):
     """If request has no param, default param values should be used."""
     mr = monorailrequest.MonorailRequest(self.services)
-    mr.ParseRequest(webapp2.Request.blank('servlet'), self.services)
+    mr.ParseRequest(testing_helpers.RequestStub('servlet'), self.services)
     self.assertEqual(mr.GetParam('r', 3), 3)
     self.assertEqual(mr.GetIntParam('r', 3), 3)
     self.assertEqual(mr.GetPositiveIntParam('r', 3), 3)
@@ -213,7 +212,7 @@
 
   def _MRWithMockRequest(
       self, path, headers=None, *mr_args, **mr_kwargs):
-    request = webapp2.Request.blank(path, headers=headers)
+    request = testing_helpers.RequestStub(path, headers=headers)
     mr = monorailrequest.MonorailRequest(self.services, *mr_args, **mr_kwargs)
     mr.ParseRequest(request, self.services)
     return mr
@@ -282,7 +281,7 @@
         mr.viewed_user_auth.user_pb)
 
   def testViewedUser_NoSuchEmail(self):
-    with self.assertRaises(webapp2.HTTPException) as cm:
+    with self.assertRaises(werkzeug.exceptions.HTTPException) as cm:
       self._MRWithMockRequest('/u/unknownuser@example.com/')
     self.assertEqual(404, cm.exception.code)
 
@@ -353,13 +352,13 @@
 
     # project colspec contains hotlist columns
     mr = testing_helpers.MakeMonorailRequest(
-        path='p/proj/issues/detail?id=123&colspec=Rank Adder Adder Owner')
+        path='/p/proj/issues/detail?id=123&colspec=Rank Adder Adder Owner')
     mr.ComputeColSpec(None)
     self.assertEqual(tracker_constants.DEFAULT_COL_SPEC, mr.col_spec)
 
     # hotlist columns are not deleted when page is a hotlist page
     mr = testing_helpers.MakeMonorailRequest(
-        path='u/jrobbins@example.com/hotlists/TestHotlist?colspec=Rank Adder',
+        path='/u/jrobbins@example.com/hotlists/TestHotlist?colspec=Rank Adder',
         hotlist=self.hotlist)
     mr.ComputeColSpec(None)
     self.assertEqual('Rank Adder', mr.col_spec)
@@ -513,10 +512,13 @@
     self.assertEqual(['Foo-Bar', 'Foo-Bar-Baz', 'Release-1.2', 'Hey', 'There'],
                      parse('Foo-Bar Foo-Bar-Baz Release-1.2 Hey!There'))
     self.assertEqual(
-        ['\xe7\xaa\xbf\xe8\x8b\xa5\xe7\xb9\xb9'.decode('utf-8'),
-         '\xe5\x9f\xba\xe5\x9c\xb0\xe3\x81\xaf'.decode('utf-8')],
-        parse('\xe7\xaa\xbf\xe8\x8b\xa5\xe7\xb9\xb9 '
-              '\xe5\x9f\xba\xe5\x9c\xb0\xe3\x81\xaf'.decode('utf-8')))
+        [
+            b'\xe7\xaa\xbf\xe8\x8b\xa5\xe7\xb9\xb9'.decode('utf-8'),
+            b'\xe5\x9f\xba\xe5\x9c\xb0\xe3\x81\xaf'.decode('utf-8')
+        ],
+        parse(
+            b'\xe7\xaa\xbf\xe8\x8b\xa5\xe7\xb9\xb9 '
+            b'\xe5\x9f\xba\xe5\x9c\xb0\xe3\x81\xaf'.decode('utf-8')))
 
   def testParseColSpec_Dedup(self):
     """An attacker cannot inflate response size by repeating a column."""
@@ -584,7 +586,7 @@
         email=lambda: email))
     self.mox.ReplayAll()
 
-    request = webapp2.Request.blank('/p/' + project_name)
+    request = testing_helpers.RequestStub('/p/' + project_name)
     mr = monorailrequest.MonorailRequest(self.services)
     with mr.profiler.Phase('parse user info'):
       mr.ParseRequest(request, self.services)
@@ -609,7 +611,7 @@
 
   def testExternalUserPermissions_Archived(self):
     mr = self.MakeRequestAsUser('archived', 'user@gmail.com')
-    self.CheckPermissions(mr.perms, False, False, False)
+    self.CheckPermissions(mr.perms, True, False, False)
 
   def testExternalUserPermissions_MembersOnly(self):
     mr = self.MakeRequestAsUser('members-only', 'user@gmail.com')
diff --git a/framework/test/paginate_test.py b/framework/test/paginate_test.py
index 99adaa9..44c8b59 100644
--- a/framework/test/paginate_test.py
+++ b/framework/test/paginate_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for pagination classes."""
 from __future__ import print_function
@@ -15,7 +14,7 @@
 from framework import exceptions
 from framework import paginate
 from testing import testing_helpers
-from proto import secrets_pb2
+from mrproto import secrets_pb2
 
 
 class PageTokenTest(unittest.TestCase):
diff --git a/framework/test/permissions_test.py b/framework/test/permissions_test.py
index cd67c6c..6280208 100644
--- a/framework/test/permissions_test.py
+++ b/framework/test/permissions_test.py
@@ -1,13 +1,13 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for permissions.py."""
 from __future__ import print_function
 from __future__ import division
 from __future__ import absolute_import
 
+import six
 import time
 import unittest
 
@@ -21,12 +21,13 @@
 from framework import framework_constants
 from framework import framework_views
 from framework import permissions
-from proto import features_pb2
-from proto import project_pb2
-from proto import site_pb2
-from proto import tracker_pb2
-from proto import user_pb2
-from proto import usergroup_pb2
+from mrproto import features_pb2
+from mrproto import project_pb2
+from mrproto import site_pb2
+from mrproto import tracker_pb2
+from mrproto import user_pb2
+from mrproto import usergroup_pb2
+from services import service_manager
 from testing import fake
 from testing import testing_helpers
 from tracker import tracker_bizobj
@@ -160,10 +161,20 @@
     self.assertEqual('PermissionSet(a, b, cc)', self.perms.DebugString())
 
   def testRepr(self):
-    self.assertEqual('PermissionSet(frozenset([]))',
-                     permissions.PermissionSet([]).__repr__())
-    self.assertEqual('PermissionSet(frozenset([\'a\']))',
-                     permissions.PermissionSet(['A']).__repr__())
+    if six.PY2:
+      self.assertEqual(
+          'PermissionSet(frozenset([]))',
+          permissions.PermissionSet([]).__repr__())
+      self.assertEqual(
+          "PermissionSet(frozenset(['a']))",
+          permissions.PermissionSet(['A']).__repr__())
+    else:
+      self.assertEqual(
+          'PermissionSet(frozenset())',
+          permissions.PermissionSet([]).__repr__())
+      self.assertEqual(
+          "PermissionSet(frozenset({'a'}))",
+          permissions.PermissionSet(['A']).__repr__())
 
 
 class PermissionsTest(unittest.TestCase):
@@ -953,8 +964,9 @@
         None, None, self.live_project))
 
     self.archived_project.delete_time = self.NOW + 1
-    self.assertFalse(permissions.UserCanViewProject(
-        None, None, self.archived_project))
+    # Anonymous users may view an archived project.
+    self.assertTrue(
+        permissions.UserCanViewProject(None, None, self.archived_project))
     self.assertTrue(permissions.UserCanViewProject(
         self.owner, {self.OWNER_USER_ID}, self.archived_project))
     self.assertTrue(permissions.UserCanViewProject(
@@ -1230,6 +1242,14 @@
   ADMIN_PERMS = permissions.ADMIN_PERMISSIONSET
   PERMS = permissions.EMPTY_PERMISSIONSET
 
+  def setUp(self):
+    self.user_svc = fake.UserService()
+    self.services = service_manager.Services(user=self.user_svc)
+
+    self.user_svc.TestAddUser('allowlisteduser@test.com', 567)
+
+    settings.config_freeze_project_ids = {}
+
   def testUpdateIssuePermissions_Normal(self):
     perms = permissions.UpdateIssuePermissions(
         permissions.COMMITTER_ACTIVE_PERMISSIONSET, self.PROJECT,
@@ -1366,6 +1386,21 @@
         self.RESTRICTED_ISSUE3, {OWNER_ID})
     self.assertIn('editissue', perms.perm_names)
 
+  def testUpdateIssuePermissions_DefaultPermsDoNotIncludeEdit(self):
+    # Permissions can be checked from the homepage without a project context.
+    perms = permissions.UpdateIssuePermissions(
+        permissions.COMMITTER_ACTIVE_PERMISSIONSET, None,
+        self.RESTRICTED_ISSUE3, {OWNER_ID})
+    self.assertNotIn('editissue', perms.perm_names)
+
+  def testUpdateIssuePermissions_OwnerRespectsArchivedProject(self):
+    project = project_pb2.Project()
+    project.state = project_pb2.ProjectState.ARCHIVED
+    perms = permissions.UpdateIssuePermissions(
+        permissions.COMMITTER_ACTIVE_PERMISSIONSET, project,
+        self.RESTRICTED_ISSUE3, {OWNER_ID})
+    self.assertNotIn('editissue', perms.perm_names)
+
   def testUpdateIssuePermissions_CustomPermissionGrantsEditPermission(self):
     project = project_pb2.Project()
     project.committer_ids.append(999)
@@ -1650,6 +1685,39 @@
         {111, 222}, permissions.PermissionSet([]), self.PROJECT,
         [333]))
 
+  def testCanEditProjectConfig_Admin(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        project=fake.Project(project_id=789))
+    mr.perms = permissions.ADMIN_PERMISSIONSET
+    self.assertTrue(permissions.CanEditProjectConfig(mr, self.services))
+
+  def testCanEditProjectConfig_NormalUser(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        project=fake.Project(project_id=789))
+    mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertFalse(permissions.CanEditProjectConfig(mr, self.services))
+
+  def testCanEditProjectConfig_Admin_FrozenConfig(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        project=fake.Project(project_id=789))
+    mr.perms = permissions.ADMIN_PERMISSIONSET
+    mr.auth.effective_ids = {567}
+
+    settings.config_freeze_override_users = {}
+    settings.config_freeze_project_ids = {789}
+    self.assertFalse(permissions.CanEditProjectConfig(mr, self.services))
+
+  def testCanEditProjectConfig_Admin_FrozenConfig_AllowedUser(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        project=fake.Project(project_id=789))
+    mr.perms = permissions.ADMIN_PERMISSIONSET
+    mr.auth.effective_ids = {567}
+
+    settings.config_freeze_override_users = {789: 'allowlisteduser@test.com'}
+    settings.config_freeze_project_ids = {789}
+
+    self.assertTrue(permissions.CanEditProjectConfig(mr, self.services))
+
   def testCanViewComponentDef_ComponentAdmin(self):
     cd = tracker_pb2.ComponentDef(admin_ids=[111])
     perms = permissions.PermissionSet([])
@@ -1667,36 +1735,51 @@
         {111}, permissions.PermissionSet([]),
         None, cd))
 
-  def testCanEditComponentDef_ComponentAdmin(self):
+  def testCanEditComponentDefLegacy_ComponentAdmin(self):
     cd = tracker_pb2.ComponentDef(admin_ids=[111], path='Whole')
     sub_cd = tracker_pb2.ComponentDef(admin_ids=[222], path='Whole>Part')
     config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
     config.component_defs.append(cd)
     config.component_defs.append(sub_cd)
     perms = permissions.PermissionSet([])
-    self.assertTrue(permissions.CanEditComponentDef(
-        {111}, perms, None, cd, config))
-    self.assertFalse(permissions.CanEditComponentDef(
-        {222}, perms, None, cd, config))
-    self.assertFalse(permissions.CanEditComponentDef(
-        {999}, perms, None, cd, config))
-    self.assertTrue(permissions.CanEditComponentDef(
-        {111}, perms, None, sub_cd, config))
-    self.assertTrue(permissions.CanEditComponentDef(
-        {222}, perms, None, sub_cd, config))
-    self.assertFalse(permissions.CanEditComponentDef(
-        {999}, perms, None, sub_cd, config))
+    self.assertTrue(
+        permissions.CanEditComponentDefLegacy({111}, perms, None, cd, config))
+    self.assertFalse(
+        permissions.CanEditComponentDefLegacy({222}, perms, None, cd, config))
+    self.assertFalse(
+        permissions.CanEditComponentDefLegacy({999}, perms, None, cd, config))
+    self.assertTrue(
+        permissions.CanEditComponentDefLegacy(
+            {111}, perms, None, sub_cd, config))
+    self.assertTrue(
+        permissions.CanEditComponentDefLegacy(
+            {222}, perms, None, sub_cd, config))
+    self.assertFalse(
+        permissions.CanEditComponentDefLegacy(
+            {999}, perms, None, sub_cd, config))
 
-  def testCanEditComponentDef_ProjectOwners(self):
+  def testCanEditComponentDefLegacy_ProjectOwners(self):
     cd = tracker_pb2.ComponentDef(path='Whole')
     config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
     config.component_defs.append(cd)
-    self.assertTrue(permissions.CanEditComponentDef(
-        {111}, permissions.PermissionSet([permissions.EDIT_PROJECT]),
-        None, cd, config))
-    self.assertFalse(permissions.CanEditComponentDef(
-        {111}, permissions.PermissionSet([]),
-        None, cd, config))
+    self.assertTrue(
+        permissions.CanEditComponentDefLegacy(
+            {111}, permissions.PermissionSet([permissions.EDIT_PROJECT]), None,
+            cd, config))
+    self.assertFalse(
+        permissions.CanEditComponentDefLegacy(
+            {111}, permissions.PermissionSet([]), None, cd, config))
+
+  def testCanEditComponentDefLegacy_FrozenProject(self):
+    cd = tracker_pb2.ComponentDef(path='Whole')
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs.append(cd)
+    project = project_pb2.Project(project_id=789)
+    settings.config_freeze_project_ids = {789}
+    self.assertFalse(
+        permissions.CanEditComponentDefLegacy(
+            {111}, permissions.PermissionSet([permissions.EDIT_PROJECT]),
+            project, cd, config))
 
   def testCanViewFieldDef_FieldAdmin(self):
     fd = tracker_pb2.FieldDef(admin_ids=[111])
diff --git a/framework/test/profiler_test.py b/framework/test/profiler_test.py
index 3cc7e85..3694e93 100644
--- a/framework/test/profiler_test.py
+++ b/framework/test/profiler_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Test for monorail.framework.profiler."""
 from __future__ import print_function
@@ -73,7 +72,7 @@
         with prof.Phase('baz'):
           raise Exception('whoops')
     except Exception as e:
-      self.assertEqual(e.message, 'whoops')
+      self.assertEqual(str(e), 'whoops')
     finally:
       self.assertEqual(prof.current_phase.name, 'overall profile')
       self.assertEqual(prof.top_phase.subphases[0].subphases[1].name, 'baz')
diff --git a/framework/test/ratelimiter_test.py b/framework/test/ratelimiter_test.py
index 84230e8..bfc8de7 100644
--- a/framework/test/ratelimiter_test.py
+++ b/framework/test/ratelimiter_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for RateLimiter.
 """
diff --git a/framework/test/reap_test.py b/framework/test/reap_test.py
index 92d17fb..e01273f 100644
--- a/framework/test/reap_test.py
+++ b/framework/test/reap_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for the reap module."""
 from __future__ import print_function
@@ -20,7 +19,7 @@
 
 from framework import reap
 from framework import sql
-from proto import project_pb2
+from mrproto import project_pb2
 from services import service_manager
 from services import template_svc
 from testing import fake
diff --git a/framework/test/registerpages_helpers_test.py b/framework/test/registerpages_helpers_test.py
deleted file mode 100644
index 61c489e..0000000
--- a/framework/test/registerpages_helpers_test.py
+++ /dev/null
@@ -1,59 +0,0 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
-
-"""Tests for URL handler registration helper functions."""
-from __future__ import print_function
-from __future__ import division
-from __future__ import absolute_import
-
-import unittest
-
-import webapp2
-
-from framework import registerpages_helpers
-
-
-class SendRedirectInScopeTest(unittest.TestCase):
-
-  def testMakeRedirectInScope_Error(self):
-    self.assertRaises(
-        AssertionError,
-        registerpages_helpers.MakeRedirectInScope, 'no/initial/slash', 'p')
-    self.assertRaises(
-        AssertionError,
-        registerpages_helpers.MakeRedirectInScope, '', 'p')
-
-  def testMakeRedirectInScope_Normal(self):
-    factory = registerpages_helpers.MakeRedirectInScope('/', 'p')
-    # Non-dasher, normal case
-    request = webapp2.Request.blank(
-        path='/p/foo', headers={'Host': 'example.com'})
-    response = webapp2.Response()
-    redirector = factory(request, response)
-    redirector.get()
-    self.assertEqual(response.location, '//example.com/p/foo/')
-    self.assertEqual(response.status, '301 Moved Permanently')
-
-  def testMakeRedirectInScope_Temporary(self):
-    factory = registerpages_helpers.MakeRedirectInScope(
-        '/', 'p', permanent=False)
-    request = webapp2.Request.blank(
-        path='/p/foo', headers={'Host': 'example.com'})
-    response = webapp2.Response()
-    redirector = factory(request, response)
-    redirector.get()
-    self.assertEqual(response.location, '//example.com/p/foo/')
-    self.assertEqual(response.status, '302 Moved Temporarily')
-
-  def testMakeRedirectInScope_KeepQueryString(self):
-    factory = registerpages_helpers.MakeRedirectInScope(
-        '/', 'p', keep_qs=True)
-    request = webapp2.Request.blank(
-        path='/p/foo?q=1', headers={'Host': 'example.com'})
-    response = webapp2.Response()
-    redirector = factory(request, response)
-    redirector.get()
-    self.assertEqual(response.location, '//example.com/p/foo/?q=1')
-    self.assertEqual(response.status, '302 Moved Temporarily')
diff --git a/framework/test/servlet_helpers_test.py b/framework/test/servlet_helpers_test.py
index 870de40..4700e12 100644
--- a/framework/test/servlet_helpers_test.py
+++ b/framework/test/servlet_helpers_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for servlet base class helper functions."""
 from __future__ import print_function
@@ -12,12 +11,12 @@
 import settings
 
 from google.appengine.ext import testbed
-
+from six.moves import urllib
 
 from framework import permissions
 from framework import servlet_helpers
-from proto import project_pb2
-from proto import tracker_pb2
+from mrproto import project_pb2
+from mrproto import tracker_pb2
 from testing import testing_helpers
 
 
@@ -217,30 +216,21 @@
   def tearDown(self):
     self.testbed.deactivate()
 
-  def testCreateLoginUrl(self):
-    _, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/issues/detail?id=123&q=term', project=self.project)
-    url = servlet_helpers.SafeCreateLoginURL(mr, 'current.url.to.return.to')
-    # Ensure that users can pick their account to use with Monorail.
-    self.assertIn('/AccountChooser', url)
-    self.assertIn('current.url.to.return.to', url)
-
-  def testCreateLoginUrl(self):
-    _, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/issues/detail?id=123&q=term', project=self.project)
-    url = servlet_helpers.SafeCreateLoginURL(mr, 'current.url.to.return.to')
-    # Ensure that users can pick their account to use with Monorail.
-    self.assertIn('/AccountChooser', url)
-    self.assertIn('current.url.to.return.to', url)
-
   def testCreateEscapedLoginUrlFromMR(self):
     _, mr = testing_helpers.GetRequestObjects(
         path='/p/proj/issues/detail?id=123&q=term', project=self.project)
     mr.current_page_url_encoded = (
         'https%3A%2F%2Fbugs.chromium.org'
-        '%2Fp%2Fchromium%2Fissues%2Fentry')
+        '%2Fp%2Fchromium%2Fissues%2Fentry%3F'
+        'template%3DBuild%2520Infrastructure%26'
+        'labels%3DRestrict-View-Google%2CInfra-Troopers')
     url = servlet_helpers.SafeCreateLoginURL(mr)
-    self.assertIn('https%3A%2F%2Fbugs.chromium.org%2Fp', url)
+    double_encoded_query = (
+        'https%253A%252F%252Fbugs.chromium.org'
+        '%252Fp%252Fchromium%252Fissues%252Fentry%253F'
+        'template%253DBuild%252520Infrastructure%2526'
+        'labels%253DRestrict-View-Google%252CInfra-Troopers')
+    self.assertIn(double_encoded_query, url)
 
   def testCreateLogoutUrl(self):
     _, mr = testing_helpers.GetRequestObjects(
diff --git a/framework/test/servlet_test.py b/framework/test/servlet_test.py
index 694e493..33f3644 100644
--- a/framework/test/servlet_test.py
+++ b/framework/test/servlet_test.py
@@ -1,8 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
-
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 """Unit tests for servlet base class module."""
 from __future__ import print_function
 from __future__ import division
@@ -11,38 +9,30 @@
 import time
 import mock
 import unittest
+import logging
 
-from google.appengine.api import app_identity
 from google.appengine.ext import testbed
 
-import webapp2
-
-from framework import framework_constants, servlet_helpers
+from framework import framework_constants
 from framework import servlet
+from framework import servlet_helpers
 from framework import xsrf
-from proto import project_pb2
-from proto import tracker_pb2
-from proto import user_pb2
+from mrproto import project_pb2
+from mrproto import tracker_pb2
+from mrproto import user_pb2
 from services import service_manager
 from testing import fake
 from testing import testing_helpers
 
 
-class TestableServlet(servlet.Servlet):
+class _TestableServlet(servlet.Servlet):
   """A tiny concrete subclass of abstract class Servlet."""
 
-  def __init__(self, request, response, services=None, do_post_redirect=True):
-    super(TestableServlet, self).__init__(request, response, services=services)
+  def __init__(self, services=None, do_post_redirect=True):
+    super(_TestableServlet, self).__init__(services=services)
     self.do_post_redirect = do_post_redirect
     self.seen_post_data = None
 
-  def ProcessFormData(self, _mr, post_data):
-    self.seen_post_data = post_data
-    if self.do_post_redirect:
-      return '/This/Is?The=Next#Page'
-    else:
-      self.response.write('sending raw data to browser')
-
 
 class ServletTest(unittest.TestCase):
 
@@ -53,8 +43,7 @@
         user=fake.UserService(),
         usergroup=fake.UserGroupService())
     services.user.TestAddUser('user@example.com', 111)
-    self.page_class = TestableServlet(
-        webapp2.Request.blank('/'), webapp2.Response(), services=services)
+    self.page_class = servlet.Servlet(services=services)
     self.testbed = testbed.Testbed()
     self.testbed.activate()
     self.testbed.init_user_stub()
@@ -69,6 +58,30 @@
     self.assertTrue(self.page_class._TEMPLATE_PATH.endswith('/templates/'))
     self.assertEqual(None, self.page_class._PAGE_TEMPLATE)
 
+  @mock.patch('flask.abort')
+  def testCheckForMovedProject_NoRedirect(self, mock_abort):
+    project = fake.Project(
+        project_name='proj', state=project_pb2.ProjectState.LIVE)
+    request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj', project=project)
+    self.page_class._CheckForMovedProject(mr, request)
+    mock_abort.assert_not_called()
+
+    request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/source/browse/p/adminAdvanced', project=project)
+    self.page_class._CheckForMovedProject(mr, request)
+    mock_abort.assert_not_called()
+
+  @mock.patch('flask.redirect')
+  def testCheckForMovedProject_Redirect(self, mock_redirect):
+    project = fake.Project(project_name='proj', moved_to='http://example.com')
+    request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj', project=project)
+    self.page_class.request_path = '/p/test'
+    self.page_class._CheckForMovedProject(mr, request)
+    mock_redirect.assert_called_once_with(
+        'http://127.0.0.1/hosting/moved?project=proj', code=302)
+
   def testGatherBaseData(self):
     project = self.page_class.services.project.TestAddProject(
         'testproj', state=project_pb2.ProjectState.LIVE)
@@ -89,284 +102,6 @@
     self.assertTrue(
         base_data['currentPageURLEncoded'].endswith('%2Fp%2Ftestproj%2Ffeeds'))
 
-  def testFormHandlerURL(self):
-    self.assertEqual('/edit.do', self.page_class._FormHandlerURL('/'))
-    self.assertEqual(
-      '/something/edit.do',
-      self.page_class._FormHandlerURL('/something/'))
-    self.assertEqual(
-      '/something/edit.do',
-      self.page_class._FormHandlerURL('/something/edit.do'))
-    self.assertEqual(
-      '/something/detail_ezt.do',
-      self.page_class._FormHandlerURL('/something/detail_ezt'))
-
-  def testProcessForm_BadToken(self):
-    user_id = 111
-    token = 'no soup for you'
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/we/we/we?so=excited',
-        params={
-            'yesterday': 'thursday',
-            'today': 'friday',
-            'token': token
-        },
-        user_info={'user_id': user_id},
-        method='POST',
-    )
-    self.assertRaises(
-        xsrf.TokenIncorrect, self.page_class._DoFormProcessing, request, mr)
-    self.assertEqual(None, self.page_class.seen_post_data)
-
-  def testProcessForm_XhrAllowed_BadToken(self):
-    user_id = 111
-    token = 'no soup for you'
-
-    self.page_class.ALLOW_XHR = True
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/we/we/we?so=excited',
-        params={
-            'yesterday': 'thursday',
-            'today': 'friday',
-            'token': token
-        },
-        user_info={'user_id': user_id},
-        method='POST',
-    )
-    self.assertRaises(
-        xsrf.TokenIncorrect, self.page_class._DoFormProcessing, request, mr)
-    self.assertEqual(None, self.page_class.seen_post_data)
-
-  def testProcessForm_XhrAllowed_AcceptsPathToken(self):
-    user_id = 111
-    token = xsrf.GenerateToken(user_id, '/we/we/we')
-
-    self.page_class.ALLOW_XHR = True
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/we/we/we?so=excited',
-        params={
-            'yesterday': 'thursday',
-            'today': 'friday',
-            'token': token
-        },
-        user_info={'user_id': user_id},
-        method='POST',
-    )
-    with self.assertRaises(webapp2.HTTPException) as cm:
-      self.page_class._DoFormProcessing(request, mr)
-    self.assertEqual(302, cm.exception.code)  # forms redirect on success
-
-    self.assertDictEqual(
-        {
-            'yesterday': 'thursday',
-            'today': 'friday',
-            'token': token
-        }, dict(self.page_class.seen_post_data))
-
-  def testProcessForm_XhrAllowed_AcceptsXhrToken(self):
-    user_id = 111
-    token = xsrf.GenerateToken(user_id, 'xhr')
-
-    self.page_class.ALLOW_XHR = True
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/we/we/we?so=excited',
-        params={'yesterday': 'thursday', 'today': 'friday', 'token': token},
-        user_info={'user_id': user_id},
-        method='POST',
-    )
-    with self.assertRaises(webapp2.HTTPException) as cm:
-      self.page_class._DoFormProcessing(request, mr)
-    self.assertEqual(302, cm.exception.code)  # forms redirect on success
-
-    self.assertDictEqual(
-        {
-            'yesterday': 'thursday',
-            'today': 'friday',
-            'token': token
-        }, dict(self.page_class.seen_post_data))
-
-  def testProcessForm_RawResponse(self):
-    user_id = 111
-    token = xsrf.GenerateToken(user_id, '/we/we/we')
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/we/we/we?so=excited',
-        params={'yesterday': 'thursday', 'today': 'friday', 'token': token},
-        user_info={'user_id': user_id},
-        method='POST',
-    )
-    self.page_class.do_post_redirect = False
-    self.page_class._DoFormProcessing(request, mr)
-    self.assertEqual(
-        'sending raw data to browser',
-        self.page_class.response.body)
-
-  def testProcessForm_Normal(self):
-    user_id = 111
-    token = xsrf.GenerateToken(user_id, '/we/we/we')
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/we/we/we?so=excited',
-        params={'yesterday': 'thursday', 'today': 'friday', 'token': token},
-        user_info={'user_id': user_id},
-        method='POST',
-    )
-    with self.assertRaises(webapp2.HTTPException) as cm:
-      self.page_class._DoFormProcessing(request, mr)
-    self.assertEqual(302, cm.exception.code)  # forms redirect on success
-
-    self.assertDictEqual(
-        {'yesterday': 'thursday', 'today': 'friday', 'token': token},
-        dict(self.page_class.seen_post_data))
-
-  def testCalcProjectAlert(self):
-    project = fake.Project(
-        project_name='alerttest', state=project_pb2.ProjectState.LIVE)
-
-    project_alert = servlet_helpers.CalcProjectAlert(project)
-    self.assertEqual(project_alert, None)
-
-    project.state = project_pb2.ProjectState.ARCHIVED
-    project_alert = servlet_helpers.CalcProjectAlert(project)
-    self.assertEqual(
-        project_alert,
-        'Project is archived: read-only by members only.')
-
-    delete_time = int(time.time() + framework_constants.SECS_PER_DAY * 1.5)
-    project.delete_time = delete_time
-    project_alert = servlet_helpers.CalcProjectAlert(project)
-    self.assertEqual(project_alert, 'Scheduled for deletion in 1 day.')
-
-    delete_time = int(time.time() + framework_constants.SECS_PER_DAY * 2.5)
-    project.delete_time = delete_time
-    project_alert = servlet_helpers.CalcProjectAlert(project)
-    self.assertEqual(project_alert, 'Scheduled for deletion in 2 days.')
-
-  def testCheckForMovedProject_NoRedirect(self):
-    project = fake.Project(
-        project_name='proj', state=project_pb2.ProjectState.LIVE)
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj', project=project)
-    self.page_class._CheckForMovedProject(mr, request)
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/source/browse/p/adminAdvanced', project=project)
-    self.page_class._CheckForMovedProject(mr, request)
-
-  def testCheckForMovedProject_Redirect(self):
-    project = fake.Project(project_name='proj', moved_to='http://example.com')
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj', project=project)
-    with self.assertRaises(webapp2.HTTPException) as cm:
-      self.page_class._CheckForMovedProject(mr, request)
-    self.assertEqual(302, cm.exception.code)  # redirect because project moved
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/source/browse/p/adminAdvanced', project=project)
-    with self.assertRaises(webapp2.HTTPException) as cm:
-      self.page_class._CheckForMovedProject(mr, request)
-    self.assertEqual(302, cm.exception.code)  # redirect because project moved
-
-  def testCheckForMovedProject_AdminAdvanced(self):
-    """We do not redirect away from the page that edits project state."""
-    project = fake.Project(project_name='proj', moved_to='http://example.com')
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/adminAdvanced', project=project)
-    self.page_class._CheckForMovedProject(mr, request)
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/adminAdvanced?ts=123234', project=project)
-    self.page_class._CheckForMovedProject(mr, request)
-
-    request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/adminAdvanced.do', project=project)
-    self.page_class._CheckForMovedProject(mr, request)
-
-  @mock.patch('settings.branded_domains',
-              {'proj': 'branded.example.com', '*': 'bugs.chromium.org'})
-  def testMaybeRedirectToBrandedDomain_RedirBrandedProject(self):
-    """We redirect for a branded project if the user typed a different host."""
-    project = fake.Project(project_name='proj')
-    request, _mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/path', project=project)
-    with self.assertRaises(webapp2.HTTPException) as cm:
-      self.page_class._MaybeRedirectToBrandedDomain(request, 'proj')
-    self.assertEqual(302, cm.exception.code)  # forms redirect on success
-    self.assertEqual('https://branded.example.com/p/proj/path?redir=1',
-                     cm.exception.location)
-
-    request, _mr = testing_helpers.GetRequestObjects(
-      path='/p/proj/path?query', project=project)
-    with self.assertRaises(webapp2.HTTPException) as cm:
-      self.page_class._MaybeRedirectToBrandedDomain(request, 'proj')
-    self.assertEqual(302, cm.exception.code)  # forms redirect on success
-    self.assertEqual('https://branded.example.com/p/proj/path?query&redir=1',
-                     cm.exception.location)
-
-  @mock.patch('settings.branded_domains',
-              {'proj': 'branded.example.com', '*': 'bugs.chromium.org'})
-  def testMaybeRedirectToBrandedDomain_AvoidRedirLoops(self):
-    """Don't redirect for a branded project if already redirected."""
-    project = fake.Project(project_name='proj')
-    request, _mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/path?redir=1', project=project)
-    # No redirect happens.
-    self.page_class._MaybeRedirectToBrandedDomain(request, 'proj')
-
-  @mock.patch('settings.branded_domains',
-              {'proj': 'branded.example.com', '*': 'bugs.chromium.org'})
-  def testMaybeRedirectToBrandedDomain_NonProjectPage(self):
-    """Don't redirect for a branded project if not in any project."""
-    request, _mr = testing_helpers.GetRequestObjects(
-        path='/u/user@example.com')
-    # No redirect happens.
-    self.page_class._MaybeRedirectToBrandedDomain(request, None)
-
-  @mock.patch('settings.branded_domains',
-              {'proj': 'branded.example.com', '*': 'bugs.chromium.org'})
-  def testMaybeRedirectToBrandedDomain_AlreadyOnBrandedHost(self):
-    """Don't redirect for a branded project if already on branded domain."""
-    project = fake.Project(project_name='proj')
-    request, _mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/path', project=project)
-    request.host = 'branded.example.com'
-    # No redirect happens.
-    self.page_class._MaybeRedirectToBrandedDomain(request, 'proj')
-
-  @mock.patch('settings.branded_domains',
-              {'proj': 'branded.example.com', '*': 'bugs.chromium.org'})
-  def testMaybeRedirectToBrandedDomain_Localhost(self):
-    """Don't redirect for a branded project on localhost."""
-    project = fake.Project(project_name='proj')
-    request, _mr = testing_helpers.GetRequestObjects(
-        path='/p/proj/path', project=project)
-    request.host = 'localhost:8080'
-    # No redirect happens.
-    self.page_class._MaybeRedirectToBrandedDomain(request, 'proj')
-
-    request.host = '0.0.0.0:8080'
-    # No redirect happens.
-    self.page_class._MaybeRedirectToBrandedDomain(request, 'proj')
-
-  @mock.patch('settings.branded_domains',
-              {'proj': 'branded.example.com', '*': 'bugs.chromium.org'})
-  def testMaybeRedirectToBrandedDomain_NotBranded(self):
-    """Don't redirect for a non-branded project."""
-    project = fake.Project(project_name='other')
-    request, _mr = testing_helpers.GetRequestObjects(
-        path='/p/other/path?query', project=project)
-    request.host = 'branded.example.com'  # But other project is unbranded.
-
-    with self.assertRaises(webapp2.HTTPException) as cm:
-      self.page_class._MaybeRedirectToBrandedDomain(request, 'other')
-    self.assertEqual(302, cm.exception.code)  # forms redirect on success
-    self.assertEqual('https://bugs.chromium.org/p/other/path?query&redir=1',
-                     cm.exception.location)
-
   def testGatherHelpData_Normal(self):
     project = fake.Project(project_name='proj')
     _request, mr = testing_helpers.GetRequestObjects(
@@ -374,102 +109,3 @@
     help_data = self.page_class.GatherHelpData(mr, {})
     self.assertEqual(None, help_data['cue'])
     self.assertEqual(None, help_data['account_cue'])
-
-  def testGatherHelpData_VacationReminder(self):
-    project = fake.Project(project_name='proj')
-    _request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj', project=project)
-    mr.auth.user_id = 111
-    mr.auth.user_pb.vacation_message = 'Gone skiing'
-    help_data = self.page_class.GatherHelpData(mr, {})
-    self.assertEqual('you_are_on_vacation', help_data['cue'])
-
-    self.page_class.services.user.SetUserPrefs(
-        'cnxn', 111,
-        [user_pb2.UserPrefValue(name='you_are_on_vacation', value='true')])
-    help_data = self.page_class.GatherHelpData(mr, {})
-    self.assertEqual(None, help_data['cue'])
-    self.assertEqual(None, help_data['account_cue'])
-
-  def testGatherHelpData_YouAreBouncing(self):
-    project = fake.Project(project_name='proj')
-    _request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj', project=project)
-    mr.auth.user_id = 111
-    mr.auth.user_pb.email_bounce_timestamp = 1497647529
-    help_data = self.page_class.GatherHelpData(mr, {})
-    self.assertEqual('your_email_bounced', help_data['cue'])
-
-    self.page_class.services.user.SetUserPrefs(
-        'cnxn', 111,
-        [user_pb2.UserPrefValue(name='your_email_bounced', value='true')])
-    help_data = self.page_class.GatherHelpData(mr, {})
-    self.assertEqual(None, help_data['cue'])
-    self.assertEqual(None, help_data['account_cue'])
-
-  def testGatherHelpData_ChildAccount(self):
-    """Display a warning when user is signed in to a child account."""
-    project = fake.Project(project_name='proj')
-    _request, mr = testing_helpers.GetRequestObjects(
-        path='/p/proj', project=project)
-    mr.auth.user_pb.linked_parent_id = 111
-    help_data = self.page_class.GatherHelpData(mr, {})
-    self.assertEqual(None, help_data['cue'])
-    self.assertEqual('switch_to_parent_account', help_data['account_cue'])
-    self.assertEqual('user@example.com', help_data['parent_email'])
-
-  def testGatherDebugData_Visibility(self):
-    project = fake.Project(
-        project_name='testtest', state=project_pb2.ProjectState.LIVE)
-    _request, mr = testing_helpers.GetRequestObjects(
-        path='/p/foo/servlet_path', project=project)
-    debug_data = self.page_class.GatherDebugData(mr, {})
-    self.assertEqual('off', debug_data['dbg'])
-
-    _request, mr = testing_helpers.GetRequestObjects(
-        path='/p/foo/servlet_path?debug=1', project=project)
-    debug_data = self.page_class.GatherDebugData(mr, {})
-    self.assertEqual('on', debug_data['dbg'])
-
-
-class ProjectIsRestrictedTest(unittest.TestCase):
-
-  def testNonRestrictedProject(self):
-    proj = project_pb2.Project()
-    mr = testing_helpers.MakeMonorailRequest()
-    mr.project = proj
-
-    proj.access = project_pb2.ProjectAccess.ANYONE
-    proj.state = project_pb2.ProjectState.LIVE
-    self.assertFalse(servlet_helpers.ProjectIsRestricted(mr))
-
-    proj.state = project_pb2.ProjectState.ARCHIVED
-    self.assertFalse(servlet_helpers.ProjectIsRestricted(mr))
-
-  def testRestrictedProject(self):
-    proj = project_pb2.Project()
-    mr = testing_helpers.MakeMonorailRequest()
-    mr.project = proj
-
-    proj.state = project_pb2.ProjectState.LIVE
-    proj.access = project_pb2.ProjectAccess.MEMBERS_ONLY
-    self.assertTrue(servlet_helpers.ProjectIsRestricted(mr))
-
-
-class VersionBaseTest(unittest.TestCase):
-
-  @mock.patch('settings.local_mode', True)
-  def testLocalhost(self):
-    request = webapp2.Request.blank('/', base_url='http://localhost:8080')
-    actual = servlet_helpers.VersionBaseURL(request)
-    expected = 'http://localhost:8080'
-    self.assertEqual(expected, actual)
-
-  @mock.patch('settings.local_mode', False)
-  @mock.patch('google.appengine.api.app_identity.get_default_version_hostname')
-  def testProd(self, mock_gdvh):
-    mock_gdvh.return_value = 'monorail-prod.appspot.com'
-    request = webapp2.Request.blank('/', base_url='https://bugs.chromium.org')
-    actual = servlet_helpers.VersionBaseURL(request)
-    expected = 'https://test-dot-monorail-prod.appspot.com'
-    self.assertEqual(expected, actual)
diff --git a/framework/test/sorting_test.py b/framework/test/sorting_test.py
index 4251308..44aa6f7 100644
--- a/framework/test/sorting_test.py
+++ b/framework/test/sorting_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for sorting.py functions."""
 from __future__ import print_function
@@ -19,7 +18,7 @@
 
 from framework import sorting
 from framework import framework_views
-from proto import tracker_pb2
+from mrproto import tracker_pb2
 from testing import fake
 from testing import testing_helpers
 from tracker import tracker_bizobj
@@ -45,18 +44,18 @@
     """The point of DescendingValue is to reverse the sort order."""
     anti_a = sorting.DescendingValue.MakeDescendingValue('a')
     anti_b = sorting.DescendingValue.MakeDescendingValue('b')
-    self.assertTrue(anti_a > anti_b)
+    self.assertGreater(anti_a, anti_b)
 
   def testMaybeMakeDescending(self):
     """It returns an accessor that makes DescendingValue iff arg is True."""
     asc_accessor = sorting._MaybeMakeDescending(lambda issue: 'a', False)
     asc_value = asc_accessor('fake issue')
-    self.assertTrue(asc_value is 'a')
+    self.assertEqual(asc_value, 'a')
 
     desc_accessor = sorting._MaybeMakeDescending(lambda issue: 'a', True)
     print(desc_accessor)
     desc_value = desc_accessor('fake issue')
-    self.assertTrue(isinstance(desc_value, sorting.DescendingValue))
+    self.assertIsInstance(desc_value, sorting.DescendingValue)
 
 
 class SortingTest(unittest.TestCase):
@@ -161,9 +160,9 @@
     accessor = sorting._IndexListAccessor(well_known_values, base_accessor)
 
     # Case 1: accessor generates no values.
-    self.assertEqual(sorting.MAX_STRING, accessor(art))
+    self.assertEqual([sorting.MAX_STRING], accessor(art))
     neg_accessor = MakeDescending(accessor)
-    self.assertEqual(sorting.MAX_STRING, neg_accessor(art))
+    self.assertEqual([sorting.MAX_STRING], neg_accessor(art))
 
     # Case 2: A single well-known value
     art.component_ids = [33]
@@ -186,9 +185,9 @@
     accessor = sorting._IndexListAccessor(well_known_values, base_accessor)
 
     # Case 1: accessor generates no values.
-    self.assertEqual(sorting.MAX_STRING, accessor(art))
+    self.assertEqual([sorting.MAX_STRING], accessor(art))
     neg_accessor = MakeDescending(accessor)
-    self.assertEqual(sorting.MAX_STRING, neg_accessor(art))
+    self.assertEqual([sorting.MAX_STRING], neg_accessor(art))
 
     # Case 2: A single oddball value
     art.component_ids = [33]
@@ -212,9 +211,9 @@
 
     # Case 1: accessor generates no values.
     accessor = sorting._IndexOrLexicalList(well_known_values, [], 'pri', {})
-    self.assertEqual(sorting.MAX_STRING, accessor(art))
+    self.assertEqual([sorting.MAX_STRING], accessor(art))
     neg_accessor = MakeDescending(accessor)
-    self.assertEqual(sorting.MAX_STRING, neg_accessor(art))
+    self.assertEqual([sorting.MAX_STRING], neg_accessor(art))
 
     # Case 2: A single well-known value
     art.labels = ['Pri-Med']
diff --git a/framework/test/sql_test.py b/framework/test/sql_test.py
index f073e24..e8408cc 100644
--- a/framework/test/sql_test.py
+++ b/framework/test/sql_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for the sql module."""
 from __future__ import print_function
diff --git a/framework/test/table_view_helpers_test.py b/framework/test/table_view_helpers_test.py
index 0260308..f0c6643 100644
--- a/framework/test/table_view_helpers_test.py
+++ b/framework/test/table_view_helpers_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for table_view_helpers classes and functions."""
 from __future__ import print_function
@@ -9,12 +8,12 @@
 from __future__ import absolute_import
 
 import collections
+import six
 import unittest
-import logging
 
 from framework import framework_views
 from framework import table_view_helpers
-from proto import tracker_pb2
+from mrproto import tracker_pb2
 from testing import fake
 from testing import testing_helpers
 from tracker import tracker_bizobj
@@ -147,8 +146,9 @@
         users_by_id=self.users_by_id)
     self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
     self.assertEqual(len(cell.values), 2)
-    self.assertItemsEqual([cell.values[0].item, cell.values[1].item],
-                          ['foo@example.com', 'f...@example.com'])
+    six.assertCountEqual(
+        self, [cell.values[0].item, cell.values[1].item],
+        ['foo@example.com', 'f...@example.com'])
 
   # TODO(jrobbins): TableCellProject, TableCellStars
 
diff --git a/framework/test/template_helpers_test.py b/framework/test/template_helpers_test.py
index 85296fa..5f018e4 100644
--- a/framework/test/template_helpers_test.py
+++ b/framework/test/template_helpers_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unit tests for template_helpers module."""
 
@@ -82,7 +81,6 @@
     # pylint: disable=anomalous-unicode-escape-in-string
     test_data = (
         u'This is a short string.',
-
         u'This is a much longer string. '
         u'This is a much longer string. '
         u'This is a much longer string. '
@@ -95,52 +93,52 @@
         u'This is a much longer string. ',
 
         # This is a short escaped i18n string
-        '\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab'.decode('utf-8'),
+        b'\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab'.decode('utf-8'),
 
         # This is a longer i18n string
-        '\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab '
-        '\xe6\x88\x91\xe8\x83\xbd\xe5\x90\x9e '
-        '\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab '
-        '\xe6\x88\x91\xe8\x83\xbd\xe5\x90\x9e '
-        '\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab '
-        '\xe6\x88\x91\xe8\x83\xbd\xe5\x90\x9e '
-        '\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab '
-        '\xe6\x88\x91\xe8\x83\xbd\xe5\x90\x9e '.decode('utf-8'),
+        b'\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab '
+        b'\xe6\x88\x91\xe8\x83\xbd\xe5\x90\x9e '
+        b'\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab '
+        b'\xe6\x88\x91\xe8\x83\xbd\xe5\x90\x9e '
+        b'\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab '
+        b'\xe6\x88\x91\xe8\x83\xbd\xe5\x90\x9e '
+        b'\xd5\xa1\xd5\xba\xd5\xa1\xd5\xaf\xd5\xab '
+        b'\xe6\x88\x91\xe8\x83\xbd\xe5\x90\x9e '.decode('utf-8'),
 
         # This is a longer i18n string that was causing trouble.
-        '\u041d\u0430 \u0431\u0435\u0440\u0435\u0433\u0443'
-        ' \u043f\u0443\u0441\u0442\u044b\u043d\u043d\u044b\u0445'
-        ' \u0432\u043e\u043b\u043d \u0421\u0442\u043e\u044f\u043b'
-        ' \u043e\u043d, \u0434\u0443\u043c'
-        ' \u0432\u0435\u043b\u0438\u043a\u0438\u0445'
-        ' \u043f\u043e\u043b\u043d, \u0418'
-        ' \u0432\u0434\u0430\u043b\u044c'
-        ' \u0433\u043b\u044f\u0434\u0435\u043b.'
-        ' \u041f\u0440\u0435\u0434 \u043d\u0438\u043c'
-        ' \u0448\u0438\u0440\u043e\u043a\u043e'
-        ' \u0420\u0435\u043a\u0430'
-        ' \u043d\u0435\u0441\u043b\u0430\u0441\u044f;'
-        ' \u0431\u0435\u0434\u043d\u044b\u0439'
-        ' \u0447\u0451\u043b\u043d \u041f\u043e'
-        ' \u043d\u0435\u0439'
-        ' \u0441\u0442\u0440\u0435\u043c\u0438\u043b\u0441\u044f'
-        ' \u043e\u0434\u0438\u043d\u043e\u043a\u043e.'
-        ' \u041f\u043e \u043c\u0448\u0438\u0441\u0442\u044b\u043c,'
-        ' \u0442\u043e\u043f\u043a\u0438\u043c'
-        ' \u0431\u0435\u0440\u0435\u0433\u0430\u043c'
-        ' \u0427\u0435\u0440\u043d\u0435\u043b\u0438'
-        ' \u0438\u0437\u0431\u044b \u0437\u0434\u0435\u0441\u044c'
-        ' \u0438 \u0442\u0430\u043c, \u041f\u0440\u0438\u044e\u0442'
-        ' \u0443\u0431\u043e\u0433\u043e\u0433\u043e'
-        ' \u0447\u0443\u0445\u043e\u043d\u0446\u0430;'
-        ' \u0418 \u043b\u0435\u0441,'
-        ' \u043d\u0435\u0432\u0435\u0434\u043e\u043c\u044b\u0439'
-        ' \u043b\u0443\u0447\u0430\u043c \u0412'
-        ' \u0442\u0443\u043c\u0430\u043d\u0435'
-        ' \u0441\u043f\u0440\u044f\u0442\u0430\u043d\u043d\u043e'
-        '\u0433\u043e \u0441\u043e\u043b\u043d\u0446\u0430,'
-        ' \u041a\u0440\u0443\u0433\u043e\u043c'
-        ' \u0448\u0443\u043c\u0435\u043b.'.decode('utf-8'))
+        u'\u041d\u0430 \u0431\u0435\u0440\u0435\u0433\u0443'
+        u' \u043f\u0443\u0441\u0442\u044b\u043d\u043d\u044b\u0445'
+        u' \u0432\u043e\u043b\u043d \u0421\u0442\u043e\u044f\u043b'
+        u' \u043e\u043d, \u0434\u0443\u043c'
+        u' \u0432\u0435\u043b\u0438\u043a\u0438\u0445'
+        u' \u043f\u043e\u043b\u043d, \u0418'
+        u' \u0432\u0434\u0430\u043b\u044c'
+        u' \u0433\u043b\u044f\u0434\u0435\u043b.'
+        u' \u041f\u0440\u0435\u0434 \u043d\u0438\u043c'
+        u' \u0448\u0438\u0440\u043e\u043a\u043e'
+        u' \u0420\u0435\u043a\u0430'
+        u' \u043d\u0435\u0441\u043b\u0430\u0441\u044f;'
+        u' \u0431\u0435\u0434\u043d\u044b\u0439'
+        u' \u0447\u0451\u043b\u043d \u041f\u043e'
+        u' \u043d\u0435\u0439'
+        u' \u0441\u0442\u0440\u0435\u043c\u0438\u043b\u0441\u044f'
+        u' \u043e\u0434\u0438\u043d\u043e\u043a\u043e.'
+        u' \u041f\u043e \u043c\u0448\u0438\u0441\u0442\u044b\u043c,'
+        u' \u0442\u043e\u043f\u043a\u0438\u043c'
+        u' \u0431\u0435\u0440\u0435\u0433\u0430\u043c'
+        u' \u0427\u0435\u0440\u043d\u0435\u043b\u0438'
+        u' \u0438\u0437\u0431\u044b \u0437\u0434\u0435\u0441\u044c'
+        u' \u0438 \u0442\u0430\u043c, \u041f\u0440\u0438\u044e\u0442'
+        u' \u0443\u0431\u043e\u0433\u043e\u0433\u043e'
+        u' \u0447\u0443\u0445\u043e\u043d\u0446\u0430;'
+        u' \u0418 \u043b\u0435\u0441,'
+        u' \u043d\u0435\u0432\u0435\u0434\u043e\u043c\u044b\u0439'
+        u' \u043b\u0443\u0447\u0430\u043c \u0412'
+        u' \u0442\u0443\u043c\u0430\u043d\u0435'
+        u' \u0441\u043f\u0440\u044f\u0442\u0430\u043d\u043d\u043e'
+        u'\u0433\u043e \u0441\u043e\u043b\u043d\u0446\u0430,'
+        u' \u041a\u0440\u0443\u0433\u043e\u043c'
+        u' \u0448\u0443\u043c\u0435\u043b.')
 
     for unicode_s in test_data:
       # Get the length in characters, not bytes.
diff --git a/framework/test/timestr_test.py b/framework/test/timestr_test.py
index ad11249..e4dd907 100644
--- a/framework/test/timestr_test.py
+++ b/framework/test/timestr_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Unittest for timestr module."""
 from __future__ import print_function
diff --git a/framework/test/ts_mon_js_test.py b/framework/test/ts_mon_js_test.py
index 4231b76..da48378 100644
--- a/framework/test/ts_mon_js_test.py
+++ b/framework/test/ts_mon_js_test.py
@@ -1,7 +1,6 @@
-# Copyright 2018 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2018 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for MonorailTSMonJSHandler."""
 from __future__ import print_function
@@ -13,10 +12,8 @@
 from mock import patch
 
 import flask
-import webapp2
 from google.appengine.ext import testbed
 
-from framework.ts_mon_js import FlaskMonorailTSMonJSHandler
 from framework.ts_mon_js import MonorailTSMonJSHandler
 from services import service_manager
 
@@ -27,66 +24,12 @@
     self.testbed = testbed.Testbed()
     self.testbed.activate()
     self.testbed.init_user_stub()
-
-  def tearDown(self):
-    self.testbed.deactivate()
-
-  @patch('framework.xsrf.ValidateToken')
-  @patch('time.time')
-  def testSubmitMetrics(self, _mockTime, _mockValidateToken):
-    """Test normal case POSTing metrics."""
-    _mockTime.return_value = 1537821859
-    req = webapp2.Request.blank('/_/ts_mon_js')
-    req.body = json.dumps({
-      'metrics': [{
-        'MetricInfo': {
-          'Name': 'monorail/frontend/issue_update_latency',
-          'ValueType': 2,
-        },
-        'Cells': [{
-          'value': {
-            'sum': 1234,
-            'count': 4321,
-            'buckets': {
-              0: 123,
-              1: 321,
-              2: 213,
-            },
-          },
-          'fields': {
-            'client_id': '789',
-            'host_name': 'rutabaga',
-            'document_visible': True,
-          },
-          'start_time': 1537821859 - 60,
-        }],
-      }],
-    })
-    res = webapp2.Response()
-    ts_mon_handler = MonorailTSMonJSHandler(request=req, response=res)
-    class MockApp(object):
-      def __init__(self):
-        self.config = {'services': service_manager.Services()}
-    ts_mon_handler.app = MockApp()
-
-    ts_mon_handler.post()
-
-    self.assertEqual(res.status_int, 201)
-    self.assertEqual(res.body, 'Ok.')
-
-
-class FlaskMonorailTSMonJSHandlerTest(unittest.TestCase):
-
-  def setUp(self):
-    self.testbed = testbed.Testbed()
-    self.testbed.activate()
-    self.testbed.init_user_stub()
     self.services = service_manager.Services()
     self.app = flask.Flask('test_app')
     self.app.config['TESTING'] = True
     self.app.add_url_rule(
         '/_/ts_mon_js.do',
-        view_func=FlaskMonorailTSMonJSHandler(
+        view_func=MonorailTSMonJSHandler(
             services=self.services).PostMonorailTSMonJSHandler,
         methods=['POST'])
 
@@ -95,7 +38,7 @@
 
   @patch('framework.xsrf.ValidateToken')
   @patch('time.time')
-  def testFlaskSubmitMetrics(self, _mockTime, _mockValidateToken):
+  def testSubmitMetrics(self, _mockTime, _mockValidateToken):
     """Test normal case POSTing metrics."""
     _mockTime.return_value = 1537821859
     res = self.app.test_client().post(
diff --git a/framework/test/validate_test.py b/framework/test/validate_test.py
index 9ea17fe..9716122 100644
--- a/framework/test/validate_test.py
+++ b/framework/test/validate_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """This file provides unit tests for Validate functions."""
 from __future__ import print_function
@@ -31,6 +30,11 @@
       'user@e-x-a-m-p-l-e.com',
       'user@e-x.am-ple.com',
       'user@e--xample.com',
+      'user@example.c',
+      'user@example.comcomcomc',
+      'user@example.co-m',
+      'user@example.c0m',
+      'very-long-email-address@very-long-domain.iam.abcdefghijklmnopqrstuvwxyz',
   ]
 
   BAD_EMAIL_ADDRESSES = [
@@ -52,12 +56,8 @@
       'user@example-.com',
       'user@example',
       'user@example.',
-      'user@example.c',
-      'user@example.comcomcomc',
-      'user@example.co-m',
       'user@exa_mple.com',
       'user@exa-_mple.com',
-      'user@example.c0m',
   ]
 
   def testIsValidEmail(self):
diff --git a/framework/test/warmup_test.py b/framework/test/warmup_test.py
index 13223f1..b3eab65 100644
--- a/framework/test/warmup_test.py
+++ b/framework/test/warmup_test.py
@@ -1,7 +1,6 @@
-# Copyright 2017 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2017 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for the warmup servlet."""
 from __future__ import print_function
@@ -25,7 +24,7 @@
       response = client.get('/')
 
     self.assertEqual(response.status_code, 200)
-    self.assertEqual(response.data, '')
+    self.assertEqual(response.data, b'')
 
   def testHandleStart(self):
     app = flask.Flask(__name__)
@@ -35,7 +34,7 @@
       response = client.get('/')
 
     self.assertEqual(response.status_code, 200)
-    self.assertEqual(response.data, '')
+    self.assertEqual(response.data, b'')
 
   def testHandleStop(self):
     app = flask.Flask(__name__)
@@ -45,4 +44,4 @@
       response = client.get('/')
 
     self.assertEqual(response.status_code, 200)
-    self.assertEqual(response.data, '')
+    self.assertEqual(response.data, b'')
diff --git a/framework/test/xsrf_test.py b/framework/test/xsrf_test.py
index aa04570..1039ab6 100644
--- a/framework/test/xsrf_test.py
+++ b/framework/test/xsrf_test.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Tests for XSRF utility functions."""
 from __future__ import print_function
diff --git a/framework/timestr.py b/framework/timestr.py
index 2b32e8c..b768c73 100644
--- a/framework/timestr.py
+++ b/framework/timestr.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Time-to-string and time-from-string routines."""
 from __future__ import print_function
diff --git a/framework/trimvisitedpages.py b/framework/trimvisitedpages.py
index f43ab09..039131f 100644
--- a/framework/trimvisitedpages.py
+++ b/framework/trimvisitedpages.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Classes to handle cron requests to trim users' hotlists/issues visited."""
 from __future__ import print_function
@@ -11,7 +10,7 @@
 from framework import jsonfeed
 
 
-class TrimVisitedPages(jsonfeed.FlaskInternalTask):
+class TrimVisitedPages(jsonfeed.InternalTask):
 
   """Look for users with more than 10 visited hotlists and deletes extras."""
 
diff --git a/framework/ts_mon_js.py b/framework/ts_mon_js.py
index 0dbb121..2db9f30 100644
--- a/framework/ts_mon_js.py
+++ b/framework/ts_mon_js.py
@@ -1,7 +1,6 @@
-# Copyright 2018 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2018 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """ts_mon JavaScript proxy handler."""
 from __future__ import print_function
@@ -12,7 +11,6 @@
 from framework import sql
 from framework import xsrf
 
-from gae_ts_mon.handlers import TSMonJSHandler
 from gae_ts_mon.flask_handlers import TSMonJSFlaskHandler
 
 from google.appengine.api import users
@@ -81,41 +79,10 @@
   units=ts_mon.MetricsDataUnits.MILLISECONDS)
 
 
-class MonorailTSMonJSHandler(TSMonJSHandler):
-
-  def __init__(self, request=None, response=None):
-    super(MonorailTSMonJSHandler, self).__init__(request, response)
-    self.register_metrics([
-        ISSUE_CREATE_LATENCY_METRIC,
-        ISSUE_UPDATE_LATENCY_METRIC,
-        AUTOCOMPLETE_POPULATE_LATENCY_METRIC,
-        CHARTS_SWITCH_DATE_RANGE_METRIC,
-        ISSUE_COMMENTS_LOAD_LATENCY_METRIC,
-        DOM_CONTENT_LOADED_METRIC,
-        ISSUE_LIST_LOAD_LATENCY_METRIC])
-
-  def xsrf_is_valid(self, body):
-    """This method expects the body dictionary to include two fields:
-    `token` and `user_id`.
-    """
-    cnxn = sql.MonorailConnection()
-    token = body.get('token')
-    user = users.get_current_user()
-    email = user.email() if user else None
-
-    services = self.app.config.get('services')
-    auth = authdata.AuthData.FromEmail(cnxn, email, services, autocreate=False)
-    try:
-      xsrf.ValidateToken(token, auth.user_id, xsrf.XHR_SERVLET_PATH)
-      return True
-    except xsrf.TokenIncorrect:
-      return False
-
-
-class FlaskMonorailTSMonJSHandler(TSMonJSFlaskHandler):
+class MonorailTSMonJSHandler(TSMonJSFlaskHandler):
 
   def __init__(self, services=None):
-    super(FlaskMonorailTSMonJSHandler, self).__init__(
+    super(MonorailTSMonJSHandler, self).__init__(
         flask=flask, services=services)
     self.register_metrics(
         [
diff --git a/framework/urls.py b/framework/urls.py
index f8a4a4d..0020b62 100644
--- a/framework/urls.py
+++ b/framework/urls.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Constants that define the Monorail URL space."""
 from __future__ import print_function
diff --git a/framework/validate.py b/framework/validate.py
index ee26396..baf6d6e 100644
--- a/framework/validate.py
+++ b/framework/validate.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """A set of Python input field validators."""
 from __future__ import print_function
@@ -10,72 +9,28 @@
 
 import re
 
-# RFC 2821-compliant email address regex
-#
-# Please see sections "4.1.2 Command Argument Syntax" and
-# "4.1.3 Address Literals" of:  http://www.faqs.org/rfcs/rfc2821.html
-#
-# The following implementation is still a subset of RFC 2821.  Fully
-# double-quoted <user> parts are not supported (since the RFC discourages
-# their use anyway), and using the backslash to escape other characters
-# that are normally invalid, such as commas, is not supported.
-#
-# The groups in this regular expression are:
-#
-# <user>: all of the valid non-quoted portion of the email address before
-#   the @ sign (not including the @ sign)
-#
-# <domain>: all of the domain name between the @ sign (but not including it)
-#   and the dot before the TLD (but not including that final dot)
-#
-# <tld>: the top-level domain after the last dot (but not including that
-#   final dot)
-#
-_RFC_2821_EMAIL_REGEX = r"""(?x)
-  (?P<user>
-    # Part of the username that comes before any dots that may occur in it.
-    # At least one of the listed non-dot characters is required before the
-    # first dot.
-    [-a-zA-Z0-9!#$%&'*+/=?^_`{|}~]+
-
-    # Remaining part of the username that starts with the dot and
-    # which may have other dots, if such a part exists.  Only one dot
-    # is permitted between each "Atom", and a trailing dot is not permitted.
-    (?:[.][-a-zA-Z0-9!#$%&'*+/=?^_`{|}~]+)*
-  )
-
-  # Domain name, where subdomains are allowed.  Also, dashes are allowed
-  # given that they are preceded and followed by at least one character.
-  @(?P<domain>
-    (?:[0-9a-zA-Z]       # at least one non-dash
-       (?:[-]*           # plus zero or more dashes
-          [0-9a-zA-Z]+   # plus at least one non-dash
-       )*                # zero or more of dashes followed by non-dashes
-    )                    # one required domain part (may be a sub-domain)
-
-    (?:\.                # dot separator before additional sub-domain part
-       [0-9a-zA-Z]       # at least one non-dash
-       (?:[-]*           # plus zero or more dashes
-          [0-9a-zA-Z]+   # plus at least one non-dash
-       )*                # zero or more of dashes followed by non-dashes
-    )*                   # at least one sub-domain part and a dot
-   )
-  \.                     # dot separator before TLD
-
-  # TLD, the part after 'usernames@domain.' which can consist of 2-9
-  # letters.
-  (?P<tld>[a-zA-Z]{2,9})
+# RFC 5322-compliant email address regex
+# https://stackoverflow.com/a/201378
+_RFC_2821_EMAIL_REGEX = r"""
+  (?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|
+  "(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|
+  \\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@
+  (?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|
+  \[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|
+  [0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:
+  (?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|
+  \\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])
   """
 
 # object used with <re>.search() or <re>.sub() to find email addresses
 # within a string (or with <re>.match() to find email addresses at the
 # beginning of a string that may be followed by trailing characters,
 # since <re>.match() implicitly anchors at the beginning of the string)
-RE_EMAIL_SEARCH = re.compile(_RFC_2821_EMAIL_REGEX)
+RE_EMAIL_SEARCH = re.compile(_RFC_2821_EMAIL_REGEX, re.X)
 
 # object used with <re>.match to find strings that contain *only* a single
 # email address (by adding the end-of-string anchor $)
-RE_EMAIL_ONLY = re.compile('^%s$' % _RFC_2821_EMAIL_REGEX)
+RE_EMAIL_ONLY = re.compile('^%s$' % _RFC_2821_EMAIL_REGEX, re.X)
 
 _SCHEME_PATTERN = r'(?:https?|ftp)://'
 _SHORT_HOST_PATTERN = (
diff --git a/framework/warmup.py b/framework/warmup.py
index a133107..d597fd3 100644
--- a/framework/warmup.py
+++ b/framework/warmup.py
@@ -1,7 +1,6 @@
-# Copyright 2017 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2017 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """A class to handle the initial warmup request from AppEngine."""
 from __future__ import print_function
diff --git a/framework/xsrf.py b/framework/xsrf.py
index 75581ef..bc5ae33 100644
--- a/framework/xsrf.py
+++ b/framework/xsrf.py
@@ -1,7 +1,6 @@
-# Copyright 2016 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style
-# license that can be found in the LICENSE file or at
-# https://developers.google.com/open-source/licenses/bsd
+# Copyright 2016 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
 
 """Utility routines for avoiding cross-site-request-forgery."""
 from __future__ import print_function
@@ -9,12 +8,12 @@
 from __future__ import absolute_import
 
 import base64
+import hashlib
 import hmac
-import logging
+import six
 import time
 
 # This is a file in the top-level directory that you must edit before deploying
-import settings
 from framework import framework_constants
 from services import secrets_svc
 
@@ -43,7 +42,7 @@
 XHR_SERVLET_PATH = 'xhr'
 
 
-DELIMITER = ':'
+DELIMITER = b':'
 
 
 def GenerateToken(user_id, servlet_path, token_time=None):
@@ -63,16 +62,16 @@
     ValueError: if the XSRF secret was not configured.
   """
   token_time = token_time or int(time.time())
-  digester = hmac.new(secrets_svc.GetXSRFKey())
-  digester.update(str(user_id))
+  digester = hmac.new(secrets_svc.GetXSRFKey(), digestmod=hashlib.md5)
+  digester.update(six.ensure_binary(str(user_id)))
   digester.update(DELIMITER)
-  digester.update(servlet_path)
+  digester.update(six.ensure_binary(servlet_path))
   digester.update(DELIMITER)
-  digester.update(str(token_time))
+  digester.update(six.ensure_binary(str(token_time)))
   digest = digester.digest()
 
-  token = base64.urlsafe_b64encode('%s%s%d' % (digest, DELIMITER, token_time))
-  return token
+  token = base64.urlsafe_b64encode(b'%s%s%d' % (digest, DELIMITER, token_time))
+  return six.ensure_str(token)
 
 
 def ValidateToken(
@@ -91,7 +90,7 @@
     raise TokenIncorrect('missing token')
 
   try:
-    decoded = base64.urlsafe_b64decode(str(token))
+    decoded = base64.urlsafe_b64decode(six.ensure_binary(token))
     token_time = int(decoded.split(DELIMITER)[-1])
   except (TypeError, ValueError):
     raise TokenIncorrect('could not decode token')
@@ -104,8 +103,14 @@
 
   # Perform constant time comparison to avoid timing attacks
   different = 0
+  # In Python 3, zip(bytes, bytes) gives ints, but in Python 2,
+  # zip(str, str) gives strs. We need to call ord() in Python 2 only.
+  if isinstance(token, six.string_types):
+    token = list(map(ord, token))
+  if isinstance(expected_token, six.string_types):
+    expected_token = list(map(ord, expected_token))
   for x, y in zip(token, expected_token):
-    different |= ord(x) ^ ord(y)
+    different |= x ^ y
   if different:
     raise TokenIncorrect(
         'presented token does not match expected token: %r != %r' % (