You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by he...@apache.org on 2015/05/29 22:40:39 UTC

[17/45] allura git commit: [#7878] Used 2to3 to see what issues would come up

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/model/stats.py
----------------------------------------------------------------------
diff --git a/model/stats.py b/model/stats.py
new file mode 100644
index 0000000..1668f45
--- /dev/null
+++ b/model/stats.py
@@ -0,0 +1,619 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from datetime import datetime
+from tg import config
+from paste.deploy.converters import asbool
+
+from ming import schema as S
+from ming.orm import Mapper
+from ming.orm import FieldProperty
+from ming.orm.declarative import MappedClass
+from datetime import timedelta
+import difflib
+
+from allura.model.session import main_orm_session
+from functools import reduce
+
+
+class Stats(MappedClass):
+
+    class __mongometa__:
+        name = 'basestats'
+        session = main_orm_session
+        unique_indexes = ['_id']
+
+    _id = FieldProperty(S.ObjectId)
+
+    visible = FieldProperty(bool, if_missing=True)
+    registration_date = FieldProperty(datetime)
+    general = FieldProperty([dict(
+        category=S.ObjectId,
+        messages=[dict(
+            messagetype=str,
+            created=int,
+            modified=int)],
+        tickets=dict(
+            solved=int,
+            assigned=int,
+            revoked=int,
+            totsolvingtime=int),
+        commits=[dict(
+            lines=int,
+            number=int,
+            language=S.ObjectId)])])
+
+    lastmonth = FieldProperty(dict(
+        messages=[dict(
+            datetime=datetime,
+            created=bool,
+            categories=[S.ObjectId],
+            messagetype=str)],
+        assignedtickets=[dict(
+            datetime=datetime,
+            categories=[S.ObjectId])],
+        revokedtickets=[dict(
+            datetime=datetime,
+            categories=[S.ObjectId])],
+        solvedtickets=[dict(
+            datetime=datetime,
+            categories=[S.ObjectId],
+            solvingtime=int)],
+        commits=[dict(
+            datetime=datetime,
+            categories=[S.ObjectId],
+            programming_languages=[S.ObjectId],
+            lines=int)]))
+
+    @property
+    def start_date(self):
+        """Date from which stats should be calculated.
+
+        The user may have registered before stats were collected,
+        making calculations based on registration date unfair."""
+        min_date = config.get('userstats.start_date', '0001-1-1')
+        return max(datetime.strptime(min_date, '%Y-%m-%d'), self.registration_date)
+
+    def getCodeContribution(self):
+        days = (datetime.today() - self.start_date).days
+        if not days:
+            days = 1
+        for val in self['general']:
+            if val['category'] is None:
+                for commits in val['commits']:
+                    if commits['language'] is None:
+                        if days > 30:
+                            return round(float(commits.lines) / days * 30, 2)
+                        else:
+                            return float(commits.lines)
+        return 0
+
+    def getDiscussionContribution(self):
+        days = (datetime.today() - self.start_date).days
+        if not days:
+            days = 1
+        for val in self['general']:
+            if val['category'] is None:
+                for artifact in val['messages']:
+                    if artifact['messagetype'] is None:
+                        tot = artifact.created + artifact.modified
+                        if days > 30:
+                            return round(float(tot) / days * 30, 2)
+                        else:
+                            return float(tot)
+        return 0
+
+    def getTicketsContribution(self):
+        for val in self['general']:
+            if val['category'] is None:
+                tickets = val['tickets']
+                if tickets.assigned == 0:
+                    return 0
+                return round(float(tickets.solved) / tickets.assigned, 2)
+        return 0
+
+    def getCommits(self, category=None):
+        i = getElementIndex(self.general, category=category)
+        if i is None:
+            return dict(number=0, lines=0)
+        cat = self.general[i]
+        j = getElementIndex(cat.commits, language=None)
+        if j is None:
+            return dict(number=0, lines=0)
+        return dict(
+            number=cat.commits[j]['number'],
+            lines=cat.commits[j]['lines'])
+
+    def getArtifacts(self, category=None, art_type=None):
+        i = getElementIndex(self.general, category=category)
+        if i is None:
+            return dict(created=0, modified=0)
+        cat = self.general[i]
+        j = getElementIndex(cat.messages, messagetype=art_type)
+        if j is None:
+            return dict(created=0, modified=0)
+        return dict(created=cat.messages[j].created, modified=cat.messages[j].modified)
+
+    def getTickets(self, category=None):
+        i = getElementIndex(self.general, category=category)
+        if i is None:
+            return dict(
+                assigned=0,
+                solved=0,
+                revoked=0,
+                averagesolvingtime=None)
+        if self.general[i].tickets.solved > 0:
+            tot = self.general[i].tickets.totsolvingtime
+            number = self.general[i].tickets.solved
+            average = tot / number
+        else:
+            average = None
+        return dict(
+            assigned=self.general[i].tickets.assigned,
+            solved=self.general[i].tickets.solved,
+            revoked=self.general[i].tickets.revoked,
+            averagesolvingtime=_convertTimeDiff(average))
+
+    def getCommitsByCategory(self):
+        from allura.model.project import TroveCategory
+
+        by_cat = {}
+        for entry in self.general:
+            cat = entry.category
+            i = getElementIndex(entry.commits, language=None)
+            if i is None:
+                n, lines = 0, 0
+            else:
+                n, lines = entry.commits[i].number, entry.commits[i].lines
+            if cat != None:
+                cat = TroveCategory.query.get(_id=cat)
+            by_cat[cat] = dict(number=n, lines=lines)
+        return by_cat
+
+    # For the moment, commit stats by language are not used, since each project
+    # can be linked to more than one programming language and we don't know how
+    # to which programming language should be credited a line of code modified
+    # within a project including two or more languages.
+    def getCommitsByLanguage(self):
+        i = getElementIndex(self.general, category=None)
+        if i is None:
+            return dict(number=0, lines=0)
+        return dict([(el.language, dict(lines=el.lines, number=el.number))
+                     for el in self.general[i].commits])
+
+    def getArtifactsByCategory(self, detailed=False):
+        from allura.model.project import TroveCategory
+
+        by_cat = {}
+        for entry in self.general:
+            cat = entry.category
+            if cat != None:
+                cat = TroveCategory.query.get(_id=cat)
+            if detailed:
+                by_cat[cat] = entry.messages
+            else:
+                i = getElementIndex(entry.messages, messagetype=None)
+                if i is not None:
+                    by_cat[cat] = entry.messages[i]
+                else:
+                    by_cat[cat] = dict(created=0, modified=0)
+        return by_cat
+
+    def getArtifactsByType(self, category=None):
+        i = getElementIndex(self.general, category=category)
+        if i is None:
+            return {}
+        entry = self.general[i].messages
+        by_type = dict([(el.messagetype, dict(created=el.created,
+                                              modified=el.modified))
+                        for el in entry])
+        return by_type
+
+    def getTicketsByCategory(self):
+        from allura.model.project import TroveCategory
+
+        by_cat = {}
+        for entry in self.general:
+            cat = entry.category
+            if cat != None:
+                cat = TroveCategory.query.get(_id=cat)
+            a, s = entry.tickets.assigned, entry.tickets.solved
+            r, time = entry.tickets.solved, entry.tickets.totsolvingtime
+            if s:
+                average = time / s
+            else:
+                average = None
+            by_cat[cat] = dict(
+                assigned=a,
+                solved=s,
+                revoked=r,
+                averagesolvingtime=_convertTimeDiff(average))
+        return by_cat
+
+    def getLastMonthCommits(self, category=None):
+        self.checkOldArtifacts()
+        lineslist = [el.lines for el in self.lastmonth.commits
+                     if category in el.categories + [None]]
+        return dict(number=len(lineslist), lines=sum(lineslist))
+
+    def getLastMonthCommitsByCategory(self):
+        from allura.model.project import TroveCategory
+
+        self.checkOldArtifacts()
+        seen = set()
+        catlist = [el.category for el in self.general
+                   if el.category not in seen and not seen.add(el.category)]
+
+        by_cat = {}
+        for cat in catlist:
+            lineslist = [el.lines for el in self.lastmonth.commits
+                         if cat in el.categories + [None]]
+            n = len(lineslist)
+            lines = sum(lineslist)
+            if cat != None:
+                cat = TroveCategory.query.get(_id=cat)
+            by_cat[cat] = dict(number=n, lines=lines)
+        return by_cat
+
+    def getLastMonthCommitsByLanguage(self):
+        from allura.model.project import TroveCategory
+
+        self.checkOldArtifacts()
+        seen = set()
+        langlist = [el.language for el in self.general
+                    if el.language not in seen and not seen.add(el.language)]
+
+        by_lang = {}
+        for lang in langlist:
+            lineslist = [el.lines for el in self.lastmonth.commits
+                         if lang in el.programming_languages + [None]]
+            n = len(lineslist)
+            lines = sum(lineslist)
+            if lang != None:
+                lang = TroveCategory.query.get(_id=lang)
+            by_lang[lang] = dict(number=n, lines=lines)
+        return by_lang
+
+    def getLastMonthArtifacts(self, category=None, art_type=None):
+        self.checkOldArtifacts()
+        cre, mod = reduce(
+            addtuple,
+            [(int(el.created), 1 - int(el.created))
+                for el in self.lastmonth.messages
+                if (category is None or category in el.categories) and
+                (el.messagetype == art_type or art_type is None)],
+            (0, 0))
+        return dict(created=cre, modified=mod)
+
+    def getLastMonthArtifactsByType(self, category=None):
+        self.checkOldArtifacts()
+        seen = set()
+        types = [el.messagetype for el in self.lastmonth.messages
+                 if el.messagetype not in seen and not seen.add(el.messagetype)]
+
+        by_type = {}
+        for t in types:
+            cre, mod = reduce(
+                addtuple,
+                [(int(el.created), 1 - int(el.created))
+                 for el in self.lastmonth.messages
+                 if el.messagetype == t and
+                 category in [None] + el.categories],
+                (0, 0))
+            by_type[t] = dict(created=cre, modified=mod)
+        return by_type
+
+    def getLastMonthArtifactsByCategory(self):
+        from allura.model.project import TroveCategory
+
+        self.checkOldArtifacts()
+        seen = set()
+        catlist = [el.category for el in self.general
+                   if el.category not in seen and not seen.add(el.category)]
+
+        by_cat = {}
+        for cat in catlist:
+            cre, mod = reduce(
+                addtuple,
+                [(int(el.created), 1 - int(el.created))
+                 for el in self.lastmonth.messages
+                 if cat in el.categories + [None]], (0, 0))
+            if cat != None:
+                cat = TroveCategory.query.get(_id=cat)
+            by_cat[cat] = dict(created=cre, modified=mod)
+        return by_cat
+
+    def getLastMonthTickets(self, category=None):
+        from allura.model.project import TroveCategory
+
+        self.checkOldArtifacts()
+        a = len([el for el in self.lastmonth.assignedtickets
+                 if category in el.categories + [None]])
+        r = len([el for el in self.lastmonth.revokedtickets
+                 if category in el.categories + [None]])
+        s, time = reduce(
+            addtuple,
+            [(1, el.solvingtime)
+             for el in self.lastmonth.solvedtickets
+             if category in el.categories + [None]],
+            (0, 0))
+        if category != None:
+            category = TroveCategory.query.get(_id=category)
+        if s > 0:
+            time = time / s
+        else:
+            time = None
+        return dict(
+            assigned=a,
+            revoked=r,
+            solved=s,
+            averagesolvingtime=_convertTimeDiff(time))
+
+    def getLastMonthTicketsByCategory(self):
+        from allura.model.project import TroveCategory
+
+        self.checkOldArtifacts()
+        seen = set()
+        catlist = [el.category for el in self.general
+                   if el.category not in seen and not seen.add(el.category)]
+        by_cat = {}
+        for cat in catlist:
+            a = len([el for el in self.lastmonth.assignedtickets
+                     if cat in el.categories + [None]])
+            r = len([el for el in self.lastmonth.revokedtickets
+                     if cat in el.categories + [None]])
+            s, time = reduce(addtuple, [(1, el.solvingtime)
+                                        for el in self.lastmonth.solvedtickets
+                                        if cat in el.categories + [None]], (0, 0))
+            if cat != None:
+                cat = TroveCategory.query.get(_id=cat)
+            if s > 0:
+                time = time / s
+            else:
+                time = None
+            by_cat[cat] = dict(
+                assigned=a,
+                revoked=r,
+                solved=s,
+                averagesolvingtime=_convertTimeDiff(time))
+        return by_cat
+
+    def checkOldArtifacts(self):
+        now = datetime.utcnow()
+        for m in self.lastmonth.messages:
+            if now - m.datetime > timedelta(30):
+                self.lastmonth.messages.remove(m)
+        for t in self.lastmonth.assignedtickets:
+            if now - t.datetime > timedelta(30):
+                self.lastmonth.assignedtickets.remove(t)
+        for t in self.lastmonth.revokedtickets:
+            if now - t.datetime > timedelta(30):
+                self.lastmonth.revokedtickets.remove(t)
+        for t in self.lastmonth.solvedtickets:
+            if now - t.datetime > timedelta(30):
+                self.lastmonth.solvedtickets.remove(t)
+        for c in self.lastmonth.commits:
+            if now - c.datetime > timedelta(30):
+                self.lastmonth.commits.remove(c)
+
+    def addNewArtifact(self, art_type, art_datetime, project):
+        self._updateArtifactsStats(art_type, art_datetime, project, "created")
+
+    def addModifiedArtifact(self, art_type, art_datetime, project):
+        self._updateArtifactsStats(art_type, art_datetime, project, "modified")
+
+    def addAssignedTicket(self, ticket_datetime, project):
+        topics = [t for t in project.trove_topic if t]
+        self._updateTicketsStats(topics, 'assigned')
+        self.lastmonth.assignedtickets.append(
+            dict(datetime=ticket_datetime, categories=topics))
+
+    def addRevokedTicket(self, ticket_datetime, project):
+        topics = [t for t in project.trove_topic if t]
+        self._updateTicketsStats(topics, 'revoked')
+        self.lastmonth.revokedtickets.append(
+            dict(datetime=ticket_datetime, categories=topics))
+        self.checkOldArtifacts()
+
+    def addClosedTicket(self, open_datetime, close_datetime, project):
+        topics = [t for t in project.trove_topic if t]
+        s_time = int((close_datetime - open_datetime).total_seconds())
+        self._updateTicketsStats(topics, 'solved', s_time=s_time)
+        self.lastmonth.solvedtickets.append(dict(
+            datetime=close_datetime,
+            categories=topics,
+            solvingtime=s_time))
+        self.checkOldArtifacts()
+
+    def addCommit(self, newcommit, commit_datetime, project):
+        def _computeLines(newblob, oldblob=None):
+            if oldblob:
+                listold = list(oldblob)
+            else:
+                listold = []
+            if newblob:
+                listnew = list(newblob)
+            else:
+                listnew = []
+
+            if oldblob is None:
+                lines = len(listnew)
+            elif newblob and newblob.has_html_view:
+                diff = difflib.unified_diff(
+                    listold, listnew,
+                    ('old' + oldblob.path()).encode('utf-8'),
+                    ('new' + newblob.path()).encode('utf-8'))
+                lines = len(
+                    [l for l in diff if len(l) > 0 and l[0] == '+']) - 1
+            else:
+                lines = 0
+            return lines
+
+        def _addCommitData(stats, topics, languages, lines):
+            lt = topics + [None]
+            ll = languages + [None]
+            for t in lt:
+                i = getElementIndex(stats.general, category=t)
+                if i is None:
+                    newstats = dict(
+                        category=t,
+                        commits=[],
+                        messages=[],
+                        tickets=dict(
+                            assigned=0,
+                            solved=0,
+                            revoked=0,
+                            totsolvingtime=0))
+                    stats.general.append(newstats)
+                    i = getElementIndex(stats.general, category=t)
+                for lang in ll:
+                    j = getElementIndex(
+                        stats.general[i]['commits'], language=lang)
+                    if j is None:
+                        stats.general[i]['commits'].append(dict(
+                            language=lang, lines=lines, number=1))
+                    else:
+                        stats.general[i]['commits'][j].lines += lines
+                        stats.general[i]['commits'][j].number += 1
+
+        topics = [t for t in project.trove_topic if t]
+        languages = [l for l in project.trove_language if l]
+
+        d = newcommit.diffs
+        if len(newcommit.parent_ids) > 0:
+            oldcommit = newcommit.repo.commit(newcommit.parent_ids[0])
+
+        totlines = 0
+        if asbool(config.get('userstats.count_lines_of_code', True)):
+            for changed in d.changed:
+                newblob = newcommit.tree.get_blob_by_path(changed)
+                oldblob = oldcommit.tree.get_blob_by_path(changed)
+                totlines += _computeLines(newblob, oldblob)
+
+            for copied in d.copied:
+                newblob = newcommit.tree.get_blob_by_path(copied['new'])
+                oldblob = oldcommit.tree.get_blob_by_path(copied['old'])
+                totlines += _computeLines(newblob, oldblob)
+
+            for added in d.added:
+                newblob = newcommit.tree.get_blob_by_path(added)
+                totlines += _computeLines(newblob)
+
+        _addCommitData(self, topics, languages, totlines)
+
+        self.lastmonth.commits.append(dict(
+            datetime=commit_datetime,
+            categories=topics,
+            programming_languages=languages,
+            lines=totlines))
+        self.checkOldArtifacts()
+
+    def _updateArtifactsStats(self, art_type, art_datetime, project, action):
+        if action not in ['created', 'modified']:
+            return
+        topics = [t for t in project.trove_topic if t]
+        lt = [None] + topics
+        for mtype in [None, art_type]:
+            for t in lt:
+                i = getElementIndex(self.general, category=t)
+                if i is None:
+                    msg = dict(
+                        category=t,
+                        commits=[],
+                        tickets=dict(
+                            solved=0,
+                            assigned=0,
+                            revoked=0,
+                            totsolvingtime=0),
+                        messages=[])
+                    self.general.append(msg)
+                    i = getElementIndex(self.general, category=t)
+                j = getElementIndex(
+                    self.general[i]['messages'], messagetype=mtype)
+                if j is None:
+                    entry = dict(messagetype=mtype, created=0, modified=0)
+                    entry[action] += 1
+                    self.general[i]['messages'].append(entry)
+                else:
+                    self.general[i]['messages'][j][action] += 1
+
+        self.lastmonth.messages.append(dict(
+            datetime=art_datetime,
+            created=(action == 'created'),
+            categories=topics,
+            messagetype=art_type))
+        self.checkOldArtifacts()
+
+    def _updateTicketsStats(self, topics, action, s_time=None):
+        if action not in ['solved', 'assigned', 'revoked']:
+            return
+        lt = topics + [None]
+        for t in lt:
+            i = getElementIndex(self.general, category=t)
+            if i is None:
+                stats = dict(
+                    category=t,
+                    commits=[],
+                    tickets=dict(
+                        solved=0,
+                        assigned=0,
+                        revoked=0,
+                        totsolvingtime=0),
+                    messages=[])
+                self.general.append(stats)
+                i = getElementIndex(self.general, category=t)
+            self.general[i]['tickets'][action] += 1
+            if action == 'solved':
+                self.general[i]['tickets']['totsolvingtime'] += s_time
+
+
+def getElementIndex(el_list, **kw):
+    for i in range(len(el_list)):
+        for k in kw:
+            if el_list[i].get(k) != kw[k]:
+                break
+        else:
+            return i
+    return None
+
+
+def addtuple(l1, l2):
+    a, b = l1
+    x, y = l2
+    return (a + x, b + y)
+
+
+def _convertTimeDiff(int_seconds):
+    if int_seconds is None:
+        return None
+    diff = timedelta(seconds=int_seconds)
+    days, seconds = diff.days, diff.seconds
+    hours = seconds / 3600
+    seconds = seconds % 3600
+    minutes = seconds / 60
+    seconds = seconds % 60
+    return dict(
+        days=days,
+        hours=hours,
+        minutes=minutes,
+        seconds=seconds)
+
+Mapper.compile_all()

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/model/timeline.py
----------------------------------------------------------------------
diff --git a/model/timeline.py b/model/timeline.py
new file mode 100644
index 0000000..dca627c
--- /dev/null
+++ b/model/timeline.py
@@ -0,0 +1,150 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import bson
+import logging
+
+from ming.odm import Mapper
+from pylons import tmpl_context as c
+
+from activitystream import ActivityDirector
+from activitystream.base import NodeBase, ActivityObjectBase
+from activitystream.managers import Aggregator as BaseAggregator
+
+from allura.lib import security
+from allura.tasks.activity_tasks import create_timelines
+
+log = logging.getLogger(__name__)
+
+
+class Director(ActivityDirector):
+
+    """Overrides the default ActivityDirector to kick off background
+    timeline aggregations after an activity is created.
+
+    """
+
+    def create_activity(self, actor, verb, obj, target=None,
+                        related_nodes=None, tags=None):
+        if c.project and c.project.notifications_disabled:
+            return
+
+        from allura.model.project import Project
+        super(Director, self).create_activity(actor, verb, obj,
+                                              target=target,
+                                              related_nodes=related_nodes,
+                                              tags=tags)
+        # aggregate actor and follower's timelines
+        if actor.node_id:
+            create_timelines.post(actor.node_id)
+        # aggregate project and follower's timelines
+        for node in [obj, target] + (related_nodes or []):
+            if isinstance(node, Project):
+                create_timelines.post(node.node_id)
+
+
+class Aggregator(BaseAggregator):
+    pass
+
+
+class ActivityNode(NodeBase):
+
+    @property
+    def node_id(self):
+        return "%s:%s" % (self.__class__.__name__, self._id)
+
+
+class ActivityObject(ActivityObjectBase):
+    '''
+    Allura's base activity class.
+    '''
+
+    @property
+    def activity_name(self):
+        """Override this for each Artifact type."""
+        return "%s %s" % (self.__mongometa__.name.capitalize(), self._id)
+
+    @property
+    def activity_url(self):
+        return self.url()
+
+    @property
+    def activity_extras(self):
+        """Return a BSON-serializable dict of extra stuff to store on the
+        activity.
+        """
+        return {"allura_id": self.allura_id}
+
+    @property
+    def allura_id(self):
+        """Return a string which uniquely identifies this object and which can
+        be used to retrieve the object from mongo.
+        """
+        return "%s:%s" % (self.__class__.__name__, self._id)
+
+    def has_activity_access(self, perm, user, activity):
+        """Return True if user has perm access to this object, otherwise
+        return False.
+        """
+        if self.project is None or self.deleted:
+            return False
+        return security.has_access(self, perm, user, self.project)
+
+
+class TransientActor(NodeBase, ActivityObjectBase):
+    """An activity actor which is not a persistent Node in the network.
+
+    """
+    def __init__(self, activity_name):
+        NodeBase.__init__(self)
+        ActivityObjectBase.__init__(self)
+        self.activity_name = activity_name
+
+
+def get_activity_object(activity_object_dict):
+    """Given a BSON-serialized activity object (e.g. activity.obj dict in a
+    timeline), return the corresponding :class:`ActivityObject`.
+
+    """
+    extras_dict = activity_object_dict.activity_extras
+    if not extras_dict:
+        return None
+    allura_id = extras_dict.get('allura_id')
+    if not allura_id:
+        return None
+    classname, _id = allura_id.split(':', 1)
+    cls = Mapper.by_classname(classname).mapped_class
+    try:
+        _id = bson.ObjectId(_id)
+    except bson.errors.InvalidId:
+        pass
+    return cls.query.get(_id=_id)
+
+
+def perm_check(user):
+    """
+    Return a function that returns True if ``user`` has 'read' access to a given activity,
+    otherwise returns False.
+    """
+    def _perm_check(activity):
+        obj = get_activity_object(activity.obj)
+        return obj is None or obj.has_activity_access('read', user, activity)
+    return _perm_check

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/model/types.py
----------------------------------------------------------------------
diff --git a/model/types.py b/model/types.py
new file mode 100644
index 0000000..e630096
--- /dev/null
+++ b/model/types.py
@@ -0,0 +1,118 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from ming.base import Object
+from ming import schema as S
+
+EVERYONE, ALL_PERMISSIONS = None, '*'
+
+
+class MarkdownCache(S.Object):
+
+    def __init__(self, **kw):
+        super(MarkdownCache, self).__init__(
+            fields=dict(
+                md5=S.String(),
+                fix7528=S.Bool,
+                html=S.String(),
+                render_time=S.Float()),
+            **kw)
+
+
+class ACE(S.Object):
+    '''
+    Access Control Entry
+
+    :var access: either ``ACE.ALLOW`` or ``ACE.DENY``
+    :var str reason: optional, user-entered text
+    :var role_id: _id for a :class:`~allura.model.auth.ProjectRole`
+    :var str permission: e.g. 'read', 'create', etc
+    '''
+
+    ALLOW, DENY = 'ALLOW', 'DENY'
+
+    def __init__(self, permissions, **kwargs):
+        if permissions is None:
+            permission = S.String()
+        else:
+            permission = S.OneOf('*', *permissions)
+        super(ACE, self).__init__(
+            fields=dict(
+                access=S.OneOf(self.ALLOW, self.DENY),
+                reason=S.String(),
+                role_id=S.ObjectId(),
+                permission=permission),
+            **kwargs)
+
+    @classmethod
+    def allow(cls, role_id, permission, reason=None):
+        return Object(
+            access=cls.ALLOW,
+            reason=reason,
+            role_id=role_id,
+            permission=permission)
+
+    @classmethod
+    def deny(cls, role_id, permission, reason=None):
+        ace = Object(
+            access=cls.DENY,
+            reason=reason,
+            role_id=role_id,
+            permission=permission)
+        return ace
+
+    @classmethod
+    def match(cls, ace, role_id, permission):
+        return (
+            ace.role_id in (role_id, EVERYONE)
+            and ace.permission in (permission, ALL_PERMISSIONS))
+
+
+class ACL(S.Array):
+    '''
+    Access Control List.  Is an array of :class:`ACE`
+    '''
+
+    def __init__(self, permissions=None, **kwargs):
+        super(ACL, self).__init__(
+            field_type=ACE(permissions), **kwargs)
+
+    @classmethod
+    def contains(cls, ace, acl):
+        """Test membership of ace in acl ignoring ace.reason field.
+
+        Return actual ACE with reason filled if ace is found in acl, None otherwise
+
+        e.g. `ACL.contains(ace, acl)` will return `{role_id=ObjectId(...), permission='read', access='DENY', reason='Spammer'}`
+        with following vars:
+
+        ace = M.ACE.deny(role_id, 'read')  # reason = None
+        acl = [{role_id=ObjectId(...), permission='read', access='DENY', reason='Spammer'}]
+        """
+        def clear_reason(ace):
+            return Object(access=ace.access, role_id=ace.role_id, permission=ace.permission)
+
+        ace_without_reason = clear_reason(ace)
+        for a in acl:
+            if clear_reason(a) == ace_without_reason:
+                return a
+
+DENY_ALL = ACE.deny(EVERYONE, ALL_PERMISSIONS)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/model/webhook.py
----------------------------------------------------------------------
diff --git a/model/webhook.py b/model/webhook.py
new file mode 100644
index 0000000..213d5c9
--- /dev/null
+++ b/model/webhook.py
@@ -0,0 +1,76 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import datetime as dt
+import json
+
+from ming.odm import FieldProperty, session
+from paste.deploy.converters import asint
+from tg import config
+
+from allura.model import Artifact
+from allura.lib import helpers as h
+
+
+class Webhook(Artifact):
+    class __mongometa__:
+        name = 'webhook'
+        unique_indexes = [('app_config_id', 'type', 'hook_url')]
+
+    type = FieldProperty(str)
+    hook_url = FieldProperty(str)
+    secret = FieldProperty(str)
+    last_sent = FieldProperty(dt.datetime, if_missing=None)
+
+    def url(self):
+        app = self.app_config.load()
+        app = app(self.app_config.project, self.app_config)
+        return '{}webhooks/{}/{}'.format(app.admin_url, self.type, self._id)
+
+    def enforce_limit(self):
+        '''Returns False if limit is reached, otherwise True'''
+        if self.last_sent is None:
+            return True
+        now = dt.datetime.utcnow()
+        config_type = self.type.replace('-', '_')
+        limit = asint(config.get('webhook.%s.limit' % config_type, 30))
+        if (now - self.last_sent) > dt.timedelta(seconds=limit):
+            return True
+        return False
+
+    def update_limit(self):
+        self.last_sent = dt.datetime.utcnow()
+        session(self).flush(self)
+
+    @classmethod
+    def max_hooks(self, type, tool_name):
+        type = type.replace('-', '_')
+        limits = json.loads(config.get('webhook.%s.max_hooks' % type, '{}'))
+        return limits.get(tool_name.lower(), 3)
+
+    def __json__(self):
+        return {
+            '_id': str(self._id),
+            'url': h.absurl('/rest' + self.url()),
+            'type': str(self.type),
+            'hook_url': str(self.hook_url),
+            'mod_date': self.mod_date,
+        }

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/rat-excludes.txt
----------------------------------------------------------------------
diff --git a/rat-excludes.txt b/rat-excludes.txt
index 4e3b800..6be4d29 100644
--- a/rat-excludes.txt
+++ b/rat-excludes.txt
@@ -30,7 +30,6 @@ Allura/allura/public/nf/js/jquery.viewport.js
 Allura/allura/public/nf/css/blueprint/
 Allura/allura/public/nf/js/sylvester.js
 Allura/allura/public/nf/js/modernizr.js
-Allura/allura/public/nf/js/react.min.js
 Allura/allura/tests/data/genshi_hello_tmpl
 Allura/allura/tests/data/test_mime/text_file.txt
 AlluraTest/jslint/

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/ApacheAccessHandler.py
----------------------------------------------------------------------
diff --git a/scripts/ApacheAccessHandler.py b/scripts/ApacheAccessHandler.py
index 1af3714..3264c8c 100644
--- a/scripts/ApacheAccessHandler.py
+++ b/scripts/ApacheAccessHandler.py
@@ -43,6 +43,10 @@ Here is a quick example for your apache settings (assuming ProxyPass)
     </Location>
 
 """
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 
 
 from mod_python import apache

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/__init__.py
----------------------------------------------------------------------
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 0000000..91068b1
--- /dev/null
+++ b/scripts/__init__.py
@@ -0,0 +1,22 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from .scripttask import ScriptTask

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/add_user_to_group.py
----------------------------------------------------------------------
diff --git a/scripts/add_user_to_group.py b/scripts/add_user_to_group.py
index aa2fe1e..65888fa 100644
--- a/scripts/add_user_to_group.py
+++ b/scripts/add_user_to_group.py
@@ -35,6 +35,10 @@ Example:
     $ paster script production.ini ../scripts/add_user_to_group.py -- admin1 Admin --nbhd=/berlios/
 
 """
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 
 from allura import model as M
 from ming.orm import ThreadLocalORMSession

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/allura_import.py
----------------------------------------------------------------------
diff --git a/scripts/allura_import.py b/scripts/allura_import.py
index d4e51fd..a0fefa7 100644
--- a/scripts/allura_import.py
+++ b/scripts/allura_import.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information
@@ -40,7 +44,7 @@ def main():
             if type(user_map) is not type({}):
                 raise ValueError
             for k, v in user_map.iteritems():
-                print k, v
+                print(k, v)
                 if not isinstance(k, basestring) or not isinstance(v, basestring):
                     raise ValueError
         except ValueError:
@@ -71,10 +75,10 @@ def import_forum(cli, project, tool, user_map, doc_txt, validate=True,
             )
     if validate:
         url += '/validate_import'
-        print cli.call(url, doc=doc_txt, user_map=json.dumps(user_map))
+        print(cli.call(url, doc=doc_txt, user_map=json.dumps(user_map)))
     else:
         url += '/perform_import'
-        print cli.call(url, doc=doc_txt, user_map=json.dumps(user_map))
+        print(cli.call(url, doc=doc_txt, user_map=json.dumps(user_map)))
 
 
 def parse_options():

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/changelog.py
----------------------------------------------------------------------
diff --git a/scripts/changelog.py b/scripts/changelog.py
index 8f49e94..c41f83f 100755
--- a/scripts/changelog.py
+++ b/scripts/changelog.py
@@ -17,6 +17,10 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 import sys
 import re
 import git
@@ -61,12 +65,12 @@ def get_ticket_summaries(tickets):
 
 
 def print_changelog(version, summaries):
-    print 'Version {version}  ({date})\n'.format(**{
+    print('Version {version}  ({date})\n'.format(**{
         'version': version,
         'date': datetime.utcnow().strftime('%B %Y'),
-    })
+    }))
     for ticket in sorted(summaries.keys()):
-        print " * [#{0}] {1}".format(ticket, summaries[ticket].encode('utf-8'))
+        print(" * [#{0}] {1}".format(ticket, summaries[ticket].encode('utf-8')))
 
 if __name__ == '__main__':
     main()

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/create-allura-sitemap.py
----------------------------------------------------------------------
diff --git a/scripts/create-allura-sitemap.py b/scripts/create-allura-sitemap.py
index f39b2a6..1124a16 100644
--- a/scripts/create-allura-sitemap.py
+++ b/scripts/create-allura-sitemap.py
@@ -27,6 +27,10 @@ things that would make it faster, if we need/want to.
 
 2. Use multiprocessing to distribute the offsets to n subprocesses.
 """
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 
 import os
 import sys
@@ -84,7 +88,7 @@ def main(options):
         sys.exit('Error: %s directory already exists.' % output_path)
     try:
         os.mkdir(output_path)
-    except OSError, e:
+    except OSError as e:
         sys.exit("Error: Couldn't create %s:\n%s" % (output_path, e))
 
     now = datetime.utcnow().date()
@@ -115,9 +119,9 @@ def main(options):
                     locs.append({'url': url,
                                  'date': p.last_updated.strftime("%Y-%m-%d")})
 
-            except Exception, e:
-                print "Error creating sitemap for project '%s': %s" %\
-                    (p.shortname, e)
+            except Exception as e:
+                print("Error creating sitemap for project '%s': %s" %\
+                    (p.shortname, e))
             creds.clear()
             if len(locs) >= options.urls_per_file:
                 write_sitemap(locs[:options.urls_per_file], file_count)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/create-moved-tickets.py
----------------------------------------------------------------------
diff --git a/scripts/create-moved-tickets.py b/scripts/create-moved-tickets.py
index 04ce906..2d6bd04 100644
--- a/scripts/create-moved-tickets.py
+++ b/scripts/create-moved-tickets.py
@@ -22,6 +22,10 @@ This is for making redirects for tickets that we move from SourceForge
 to Apache, but could be generalized pretty easily to work for making
 any type of redirect (change SF/Apache specifics to commandline arguments)
 '''
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 
 import argparse
 import pymongo
@@ -52,8 +56,8 @@ nbhd = main_db.neighborhood.find_one({'url_prefix': '/%s/' % opts.n})
 project = main_db.project.find_one({'neighborhood_id': nbhd['_id'], 'shortname': opts.p})
 tool = project_data.config.find_one({'project_id': project['_id'], 'options.mount_point': opts.t})
 
-print "Tool id: %s" % tool['_id']
-print 'Setting app_config_id to: %s for tickets: %s' % ('moved-to-apache', ticket_nums)
+print("Tool id: %s" % tool['_id'])
+print('Setting app_config_id to: %s for tickets: %s' % ('moved-to-apache', ticket_nums))
 
 if not opts.dry_run:
     project_data.ticket.update({
@@ -61,7 +65,7 @@ if not opts.dry_run:
         'ticket_num': {'$in': ticket_nums}
     }, {'$set': {'app_config_id': 'moved-to-apache'}}, multi=True)
 
-print 'Creating MovingTickets for tickets: %s' % ticket_nums
+print('Creating MovingTickets for tickets: %s' % ticket_nums)
 
 if not opts.dry_run:
     for num in ticket_nums:

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/create_deleted_comments.py
----------------------------------------------------------------------
diff --git a/scripts/create_deleted_comments.py b/scripts/create_deleted_comments.py
new file mode 100644
index 0000000..033fbd3
--- /dev/null
+++ b/scripts/create_deleted_comments.py
@@ -0,0 +1,114 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import sys
+import argparse
+import logging
+
+from ming.odm import session
+from pylons import tmpl_context as c
+
+from allura.scripts import ScriptTask
+from allura import model as M
+from allura.lib.utils import chunked_find
+from forgediscussion.model import ForumPost
+
+
+log = logging.getLogger(__name__)
+
+
+class CreateDeletedComments(ScriptTask):
+
+    @classmethod
+    def execute(cls, options):
+        models = [M.Post, ForumPost]
+        app_config_id = cls.get_tool_id(options.tool)
+        # Find all posts that have parent_id, but does not have actual parent
+        # and create fake parent for them
+        for model in models:
+            q = {'parent_id': {'$ne': None},
+                 'app_config_id': app_config_id}
+            for chunk in chunked_find(model, q):
+                for post in chunk:
+                    if not post.parent:
+                        log.info('Creating deleted parent for %s %s',
+                                 model.__mongometa__.name, post._id)
+                        c.project = post.app_config.project
+                        slug = post.slug.rsplit('/', 1)[0]
+                        full_slug = post.full_slug.rsplit('/', 1)[0]
+                        author = c.project.admins()[0]
+                        deleted_post = model(
+                            _id=post.parent_id,
+                            deleted=True,
+                            text="Automatically created in place of deleted post",
+                            app_id=post.app_id,
+                            app_config_id=post.app_config_id,
+                            discussion_id=post.discussion_id,
+                            thread_id=post.thread_id,
+                            author_id=author._id,
+                            slug=slug,
+                            full_slug=full_slug,
+                        )
+                        if options.dry_run:
+                            session(deleted_post).expunge(deleted_post)
+                        else:
+                            session(deleted_post).flush(deleted_post)
+
+    @classmethod
+    def get_tool_id(cls, tool):
+        _n, _p, _mount = tool.split('/')
+        n = M.Neighborhood.query.get(url_prefix='/{}/'.format(_n))
+        if not n:
+            log.error('Can not find neighborhood %s', _n)
+            sys.exit(1)
+        p = M.Project.query.get(neighborhood_id=n._id, shortname=_p)
+        if not p:
+            log.error('Can not find project %s', _p)
+            sys.exit(1)
+        t = p.app_instance(_mount)
+        if not t:
+            log.error('Can not find tool with mount point %s', _mount)
+            sys.exit(1)
+        return t.config._id
+
+    @classmethod
+    def parser(cls):
+        parser = argparse.ArgumentParser(
+            description='Create comments marked as deleted in place of '
+                        'actually deleted parent comments (ticket:#1731)'
+        )
+        parser.add_argument(
+            '--dry-run',
+            action='store_true',
+            dest='dry_run',
+            default=False,
+            help='Show comments that will be created, but do not actually '
+                 'create anything',
+        )
+        parser.add_argument(
+            '-t', '--tool',
+            required=True,
+            help='Create comments only in specified tool, e.g. p/test/wiki')
+        return parser
+
+
+if __name__ == '__main__':
+    CreateDeletedComments.main()

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/fix-wiki-page-names.py
----------------------------------------------------------------------
diff --git a/scripts/fix-wiki-page-names.py b/scripts/fix-wiki-page-names.py
index b53e708..4731f7d 100644
--- a/scripts/fix-wiki-page-names.py
+++ b/scripts/fix-wiki-page-names.py
@@ -16,6 +16,10 @@
 #       under the License.
 
 """Rename page/title to page-title"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 
 import sys
 import logging

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/import_trove_categories.py
----------------------------------------------------------------------
diff --git a/scripts/import_trove_categories.py b/scripts/import_trove_categories.py
index 123a56c..e884a73 100644
--- a/scripts/import_trove_categories.py
+++ b/scripts/import_trove_categories.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/000-fix-tracker-fields.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/000-fix-tracker-fields.py b/scripts/migrations/000-fix-tracker-fields.py
index 5d2b6b1..1159442 100644
--- a/scripts/migrations/000-fix-tracker-fields.py
+++ b/scripts/migrations/000-fix-tracker-fields.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/001-restore-labels.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/001-restore-labels.py b/scripts/migrations/001-restore-labels.py
index ef68c09..59b787f 100644
--- a/scripts/migrations/001-restore-labels.py
+++ b/scripts/migrations/001-restore-labels.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/002-fix-tracker-thread-subjects.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/002-fix-tracker-thread-subjects.py b/scripts/migrations/002-fix-tracker-thread-subjects.py
index 81861a0..f65502b 100644
--- a/scripts/migrations/002-fix-tracker-thread-subjects.py
+++ b/scripts/migrations/002-fix-tracker-thread-subjects.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/003-migrate_project_roles.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/003-migrate_project_roles.py b/scripts/migrations/003-migrate_project_roles.py
index 32bcc57..9edce70 100644
--- a/scripts/migrations/003-migrate_project_roles.py
+++ b/scripts/migrations/003-migrate_project_roles.py
@@ -17,6 +17,10 @@
 
 '''Merge all the OldProjectRole collections in into a ProjectRole collection.
 '''
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 import logging
 
 from ming.orm import session, state

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/004-make-attachments-polymorphic.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/004-make-attachments-polymorphic.py b/scripts/migrations/004-make-attachments-polymorphic.py
index e6133ec..7d2ad3f 100644
--- a/scripts/migrations/004-make-attachments-polymorphic.py
+++ b/scripts/migrations/004-make-attachments-polymorphic.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/005-remove_duplicate_ticket_notifications.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/005-remove_duplicate_ticket_notifications.py b/scripts/migrations/005-remove_duplicate_ticket_notifications.py
index 716c604..6d49f96 100644
--- a/scripts/migrations/005-remove_duplicate_ticket_notifications.py
+++ b/scripts/migrations/005-remove_duplicate_ticket_notifications.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information
@@ -34,16 +38,16 @@ log = logging.getLogger(__name__)
 def trim_subs(subs, test):
     prime = False
 
-    print "Found %d '%s' subs with for user '%s'" % (len(subs), subs[0].artifact_title, str(subs[0].user_id))
+    print("Found %d '%s' subs with for user '%s'" % (len(subs), subs[0].artifact_title, str(subs[0].user_id)))
     for sub in subs:
         if sub.artifact_url and not prime:
             prime = True
-            print "   Keeping good subscription with a URL of '%s'" % sub.artifact_url
+            print("   Keeping good subscription with a URL of '%s'" % sub.artifact_url)
         else:
             if not sub.artifact_url:
-                print "   Found subscription with no artifact URL, deleting."
+                print("   Found subscription with no artifact URL, deleting.")
             else:
-                print "   Subscription has URL, but is a duplicate, deleting."
+                print("   Subscription has URL, but is a duplicate, deleting.")
             if not test:
                 sub.delete()
 

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/006-migrate-artifact-refs.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/006-migrate-artifact-refs.py b/scripts/migrations/006-migrate-artifact-refs.py
index aeae677..e2f8f82 100644
--- a/scripts/migrations/006-migrate-artifact-refs.py
+++ b/scripts/migrations/006-migrate-artifact-refs.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/007-update-acls.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/007-update-acls.py b/scripts/migrations/007-update-acls.py
index 0f97ee1..04eabaf 100644
--- a/scripts/migrations/007-update-acls.py
+++ b/scripts/migrations/007-update-acls.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information
@@ -77,8 +81,8 @@ def main():
     log.info('====================================')
     log.info('Update neighborhood ACLs')
     for n in q_neighborhoods:
-        p = c_project.find(dict(
-            neighborhood_id=n['_id'], shortname='--init--')).next()
+        p = next(c_project.find(dict(
+            neighborhood_id=n['_id'], shortname='--init--')))
         update_neighborhood_acl(n, p)
         if not options.test:
             c_neighborhood.save(n)
@@ -149,7 +153,7 @@ def update_neighborhood_acl(neighborhood_doc, init_doc):
     new_acl = list(init_doc['acl'])
     assert acl['read'] == [None]  # nbhd should be public
     for uid in acl['admin'] + acl['moderate']:
-        u = c_user.find(dict(_id=uid)).next()
+        u = next(c_user.find(dict(_id=uid)))
         if options.test:
             log.info('... grant nbhd admin to: %s', u['username'])
             continue
@@ -227,7 +231,7 @@ def _format_role(rid):
         if role['name']:
             return role['name']
         if role['user_id']:
-            u = c_user.find(_id=role['user_id']).next()
+            u = next(c_user.find(_id=role['user_id']))
             return u['username']
         break
     return '--invalid--'

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/008-remove-forumpost-subject.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/008-remove-forumpost-subject.py b/scripts/migrations/008-remove-forumpost-subject.py
index af344d5..874fa75 100644
--- a/scripts/migrations/008-remove-forumpost-subject.py
+++ b/scripts/migrations/008-remove-forumpost-subject.py
@@ -18,6 +18,10 @@
 """
 Remove the subject FieldProperty from all ForumPost objects. [#2071]
 """
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 
 import logging
 import sys

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/009-set_landing_page.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/009-set_landing_page.py b/scripts/migrations/009-set_landing_page.py
index 65ca7a1..9f490b4 100644
--- a/scripts/migrations/009-set_landing_page.py
+++ b/scripts/migrations/009-set_landing_page.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/010-fix-home-permissions.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/010-fix-home-permissions.py b/scripts/migrations/010-fix-home-permissions.py
index 4506a17..26be5f3 100644
--- a/scripts/migrations/010-fix-home-permissions.py
+++ b/scripts/migrations/010-fix-home-permissions.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/011-fix-subroles.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/011-fix-subroles.py b/scripts/migrations/011-fix-subroles.py
index cb63c6b..19b6ea3 100644
--- a/scripts/migrations/011-fix-subroles.py
+++ b/scripts/migrations/011-fix-subroles.py
@@ -26,6 +26,10 @@ For project.users:
     * user.project_role().roles, if it contains Developer, should not contain
       Member
 """
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 import sys
 import logging
 

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/012-uninstall-home.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/012-uninstall-home.py b/scripts/migrations/012-uninstall-home.py
index c59fdb2..8447295 100644
--- a/scripts/migrations/012-uninstall-home.py
+++ b/scripts/migrations/012-uninstall-home.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/013-update-ordinals.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/013-update-ordinals.py b/scripts/migrations/013-update-ordinals.py
index f26f112..8724347 100644
--- a/scripts/migrations/013-update-ordinals.py
+++ b/scripts/migrations/013-update-ordinals.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/015-add-neighborhood_id-to-blog-posts.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/015-add-neighborhood_id-to-blog-posts.py b/scripts/migrations/015-add-neighborhood_id-to-blog-posts.py
index 8c4abd9..5b75d35 100644
--- a/scripts/migrations/015-add-neighborhood_id-to-blog-posts.py
+++ b/scripts/migrations/015-add-neighborhood_id-to-blog-posts.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/018-add-svn-checkout-url.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/018-add-svn-checkout-url.py b/scripts/migrations/018-add-svn-checkout-url.py
index 2a5469c..078fd47 100644
--- a/scripts/migrations/018-add-svn-checkout-url.py
+++ b/scripts/migrations/018-add-svn-checkout-url.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/020-remove-wiki-title-slashes.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/020-remove-wiki-title-slashes.py b/scripts/migrations/020-remove-wiki-title-slashes.py
index 34db4ce..43c9e53 100644
--- a/scripts/migrations/020-remove-wiki-title-slashes.py
+++ b/scripts/migrations/020-remove-wiki-title-slashes.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information
@@ -29,10 +33,10 @@ log = logging.getLogger(__name__)
 def main():
     c.project = None
     pages = WM.Page.query.find({'title': {'$regex': '\/'}}).all()
-    print 'Found %s wiki titles containing "/"...' % len(pages)
+    print('Found %s wiki titles containing "/"...' % len(pages))
     for page in pages:
         page.title = page.title.replace('/', '-')
-        print 'Updated: %s' % page.title
+        print('Updated: %s' % page.title)
     ThreadLocalORMSession.flush_all()
 
 if __name__ == '__main__':

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/022-change-anon-display-name.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/022-change-anon-display-name.py b/scripts/migrations/022-change-anon-display-name.py
index dbe9911..95ae267 100644
--- a/scripts/migrations/022-change-anon-display-name.py
+++ b/scripts/migrations/022-change-anon-display-name.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/024-migrate-custom-profile-text.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/024-migrate-custom-profile-text.py b/scripts/migrations/024-migrate-custom-profile-text.py
index 18a82da..19db2d5 100644
--- a/scripts/migrations/024-migrate-custom-profile-text.py
+++ b/scripts/migrations/024-migrate-custom-profile-text.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/025-add-is-nbhd-project.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/025-add-is-nbhd-project.py b/scripts/migrations/025-add-is-nbhd-project.py
index ca3fc77..9e92878 100644
--- a/scripts/migrations/025-add-is-nbhd-project.py
+++ b/scripts/migrations/025-add-is-nbhd-project.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/026-install-activity-tool.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/026-install-activity-tool.py b/scripts/migrations/026-install-activity-tool.py
index c7eb39f..f25b807 100644
--- a/scripts/migrations/026-install-activity-tool.py
+++ b/scripts/migrations/026-install-activity-tool.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/027-change-ticket-write-permissions.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/027-change-ticket-write-permissions.py b/scripts/migrations/027-change-ticket-write-permissions.py
index 3c6877a..9c5e42c 100644
--- a/scripts/migrations/027-change-ticket-write-permissions.py
+++ b/scripts/migrations/027-change-ticket-write-permissions.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/028-remove-svn-trees.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/028-remove-svn-trees.py b/scripts/migrations/028-remove-svn-trees.py
index 79e82aa..019325b 100644
--- a/scripts/migrations/028-remove-svn-trees.py
+++ b/scripts/migrations/028-remove-svn-trees.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information
@@ -33,16 +37,16 @@ def kill_tree(repo, commit_id, path, tree):
         tid = repo._tree_oid(commit_id, path + '/' + tree_rec.name)
         child_tree = M.repository.Tree.query.get(_id=tid)
         if child_tree:
-            print '  Found {0}'.format((path + '/' + tree_rec.name).encode('utf8'))
+            print('  Found {0}'.format((path + '/' + tree_rec.name).encode('utf8')))
             kill_tree(repo, commit_id, path + '/' + tree_rec.name, child_tree)
         else:
-            print '  Missing {0}'.format((path + '/' + tree_rec.name).encode('utf8'))
+            print('  Missing {0}'.format((path + '/' + tree_rec.name).encode('utf8')))
 
 
 def main():
     for chunk in utils.chunked_find(SM.Repository):
         for r in chunk:
-            print 'Processing {0}'.format(r)
+            print('Processing {0}'.format(r))
             all_commit_ids = r._impl.all_commit_ids()
             if all_commit_ids:
                 for commit in M.repository.Commit.query.find({'_id': {'$in': all_commit_ids}}):

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/029-set-mailbox-queue_empty.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/029-set-mailbox-queue_empty.py b/scripts/migrations/029-set-mailbox-queue_empty.py
index c2075b7..b18793f 100644
--- a/scripts/migrations/029-set-mailbox-queue_empty.py
+++ b/scripts/migrations/029-set-mailbox-queue_empty.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/migrations/031-set-user-pending-to-false.py
----------------------------------------------------------------------
diff --git a/scripts/migrations/031-set-user-pending-to-false.py b/scripts/migrations/031-set-user-pending-to-false.py
index 02d35d9..7f58ca7 100644
--- a/scripts/migrations/031-set-user-pending-to-false.py
+++ b/scripts/migrations/031-set-user-pending-to-false.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information
@@ -28,7 +32,7 @@ log = logging.getLogger(__name__)
 def main():
     for chunk in utils.chunked_find(M.User):
         for user in chunk:
-            print 'Processing {0}'.format(user.username)
+            print('Processing {0}'.format(user.username))
             user.pending = False
             # Ming doesn't mark document for update, since pending is False
             # by default, even if field is missing from mongo

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/new_ticket.py
----------------------------------------------------------------------
diff --git a/scripts/new_ticket.py b/scripts/new_ticket.py
index 9b23328..5c6970f 100755
--- a/scripts/new_ticket.py
+++ b/scripts/new_ticket.py
@@ -16,6 +16,10 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 import sys
 import argparse
 import requests
@@ -36,10 +40,10 @@ def get_opts():
 opts = get_opts()
 access_token = raw_input('Access (bearer) token: ')
 summary = raw_input('Summary: ')
-print 'Description (C-d to end):'
-print '-----------------------------------------------'
+print('Description (C-d to end):')
+print('-----------------------------------------------')
 description = sys.stdin.read()
-print '-----------------------------------------------'
+print('-----------------------------------------------')
 
 r = requests.post(opts.url, params={
     'access_token': access_token,
@@ -47,7 +51,7 @@ r = requests.post(opts.url, params={
     'ticket_form.description': description,
 })
 if r.status_code == 200:
-    print 'Ticket created at: %s' % r.url
+    print('Ticket created at: %s' % r.url)
     pprint(r.json())
 else:
-    print 'Error [%s]:\n%s' % (r.status_code, r.text)
+    print('Error [%s]:\n%s' % (r.status_code, r.text))

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/open_relay.py
----------------------------------------------------------------------
diff --git a/scripts/open_relay.py b/scripts/open_relay.py
index ba21862..bbf1833 100644
--- a/scripts/open_relay.py
+++ b/scripts/open_relay.py
@@ -17,6 +17,10 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 import logging
 import os
 import smtpd

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/perf/benchmark-scm.py
----------------------------------------------------------------------
diff --git a/scripts/perf/benchmark-scm.py b/scripts/perf/benchmark-scm.py
index be9b93a..de39e2c 100755
--- a/scripts/perf/benchmark-scm.py
+++ b/scripts/perf/benchmark-scm.py
@@ -18,6 +18,10 @@
 #       under the License.
 
 
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 import os
 import sys
 import argparse
@@ -71,9 +75,9 @@ def main(opts):
         impl(repo, cid, path, names, opts.repo_path)
         end = datetime.now()
         total += (end - start).total_seconds()
-    print
-    print 'Total time:           %s' % total
-    print 'Average time per run: %s' % (total / opts.count)
+    print()
+    print('Total time:           %s' % total)
+    print('Average time per run: %s' % (total / opts.count))
 
 
 def impl_git_tree(repo, cid, path, names, *args):

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/perf/call_count.py
----------------------------------------------------------------------
diff --git a/scripts/perf/call_count.py b/scripts/perf/call_count.py
index 671e6f2..f67be7a 100755
--- a/scripts/perf/call_count.py
+++ b/scripts/perf/call_count.py
@@ -17,6 +17,10 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 import argparse
 import json
 import logging
@@ -68,7 +72,7 @@ def main(args):
 
     counts = count_page(test, url, verbose=args.verbose,
                         debug_html=args.debug_html)
-    print json.dumps(counts)
+    print(json.dumps(counts))
     write_csv(counts, args.id, args.data_file)
     test.tearDown()
 
@@ -116,17 +120,17 @@ def count_page(test, url, verbose=False, debug_html=False):
 
     with LogCapture('stats') as stats, LogCapture('timermiddleware') as calls:
         resp = test.app.get(url, extra_environ=dict(username='*anonymous'))
-        print url, resp.status
+        print(url, resp.status)
         if debug_html:
             debug_filename = 'call-{}.html'.format(''.join([random.choice(string.ascii_letters + string.digits)
                                                    for n in xrange(10)]))
             with open(debug_filename, 'w') as out:
                 out.write(resp.body)
-            print debug_filename
+            print(debug_filename)
 
     if verbose:
         for r in calls.records:
-            print r.getMessage()
+            print(r.getMessage())
 
     assert len(stats.records) == 1
     timings = json.loads(stats.records[0].getMessage())

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/perf/generate-projects.py
----------------------------------------------------------------------
diff --git a/scripts/perf/generate-projects.py b/scripts/perf/generate-projects.py
index 0374969..16fa5ea 100644
--- a/scripts/perf/generate-projects.py
+++ b/scripts/perf/generate-projects.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information
@@ -28,10 +32,10 @@ def main(start, cnt):
         name = 'gen-proj-{}'.format(i)
         project = n.register_project(name, admin)
         if (i-start) > 0 and (i-start) % 100 == 0:
-            print 'Created {} projects'.format(i-start)
-    print 'Flushing...'
+            print('Created {} projects'.format(i-start))
+    print('Flushing...')
     ThreadLocalORMSession.flush_all()
-    print 'Done'
+    print('Done')
 
 if __name__ == '__main__':
     import sys

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/perf/md_perf.py
----------------------------------------------------------------------
diff --git a/scripts/perf/md_perf.py b/scripts/perf/md_perf.py
index e8d0f70..d92af2b 100644
--- a/scripts/perf/md_perf.py
+++ b/scripts/perf/md_perf.py
@@ -44,6 +44,10 @@ user    0m12.749s
 sys     0m1.112s
 
 """
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 
 import argparse
 import cProfile
@@ -85,24 +89,24 @@ def main(opts):
 
 def render(artifact, md, opts):
     start = begin = time.time()
-    print "%4s %20s %10s %s" % ('', 'Conversion Time (s)', 'Text Size', 'Post._id')
+    print("%4s %20s %10s %s" % ('', 'Conversion Time (s)', 'Text Size', 'Post._id'))
     for i, p in enumerate(artifact.discussion_thread.posts):
         text = DUMMYTEXT or p.text
         if opts.n and i + 1 not in opts.n:
-            print 'Skipping post %s' % str(i + 1)
+            print('Skipping post %s' % str(i + 1))
             continue
         if opts.profile:
-            print 'Profiling post %s' % str(i + 1)
+            print('Profiling post %s' % str(i + 1))
             cProfile.runctx('output = md.convert(text)', globals(), locals())
         else:
             output = md.convert(text)
         elapsed = time.time() - start
-        print "%4s %1.18f %10s %s" % (i + 1, elapsed, len(text), p._id)
+        print("%4s %1.18f %10s %s" % (i + 1, elapsed, len(text), p._id))
         if opts.output:
-            print 'Input:', text[:min(300, len(text))]
-            print 'Output:', output[:min(MAX_OUTPUT, len(output))]
+            print('Input:', text[:min(300, len(text))])
+            print('Output:', output[:min(MAX_OUTPUT, len(output))])
         start = time.time()
-    print "Total time:", start - begin
+    print("Total time:", start - begin)
     return output
 
 
@@ -128,4 +132,4 @@ if __name__ == '__main__':
     if opts.compare:
         opts.re2 = not opts.re2
         out2 = main(opts)
-        print 're/re2 outputs match: ', out1 == out2
+        print('re/re2 outputs match: ', out1 == out2)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/perf/sstress.py
----------------------------------------------------------------------
diff --git a/scripts/perf/sstress.py b/scripts/perf/sstress.py
index 3f1a1b1..26bcbce 100644
--- a/scripts/perf/sstress.py
+++ b/scripts/perf/sstress.py
@@ -20,6 +20,10 @@
 '''
 sstress - an SMTP stress testing tool
 '''
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 
 import smtplib
 import threading
@@ -43,8 +47,8 @@ def main():
         t.join()
     end = time.time()
     elapsed = end - begin
-    print '%d requests completed in %f seconds' % (N, elapsed)
-    print '%f requests/second' % (N / elapsed)
+    print('%d requests completed in %f seconds' % (N, elapsed))
+    print('%f requests/second' % (N / elapsed))
 
 
 def stress():

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/perf/test_git_lcd.py
----------------------------------------------------------------------
diff --git a/scripts/perf/test_git_lcd.py b/scripts/perf/test_git_lcd.py
index 8d75e54..01a1831 100644
--- a/scripts/perf/test_git_lcd.py
+++ b/scripts/perf/test_git_lcd.py
@@ -17,6 +17,10 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 import sys
 import os
 from glob import glob
@@ -42,11 +46,11 @@ def main(repo_dir, sub_dir='', commit=None):
     commit = Mock(_id=commit or git.head)
     paths = glob(os.path.join(repo_dir, sub_dir, '*'))
     paths = [path.replace(repo_dir + '/', '', 1) for path in paths]
-    print "Timing LCDs for %s at %s" % (paths, commit._id)
+    print("Timing LCDs for %s at %s" % (paths, commit._id))
     with benchmark() as timer:
         result = git.last_commit_ids(commit, paths)
     pprint(result)
-    print "Took %f seconds" % timer['result']
+    print("Took %f seconds" % timer['result'])
 
 if __name__ == '__main__':
     main(*sys.argv[1:])

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/prep-scm-sandbox.py
----------------------------------------------------------------------
diff --git a/scripts/prep-scm-sandbox.py b/scripts/prep-scm-sandbox.py
index 414280f..883d4a9 100644
--- a/scripts/prep-scm-sandbox.py
+++ b/scripts/prep-scm-sandbox.py
@@ -1,3 +1,7 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 #       Licensed to the Apache Software Foundation (ASF) under one
 #       or more contributor license agreements.  See the NOTICE file
 #       distributed with this work for additional information
@@ -41,26 +45,26 @@ def main():
             sb_host=sb_host,
             sb=sb,
             veid='%d0%.2d' % (sb_host, sb))
-        for sb_host in 5, 6, 7, 9
+        for sb_host in (5, 6, 7, 9)
         for sb in range(99)]
     new_lines = '\n'.join(new_lines)
     found_star = False
     with open(SSH_CONFIG, 'w') as fp:
         for line in lines:
             if not found_star and line.startswith('Host *'):
-                print >> fp, new_lines
+                print(new_lines, file=fp)
                 found_star = True
-            print >> fp, line.rstrip()
+            print(line.rstrip(), file=fp)
         if not found_star:
-            print >> fp, new_lines
+            print(new_lines, file=fp)
     os.system("ssh-keygen -t rsa -b 2048 -N '' -f %s" % KEYFILE)
 
     # Generate ldif
     pubkey = open(KEYFILE + '.pub').read()
     with open(LDIF_FILE, 'w') as fp:
         for user in USERS:
-            print >> fp, LDIF_TMPL.substitute(
-                user=user, pubkey=pubkey)
+            print(LDIF_TMPL.substitute(
+                user=user, pubkey=pubkey), file=fp)
 
     # Update LDAP
     assert 0 == os.system('/usr/local/sbin/ldaptool modify -v -f %s' %

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/scripts/prepare-allura-tickets-for-import.py
----------------------------------------------------------------------
diff --git a/scripts/prepare-allura-tickets-for-import.py b/scripts/prepare-allura-tickets-for-import.py
index dc6a695..743277b 100644
--- a/scripts/prepare-allura-tickets-for-import.py
+++ b/scripts/prepare-allura-tickets-for-import.py
@@ -16,6 +16,10 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
 from itertools import tee, izip, chain
 import json
 import git