Merge 3.26
authorPhilippe Pepiot <philippe.pepiot@logilab.fr>
Wed, 27 Feb 2019 13:36:31 +0100
changeset 12379 04348101688a
parent 12378 9dcb5e4e705b (diff)
parent 12377 15a068b2def5 (current diff)
child 12380 5efff873705e
Merge 3.26
cubicweb/__pkginfo__.py
cubicweb/pyramid/core.py
doc/tutorials/base/blog-in-five-minutes.rst
tox.ini
--- a/cubicweb/__init__.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/__init__.py	Wed Feb 27 13:36:31 2019 +0100
@@ -80,7 +80,7 @@
 
 class Binary(BytesIO):
     """class to hold binary data. Use BytesIO to prevent use of unicode data"""
-    _allowed_types = (binary_type, bytearray, buffer if PY2 else memoryview)
+    _allowed_types = (binary_type, bytearray, buffer if PY2 else memoryview)  # noqa: F405
 
     def __init__(self, buf=b''):
         assert isinstance(buf, self._allowed_types), \
--- a/cubicweb/__pkginfo__.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/__pkginfo__.py	Wed Feb 27 13:36:31 2019 +0100
@@ -22,8 +22,8 @@
 
 modname = distname = "cubicweb"
 
-numversion = (3, 26, 7)
-version = '.'.join(str(num) for num in numversion)
+numversion = (3, 27, 0)
+version = '.'.join(str(num) for num in numversion) + '.dev0'
 
 description = "a repository of entities / relations for knowledge management"
 author = "Logilab"
--- a/cubicweb/cwconfig.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/cwconfig.py	Wed Feb 27 13:36:31 2019 +0100
@@ -49,12 +49,9 @@
 Within virtual environment
 ``````````````````````````
 
-If you are not administrator of you machine or if you need to play with some
-specific version of |cubicweb| you can use virtualenv_ a tool to create
-isolated Python environments.
-
-- instances are stored in :file:`<VIRTUAL_ENV>/etc/cubicweb.d`
-- temporary files (such as pid file) in :file:`<VIRTUAL_ENV>/var/run/cubicweb`
+When installed within a virtualenv, CubicWeb will look for instances data as in
+user mode by default, that is in $HOME/etc/cubicweb.d. However the
+CW_INSTANCES_DIR environment variable should be preferably used.
 
 .. _virtualenv: http://pypi.python.org/pypi/virtualenv
 
--- a/cubicweb/devtools/__init__.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/devtools/__init__.py	Wed Feb 27 13:36:31 2019 +0100
@@ -32,6 +32,7 @@
 from os.path import abspath, join, exists, split, isdir, dirname
 from functools import partial
 
+import filelock
 from six import text_type
 from six.moves import cPickle as pickle
 
@@ -427,11 +428,12 @@
         raise ValueError('no initialization function for driver %r' % self.DRIVER)
 
     def has_cache(self, db_id):
-        """Check if a given database id exist in cb cache for the current config"""
-        cache_glob = self.absolute_backup_file('*', '*')
-        if cache_glob not in self.explored_glob:
-            self.discover_cached_db()
-        return self.db_cache_key(db_id) in self.db_cache
+        """Check if a given database id exist in db cache for the current config"""
+        key = self.db_cache_key(db_id)
+        if key in self.db_cache:
+            return True
+        self.discover_cached_db()
+        return key in self.db_cache
 
     def discover_cached_db(self):
         """Search available db_if for the current config"""
@@ -469,20 +471,23 @@
         ``pre_setup_func`` to setup the database.
 
         This function backup any database it build"""
-        if self.has_cache(test_db_id):
-            return  # test_db_id, 'already in cache'
-        if test_db_id is DEFAULT_EMPTY_DB_ID:
-            self.init_test_database()
-        else:
-            print('Building %s for database %s' % (test_db_id, self.dbname))
-            self.build_db_cache(DEFAULT_EMPTY_DB_ID)
-            self.restore_database(DEFAULT_EMPTY_DB_ID)
-            self.get_repo(startup=True)
-            cnx = self.get_cnx()
-            with cnx:
-                pre_setup_func(cnx, self.config)
-                cnx.commit()
-        self.backup_database(test_db_id)
+        lockfile = join(self._ensure_test_backup_db_dir(),
+                        '{}.lock'.format(test_db_id))
+        with filelock.FileLock(lockfile):
+            if self.has_cache(test_db_id):
+                return  # test_db_id, 'already in cache'
+            if test_db_id is DEFAULT_EMPTY_DB_ID:
+                self.init_test_database()
+            else:
+                print('Building %s for database %s' % (test_db_id, self.dbname))
+                self.build_db_cache(DEFAULT_EMPTY_DB_ID)
+                self.restore_database(DEFAULT_EMPTY_DB_ID)
+                self.get_repo(startup=True)
+                cnx = self.get_cnx()
+                with cnx:
+                    pre_setup_func(cnx, self.config)
+                    cnx.commit()
+            self.backup_database(test_db_id)
 
 
 class NoCreateDropDatabaseHandler(TestDataBaseHandler):
--- a/cubicweb/entities/adapters.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/entities/adapters.py	Wed Feb 27 13:36:31 2019 +0100
@@ -400,8 +400,8 @@
             path.append(entity.eid)
             try:
                 # check we are not jumping to another tree
-                if (adapter.tree_relation != self.tree_relation or
-                        adapter.child_role != self.child_role):
+                if (adapter.tree_relation != self.tree_relation
+                        or adapter.child_role != self.child_role):
                     break
                 entity = adapter.parent()
                 adapter = entity.cw_adapt_to('ITree')
--- a/cubicweb/hooks/test/data/hooks.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/hooks/test/data/hooks.py	Wed Feb 27 13:36:31 2019 +0100
@@ -3,6 +3,6 @@
 
 
 class FolderUpdateHook(notification.EntityUpdateHook):
-    __select__ = (notification.EntityUpdateHook.__select__ &
-                  is_instance('Folder'))
+    __select__ = (notification.EntityUpdateHook.__select__
+                  & is_instance('Folder'))
     order = 100  # late trigger so that metadata hooks come before.
--- a/cubicweb/pyramid/bwcompat.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/pyramid/bwcompat.py	Wed Feb 27 13:36:31 2019 +0100
@@ -112,7 +112,7 @@
                 # earlier in the controllers, not here. In the end, the
                 # ValidationError should never by handled here.
                 content = self.appli.validation_error_handler(req, ex)
-            except cubicweb.web.RemoteCallFailed as ex:
+            except cubicweb.web.RemoteCallFailed:
                 # XXX The default pyramid error handler (or one that we provide
                 # for this exception) should be enough
                 # content = self.appli.ajax_error_handler(req, ex)
--- a/cubicweb/pyramid/config.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/pyramid/config.py	Wed Feb 27 13:36:31 2019 +0100
@@ -44,8 +44,8 @@
     cube_appobject_path = (BaseWebConfiguration.cube_appobject_path
                            | ServerConfiguration.cube_appobject_path)
 
-    options = merge_options(ServerConfiguration.options +
-                            BaseWebConfiguration.options)
+    options = merge_options(ServerConfiguration.options
+                            + BaseWebConfiguration.options)
 
     def init_log(self, *args, **kwargs):
         """Rely on logging configuration in Pyramid's .ini file, do nothing
--- a/cubicweb/pyramid/core.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/pyramid/core.py	Wed Feb 27 13:36:31 2019 +0100
@@ -129,7 +129,7 @@
              DeprecationWarning, stacklevel=2)
         request.body = ex.content
         request.status_int = ex.status
-    except cubicweb.web.Unauthorized as ex:
+    except cubicweb.web.Unauthorized:
         raise httpexceptions.HTTPForbidden(
             request.cw_request._(
                 'You\'re not authorized to access this page. '
@@ -143,7 +143,7 @@
                 'If you think it should be allowed, please contact the site '
                 'administrator.'),
             headers=cw_headers(request))
-    except (rql.BadRQLQuery, cubicweb.web.RequestError) as ex:
+    except (rql.BadRQLQuery, cubicweb.web.RequestError):
         raise
 
 
--- a/cubicweb/rqlrewrite.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/rqlrewrite.py	Wed Feb 27 13:36:31 2019 +0100
@@ -278,10 +278,10 @@
         nbtrees = len(localchecks)
         myunion = union = select.parent
         # transform in subquery when len(localchecks)>1 and groups
-        if nbtrees > 1 and (select.orderby or select.groupby or
-                            select.having or select.has_aggregat or
-                            select.distinct or
-                            select.limit or select.offset):
+        if nbtrees > 1 and (select.orderby or select.groupby
+                            or select.having or select.has_aggregat
+                            or select.distinct
+                            or select.limit or select.offset):
             newselect = stmts.Select()
             # only select variables in subqueries
             origselection = select.selection
--- a/cubicweb/rset.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/rset.py	Wed Feb 27 13:36:31 2019 +0100
@@ -1,4 +1,4 @@
-# copyright 2003-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# copyright 2003-2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 #
 # This file is part of CubicWeb.
@@ -393,6 +393,8 @@
             if self.rows[i][col] is not None:
                 yield self.get_entity(i, col)
 
+    all = entities
+
     def iter_rows_with_entities(self):
         """ iterates over rows, and for each row
         eids are converted to plain entities
@@ -459,6 +461,34 @@
         else:
             raise MultipleResultsError("Multiple rows were found for one()")
 
+    def first(self, col=0):
+        """Retrieve the first entity from the query.
+
+        If the result set is empty, raises :exc:`NoResultError`.
+
+        :type col: int
+        :param col: The column localising the entity in the unique row
+
+        :return: the partially initialized `Entity` instance
+        """
+        if len(self) == 0:
+            raise NoResultError("No row was found for first()")
+        return self.get_entity(0, col)
+
+    def last(self, col=0):
+        """Retrieve the last entity from the query.
+
+        If the result set is empty, raises :exc:`NoResultError`.
+
+        :type col: int
+        :param col: The column localising the entity in the unique row
+
+        :return: the partially initialized `Entity` instance
+        """
+        if len(self) == 0:
+            raise NoResultError("No row was found for last()")
+        return self.get_entity(-1, col)
+
     def _make_entity(self, row, col):
         """Instantiate an entity, and store it in the entity cache"""
         # build entity instance
--- a/cubicweb/schema.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/schema.py	Wed Feb 27 13:36:31 2019 +0100
@@ -297,8 +297,8 @@
                     prefix, action, suffix = rel.r_type.split('_')
                 except ValueError:
                     continue
-                if (prefix != 'has' or suffix != 'permission' or
-                        action not in ('add', 'delete', 'update', 'read')):
+                if (prefix != 'has' or suffix != 'permission'
+                        or action not in ('add', 'delete', 'update', 'read')):
                     continue
                 if found is None:
                     found = []
@@ -696,8 +696,8 @@
 
 @_override_method(PermissionMixIn)
 def may_have_permission(self, action, req):
-    if action != 'read' and not (self.has_local_role('read') or
-                                 self.has_perm(req, 'read')):
+    if action != 'read' and not (self.has_local_role('read')
+                                 or self.has_perm(req, 'read')):
         return False
     return self.has_local_role(action) or self.has_perm(req, action)
 
@@ -1188,8 +1188,8 @@
 
     This name may be used as name for the constraint in the database.
     """
-    return 'cstr' + md5((rdef.subject.type + rdef.rtype.type + self.type() +
-                         (self.serialize() or '')).encode('ascii')).hexdigest()
+    return 'cstr' + md5((rdef.subject.type + rdef.rtype.type + self.type()
+                         + (self.serialize() or '')).encode('ascii')).hexdigest()
 
 
 class BaseRQLConstraint(RRQLExpression, BaseConstraint):
@@ -1455,9 +1455,9 @@
     if cw is not None:
         if hasattr(cw, 'write_security'):  # test it's a session and not a request
             # cw is a server session
-            hasperm = (not cw.write_security or
-                       not cw.is_hook_category_activated('integrity') or
-                       cw.user.matching_groups(MAY_USE_TEMPLATE_FORMAT))
+            hasperm = (not cw.write_security
+                       or not cw.is_hook_category_activated('integrity')
+                       or cw.user.matching_groups(MAY_USE_TEMPLATE_FORMAT))
         else:
             hasperm = cw.user.matching_groups(MAY_USE_TEMPLATE_FORMAT)
         if hasperm:
--- a/cubicweb/server/edition.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/server/edition.py	Wed Feb 27 13:36:31 2019 +0100
@@ -155,5 +155,6 @@
         thecopy = EditedEntity(copy(self.entity))
         thecopy.entity.cw_attr_cache = copy(self.entity.cw_attr_cache)
         thecopy.entity._cw_related_cache = {}
+        thecopy.entity._cw_adapters_cache = {}
         thecopy.update(self, skipsec=False)
         return thecopy
--- a/cubicweb/server/rqlannotation.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/server/rqlannotation.py	Wed Feb 27 13:36:31 2019 +0100
@@ -100,9 +100,9 @@
                         ostinfo = rhs.children[0].variable.stinfo
                     else:
                         ostinfo = lhs.variable.stinfo
-                    if not (ostinfo.get('optcomparisons') or
-                            any(orel for orel in ostinfo['relations']
-                                if orel.optional and orel is not rel)):
+                    if not (ostinfo.get('optcomparisons')
+                            or any(orel for orel in ostinfo['relations']
+                                   if orel.optional and orel is not rel)):
                         break
             if rschema.final or (onlhs and rschema.inlined):
                 if rschema.type != 'has_text':
--- a/cubicweb/server/schema2sql.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/server/schema2sql.py	Wed Feb 27 13:36:31 2019 +0100
@@ -54,9 +54,8 @@
     """Return a predictable-but-size-constrained name for an index on `table(*columns)`, using an
     md5 hash.
     """
-    return '%s%s' % (prefix, md5((table +
-                                  ',' +
-                                  ','.join(sorted(columns))).encode('ascii')).hexdigest())
+    return '%s%s' % (prefix, md5((
+        table + ',' + ','.join(sorted(columns))).encode('ascii')).hexdigest())
 
 
 def rschema_has_table(rschema, skip_relations):
--- a/cubicweb/server/sources/datafeed.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/server/sources/datafeed.py	Wed Feb 27 13:36:31 2019 +0100
@@ -19,6 +19,7 @@
 database
 """
 
+from warnings import warn
 from io import BytesIO
 from os.path import exists
 from datetime import datetime, timedelta
@@ -157,11 +158,11 @@
                     {'x': self.eid})
         cnx.commit()
 
-    def pull_data(self, cnx, force=False, raise_on_error=False, async=False):
+    def pull_data(self, cnx, force=False, raise_on_error=False, sync=True, **kwargs):
         """Launch synchronization of the source if needed.
 
-        If `async` is true, the method return immediatly a dictionnary containing the import log's
-        eid, and the actual synchronization is done asynchronously. If `async` is false, return some
+        If `sync` is false, the method return immediatly a dictionnary containing the import log's
+        eid, and the actual synchronization is done asynchronously. If `sync` is True, return some
         imports statistics (e.g. number of created and updated entities).
 
         This method is responsible to handle commit/rollback on the given connection.
@@ -176,10 +177,14 @@
             self.error(str(exc))
             return {}
         try:
-            if async:
+            if kwargs.get('async') is not None:
+                warn('[3.27] `async` is reserved keyword in py3.7 use `sync` param instead',
+                     DeprecationWarning)
+                sync = not kwargs['async']
+            if sync:
+                return self._pull_data(cnx, force, raise_on_error)
+            else:
                 return self._async_pull_data(cnx, force, raise_on_error)
-            else:
-                return self._pull_data(cnx, force, raise_on_error)
         finally:
             cnx.rollback()  # rollback first in case there is some dirty transaction remaining
             self.release_synchronization_lock(cnx)
--- a/cubicweb/server/sources/native.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/server/sources/native.py	Wed Feb 27 13:36:31 2019 +0100
@@ -693,7 +693,7 @@
                     cnx.cnxset.rollback()
                     if self.repo.config.mode != 'test':
                         self.debug('transaction has been rolled back')
-                except Exception as ex:
+                except Exception:
                     pass
             if ex.__class__.__name__ == 'IntegrityError':
                 # need string comparison because of various backends
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/server/test/unittest_edition.py	Wed Feb 27 13:36:31 2019 +0100
@@ -0,0 +1,57 @@
+# copyright 2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Tests for the entity edition"""
+
+from cubicweb.devtools.testlib import CubicWebTC
+from cubicweb.server.edition import EditedEntity
+
+
+class EditedEntityTC(CubicWebTC):
+    """
+    Test cases for EditedEntity
+    """
+
+    def test_clone_cache_reset(self):
+        """
+        Tests that when an EditedEntity is cloned the caches are reset in the cloned instance
+        :return: Nothing
+        """
+        # Create an entity, create the EditedEntity and clone it
+        with self.admin_access.cnx() as cnx:
+            affaire = cnx.create_entity("Affaire", sujet=u"toto")
+            ee = EditedEntity(affaire)
+            ee.entity.cw_adapt_to("IWorkflowable")
+            self.assertTrue(ee.entity._cw_related_cache)
+            self.assertTrue(ee.entity._cw_adapters_cache)
+            the_clone = ee.clone()
+            self.assertFalse(the_clone.entity._cw_related_cache)
+            self.assertFalse(the_clone.entity._cw_adapters_cache)
+            cnx.rollback()
+        # Check the attributes
+        with self.admin_access.cnx() as cnx:
+            # Assume a different connection set on the entity
+            self.assertNotEqual(the_clone.entity._cw, cnx)
+            # Use the new connection
+            the_clone.entity._cw = cnx
+            self.assertEqual("toto", the_clone.entity.sujet)
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()
--- a/cubicweb/skeleton/tox.ini.tmpl	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/skeleton/tox.ini.tmpl	Wed Feb 27 13:36:31 2019 +0100
@@ -1,5 +1,5 @@
 [tox]
-envlist = py27,py34,flake8
+envlist = py27,py3,flake8
 
 [testenv]
 deps =
@@ -8,9 +8,8 @@
   {envpython} -m pytest {posargs:test}
 
 [testenv:flake8]
+basepython = python3
 skip_install = true
-whitelist_externals =
-  flake8
 deps =
   flake8
 commands = flake8
--- a/cubicweb/sobjects/services.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/sobjects/services.py	Wed Feb 27 13:36:31 2019 +0100
@@ -143,5 +143,5 @@
 
     def call(self, source_eid):
         source = self._cw.repo.source_by_eid(source_eid)
-        result = source.pull_data(self._cw, force=True, async=True)
+        result = source.pull_data(self._cw, force=True, sync=False)
         return result['import_log_eid']
--- a/cubicweb/sobjects/textparsers.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/sobjects/textparsers.py	Wed Feb 27 13:36:31 2019 +0100
@@ -59,7 +59,7 @@
 
          :<transition name>: #?<eid>
     """
-    instr_rgx = re.compile(':(\w+):\s*#?(\d+)', re.U)
+    instr_rgx = re.compile(r':(\w+):\s*#?(\d+)', re.U)
 
     def parse(self, caller, text):
         for trname, eid in self.instr_rgx.findall(text):
--- a/cubicweb/statsd_logger.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/statsd_logger.py	Wed Feb 27 13:36:31 2019 +0100
@@ -58,6 +58,7 @@
 
 import time
 import socket
+from contextlib import contextmanager
 
 _bucket = 'cubicweb'
 _address = None
@@ -87,19 +88,32 @@
     _socket = socket.socket(family, socket.SOCK_DGRAM)
 
 
+def teardown():
+    """Unconfigure the statsd endpoint
+
+    This is most likely only useful for unit tests"""
+    global _bucket, _address, _socket
+    _bucket = 'cubicweb'
+    _address = None
+    _socket = None
+
+
 def statsd_c(context, n=1):
     if _address is not None:
-        _socket.sendto('{0}.{1}:{2}|c\n'.format(_bucket, context, n), _address)
+        _socket.sendto('{0}.{1}:{2}|c\n'.format(_bucket, context, n).encode(),
+                       _address)
 
 
 def statsd_g(context, value):
     if _address is not None:
-        _socket.sendto('{0}.{1}:{2}|g\n'.format(_bucket, context, value), _address)
+        _socket.sendto('{0}.{1}:{2}|g\n'.format(_bucket, context, value).encode(),
+                       _address)
 
 
 def statsd_t(context, value):
     if _address is not None:
-        _socket.sendto('{0}.{1}:{2:.4f}|ms\n'.format(_bucket, context, value), _address)
+        _socket.sendto('{0}.{1}:{2:.4f}|ms\n'.format(_bucket, context, value).encode(),
+                       _address)
 
 
 class statsd_timeit(object):
@@ -125,7 +139,7 @@
         finally:
             dt = 1000 * (time.time() - t0)
             msg = '{0}.{1}:{2:.4f}|ms\n{0}.{1}:1|c\n'.format(
-                _bucket, self.__name__, dt)
+                _bucket, self.__name__, dt).encode()
             _socket.sendto(msg, _address)
 
     def __get__(self, obj, objtype):
@@ -134,3 +148,17 @@
             return self
         import functools
         return functools.partial(self.__call__, obj)
+
+
+@contextmanager
+def statsd_timethis(ctxmsg):
+    if _address is not None:
+        t0 = time.time()
+    try:
+        yield
+    finally:
+        if _address is not None:
+            dt = 1000 * (time.time() - t0)
+            msg = '{0}.{1}:{2:.4f}|ms\n{0}.{1}:1|c\n'.format(
+                _bucket, ctxmsg, dt).encode()
+            _socket.sendto(msg, _address)
--- a/cubicweb/test/unittest_binary.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/test/unittest_binary.py	Wed Feb 27 13:36:31 2019 +0100
@@ -33,7 +33,7 @@
         Binary(b'toto')
         Binary(bytearray(b'toto'))
         if PY2:
-            Binary(buffer('toto'))
+            Binary(buffer('toto'))  # noqa: F821
         else:
             Binary(memoryview(b'toto'))
         with self.assertRaises((AssertionError, TypeError)):
@@ -45,7 +45,7 @@
         b.write(b'toto')
         b.write(bytearray(b'toto'))
         if PY2:
-            b.write(buffer('toto'))
+            b.write(buffer('toto'))  # noqa: F821
         else:
             b.write(memoryview(b'toto'))
         with self.assertRaises((AssertionError, TypeError)):
--- a/cubicweb/test/unittest_rset.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/test/unittest_rset.py	Wed Feb 27 13:36:31 2019 +0100
@@ -1,5 +1,5 @@
 # coding: utf-8
-# copyright 2003-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# copyright 2003-2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 #
 # This file is part of CubicWeb.
@@ -16,7 +16,7 @@
 #
 # You should have received a copy of the GNU Lesser General Public License along
 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
-"""unit tests for module cubicweb.utils"""
+"""unit tests for module cubicweb.rset"""
 
 from six import string_types
 from six.moves import cPickle as pickle
@@ -44,9 +44,12 @@
     def test_relations_description(self):
         """tests relations_description() function"""
         queries = {
-            'Any U,L,M where U is CWUser, U login L, U mail M': [(1, 'login', 'subject'), (2, 'mail', 'subject')],
-            'Any U,L,M where U is CWUser, L is Foo, U mail M': [(2, 'mail', 'subject')],
-            'Any C,P where C is Company, C employs P': [(1, 'employs', 'subject')],
+            'Any U,L,M where U is CWUser, U login L, U mail M': [
+                (1, 'login', 'subject'), (2, 'mail', 'subject')],
+            'Any U,L,M where U is CWUser, L is Foo, U mail M': [
+                (2, 'mail', 'subject')],
+            'Any C,P where C is Company, C employs P': [
+                (1, 'employs', 'subject')],
             'Any C,P where C is Company, P employed_by P': [],
             'Any C where C is Company, C employs P': [],
         }
@@ -68,14 +71,17 @@
 
     def test_subquery_callfunc(self):
         rql = ('Any A,B,C,COUNT(D) GROUPBY A,B,C WITH A,B,C,D BEING '
-               '(Any YEAR(CD), MONTH(CD), S, X WHERE X is CWUser, X creation_date CD, X in_state S)')
+               '(Any YEAR(CD), MONTH(CD), S, X WHERE X is CWUser, '
+               'X creation_date CD, X in_state S)')
         rqlst = parse(rql)
         select, col = rqlst.locate_subquery(2, 'CWUser', None)
         result = list(attr_desc_iterator(select, col, 2))
         self.assertEqual(result, [])
 
     def test_subquery_callfunc_2(self):
-        rql = ('Any X,S,L WHERE X in_state S WITH X, L BEING (Any X,MAX(L) GROUPBY X WHERE X is CWUser, T wf_info_for X, T creation_date L)')
+        rql = ('Any X,S,L WHERE '
+               'X in_state S WITH X, L BEING (Any X,MAX(L) GROUPBY X WHERE '
+               'X is CWUser, T wf_info_for X, T creation_date L)')
         rqlst = parse(rql)
         select, col = rqlst.locate_subquery(0, 'CWUser', None)
         result = list(attr_desc_iterator(select, col, 0))
@@ -115,20 +121,19 @@
                               '%sview?vid=foo&rql=yo' % baseurl)
             self.compare_urls(req.build_url('view', _restpath='task/title/go'),
                               '%stask/title/go' % baseurl)
-            #self.compare_urls(req.build_url('view', _restpath='/task/title/go'),
-            #                  '%stask/title/go' % baseurl)
+            # self.compare_urls(req.build_url('view', _restpath='/task/title/go'),
+            #                   '%stask/title/go' % baseurl)
             # empty _restpath should not crash
             self.compare_urls(req.build_url('view', _restpath=''), baseurl)
 
-
     def test_build(self):
         """test basic build of a ResultSet"""
-        rs = ResultSet([1,2,3], 'CWGroup X', description=['CWGroup', 'CWGroup', 'CWGroup'])
+        rs = ResultSet([1, 2, 3], 'CWGroup X',
+                       description=['CWGroup', 'CWGroup', 'CWGroup'])
         self.assertEqual(rs.rowcount, 3)
-        self.assertEqual(rs.rows, [1,2,3])
+        self.assertEqual(rs.rows, [1, 2, 3])
         self.assertEqual(rs.description, ['CWGroup', 'CWGroup', 'CWGroup'])
 
-
     def test_limit(self):
         rs = ResultSet([[12000, 'adim'], [13000, 'syt'], [14000, 'nico']],
                        'Any U,L where U is CWUser, U login L',
@@ -148,7 +153,7 @@
             rs = req.execute('Any E,U WHERE E is CWEType, E created_by U')
             # get entity on row 9. This will fill its created_by relation cache,
             # with cwuser on row 9 as well
-            e1 = rs.get_entity(9, 0)
+            e1 = rs.get_entity(9, 0)  # noqa
             # get entity on row 10. This will fill its created_by relation cache,
             # with cwuser built on row 9
             e2 = rs.get_entity(10, 0)
@@ -171,6 +176,7 @@
         with self.admin_access.web_request() as req:
             rs.req = req
             rs.vreg = self.vreg
+
             def test_filter(entity):
                 return entity.login != 'nico'
 
@@ -185,12 +191,13 @@
                        description=[['CWUser', 'String']] * 3)
         with self.admin_access.web_request() as req:
             rs.req = req
+
             def test_transform(row, desc):
                 return row[1:], desc[1:]
             rs2 = rs.transformed_rset(test_transform)
 
             self.assertEqual(len(rs2), 3)
-            self.assertEqual(list(rs2), [['adim'],['syt'],['nico']])
+            self.assertEqual(list(rs2), [['adim'], ['syt'], ['nico']])
 
     def test_sort(self):
         rs = ResultSet([[12000, 'adim'], [13000, 'syt'], [14000, 'nico']],
@@ -200,13 +207,13 @@
             rs.req = req
             rs.vreg = self.vreg
 
-            rs2 = rs.sorted_rset(lambda e:e.cw_attr_cache['login'])
+            rs2 = rs.sorted_rset(lambda e: e.cw_attr_cache['login'])
             self.assertEqual(len(rs2), 3)
             self.assertEqual([login for _, login in rs2], ['adim', 'nico', 'syt'])
             # make sure rs is unchanged
             self.assertEqual([login for _, login in rs], ['adim', 'syt', 'nico'])
 
-            rs2 = rs.sorted_rset(lambda e:e.cw_attr_cache['login'], reverse=True)
+            rs2 = rs.sorted_rset(lambda e: e.cw_attr_cache['login'], reverse=True)
             self.assertEqual(len(rs2), 3)
             self.assertEqual([login for _, login in rs2], ['syt', 'nico', 'adim'])
             # make sure rs is unchanged
@@ -221,47 +228,47 @@
     def test_split(self):
         rs = ResultSet([[12000, 'adim', u'Adim chez les pinguins'],
                         [12000, 'adim', u'Jardiner facile'],
-                        [13000, 'syt',  u'Le carrelage en 42 leçons'],
+                        [13000, 'syt', u'Le carrelage en 42 leçons'],
                         [14000, 'nico', u'La tarte tatin en 15 minutes'],
                         [14000, 'nico', u"L'épluchage du castor commun"]],
-                       'Any U, L, T WHERE U is CWUser, U login L,'\
-                       'D created_by U, D title T',
+                       ('Any U, L, T WHERE U is CWUser, U login L,'
+                        'D created_by U, D title T'),
                        description=[['CWUser', 'String', 'String']] * 5)
         with self.admin_access.web_request() as req:
             rs.req = req
             rs.vreg = self.vreg
-            rsets = rs.split_rset(lambda e:e.cw_attr_cache['login'])
+            rsets = rs.split_rset(lambda e: e.cw_attr_cache['login'])
             self.assertEqual(len(rsets), 3)
-            self.assertEqual([login for _, login,_ in rsets[0]], ['adim', 'adim'])
-            self.assertEqual([login for _, login,_ in rsets[1]], ['syt'])
-            self.assertEqual([login for _, login,_ in rsets[2]], ['nico', 'nico'])
+            self.assertEqual([login for _, login, _ in rsets[0]], ['adim', 'adim'])
+            self.assertEqual([login for _, login, _ in rsets[1]], ['syt'])
+            self.assertEqual([login for _, login, _ in rsets[2]], ['nico', 'nico'])
             # make sure rs is unchanged
-            self.assertEqual([login for _, login,_ in rs], ['adim', 'adim', 'syt', 'nico', 'nico'])
+            self.assertEqual([login for _, login, _ in rs], ['adim', 'adim', 'syt', 'nico', 'nico'])
 
-            rsets = rs.split_rset(lambda e:e.cw_attr_cache['login'], return_dict=True)
+            rsets = rs.split_rset(lambda e: e.cw_attr_cache['login'], return_dict=True)
             self.assertEqual(len(rsets), 3)
-            self.assertEqual([login for _, login,_ in rsets['nico']], ['nico', 'nico'])
-            self.assertEqual([login for _, login,_ in rsets['adim']], ['adim', 'adim'])
-            self.assertEqual([login for _, login,_ in rsets['syt']], ['syt'])
+            self.assertEqual([login for _, login, _ in rsets['nico']], ['nico', 'nico'])
+            self.assertEqual([login for _, login, _ in rsets['adim']], ['adim', 'adim'])
+            self.assertEqual([login for _, login, _ in rsets['syt']], ['syt'])
             # make sure rs is unchanged
-            self.assertEqual([login for _, login,_ in rs], ['adim', 'adim', 'syt', 'nico', 'nico'])
+            self.assertEqual([login for _, login, _ in rs], ['adim', 'adim', 'syt', 'nico', 'nico'])
 
             rsets = rs.split_rset(lambda s: s.count('d'), col=2)
             self.assertEqual(len(rsets), 2)
             self.assertEqual([title for _, _, title in rsets[0]],
-                              [u"Adim chez les pinguins",
-                               u"Jardiner facile",
-                               u"L'épluchage du castor commun",])
+                             [u"Adim chez les pinguins",
+                              u"Jardiner facile",
+                              u"L'épluchage du castor commun"])
             self.assertEqual([title for _, _, title in rsets[1]],
-                              [u"Le carrelage en 42 leçons",
-                               u"La tarte tatin en 15 minutes",])
+                             [u"Le carrelage en 42 leçons",
+                              u"La tarte tatin en 15 minutes"])
             # make sure rs is unchanged
             self.assertEqual([title for _, _, title in rs],
-                              [u'Adim chez les pinguins',
-                               u'Jardiner facile',
-                               u'Le carrelage en 42 leçons',
-                               u'La tarte tatin en 15 minutes',
-                               u"L'épluchage du castor commun"])
+                             [u'Adim chez les pinguins',
+                              u'Jardiner facile',
+                              u'Le carrelage en 42 leçons',
+                              u'La tarte tatin en 15 minutes',
+                              u"L'épluchage du castor commun"])
 
     def test_cached_syntax_tree(self):
         """make sure syntax tree is cached"""
@@ -308,13 +315,11 @@
             self.assertEqual(e.cw_col, 1)
             self.assertEqual(e.cw_attr_cache['login'], 'anon')
             self.assertRaises(KeyError, e.cw_attr_cache.__getitem__, 'firstname')
-            self.assertEqual(pprelcachedict(e._cw_related_cache),
-                              [])
+            self.assertEqual(pprelcachedict(e._cw_related_cache), [])
             e.complete()
             self.assertEqual(e.cw_attr_cache['firstname'], None)
             self.assertEqual(e.view('text'), 'anon')
-            self.assertEqual(pprelcachedict(e._cw_related_cache),
-                              [])
+            self.assertEqual(pprelcachedict(e._cw_related_cache), [])
 
             self.assertRaises(NotAnEntity, rset.get_entity, 0, 2)
             self.assertRaises(NotAnEntity, rset.get_entity, 0, 3)
@@ -327,14 +332,14 @@
             # for_user / in_group are prefetched in CWUser __init__, in_state should
             # be filed from our query rset
             self.assertEqual(pprelcachedict(e._cw_related_cache),
-                              [('in_state_subject', [seid])])
+                             [('in_state_subject', [seid])])
 
     def test_get_entity_advanced_prefilled_cache(self):
         with self.admin_access.web_request() as req:
             e = req.create_entity('Bookmark', title=u'zou', path=u'path')
             req.cnx.commit()
             rset = req.execute('Any X,U,S,XT,UL,SN WHERE X created_by U, U in_state S, '
-                                'X title XT, S name SN, U login UL, X eid %s' % e.eid)
+                               'X title XT, S name SN, U login UL, X eid %s' % e.eid)
             e = rset.get_entity(0, 0)
             self.assertEqual(e.cw_attr_cache['title'], 'zou')
             self.assertEqual(pprelcachedict(e._cw_related_cache),
@@ -371,11 +376,10 @@
             u = cnx.user
             self.assertTrue(u.cw_relation_cached('primary_email', 'subject'))
 
-
     def test_get_entity_cache_with_left_outer_join(self):
         with self.admin_access.web_request() as req:
             eid = req.execute('INSERT CWUser E: E login "joe", E upassword "joe", E in_group G '
-                               'WHERE G name "users"')[0][0]
+                              'WHERE G name "users"')[0][0]
             rset = req.execute('Any X,E WHERE X eid %(x)s, X primary_email E?', {'x': eid})
             e = rset.get_entity(0, 0)
             # if any of the assertion below fails with a KeyError, the relation is not cached
@@ -386,19 +390,18 @@
             self.assertIsInstance(cached, ResultSet)
             self.assertEqual(cached.rowcount, 0)
 
-
     def test_get_entity_union(self):
         with self.admin_access.web_request() as req:
-            e = req.create_entity('Bookmark', title=u'manger', path=u'path')
+            req.create_entity('Bookmark', title=u'manger', path=u'path')
             req.drop_entity_cache()
             rset = req.execute('Any X,N ORDERBY N WITH X,N BEING '
-                                '((Any X,N WHERE X is Bookmark, X title N)'
-                                ' UNION '
-                                ' (Any X,N WHERE X is CWGroup, X name N))')
+                               '((Any X,N WHERE X is Bookmark, X title N)'
+                               ' UNION '
+                               ' (Any X,N WHERE X is CWGroup, X name N))')
             expected = (('CWGroup', 'guests'), ('CWGroup', 'managers'),
                         ('Bookmark', 'manger'), ('CWGroup', 'owners'),
                         ('CWGroup', 'users'))
-            for entity in rset.entities(): # test get_entity for each row actually
+            for entity in rset.entities():  # test get_entity for each row actually
                 etype, n = expected[entity.cw_row]
                 self.assertEqual(entity.cw_etype, etype)
                 attr = etype == 'Bookmark' and 'title' or 'name'
@@ -406,10 +409,11 @@
 
     def test_one(self):
         with self.admin_access.web_request() as req:
-            req.create_entity('CWUser', login=u'cdevienne',
-                                         upassword=u'cdevienne',
-                                         surname=u'de Vienne',
-                                         firstname=u'Christophe')
+            req.create_entity('CWUser',
+                              login=u'cdevienne',
+                              upassword=u'cdevienne',
+                              surname=u'de Vienne',
+                              firstname=u'Christophe')
             e = req.execute('Any X WHERE X login "cdevienne"').one()
 
             self.assertEqual(e.surname, u'de Vienne')
@@ -440,9 +444,81 @@
             with self.assertRaises(MultipleResultsError):
                 req.execute('Any X WHERE X is CWUser').one()
 
+    def test_first(self):
+        with self.admin_access.web_request() as req:
+            req.create_entity('CWUser',
+                              login=u'cdevienne',
+                              upassword=u'cdevienne',
+                              surname=u'de Vienne',
+                              firstname=u'Christophe')
+            e = req.execute('Any X WHERE X login "cdevienne"').first()
+            self.assertEqual(e.surname, u'de Vienne')
+
+            e = req.execute(
+                'Any X, N WHERE X login "cdevienne", X surname N').first()
+            self.assertEqual(e.surname, u'de Vienne')
+
+            e = req.execute(
+                'Any N, X WHERE X login "cdevienne", X surname N').first(col=1)
+            self.assertEqual(e.surname, u'de Vienne')
+
+    def test_first_no_rows(self):
+        with self.admin_access.web_request() as req:
+            with self.assertRaises(NoResultError):
+                req.execute('Any X WHERE X login "patanok"').first()
+
+    def test_first_multiple_rows(self):
+        with self.admin_access.web_request() as req:
+            req.create_entity(
+                'CWUser', login=u'user1', upassword=u'cdevienne',
+                surname=u'de Vienne', firstname=u'Christophe')
+            req.create_entity(
+                'CWUser', login=u'user2', upassword='adim',
+                surname=u'di mascio', firstname=u'adrien')
+
+            e = req.execute('Any X ORDERBY X WHERE X is CWUser, '
+                            'X login LIKE "user%"').first()
+            self.assertEqual(e.login, 'user1')
+
+    def test_last(self):
+        with self.admin_access.web_request() as req:
+            req.create_entity('CWUser',
+                              login=u'cdevienne',
+                              upassword=u'cdevienne',
+                              surname=u'de Vienne',
+                              firstname=u'Christophe')
+            e = req.execute('Any X WHERE X login "cdevienne"').last()
+            self.assertEqual(e.surname, u'de Vienne')
+
+            e = req.execute(
+                'Any X, N WHERE X login "cdevienne", X surname N').last()
+            self.assertEqual(e.surname, u'de Vienne')
+
+            e = req.execute(
+                'Any N, X WHERE X login "cdevienne", X surname N').last(col=1)
+            self.assertEqual(e.surname, u'de Vienne')
+
+    def test_last_no_rows(self):
+        with self.admin_access.web_request() as req:
+            with self.assertRaises(NoResultError):
+                req.execute('Any X WHERE X login "patanok"').last()
+
+    def test_last_multiple_rows(self):
+        with self.admin_access.web_request() as req:
+            req.create_entity(
+                'CWUser', login=u'user1', upassword=u'cdevienne',
+                surname=u'de Vienne', firstname=u'Christophe')
+            req.create_entity(
+                'CWUser', login=u'user2', upassword='adim',
+                surname=u'di mascio', firstname=u'adrien')
+
+            e = req.execute('Any X ORDERBY X WHERE X is CWUser, '
+                            'X login LIKE "user%"').last()
+            self.assertEqual(e.login, 'user2')
+
     def test_related_entity_optional(self):
         with self.admin_access.web_request() as req:
-            e = req.create_entity('Bookmark', title=u'aaaa', path=u'path')
+            req.create_entity('Bookmark', title=u'aaaa', path=u'path')
             rset = req.execute('Any B,U,L WHERE B bookmarked_by U?, U login L')
             entity, rtype = rset.related_entity(0, 2)
             self.assertEqual(entity, None)
@@ -452,9 +528,9 @@
         with self.admin_access.web_request() as req:
             e = req.create_entity('Bookmark', title=u'aaaa', path=u'path')
             rset = req.execute('Any X,N ORDERBY N WITH X,N BEING '
-                                '((Any X,N WHERE X is CWGroup, X name N)'
-                                ' UNION '
-                                ' (Any X,N WHERE X is Bookmark, X title N))')
+                               '((Any X,N WHERE X is CWGroup, X name N)'
+                               ' UNION '
+                               ' (Any X,N WHERE X is Bookmark, X title N))')
             entity, rtype = rset.related_entity(0, 1)
             self.assertEqual(entity.eid, e.eid)
             self.assertEqual(rtype, 'title')
@@ -468,9 +544,9 @@
         with self.admin_access.web_request() as req:
             e = req.create_entity('Bookmark', title=u'aaaa', path=u'path')
             rset = req.execute('Any X,N ORDERBY N WHERE X is Bookmark WITH X,N BEING '
-                                '((Any X,N WHERE X is CWGroup, X name N)'
-                                ' UNION '
-                                ' (Any X,N WHERE X is Bookmark, X title N))')
+                               '((Any X,N WHERE X is CWGroup, X name N)'
+                               ' UNION '
+                               ' (Any X,N WHERE X is Bookmark, X title N))')
             entity, rtype = rset.related_entity(0, 1)
             self.assertEqual(entity.eid, e.eid)
             self.assertEqual(rtype, 'title')
@@ -480,9 +556,9 @@
         with self.admin_access.web_request() as req:
             e = req.create_entity('Bookmark', title=u'aaaa', path=u'path')
             rset = req.execute('Any X,N ORDERBY N WITH N,X BEING '
-                                '((Any N,X WHERE X is CWGroup, X name N)'
-                                ' UNION '
-                                ' (Any N,X WHERE X is Bookmark, X title N))')
+                               '((Any N,X WHERE X is CWGroup, X name N)'
+                               ' UNION '
+                               ' (Any N,X WHERE X is Bookmark, X title N))')
             entity, rtype = rset.related_entity(0, 1)
             self.assertEqual(entity.eid, e.eid)
             self.assertEqual(rtype, 'title')
@@ -492,9 +568,9 @@
         with self.admin_access.web_request() as req:
             e = req.create_entity('Bookmark', title=u'aaaa', path=u'path')
             rset = req.execute('Any X,X, N ORDERBY N WITH X,N BEING '
-                                '((Any X,N WHERE X is CWGroup, X name N)'
-                                ' UNION '
-                                ' (Any X,N WHERE X is Bookmark, X title N))')
+                               '((Any X,N WHERE X is CWGroup, X name N)'
+                               ' UNION '
+                               ' (Any X,N WHERE X is Bookmark, X title N))')
             entity, rtype = rset.related_entity(0, 2)
             self.assertEqual(entity.eid, e.eid)
             self.assertEqual(rtype, 'title')
@@ -505,14 +581,14 @@
             req.create_entity('Bookmark', title=u'test bookmark', path=u'')
             req.execute('SET B bookmarked_by U WHERE U login "admin"')
             rset = req.execute('Any B,T,L WHERE B bookmarked_by U, U login L '
-                                'WITH B,T BEING (Any B,T WHERE B is Bookmark, B title T)')
+                               'WITH B,T BEING (Any B,T WHERE B is Bookmark, B title T)')
             rset.related_entity(0, 2)
 
     def test_related_entity_subquery_outerjoin(self):
         with self.admin_access.web_request() as req:
             rset = req.execute('Any X,S,L WHERE X in_state S '
-                                'WITH X, L BEING (Any X,MAX(L) GROUPBY X '
-                                'WHERE X is CWUser, T? wf_info_for X, T creation_date L)')
+                               'WITH X, L BEING (Any X,MAX(L) GROUPBY X '
+                               'WHERE X is CWUser, T? wf_info_for X, T creation_date L)')
             self.assertEqual(len(rset), 2)
             rset.related_entity(0, 1)
             rset.related_entity(0, 2)
@@ -523,9 +599,9 @@
             # make sure we have at least one element
             self.assertTrue(rset)
             self.assertEqual(set(e.e_schema.type for e in rset.entities(0)),
-                              set(['CWUser',]))
+                             set(['CWUser']))
             self.assertEqual(set(e.e_schema.type for e in rset.entities(1)),
-                              set(['CWGroup',]))
+                             set(['CWGroup']))
 
     def test_iter_rows_with_entities(self):
         with self.admin_access.web_request() as req:
@@ -533,35 +609,35 @@
             # make sure we have at least one element
             self.assertTrue(rset)
             out = list(rset.iter_rows_with_entities())[0]
-            self.assertEqual( out[0].login, out[1] )
-            self.assertEqual( out[2].name, out[3] )
+            self.assertEqual(out[0].login, out[1])
+            self.assertEqual(out[2].name, out[3])
 
     def test_printable_rql(self):
         with self.admin_access.web_request() as req:
             rset = req.execute(u'CWEType X WHERE X final FALSE')
             self.assertEqual(rset.printable_rql(),
-                              'Any X WHERE X final FALSE, X is CWEType')
+                             'Any X WHERE X final FALSE, X is CWEType')
 
     def test_searched_text(self):
         with self.admin_access.web_request() as req:
             rset = req.execute(u'Any X WHERE X has_text "foobar"')
             self.assertEqual(rset.searched_text(), 'foobar')
-            rset = req.execute(u'Any X WHERE X has_text %(text)s', {'text' : 'foo'})
+            rset = req.execute(u'Any X WHERE X has_text %(text)s', {'text': 'foo'})
             self.assertEqual(rset.searched_text(), 'foo')
 
     def test_union_limited_rql(self):
         with self.admin_access.web_request() as req:
             rset = req.execute('(Any X,N WHERE X is Bookmark, X title N)'
-                                ' UNION '
-                                '(Any X,N WHERE X is CWGroup, X name N)')
+                               ' UNION '
+                               '(Any X,N WHERE X is CWGroup, X name N)')
             rset.limit(2, 10, inplace=True)
             self.assertEqual(rset.limited_rql(),
-                              'Any A,B LIMIT 2 OFFSET 10 '
-                              'WITH A,B BEING ('
-                              '(Any X,N WHERE X is Bookmark, X title N) '
-                              'UNION '
-                              '(Any X,N WHERE X is CWGroup, X name N)'
-                              ')')
+                             'Any A,B LIMIT 2 OFFSET 10 '
+                             'WITH A,B BEING ('
+                             '(Any X,N WHERE X is Bookmark, X title N) '
+                             'UNION '
+                             '(Any X,N WHERE X is CWGroup, X name N)'
+                             ')')
 
     def test_possible_actions_cache(self):
         with self.admin_access.web_request() as req:
@@ -572,8 +648,9 @@
 
     def test_count_users_by_date(self):
         with self.admin_access.web_request() as req:
-            rset = req.execute('Any D, COUNT(U) GROUPBY D WHERE U is CWUser, U creation_date D')
-            self.assertEqual(rset.related_entity(0,0), (None, None))
+            rset = req.execute('Any D, COUNT(U) GROUPBY D '
+                               'WHERE U is CWUser, U creation_date D')
+            self.assertEqual(rset.related_entity(0, 0), (None, None))
 
     def test_str(self):
         with self.admin_access.web_request() as req:
@@ -594,15 +671,15 @@
     def test_slice(self):
         rs = ResultSet([[12000, 'adim', u'Adim chez les pinguins'],
                         [12000, 'adim', u'Jardiner facile'],
-                        [13000, 'syt',  u'Le carrelage en 42 leçons'],
+                        [13000, 'syt', u'Le carrelage en 42 leçons'],
                         [14000, 'nico', u'La tarte tatin en 15 minutes'],
                         [14000, 'nico', u"L'épluchage du castor commun"]],
-                       'Any U, L, T WHERE U is CWUser, U login L,'\
-                       'D created_by U, D title T',
+                       ('Any U, L, T WHERE U is CWUser, U login L,'
+                        'D created_by U, D title T'),
                        description=[['CWUser', 'String', 'String']] * 5)
         self.assertEqual(rs[1::2],
-            [[12000, 'adim', u'Jardiner facile'],
-             [14000, 'nico', u'La tarte tatin en 15 minutes']])
+                         [[12000, 'adim', u'Jardiner facile'],
+                         [14000, 'nico', u'La tarte tatin en 15 minutes']])
 
     def test_nonregr_symmetric_relation(self):
         # see https://www.cubicweb.org/ticket/4739253
@@ -611,7 +688,8 @@
             cnx.create_entity('Personne', nom=u'denis', connait=p1)
             cnx.commit()
             rset = cnx.execute('Any X,Y WHERE X connait Y')
-            rset.get_entity(0, 1) # used to raise KeyError
+            rset.get_entity(0, 1)  # used to raise KeyError
+
 
 if __name__ == '__main__':
     unittest_main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/test/unittest_statsd.py	Wed Feb 27 13:36:31 2019 +0100
@@ -0,0 +1,136 @@
+# -*- coding: utf-8 -*-
+# copyright 2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""unit tests for module cubicweb.statsd_logger"""
+
+import threading
+import socket
+import time
+import re
+
+from unittest import TestCase
+from cubicweb import statsd_logger as statsd
+
+
+UDP_PORT = None
+RUNNING = True
+SOCK = socket.socket(socket.AF_INET,
+                     socket.SOCK_DGRAM)
+SOCK.settimeout(0.1)
+STATSD = None
+DATA = []
+
+
+def statsd_rcv():
+    while RUNNING:
+        try:
+            data, addr = SOCK.recvfrom(1024)
+            if data:
+                rcv = [row.strip().decode() for row in data.splitlines()]
+                DATA.extend(rcv)
+        except socket.timeout:
+            pass
+
+
+def setUpModule(*args):
+    global UDP_PORT, STATSD
+    SOCK.bind(('127.0.0.1', 0))
+    UDP_PORT = SOCK.getsockname()[1]
+    STATSD = threading.Thread(target=statsd_rcv)
+    STATSD.start()
+    statsd.setup('test', ('127.0.0.1', UDP_PORT))
+
+
+def tearDownModule(*args):
+    global RUNNING
+    RUNNING = False
+    STATSD.join()
+    statsd.teardown()
+
+
+class StatsdTC(TestCase):
+
+    def setUp(self):
+        super(StatsdTC, self).setUp()
+        DATA[:] = []
+
+    def check_received(self, value):
+        for i in range(10):
+            if value in DATA:
+                break
+            time.sleep(0.01)
+        else:
+            self.assertIn(value, DATA)
+
+    def check_received_ms(self, value):
+        value = re.compile(value.replace('?', r'\d'))
+        for i in range(10):
+            if [x for x in DATA if value.match(x)]:
+                break
+            time.sleep(0.01)
+        else:
+            self.assertTrue([x for x in DATA if value.match(x)], DATA)
+
+    def test_statsd_c(self):
+        statsd.statsd_c('context')
+        self.check_received('test.context:1|c')
+        statsd.statsd_c('context', 10)
+        self.check_received('test.context:10|c')
+
+    def test_statsd_g(self):
+        statsd.statsd_g('context', 42)
+        self.check_received('test.context:42|g')
+        statsd.statsd_g('context', 'Igorrr')
+        self.check_received('test.context:Igorrr|g')
+
+    def test_statsd_t(self):
+        statsd.statsd_t('context', 1)
+        self.check_received('test.context:1.0000|ms')
+        statsd.statsd_t('context', 10)
+        self.check_received('test.context:10.0000|ms')
+        statsd.statsd_t('context', 0.12344)
+        self.check_received('test.context:0.1234|ms')
+        statsd.statsd_t('context', 0.12345)
+        self.check_received('test.context:0.1235|ms')
+
+    def test_decorator(self):
+
+        @statsd.statsd_timeit
+        def measure_me_please():
+            "some nice function"
+            return 42
+
+        self.assertEqual(measure_me_please.__doc__,
+                         "some nice function")
+
+        measure_me_please()
+        self.check_received_ms('test.measure_me_please:0.0???|ms')
+        self.check_received('test.measure_me_please:1|c')
+
+    def test_context_manager(self):
+
+        with statsd.statsd_timethis('cm'):
+            time.sleep(0.1)
+
+        self.check_received_ms('test.cm:100.????|ms')
+        self.check_received('test.cm:1|c')
+
+
+if __name__ == '__main__':
+    from unittest import main
+    main()
--- a/cubicweb/test/unittest_utils.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/test/unittest_utils.py	Wed Feb 27 13:36:31 2019 +0100
@@ -109,6 +109,95 @@
                           'itemcount': 10,
                           'permanentcount': 5})
 
+    def test_clear_on_overflow(self):
+        """Tests that only non-permanent items in the cache are wiped-out on ceiling overflow
+        """
+        c = QueryCache(ceiling=10)
+        # set 10 values
+        for x in range(10):
+            c[x] = x
+        # arrange for the first 5 to be permanent
+        for x in range(5):
+            for r in range(QueryCache._maxlevel + 2):
+                v = c[x]
+                self.assertEqual(v, x)
+        # Add the 11-th
+        c[10] = 10
+        self.assertEqual(c._usage_report(),
+                         {'transientcount': 0,
+                          'itemcount': 6,
+                          'permanentcount': 5})
+
+    def test_get_with_default(self):
+        """
+        Tests the capability of QueryCache for retrieving items with a default value
+        """
+        c = QueryCache(ceiling=20)
+        # set 10 values
+        for x in range(10):
+            c[x] = x
+        # arrange for the first 5 to be permanent
+        for x in range(5):
+            for r in range(QueryCache._maxlevel + 2):
+                v = c[x]
+                self.assertEqual(v, x)
+        self.assertEqual(c._usage_report(),
+                         {'transientcount': 0,
+                          'itemcount': 10,
+                          'permanentcount': 5})
+        # Test defaults for existing (including in permanents)
+        for x in range(10):
+            v = c.get(x, -1)
+            self.assertEqual(v, x)
+        # Test defaults for others
+        for x in range(10, 15):
+            v = c.get(x, -1)
+            self.assertEqual(v, -1)
+
+    def test_iterkeys(self):
+        """
+        Tests the iterating on keys in the cache
+        """
+        c = QueryCache(ceiling=20)
+        # set 10 values
+        for x in range(10):
+            c[x] = x
+        # arrange for the first 5 to be permanent
+        for x in range(5):
+            for r in range(QueryCache._maxlevel + 2):
+                v = c[x]
+                self.assertEqual(v, x)
+        self.assertEqual(c._usage_report(),
+                         {'transientcount': 0,
+                          'itemcount': 10,
+                          'permanentcount': 5})
+        keys = sorted(c)
+        for x in range(10):
+            self.assertEquals(x, keys[x])
+
+    def test_items(self):
+        """
+        Tests the iterating on key-value couples in the cache
+        """
+        c = QueryCache(ceiling=20)
+        # set 10 values
+        for x in range(10):
+            c[x] = x
+        # arrange for the first 5 to be permanent
+        for x in range(5):
+            for r in range(QueryCache._maxlevel + 2):
+                v = c[x]
+                self.assertEqual(v, x)
+        self.assertEqual(c._usage_report(),
+                         {'transientcount': 0,
+                          'itemcount': 10,
+                          'permanentcount': 5})
+        content = sorted(c.items())
+        for x in range(10):
+            self.assertEquals(x, content[x][0])
+            self.assertEquals(x, content[x][1])
+
+
 class UStringIOTC(TestCase):
     def test_boolean_value(self):
         self.assertTrue(UStringIO())
--- a/cubicweb/toolsutils.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/toolsutils.py	Wed Feb 27 13:36:31 2019 +0100
@@ -19,19 +19,19 @@
 from __future__ import print_function
 
 
-
 # XXX move most of this in logilab.common (shellutils ?)
 
 import io
-import os, sys
+import os
+import sys
 import subprocess
-from os import listdir, makedirs, environ, chmod, walk, remove
-from os.path import exists, join, abspath, normpath
+from os import listdir, makedirs, chmod, walk, remove
+from os.path import exists, join, normpath
 import re
 from rlcompleter import Completer
 try:
     import readline
-except ImportError: # readline not available, no completion
+except ImportError:  # readline not available, no completion
     pass
 try:
     from os import symlink
@@ -44,11 +44,13 @@
 from logilab.common.clcommands import Command as BaseCommand
 from logilab.common.shellutils import ASK
 
-from cubicweb import warning # pylint: disable=E0611
+from cubicweb import warning  # pylint: disable=E0611
 from cubicweb import ConfigurationError, ExecutionError
 
+
 def underline_title(title, car='-'):
-    return title+'\n'+(car*len(title))
+    return title + '\n' + (car * len(title))
+
 
 def iter_dir(directory, condition_file=None, ignore=()):
     """iterate on a directory"""
@@ -56,12 +58,13 @@
         if sub in ('CVS', '.svn', '.hg'):
             continue
         if condition_file is not None and \
-               not exists(join(directory, sub, condition_file)):
+                not exists(join(directory, sub, condition_file)):
             continue
         if sub in ignore:
             continue
         yield sub
 
+
 def create_dir(directory):
     """create a directory if it doesn't exist yet"""
     try:
@@ -73,6 +76,7 @@
             raise
         print('-> no need to create existing directory %s' % directory)
 
+
 def create_symlink(source, target):
     """create a symbolic link"""
     if exists(target):
@@ -80,16 +84,19 @@
     symlink(source, target)
     print('[symlink] %s <-- %s' % (target, source))
 
+
 def create_copy(source, target):
     import shutil
     print('[copy] %s <-- %s' % (target, source))
     shutil.copy2(source, target)
 
+
 def rm(whatever):
     import shutil
     shutil.rmtree(whatever)
     print('-> removed %s' % whatever)
 
+
 def show_diffs(appl_file, ref_file, askconfirm=True):
     """interactivly replace the old file with the new file according to
     user decision
@@ -122,7 +129,10 @@
     else:
         print('no diff between %s and %s' % (appl_file, ref_file))
 
+
 SKEL_EXCLUDE = ('*.py[co]', '*.orig', '*~', '*_flymake.py')
+
+
 def copy_skeleton(skeldir, targetdir, context,
                   exclude=SKEL_EXCLUDE, askconfirm=False):
     import shutil
@@ -148,7 +158,7 @@
             if fname.endswith('.tmpl'):
                 tfpath = tfpath[:-5]
                 if not askconfirm or not exists(tfpath) or \
-                       ASK.confirm('%s exists, overwrite?' % tfpath):
+                        ASK.confirm('%s exists, overwrite?' % tfpath):
                     fill_templated_file(fpath, tfpath, context)
                     print('[generate] %s <-- %s' % (tfpath, fpath))
             elif exists(tfpath):
@@ -157,12 +167,14 @@
                 shutil.copyfile(fpath, tfpath)
                 shutil.copymode(fpath, tfpath)
 
+
 def fill_templated_file(fpath, tfpath, context):
     with io.open(fpath, encoding='ascii') as fobj:
         template = fobj.read()
     with io.open(tfpath, 'w', encoding='ascii') as fobj:
         fobj.write(template % context)
 
+
 def restrict_perms_to_user(filepath, log=None):
     """set -rw------- permission on the given file"""
     if log:
@@ -198,7 +210,7 @@
                     # start a section
                     section = option[1:-1]
                     assert section not in config, \
-                           'Section %s is defined more than once' % section
+                        'Section %s is defined more than once' % section
                     config[section] = current = {}
                     continue
                 sys.stderr.write('ignoring malformed line\n%r\n' % line)
@@ -218,6 +230,7 @@
 
 _HDLRS = {}
 
+
 class metacmdhandler(type):
     def __new__(mcs, name, bases, classdict):
         cls = super(metacmdhandler, mcs).__new__(mcs, name, bases, classdict)
@@ -229,6 +242,7 @@
 @add_metaclass(metacmdhandler)
 class CommandHandler(object):
     """configuration specific helper for cubicweb-ctl commands"""
+
     def __init__(self, config):
         self.config = config
 
@@ -258,24 +272,25 @@
 
 CONNECT_OPTIONS = (
     ("user",
-     {'short': 'u', 'type' : 'string', 'metavar': '<user>',
+     {'short': 'u', 'type': 'string', 'metavar': '<user>',
       'help': 'connect as <user> instead of being prompted to give it.',
       }
      ),
     ("password",
-     {'short': 'p', 'type' : 'password', 'metavar': '<password>',
+     {'short': 'p', 'type': 'password', 'metavar': '<password>',
       'help': 'automatically give <password> for authentication instead of \
 being prompted to give it.',
       }),
     ("host",
-     {'short': 'H', 'type' : 'string', 'metavar': '<hostname>',
+     {'short': 'H', 'type': 'string', 'metavar': '<hostname>',
       'default': None,
       'help': 'specify the name server\'s host name. Will be detected by \
 broadcast if not provided.',
       }),
-    )
+)
 
-## cwshell helpers #############################################################
+# cwshell helpers #############################################################
+
 
 class AbstractMatcher(object):
     """Abstract class for CWShellCompleter's matchers.
@@ -350,7 +365,7 @@
             'rql_offset': len(func_prefix) + 2,
             # incomplete rql query
             'rql_query': parameters_text,
-            }
+        }
 
     def possible_matches(self, text):
         """call ``rql.suggestions`` component to complete user's input.
@@ -372,6 +387,7 @@
 class DefaultMatcher(AbstractMatcher):
     """Default matcher: delegate to standard's `rlcompleter.Completer`` class
     """
+
     def __init__(self, local_ctx):
         self.completer = Completer(local_ctx)
 
@@ -421,7 +437,7 @@
                     self.matches = matches
                     break
             else:
-                return None # no matcher able to handle `text`
+                return None  # no matcher able to handle `text`
         try:
             return self.matches[state]
         except IndexError:
--- a/cubicweb/utils.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/utils.py	Wed Feb 27 13:36:31 2019 +0100
@@ -632,6 +632,29 @@
         with self._lock:
             return len(self._data)
 
+    def items(self):
+        """Get an iterator over the dictionary's items: (key, value) pairs"""
+        with self._lock:
+            for k, v in self._data.items():
+                yield k, v
+
+    def get(self, k, default=None):
+        """Get the value associated to the specified key
+
+        :param k: The key to look for
+        :param default: The default value when the key is not found
+        :return: The associated value (or the default value)
+        """
+        try:
+            return self._data[k]
+        except KeyError:
+            return default
+
+    def __iter__(self):
+        with self._lock:
+            for k in iter(self._data):
+                yield k
+
     def __getitem__(self, k):
         with self._lock:
             if k in self._permanent:
@@ -689,10 +712,20 @@
                     break
                 level = v
         else:
-            # we removed cruft but everything is permanent
+            # we removed cruft
             if len(self._data) >= self._max:
-                logger.warning('Cache %s is full.' % id(self))
-                self._clear()
+                if len(self._permanent) >= self._max:
+                    # we really are full with permanents => clear
+                    logger.warning('Cache %s is full.' % id(self))
+                    self._clear()
+                else:
+                    # pathological case where _transient was probably empty ...
+                    # drop all non-permanents
+                    to_drop = set(self._data.keys()).difference(self._permanent)
+                    for k in to_drop:
+                        # should not be in _transient
+                        assert k not in self._transient
+                        self._data.pop(k, None)
 
     def _usage_report(self):
         with self._lock:
--- a/cubicweb/web/data/cubicweb.widgets.js	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/web/data/cubicweb.widgets.js	Wed Feb 27 13:36:31 2019 +0100
@@ -151,7 +151,7 @@
               */
             var settings = $(this.element).data('settings');
             var value = this.valueMethod.apply( this.element, arguments );
-            if (settings.multiple & arguments.length === 0) {
+            if (settings.multiple && arguments.length === 0) {
                 return extractLast(value);
             }
             return value
--- a/cubicweb/web/test/unittest_application.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/web/test/unittest_application.py	Wed Feb 27 13:36:31 2019 +0100
@@ -343,10 +343,10 @@
             }
             req.form.update(kwargs)
             req.form['_cw_entity_fields:%s' % dir_eid] = ','.join(
-                ['parent-%s' % role] +
-                [key.split(':')[0]
-                 for key in kwargs.keys()
-                 if not key.startswith('_')])
+                ['parent-%s' % role]
+                + [key.split(':')[0]
+                   for key in kwargs.keys()
+                   if not key.startswith('_')])
             self.expect_redirect_handle_request(req)
 
     def _edit_in_version(self, ticket_eid, version_eid, **kwargs):
@@ -360,10 +360,10 @@
             }
             req.form.update(kwargs)
             req.form['_cw_entity_fields:%s' % ticket_eid] = ','.join(
-                ['in_version-subject'] +
-                [key.split(':')[0]
-                 for key in kwargs.keys()
-                 if not key.startswith('_')])
+                ['in_version-subject']
+                + [key.split(':')[0]
+                   for key in kwargs.keys()
+                   if not key.startswith('_')])
             self.expect_redirect_handle_request(req)
 
     def test_create_and_link_directories(self):
--- a/cubicweb/web/test/unittest_webconfig.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/web/test/unittest_webconfig.py	Wed Feb 27 13:36:31 2019 +0100
@@ -45,7 +45,7 @@
     def test_locate_resource(self):
         self.assertIn('FILE_ICON', self.config.uiprops)
         rname = self.config.uiprops['FILE_ICON'].replace(self.config.datadir_url, '')
-        self.assertIn('file', self.config.locate_resource(rname)[0].split(os.sep))
+        self.assertIn('cubicweb_file', self.config.locate_resource(rname)[0].split(os.sep))
         cubicwebcsspath = self.config.locate_resource('cubicweb.css')[0].split(os.sep)
 
         # 'shared' if tests under apycot
@@ -57,7 +57,7 @@
         wdocfiles = list(self.config.locate_all_files('toc.xml'))
         for fpath in wdocfiles:
             self.assertTrue(path.exists(fpath), fpath)
-        for expected in [path.join('cubes', 'file', 'wdoc', 'toc.xml'),
+        for expected in [path.join('cubicweb_file', 'wdoc', 'toc.xml'),
                          path.join('cubicweb', 'web', 'wdoc', 'toc.xml')]:
             for fpath in wdocfiles:
                 if fpath.endswith(expected):
--- a/cubicweb/web/views/cwuser.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/web/views/cwuser.py	Wed Feb 27 13:36:31 2019 +0100
@@ -43,8 +43,8 @@
 
 class UserPreferencesEntityAction(action.Action):
     __regid__ = 'prefs'
-    __select__ = (one_line_rset() & is_instance('CWUser') &
-                  match_user_groups('owners', 'managers'))
+    __select__ = (one_line_rset() & is_instance('CWUser')
+                  & match_user_groups('owners', 'managers'))
 
     title = _('preferences')
     category = 'mainactions'
@@ -65,7 +65,7 @@
     def call(self):
         self.w(u'''<?xml version="1.0" encoding="%s"?>
 <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
-         xmlns:rdfs="http://www.w3org/2000/01/rdf-schema#"
+         xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
          xmlns:foaf="http://xmlns.com/foaf/0.1/"> ''' % self._cw.encoding)
         for i in range(self.cw_rset.rowcount):
             self.cell_call(i, 0)
--- a/cubicweb/web/views/editcontroller.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/web/views/editcontroller.py	Wed Feb 27 13:36:31 2019 +0100
@@ -312,8 +312,7 @@
         try:
             for field, value in field.process_posted(form):
                 if not ((field.role == 'subject' and field.name in eschema.subjrels)
-                        or
-                        (field.role == 'object' and field.name in eschema.objrels)):
+                        or (field.role == 'object' and field.name in eschema.objrels)):
                     continue
 
                 rschema = self._cw.vreg.schema.rschema(field.name)
--- a/cubicweb/web/views/json.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/web/views/json.py	Wed Feb 27 13:36:31 2019 +0100
@@ -138,8 +138,8 @@
 
     The returned json object will contain err / traceback informations.
     """
-    __select__ = (management.ErrorView.__select__ &
-                  _requested_vid('jsonexport', 'ejsonexport'))
+    __select__ = (management.ErrorView.__select__
+                  & _requested_vid('jsonexport', 'ejsonexport'))
 
     def call(self):
         errmsg, exclass, excinfo = self._excinfo()
--- a/cubicweb/web/views/startup.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/web/views/startup.py	Wed Feb 27 13:36:31 2019 +0100
@@ -131,7 +131,6 @@
                 label = display_name(req, etype, 'plural')
             else:
                 label = display_name(req, etype)
-            nb = req.execute('Any COUNT(X) WHERE X is %s' % etype)[0][0]
             url = self._cw.build_url(etype)
             etypelink = u'&#160;<a href="%s">%s</a> (%d)' % (
                 xml_escape(url), label, nb)
--- a/cubicweb/web/views/uicfg.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/web/views/uicfg.py	Wed Feb 27 13:36:31 2019 +0100
@@ -233,7 +233,7 @@
             for key in list(self._tagdefs):
                 stype, rtype, otype, role = key
                 rschema = schema.rschema(rtype)
-                if stype == '*' and stype == '*':
+                if stype == '*' and otype == '*':
                     concrete_rdefs = rschema.rdefs.keys()
                 elif stype == '*':
                     concrete_rdefs = zip(rschema.subjects(otype), repeat(otype))
--- a/cubicweb/web/views/workflow.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/web/views/workflow.py	Wed Feb 27 13:36:31 2019 +0100
@@ -189,8 +189,8 @@
 class WorkflowActions(action.Action):
     """fill 'workflow' sub-menu of the actions box"""
     __regid__ = 'workflow'
-    __select__ = (action.Action.__select__ & one_line_rset() &
-                  relation_possible('in_state'))
+    __select__ = (action.Action.__select__ & one_line_rset()
+                  & relation_possible('in_state'))
 
     submenu = _('workflow')
     order = 10
--- a/cubicweb/wfutils.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/cubicweb/wfutils.py	Wed Feb 27 13:36:31 2019 +0100
@@ -124,8 +124,8 @@
         wf.cw_set(initial_state=states[wfdef['initial_state']])
 
     for trname, trdef in wfdef['transitions'].items():
-        tr = (wf.transition_by_name(trname) or
-              cnx.create_entity('Transition', name=trname))
+        tr = (wf.transition_by_name(trname)
+              or cnx.create_entity('Transition', name=trname))
         tr.cw_set(transition_of=wf)
         if trdef.get('tostate'):
             tr.cw_set(destination_state=states[trdef['tostate']])
--- a/debian/control	Wed Feb 13 14:40:39 2019 +0100
+++ b/debian/control	Wed Feb 27 13:36:31 2019 +0100
@@ -28,6 +28,7 @@
  python-passlib,
  python-repoze.lru,
  python-wsgicors,
+ python-filelock,
  sphinx-common,
 Standards-Version: 3.9.6
 Homepage: https://www.cubicweb.org
@@ -51,6 +52,7 @@
  python-passlib,
  python-tz,
  graphviz,
+ python-filelock,
  gettext,
 Recommends:
  cubicweb-ctl (= ${source:Version}),
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/changes/3.27.rst	Wed Feb 27 13:36:31 2019 +0100
@@ -0,0 +1,15 @@
+3.27 (not yet released)
+=======================
+
+New features
+------------
+
+* Tests can now be run concurrently across multiple processes. You can use
+  `pytest-xdist`_ for that. For tests using `PostgresApptestConfiguration` you
+  should be aware that `startpgcluster()` can't run concurrently. Workaround is
+  to call pytest with ``--dist=loadfile`` to use a single test process per test
+  module or use an existing database cluster and set ``db-host`` and
+  ``db-port`` of ``devtools.DEFAULT_PSQL_SOURCES['system']`` accordingly.
+
+.. _pytest-xdist: https://github.com/pytest-dev/pytest-xdist
+
--- a/doc/dev/coding_standards_css.rst	Wed Feb 13 14:40:39 2019 +0100
+++ b/doc/dev/coding_standards_css.rst	Wed Feb 27 13:36:31 2019 +0100
@@ -7,11 +7,12 @@
 
 Indentation rules
 ~~~~~~~~~~~~~~~~~
-- 2 espaces avant les propriétés
+
+- 2 spaces before properties
 
-- pas d'espace avant les ":", un espace après
+- no space before ":", one space after
 
-- 1 seul espace entre les différentes valeurs pour une même propriété
+- only one space between values of the same property
 
 
 Documentation
@@ -29,5 +30,3 @@
 
 - Avoid introducing a new CSS file for a few lines of CSS, at least while the
   framework doesn't include packing functionalities
-
-
--- a/doc/dev/coding_standards_js.rst	Wed Feb 13 14:40:39 2019 +0100
+++ b/doc/dev/coding_standards_js.rst	Wed Feb 27 13:36:31 2019 +0100
@@ -7,11 +7,11 @@
 
 Indentation rules
 ~~~~~~~~~~~~~~~~~
-- espace avant accolade ouvrante
+
+- one space before an opening curly bracket ({)
 
-- retour à la ligne après accolade ouvrante (éventuellement pas
-  de retour à la ligne s'il y a tout sur la même ligne, mais ce n'est
-  pas le cas ici.
+- line break after a closing curly bracket (}) (possibly no line break if
+  everything is on the same line)
 
 - no tabs
 
@@ -34,4 +34,4 @@
 
 See also
 ~~~~~~~~
-http://google-styleguide.googlecode.com/svn/trunk/javascriptguide.xml
\ No newline at end of file
+http://google-styleguide.googlecode.com/svn/trunk/javascriptguide.xml
--- a/doc/tutorials/base/blog-in-five-minutes.rst	Wed Feb 13 14:40:39 2019 +0100
+++ b/doc/tutorials/base/blog-in-five-minutes.rst	Wed Feb 27 13:36:31 2019 +0100
@@ -23,6 +23,14 @@
 
     cubicweb-ctl create blog myblog
 
+The `blog` argument is the cube on which you want to base your instance and
+`myblog` is the name of your instance.
+
+.. Note::
+
+   If you get an a permission error of this kind `OSError: [Errno 13]
+   Permission denied: '/etc/cubicweb.d/myblog'`, read the :ref:`next section`.
+
 You'll be asked a few questions, and you can keep the default answer for most of
 them. The one question you'll have to think about is the database you'll want to
 use for that instance. For a quick test, if you don't have `postgresql` installed
@@ -44,6 +52,7 @@
    If you get a traceback when going on the web interface make sure your
    version of twisted is **inferior** to 17.
 
+.. _AboutFileSystemPermissions:
 
 About file system permissions
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -73,7 +82,7 @@
      cubicweb-ctl db-create myblog
 
 Other parameters, like web server or emails parameters, can be modified in the
-:file:`/etc/cubicweb.d/myblog/all-in-one.conf` file.
+:file:`/etc/cubicweb.d/myblog/all-in-one.conf` file (or :file:`~/etc/cubicweb.d/myblog/all-in-one.conf` depending on your configuration.)
 
 You'll have to restart the instance after modification in one of those files.
 
--- a/doc/tutorials/base/conclusion.rst	Wed Feb 13 14:40:39 2019 +0100
+++ b/doc/tutorials/base/conclusion.rst	Wed Feb 27 13:36:31 2019 +0100
@@ -15,4 +15,4 @@
 discover! You will find more `tutorials and howtos`_ in the blog published on the
 CubicWeb.org website.
 
-.. _`tutorials and howtos`: http://www.cubicweb.org/view?rql=Any+X+ORDERBY+D+DESC+WHERE+X+is+BlogEntry%2C+T+tags+X%2C+T+name+IN+%28%22tutorial%22%2C+%22howto%22%29%2C+X+creation_date+D
+.. _`tutorials and howtos`: ../../tutorials
--- a/doc/tutorials/base/customizing-the-application.rst	Wed Feb 13 14:40:39 2019 +0100
+++ b/doc/tutorials/base/customizing-the-application.rst	Wed Feb 27 13:36:31 2019 +0100
@@ -98,7 +98,7 @@
 
 This file defines the following:
 
-* a `Community` has a title and a description as attributes
+* a `Community` has a name and a description as attributes
 
   - the name is a string that is required and can't be longer than 50 characters
 
@@ -108,8 +108,8 @@
 * a `Community` may be linked to a `Blog` using the `community_blog` relation
 
   - ``*`` means a community may be linked to 0 to N blog, ``?`` means a blog may
-    be linked to 0 to 1 community. For completeness, remember that you can also
-    use ``+`` for 1 to N, and ``1`` for single, mandatory relation (e.g. one to one);
+    be linked to 0 to 1 community. For completeness, you can also use ``+`` for
+    1 to N, and ``1`` for single, mandatory relation (e.g. one to one);
 
   - this is a composite relation where `Community` (e.g. the subject of the
     relation) is the composite. That means that if you delete a community, its
@@ -135,7 +135,7 @@
 
   cubicweb-ctl stop myblog # or Ctrl-C in the terminal running the server in debug mode
   cubicweb-ctl delete myblog
-  cubicweb-ctl create myblog
+  cubicweb-ctl create myblog myblog
   cubicweb-ctl start -D myblog
 
 Another way is to add our cube to the instance using the cubicweb-ctl shell
@@ -185,7 +185,7 @@
 Defining your views
 ~~~~~~~~~~~~~~~~~~~
 
-|cubicweb| provides a lot of standard views in directory
+|cubicweb| provides a lot of standard views in the directory
 :file:`cubicweb/web/views/`. We already talked about 'primary' and 'list' views,
 which are views which apply to one ore more entities.
 
@@ -193,7 +193,7 @@
 
   - an identifier: all objects used to build the user interface in |cubicweb| are
     recorded in a registry and this identifier will be used as a key in that
-    registry. There may be multiple views for the same identifier.
+    registry to store the view. There may be multiple views for the same identifier.
 
   - a *selector*, which is a kind of filter telling how well a view suit to a
     particular context. When looking for a particular view (e.g. given an
@@ -254,10 +254,12 @@
 
   from cubicweb.web.views import basetemplates
 
+
   class MyHTMLPageFooter(basetemplates.HTMLPageFooter):
 
       def footer_content(self):
-	  self.w(u'This website has been created with <a href="http://cubicweb.org">CubicWeb</a>.')
+          self.w(u'This website has been created with <a href="http://cubicweb.org">CubicWeb</a>.')
+
 
   def registration_callback(vreg):
       vreg.register_all(globals().values(), __name__, (MyHTMLPageFooter,))
@@ -306,19 +308,21 @@
 details in :ref:`primary_view`).
 
 
-So... Some code! That we'll put again in the module ``views`` of our cube.
+So... Some code! That we'll put again in the module ``views`` (``myblog/views.py``) of our cube.
 
 .. sourcecode:: python
 
   from cubicweb.predicates import is_instance
   from cubicweb.web.views import primary
 
+
   class CommunityPrimaryView(primary.PrimaryView):
       __select__ = is_instance('Community')
 
       def cell_call(self, row, col):
           entity = self.cw_rset.get_entity(row, col)
           self.w(u'<h1>Welcome to the "%s" community</h1>' % entity.printable_value('name'))
+
           if entity.description:
               self.w(u'<p>%s</p>' % entity.printable_value('description'))
 
@@ -331,10 +335,11 @@
   some entity of the `Community` type. This is enough to get an higher score than
   the default view for entities of this type.
 
-* View applying to entities usually have to define `cell_call` as entry point,
-  and are given `row` and `col` arguments tell to which entity in the result set
-  the view is applied. We can then get this entity from the result set
-  (`self.cw_rset`) by using the `get_entity` method.
+* A view that applies to an entity usually have to define the method
+  `cell_call` as an entry point. This method will received the arguments
+  `row` and `col` that tell to which entity in the result set the view is
+  applied. We can then get this entity from the result set (`self.cw_rset`) by
+  using the `get_entity` method.
 
 * To ease thing, we access our entity's attribute for display using its
   printable_value method, which will handle formatting and escaping when
@@ -378,7 +383,7 @@
 - defines a :attr:`__regid__` linked to the corresponding data type of your schema
 
 You may then want to add your own methods, override default implementation of some
-method, etc...
+method, etc... To do so, write this code in ``myblog/entities.py``:
 
 .. sourcecode:: python
 
@@ -408,7 +413,7 @@
   have had the same result)
 
 * we implemented here a method :meth:`display_cw_logo` which tests if the blog
-  entry title contains 'CW'.  It can then be used when you're writing code
+  entry title contains 'CubicWeb'. It can then be used when you're writing code
   involving 'Community' entities in your views, hooks, etc. For instance, you can
   modify your previous views as follows:
 
@@ -421,8 +426,10 @@
       def cell_call(self, row, col):
           entity = self.cw_rset.get_entity(row, col)
           self.w(u'<h1>Welcome to the "%s" community</h1>' % entity.printable_value('name'))
+
           if entity.display_cw_logo():
               self.w(u'<img src="https://docs.cubicweb.org/_static/logo-cubicweb-small.svg"/>')
+
           if entity.description:
               self.w(u'<p>%s</p>' % entity.printable_value('description'))
 
@@ -449,7 +456,7 @@
 
 
 A library of standard cubes is available from `CubicWeb Forge`_, to address a
-lot of common concerns such has manipulating people, files, things to do, etc. In
+lot of common concerns such has manipulating files, people, things to do, etc. In
 our community blog case, we could be interested for instance in functionalities
 provided by the `comment` and `tag` cubes. The former provides threaded
 discussion functionalities, the latter a simple tag mechanism to classify content.
@@ -475,6 +482,7 @@
       cardinality = '1*'
       composite = 'object'
 
+
   class tags(RelationDefinition):
       subject = 'Tag'
       object = ('Community', 'BlogEntry')
@@ -485,7 +493,7 @@
 cubes will then be automatically displayed when one of those relations is
 supported.
 
-Let's synchronize the data model as we've done earlier: ::
+Let's install the cubes and synchronize the data model as we've done earlier: ::
 
 
   $ cubicweb-ctl stop myblog
@@ -511,6 +519,8 @@
 custom rendering but also extension points provided by the default
 implementation.
 
+In ``myblog/views.py``:
+
 
 .. sourcecode:: python
 
@@ -518,13 +528,14 @@
       __select__ = is_instance('Community')
 
       def render_entity_title(self, entity):
-	  self.w(u'<h1>Welcome to the "%s" community</h1>' % entity.printable_value('name'))
+          self.w(u'<h1>Welcome to the "%s" community</h1>' % entity.printable_value('name'))
 
       def render_entity_attributes(self, entity):
-	  if entity.display_cw_logo():
-	      self.w(u'<img src="https://docs.cubicweb.org/_static/logo-cubicweb-small.svg"/>')
-	  if entity.description:
-	      self.w(u'<p>%s</p>' % entity.printable_value('description'))
+          if entity.display_cw_logo():
+              self.w(u'<img src="https://docs.cubicweb.org/_static/logo-cubicweb-small.svg"/>')
+
+          if entity.description:
+              self.w(u'<p>%s</p>' % entity.printable_value('description'))
 
 It appears now properly:
 
--- a/doc/tutorials/base/discovering-the-ui.rst	Wed Feb 13 14:40:39 2019 +0100
+++ b/doc/tutorials/base/discovering-the-ui.rst	Wed Feb 27 13:36:31 2019 +0100
@@ -29,7 +29,7 @@
 
 Before creating entities, let's change that 'unset title' thing that appears
 here and there. This comes from a |cubicweb| system properties. To set it,
-click on the 'site configuration link' in the pop-up menu behind your login name
+click on the 'configuration link' in the pop-up menu behind your login name
 in the upper left-hand corner
 
 .. image:: ../../images/tutos-base_user-menu_en.png
--- a/flake8-ok-files.txt	Wed Feb 13 14:40:39 2019 +0100
+++ b/flake8-ok-files.txt	Wed Feb 27 13:36:31 2019 +0100
@@ -60,6 +60,7 @@
 cubicweb/server/test/data-schema2sql/__init__.py
 cubicweb/server/test/unittest_checkintegrity.py
 cubicweb/server/test/unittest_datafeed.py
+cubicweb/server/test/unittest_edition.py
 cubicweb/server/test/unittest_ldapsource.py
 cubicweb/server/test/unittest_migractions.py
 cubicweb/server/test/unittest_serverctl.py
@@ -102,10 +103,13 @@
 cubicweb/test/unittest_repoapi.py
 cubicweb/test/unittest_req.py
 cubicweb/test/unittest_rqlrewrite.py
+cubicweb/test/unittest_rset.py
 cubicweb/test/unittest_rtags.py
 cubicweb/test/unittest_schema.py
+cubicweb/test/unittest_statsd.py
 cubicweb/test/unittest_toolsutils.py
 cubicweb/test/unittest_wfutils.py
+cubicweb/toolsutils.py
 cubicweb/web/application.py
 cubicweb/web/formwidgets.py
 cubicweb/web/test/data/entities.py
--- a/requirements/test-web.txt	Wed Feb 13 14:40:39 2019 +0100
+++ b/requirements/test-web.txt	Wed Feb 27 13:36:31 2019 +0100
@@ -3,5 +3,5 @@
 requests
 webtest
 cubicweb-blog
-cubicweb-file < 2.0.0
+cubicweb-file >= 2.0.0
 cubicweb-tag
--- a/setup.py	Wed Feb 13 14:40:39 2019 +0100
+++ b/setup.py	Wed Feb 27 13:36:31 2019 +0100
@@ -74,6 +74,7 @@
         'pytz',
         'Markdown',
         'unittest2 >= 0.7.0',
+        'filelock',
     ],
     entry_points={
         'console_scripts': [
--- a/tox.ini	Wed Feb 13 14:40:39 2019 +0100
+++ b/tox.ini	Wed Feb 27 13:36:31 2019 +0100
@@ -18,10 +18,10 @@
   web: {envpython} -m pytest {posargs} {toxinidir}/cubicweb/web/test
 
 [testenv:flake8]
-basepython=python2
+basepython=python3
 skip_install = true
 deps =
-  flake8 >= 3.5, < 3.6
+  flake8 >= 3.6
 whitelist_externals =
   /bin/sh
 commands = /bin/sh -c "flake8 `xargs -a {toxinidir}/flake8-ok-files.txt`"