[pkg] merge 3.27
authorPhilippe Pepiot <philippe.pepiot@logilab.fr>
Tue, 17 Mar 2020 13:34:54 +0100
changeset 12917 db0f56b19583
parent 12913 ebf4806e4ab7 (diff)
parent 12916 7d67f69ebe88 (current diff)
child 12918 d6e2112c69f5
[pkg] merge 3.27 Require python >= 3.6 since recent typing notations require >= 3.6
cubicweb/__pkginfo__.py
setup.py
--- a/cubicweb/__pkginfo__.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/__pkginfo__.py	Tue Mar 17 13:34:54 2020 +0100
@@ -22,8 +22,8 @@
 
 modname = distname = "cubicweb"
 
-numversion = (3, 27, 2)
-version = '.'.join(str(num) for num in numversion)
+numversion = (3, 28, 0)
+version = '.'.join(str(num) for num in numversion) + '.dev0'
 
 description = "a repository of entities / relations for knowledge management"
 author = "Logilab"
--- a/cubicweb/cwvreg.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/cwvreg.py	Tue Mar 17 13:34:54 2020 +0100
@@ -550,7 +550,7 @@
         return RQLHelper(self.schema,
                          special_relations={'eid': 'uid', 'has_text': 'fti'})
 
-    def solutions(self, req, rqlst, args):
+    def compute_var_types(self, req, rqlst, args):
         def type_from_eid(eid, req=req):
             return req.entity_type(eid)
         return self.rqlhelper.compute_solutions(rqlst, {'eid': type_from_eid}, args)
@@ -558,7 +558,7 @@
     def parse(self, req, rql, args=None):
         rqlst = self.rqlhelper.parse(rql)
         try:
-            self.solutions(req, rqlst, args)
+            self.compute_var_types(req, rqlst, args)
         except UnknownEid:
             for select in rqlst.children:
                 select.solutions = []
--- a/cubicweb/devtools/__init__.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/devtools/__init__.py	Tue Mar 17 13:34:54 2020 +0100
@@ -624,14 +624,14 @@
     def init_test_database(self):
         """initialize a fresh postgresql database used for testing purpose"""
         from cubicweb.server import init_repository
-        from cubicweb.server.serverctl import system_source_cnx, createdb
+        from cubicweb.server.serverctl import source_cnx, createdb
         # connect on the dbms system base to create our base
         try:
             self._drop(self.system_source['db-name'])
             createdb(self.helper, self.system_source, self.dbcnx, self.cursor)
             self.dbcnx.commit()
-            cnx = system_source_cnx(self.system_source, special_privs='LANGUAGE C',
-                                    interactive=False)
+            cnx = source_cnx(self.system_source, special_privs='LANGUAGE C',
+                             interactive=False)
             templcursor = cnx.cursor()
             try:
                 # XXX factorize with db-create code
--- a/cubicweb/devtools/repotest.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/devtools/repotest.py	Tue Mar 17 13:34:54 2020 +0100
@@ -209,7 +209,7 @@
 
     def _prepare_plan(self, cnx, rql, kwargs=None):
         rqlst = self.repo.vreg.rqlhelper.parse(rql, annotate=True)
-        self.repo.vreg.solutions(cnx, rqlst, kwargs)
+        self.repo.vreg.compute_var_types(cnx, rqlst, kwargs)
         if rqlst.TYPE == 'select':
             self.repo.vreg.rqlhelper.annotate(rqlst)
             for select in rqlst.children:
--- a/cubicweb/entities/adapters.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/entities/adapters.py	Tue Mar 17 13:34:54 2020 +0100
@@ -20,18 +20,123 @@
 """
 from cubicweb import _
 
+from hashlib import sha1
 from itertools import chain
 
+from rdflib import URIRef, Literal, namespace as rdflib_namespace
 from logilab.mtconverter import TransformError
-from logilab.common.decorators import cached
+from logilab.common.decorators import cached, cachedproperty
 
-from cubicweb import (Unauthorized, ValidationError, view, ViolatedConstraint,
+from cubicweb import rdf
+from cubicweb import (Unauthorized, ValidationError, ViolatedConstraint,
                       UniqueTogetherError)
-from cubicweb.schema import constraint_name_for
+from cubicweb.entity import EntityAdapter
+from cubicweb.schema import constraint_name_for, VIRTUAL_RTYPES
 from cubicweb.predicates import is_instance, relation_possible, match_exception
 
 
-class IDublinCoreAdapter(view.EntityAdapter):
+class EntityRDFAdapter(EntityAdapter):
+    """EntityRDFAdapter is to be specialized for each entity that wants to
+    be converted to RDF using the mechanism from cubicweb.rdf
+    """
+    __regid__ = "rdf"
+
+    SKIP_RTYPES = VIRTUAL_RTYPES | set(['cwuri', 'is', 'is_instance_of'])
+
+    def __init__(self, _cw, **kwargs):
+        super().__init__(_cw, **kwargs)
+        self.entity.complete()
+        self.used_namespaces = {}
+
+    def _use_namespace(self, prefix, base_url=None):
+        if prefix not in self.used_namespaces:
+            if base_url is not None:
+                if prefix in rdf.NAMESPACES:
+                    raise KeyError('prefix redefinition not allowed: '
+                                   f'"{prefix}" already exists as "{rdf.NAMESPACES[prefix]}"')
+                ns = rdflib_namespace.Namespace(base_url)
+            else:
+                ns = rdf.NAMESPACES[prefix]
+            self.used_namespaces[prefix] = ns
+        elif base_url is not None:
+            if self.used_namespaces[prefix] != rdflib_namespace.Namespace(base_url):
+                raise ValueError('prefix redefinition not allowed: '
+                                 f'"{prefix}" already exists as "{self.used_namespaces[prefix]}"')
+        return self.used_namespaces[prefix]
+
+    @cachedproperty
+    def uri(self):
+        return URIRef(self.entity.cwuri)
+
+    def triples(self):
+        """return sequence of 3-tuple of rdflib identifiers"""
+        yield from self.cw_triples()
+        yield from self.dc_triples()
+
+    def cw_triples(self):
+        RDF = self._use_namespace('rdf')
+        CW = self._use_namespace('cubicweb')
+
+        yield (self.uri, RDF.type, CW[self.entity.e_schema.type])
+
+        for rschema, targettypes, role in self.entity.e_schema.relation_definitions('relation'):
+            rtype = rschema.type
+            if rtype in self.SKIP_RTYPES or rtype.endswith('_permission'):
+                continue
+            for targetype in targettypes:
+                # if rschema is an attribute
+                if targetype.final:
+                    try:
+                        value = self.entity.cw_attr_cache[rtype]
+                    except KeyError:
+                        continue
+                    if value is not None:
+                        yield (self.uri, CW[rtype], Literal(value))
+                # else if rschema is a relation
+                else:
+                    for related in self.entity.related(rtype, role, entities=True, safe=True):
+                        if role == 'subject':
+                            yield (self.uri, CW[rtype], URIRef(related.cwuri))
+                        else:
+                            yield (URIRef(related.cwuri), CW[rtype], self.uri)
+
+    def dc_triples(self):
+        dc_entity = self.entity.cw_adapt_to('IDublinCore')
+        DC = self._use_namespace('dc')
+        yield (self.uri, DC.title, Literal(dc_entity.long_title()))  # or title() ?
+        desc = dc_entity.description()
+        if desc:
+            yield (self.uri, DC.description, Literal(desc))
+        creator = dc_entity.creator()  # use URI instead of Literal ?
+        if creator:
+            yield (self.uri, DC.creator, Literal(creator))
+        yield (self.uri, DC.date, Literal(dc_entity.date()))
+        yield (self.uri, DC.type, Literal(dc_entity.type()))
+        yield (self.uri, DC.language, Literal(dc_entity.language()))
+
+
+class CWUserRDFAdapter(EntityRDFAdapter):
+    __select__ = is_instance("CWUser")
+
+    def triples(self):
+        yield from super().triples()
+        yield from self.foaf_triples()
+
+    def foaf_triples(self):
+        RDF = self._use_namespace('rdf')
+        FOAF = self._use_namespace('foaf')
+        yield (self.uri, RDF.type, FOAF.Person)
+        if self.entity.surname:
+            yield (self.uri, FOAF.familyName, Literal(self.entity.surname))
+        if self.entity.firstname:
+            yield (self.uri, FOAF.givenName, Literal(self.entity.firstname))
+        emailaddr = self.entity.cw_adapt_to("IEmailable").get_email()
+        if emailaddr:
+            email_digest = sha1(emailaddr.encode("utf-8")).hexdigest()
+            yield (self.uri, FOAF.mbox_sha1sum, Literal(email_digest))
+
+
+class IDublinCoreAdapter(EntityAdapter):
     __regid__ = 'IDublinCore'
     __select__ = is_instance('Any')
 
@@ -60,7 +165,7 @@
             return self.entity.printable_value('description', format=format)
         return u''
 
-    def authors(self):
+    def authors(self):  # XXX is this part of DC ?
         """Return a suitable description for the author(s) of the entity"""
         try:
             return u', '.join(u.name() for u in self.entity.owned_by)
@@ -88,12 +193,12 @@
         # check if entities has internationalizable attributes
         # XXX one is enough or check if all String attributes are internationalizable?
         for rschema, attrschema in eschema.attribute_definitions():
-            if rschema.rdef(eschema, attrschema).internationalizable:
+            if getattr(rschema.rdef(eschema, attrschema), 'internationalizable', False):
                 return self._cw._(self._cw.user.property_value('ui.language'))
         return self._cw._(self._cw.vreg.property_value('ui.language'))
 
 
-class IEmailableAdapter(view.EntityAdapter):
+class IEmailableAdapter(EntityAdapter):
     __regid__ = 'IEmailable'
     __select__ = relation_possible('primary_email') | relation_possible('use_email')
 
@@ -126,7 +231,7 @@
                     for attr in self.allowed_massmail_keys())
 
 
-class INotifiableAdapter(view.EntityAdapter):
+class INotifiableAdapter(EntityAdapter):
     __regid__ = 'INotifiable'
     __select__ = is_instance('Any')
 
@@ -145,7 +250,7 @@
         return ()
 
 
-class IFTIndexableAdapter(view.EntityAdapter):
+class IFTIndexableAdapter(EntityAdapter):
     """standard adapter to handle fulltext indexing
 
     .. automethod:: cubicweb.entities.adapters.IFTIndexableAdapter.fti_containers
@@ -226,7 +331,7 @@
         maindict.setdefault(weight, []).extend(words)
 
 
-class IDownloadableAdapter(view.EntityAdapter):
+class IDownloadableAdapter(EntityAdapter):
     """interface for downloadable entities"""
     __regid__ = 'IDownloadable'
     __abstract__ = True
@@ -256,7 +361,7 @@
 
 
 # XXX should propose to use two different relations for children/parent
-class ITreeAdapter(view.EntityAdapter):
+class ITreeAdapter(EntityAdapter):
     """This adapter provides a tree interface.
 
     It has to be overriden to be configured using the tree_relation,
@@ -412,7 +517,7 @@
         return path
 
 
-class ISerializableAdapter(view.EntityAdapter):
+class ISerializableAdapter(EntityAdapter):
     """Adapter to serialize an entity to a bare python structure that may be
     directly serialized to e.g. JSON.
     """
@@ -441,7 +546,7 @@
 # error handling adapters ######################################################
 
 
-class IUserFriendlyError(view.EntityAdapter):
+class IUserFriendlyError(EntityAdapter):
     __regid__ = 'IUserFriendlyError'
     __abstract__ = True
 
--- a/cubicweb/entities/wfobjs.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/entities/wfobjs.py	Tue Mar 17 13:34:54 2020 +0100
@@ -24,7 +24,7 @@
 from logilab.common.decorators import cached, clear_cache
 
 from cubicweb.entities import AnyEntity, fetch_config
-from cubicweb.view import EntityAdapter
+from cubicweb.entity import EntityAdapter
 from cubicweb.predicates import relation_possible
 
 
--- a/cubicweb/entity.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/entity.py	Tue Mar 17 13:34:54 2020 +0100
@@ -1118,7 +1118,7 @@
         etypecls.fetch_rqlst(self._cw.user, select, searchedvar,
                              ordermethod=ordermethod)
         # from now on, we need variable type resolving
-        self._cw.vreg.solutions(self._cw, select, args)
+        self._cw.vreg.compute_var_types(self._cw, select, args)
         # insert RQL expressions for schema constraints into the rql syntax tree
         if vocabconstraints:
             cstrcls = (RQLVocabularyConstraint, RQLConstraint)
@@ -1314,6 +1314,28 @@
     def __set__(self, eobj, value):
         raise NotImplementedError
 
+# entity adapters #############################################################
+
+class Adapter(AppObject):
+    """base class for adapters"""
+    __registry__ = 'adapters'
+
+
+class EntityAdapter(Adapter):
+    """base class for entity adapters (eg adapt an entity to an interface)
+
+    An example would be:
+
+    >>> some_entity.cw_adapt_to('IDownloadable')
+    """
+    def __init__(self, _cw, **kwargs):
+        try:
+            self.entity = kwargs.pop('entity')
+        except KeyError:
+            self.entity = kwargs['rset'].get_entity(kwargs.get('row') or 0,
+                                                    kwargs.get('col') or 0)
+        Adapter.__init__(self, _cw, **kwargs)
+
 
 from logging import getLogger
 from cubicweb import set_log_methods
--- a/cubicweb/pyramid/__init__.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/pyramid/__init__.py	Tue Mar 17 13:34:54 2020 +0100
@@ -231,6 +231,8 @@
     cwconfig = config.registry.get('cubicweb.config')
     repo = config.registry.get('cubicweb.repository')
 
+    config.include('cubicweb.pyramid.rest_api')
+
     if repo is not None:
         if cwconfig is None:
             config.registry['cubicweb.config'] = cwconfig = repo.config
--- a/cubicweb/pyramid/predicates.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/pyramid/predicates.py	Tue Mar 17 13:34:54 2020 +0100
@@ -21,22 +21,60 @@
 """Contains predicates used in Pyramid views.
 """
 
+from cubicweb._exceptions import UnknownEid
 
-class MatchIsETypePredicate(object):
-    """A predicate that match if a given etype exist in schema.
+
+class MatchIsEIDPredicate(object):
+    """A predicate that match if a given eid exist in the database.
     """
     def __init__(self, matchname, config):
         self.matchname = matchname
 
     def text(self):
-        return 'match_is_etype = %s' % self.matchname
+        return 'match_is_eid = %s' % self.matchname
 
     phash = text
 
     def __call__(self, info, request):
-        return info['match'][self.matchname].lower() in \
-            request.registry['cubicweb.registry'].case_insensitive_etypes
+        try:
+            eid = int(info['match'][self.matchname])
+        except ValueError:
+            return False
+
+        try:
+            request.cw_cnx.entity_from_eid(eid)
+        except UnknownEid:
+            return False
+        return True
+
+
+class MatchIsETypeAndEIDPredicate(object):
+    """A predicate that match if a given eid exist in the database and if the
+    etype of the entity same as the one given in the URL
+    """
+    def __init__(self, matchnames, config):
+        self.match_etype, self.match_eid = matchnames
+
+    def text(self):
+        return f"match_is_etype_and_eid = {self.match_etype}/{self.match_eid}"
+
+    phash = text
+
+    def __call__(self, info, request):
+        try:
+            eid = int(info['match'][self.match_eid])
+        except ValueError:
+            return False
+
+        try:
+            entity = request.cw_cnx.entity_from_eid(eid)
+        except UnknownEid:
+            return False
+
+        etype = info['match'][self.match_etype]
+        return entity.__regid__.lower() == etype.lower()
 
 
 def includeme(config):
-    config.add_route_predicate('match_is_etype', MatchIsETypePredicate)
+    config.add_route_predicate('match_is_eid', MatchIsEIDPredicate)
+    config.add_route_predicate('match_is_etype_and_eid', MatchIsETypeAndEIDPredicate)
--- a/cubicweb/pyramid/resources.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/pyramid/resources.py	Tue Mar 17 13:34:54 2020 +0100
@@ -18,84 +18,33 @@
 # You should have received a copy of the GNU Lesser General Public License along
 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
 
-"""Pyramid resource definitions for CubicWeb."""
+from pyramid.httpexceptions import HTTPNotFound
 
-from rql import TypeResolverException
-
-from pyramid.decorator import reify
-from pyramid.httpexceptions import HTTPNotFound
+from cubicweb import rdf
 
 
-class EntityResource(object):
-
-    """A resource class for an entity. It provide method to retrieve an entity
-    by eid.
-    """
-
-    @classmethod
-    def from_eid(cls):
-        def factory(request):
-            return cls(request, None, None, request.matchdict['eid'])
-        return factory
-
-    def __init__(self, request, cls, attrname, value):
-        self.request = request
-        self.cls = cls
-        self.attrname = attrname
-        self.value = value
-
-    @reify
-    def rset(self):
-        req = self.request.cw_request
-        if self.cls is None:
-            return req.execute('Any X WHERE X eid %(x)s',
-                               {'x': int(self.value)})
-        st = self.cls.fetch_rqlst(self.request.cw_cnx.user, ordermethod=None)
-        st.add_constant_restriction(st.get_variable('X'), self.attrname,
-                                    'x', 'Substitute')
-        if self.attrname == 'eid':
-            try:
-                rset = req.execute(st.as_string(), {'x': int(self.value)})
-            except (ValueError, TypeResolverException):
-                # conflicting eid/type
-                raise HTTPNotFound()
-        else:
-            rset = req.execute(st.as_string(), {'x': self.value})
-        return rset
+def negociate_mime_type(request, possible_mimetypes):
+    accepted_headers_by_weight = sorted(
+        request.accept.parsed or [], key=lambda h: h[1], reverse=True
+    )
+    mime_type_negociated = None
+    for parsed_header in accepted_headers_by_weight:
+        accepted_mime_type = parsed_header[0]
+        if accepted_mime_type in possible_mimetypes:
+            mime_type_negociated = accepted_mime_type
+            break
+    return mime_type_negociated
 
 
-class ETypeResource(object):
-
-    """A resource for etype.
-    """
-    @classmethod
-    def from_match(cls, matchname):
-        def factory(request):
-            return cls(request, request.matchdict[matchname])
-        return factory
-
-    def __init__(self, request, etype):
-        vreg = request.registry['cubicweb.registry']
-
-        self.request = request
-        self.etype = vreg.case_insensitive_etypes[etype.lower()]
-        self.cls = vreg['etypes'].etype_class(self.etype)
+def rdf_context_from_eid(request):
+    mime_type = negociate_mime_type(request, rdf.RDF_MIMETYPE_TO_FORMAT)
+    if mime_type is None:
+        raise HTTPNotFound()
+    entity = request.cw_request.entity_from_eid(request.matchdict['eid'])
+    return RDFResource(entity, mime_type)
 
-    def __getitem__(self, value):
-        # Try eid first, then rest attribute as for URL path evaluation
-        # mecanism in cubicweb.web.views.urlpublishing.
-        for attrname in ('eid', self.cls.cw_rest_attr_info()[0]):
-            resource = EntityResource(self.request, self.cls, attrname, value)
-            try:
-                rset = resource.rset
-            except HTTPNotFound:
-                continue
-            if rset.rowcount:
-                return resource
-        raise KeyError(value)
 
-    @reify
-    def rset(self):
-        rql = self.cls.fetch_rql(self.request.cw_cnx.user)
-        rset = self.request.cw_request.execute(rql)
-        return rset
+class RDFResource:
+    def __init__(self, entity, mime_type):
+        self.entity = entity
+        self.mime_type = mime_type
--- a/cubicweb/pyramid/rest_api.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/pyramid/rest_api.py	Tue Mar 17 13:34:54 2020 +0100
@@ -20,26 +20,44 @@
 
 """Experimental REST API for CubicWeb using Pyramid."""
 
-from __future__ import absolute_import
-
+import rdflib
 
 from pyramid.view import view_config
-from cubicweb.pyramid.resources import EntityResource, ETypeResource
+from pyramid.response import Response
+
+from cubicweb import rdf
+from cubicweb.pyramid.resources import rdf_context_from_eid, RDFResource
 
 
 @view_config(
-    route_name='cwentities',
-    context=EntityResource,
-    request_method='DELETE')
-def delete_entity(context, request):
-    context.rset.one().cw_delete()
-    request.response.status_int = 204
-    return request.response
+    route_name='one_entity',
+    context=RDFResource,
+)
+@view_config(
+    route_name='one_entity_eid',
+    context=RDFResource,
+)
+def view_entity_as_rdf(context, request):
+    graph = rdflib.ConjunctiveGraph()
+    rdf.add_entity_to_graph(graph, context.entity)
+    rdf_format = rdf.RDF_MIMETYPE_TO_FORMAT[context.mime_type]
+    response = Response(graph.serialize(format=rdf_format))
+    response.content_type = context.mime_type
+    return response
 
 
 def includeme(config):
     config.include('.predicates')
     config.add_route(
-        'cwentities', '/{etype}/*traverse',
-        factory=ETypeResource.from_match('etype'), match_is_etype='etype')
+        'one_entity',
+        '/{etype}/{eid}',
+        factory=rdf_context_from_eid,
+        match_is_etype_and_eid=('etype', 'eid'),
+    )
+    config.add_route(
+        'one_entity_eid',
+        '/{eid}',
+        factory=rdf_context_from_eid,
+        match_is_eid='eid'
+    )
     config.scan(__name__)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/rdf.py	Tue Mar 17 13:34:54 2020 +0100
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# copyright 2019 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr -- mailto:contact@logilab.fr
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
+
+from rdflib import plugin, namespace
+import rdflib_jsonld  # noqa
+
+plugin.register("jsonld", plugin.Serializer, "rdflib_jsonld.serializer", "JsonLDSerializer")
+
+RDF_MIMETYPE_TO_FORMAT = {
+    'application/rdf+xml': 'xml',
+    'text/turtle': 'turtle',
+    'text/n3': 'n3',
+    'application/n-quads': 'nquads',
+    'application/n-triples': 'nt',
+    'application/trig': 'trig',
+    'application/ld+json': 'json-ld',
+}
+
+NAMESPACES = {
+    "rdf": namespace.RDF,
+    "rdfs": namespace.RDFS,
+    "owl": namespace.OWL,
+    "xsd": namespace.XSD,
+    "skos": namespace.SKOS,
+    "void": namespace.VOID,
+    "dc": namespace.DC,
+    "dcterms": namespace.DCTERMS,
+    "foaf": namespace.FOAF,
+    "doap": namespace.DOAP,
+    "schema": namespace.Namespace("http://schema.org/"),
+    "cubicweb": namespace.Namespace("http://ns.cubicweb.org/cubicweb/0.0/")
+}
+
+
+def add_entity_to_graph(graph, entity):
+    adapter = entity.cw_adapt_to("rdf")
+    if adapter:
+        for triple in adapter.triples():
+            graph.add(triple)
+        for prefix, rdfns in adapter.used_namespaces.items():
+            graph.bind(prefix, rdfns)
--- a/cubicweb/rqlrewrite.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/rqlrewrite.py	Tue Mar 17 13:34:54 2020 +0100
@@ -245,7 +245,7 @@
         vreg = session.vreg
         self.schema = vreg.schema
         self.annotate = vreg.rqlhelper.annotate
-        self._compute_solutions = vreg.solutions
+        self._compute_solutions = vreg.compute_var_types
 
     def compute_solutions(self):
         self.annotate(self.select)
--- a/cubicweb/server/hook.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/server/hook.py	Tue Mar 17 13:34:54 2020 +0100
@@ -248,6 +248,7 @@
 
 from logging import getLogger
 from itertools import chain
+from typing import Union, Tuple
 
 from logilab.common.decorators import classproperty, cached
 from logilab.common.logging_ext import set_log_methods
@@ -521,8 +522,8 @@
     """
     __select__ = enabled_category()
     # set this in derivated classes
-    events = None
-    category = None
+    events: Union[None, Tuple[str], Tuple[str, str]] = None
+    category: Union[None, str] = None
     order = 0
     # stop pylint from complaining about missing attributes in Hooks classes
     eidfrom = eidto = entity = rtype = repo = None
--- a/cubicweb/server/querier.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/server/querier.py	Tue Mar 17 13:34:54 2020 +0100
@@ -36,9 +36,9 @@
 
 from cubicweb.utils import QueryCache, RepeatList
 from cubicweb.misc.source_highlight import highlight_terminal
-from cubicweb.server.rqlannotation import SQLGenAnnotator, set_qdata
+from cubicweb.server.rqlannotation import RQLAnnotator, set_qdata
 from cubicweb.server.ssplanner import (READ_ONLY_RTYPES, add_types_restriction,
-                                       SSPlanner)
+                                       prepare_plan)
 from cubicweb.server.edition import EditedEntity
 from cubicweb.statsd_logger import statsd_timeit, statsd_c
 
@@ -154,20 +154,15 @@
 class ExecutionPlan(object):
     """the execution model of a rql query, composed of querier steps"""
 
-    def __init__(self, querier, rqlst, args, cnx):
+    def __init__(self, schema, rqlst, args, cnx):
+        self.schema = schema
         # original rql syntax tree
         self.rqlst = rqlst
         self.args = args or {}
         # cnx executing the query
         self.cnx = cnx
-        # quick reference to the system source
-        self.syssource = cnx.repo.system_source
         # execution steps
         self.steps = []
-        # various resource accesors
-        self.querier = querier
-        self.schema = querier.schema
-        self.rqlhelper = cnx.vreg.rqlhelper
         # tracing token for debugging
         self.rql_query_tracing_token = None
 
@@ -176,7 +171,7 @@
         self.steps.append(step)
 
     def sqlexec(self, sql, args=None):
-        return self.syssource.sqlexec(self.cnx, sql, args)
+        return self.cnx.repo.system_source.sqlexec(self.cnx, sql, args)
 
     def execute(self):
         """execute a plan and return resulting rows"""
@@ -215,8 +210,8 @@
         else:
             noinvariant = ()
         if cached is None:
-            self.rqlhelper.simplify(union)
-            self.querier.sqlgen_annotate(union)
+            self.cnx.vreg.rqlhelper.simplify(union)
+            RQLAnnotator(self.schema).annotate(union)
             set_qdata(self.schema.rschema, union, noinvariant)
         if union.has_text_query:
             self.cache_key = None
@@ -314,7 +309,7 @@
                 sol[newvarname] = nvartype
         select.clean_solutions(solutions)
         add_types_restriction(self.schema, select)
-        self.rqlhelper.annotate(rqlst)
+        self.cnx.vreg.rqlhelper.annotate(rqlst)
         self.preprocess(rqlst, security=False)
         return rqlst
 
@@ -323,8 +318,8 @@
     """an execution model specific to the INSERT rql query
     """
 
-    def __init__(self, querier, rqlst, args, cnx):
-        ExecutionPlan.__init__(self, querier, rqlst, args, cnx)
+    def __init__(self, schema, rqlst, args, cnx):
+        ExecutionPlan.__init__(self, schema, rqlst, args, cnx)
         # save originally selected variable, we may modify this
         # dictionary for substitution (query parameters)
         self.selected = rqlst.selection
@@ -478,10 +473,6 @@
     def set_schema(self, schema):
         self.schema = schema
         self.clear_caches()
-        # rql planner
-        self._planner = SSPlanner(schema, self._repo.vreg.rqlhelper)
-        # sql generation annotator
-        self.sqlgen_annotate = SQLGenAnnotator(schema).annotate
 
     def clear_caches(self, eids=None, etypes=None):
         if eids is None:
@@ -496,8 +487,8 @@
     def plan_factory(self, rqlst, args, cnx):
         """create an execution plan for an INSERT RQL query"""
         if rqlst.TYPE == 'insert':
-            return InsertPlan(self, rqlst, args, cnx)
-        return ExecutionPlan(self, rqlst, args, cnx)
+            return InsertPlan(self.schema, rqlst, args, cnx)
+        return ExecutionPlan(self.schema, rqlst, args, cnx)
 
     @statsd_timeit
     def execute(self, cnx, rql, args=None, build_descr=True):
@@ -555,7 +546,7 @@
         plan = self.plan_factory(rqlst, args, cnx)
         plan.cache_key = cachekey
         plan.rql_query_tracing_token = str(uuid.uuid4())
-        self._planner.build_plan(plan)
+        prepare_plan(plan, self.schema, self._repo.vreg.rqlhelper)
 
         query_debug_informations = {
             "rql": rql,
@@ -567,7 +558,6 @@
         }
 
         start = time.time()
-
         # execute the plan
         try:
             results = plan.execute()
@@ -634,7 +624,7 @@
         # some cache usage stats
         self.cache_hit, self.cache_miss = 0, 0
         # rql parsing / analysing helper
-        self.solutions = repo.vreg.solutions
+        self.compute_var_types = repo.vreg.compute_var_types
         rqlhelper = repo.vreg.rqlhelper
         # set backend on the rql helper, will be used for function checking
         rqlhelper.backend = repo.config.system_source_config['db-driver']
@@ -677,7 +667,7 @@
             # which are eids. Notice that if you may not need `eidkeys`, we
             # have to compute solutions anyway (kept as annotation on the
             # tree)
-            eidkeys = self.solutions(cnx, rqlst, args)
+            eidkeys = self.compute_var_types(cnx, rqlst, args)
             if args and rql not in self._ck_cache:
                 self._ck_cache[rql] = eidkeys
                 if eidkeys:
--- a/cubicweb/server/rqlannotation.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/server/rqlannotation.py	Tue Mar 17 13:34:54 2020 +0100
@@ -117,7 +117,8 @@
                 var._q_invariant = False
 
 
-class SQLGenAnnotator(object):
+class RQLAnnotator(object):
+    """Annotate the RQL abstract syntax tree to inform the SQL generation"""
 
     def __init__(self, schema):
         self.schema = schema
--- a/cubicweb/server/serverctl.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/server/serverctl.py	Tue Mar 17 13:34:54 2020 +0100
@@ -26,6 +26,7 @@
 
 from logilab.common.configuration import Configuration, merge_options
 from logilab.common.shellutils import ASK, generate_password
+from logilab.common.deprecation import deprecated
 
 from logilab.database import get_db_helper, get_connection
 
@@ -96,6 +97,9 @@
     return cnx
 
 
+@deprecated('[3.28] instead of system_source_cnx(source, True, **args) use '
+            'source_cnx(source, get_db_helper(source[\'db-driver\']).system_database(), '
+            '**args)')
 def system_source_cnx(source, dbms_system_base=False,
                       special_privs='CREATE/DROP DATABASE', interactive=True):
     """shortcut to get a connextion to the instance system database
@@ -118,8 +122,9 @@
     import logilab.common as lgp
     lgp.USE_MX_DATETIME = False
     # connect on the dbms system base to create our base
-    cnx = system_source_cnx(source, True, special_privs=special_privs,
-                            interactive=interactive)
+    system_db = get_db_helper(source['db-driver']).system_database()
+    cnx = source_cnx(source, system_db, special_privs=special_privs,
+                     interactive=interactive)
     # disable autocommit (isolation_level(1)) because DROP and
     # CREATE DATABASE can't be executed in a transaction
     set_isolation_level = getattr(cnx, 'set_isolation_level', None)
@@ -201,7 +206,7 @@
 @contextmanager
 def db_transaction(source, privilege):
     """Open a transaction to the instance database"""
-    cnx = system_source_cnx(source, special_privs=privilege)
+    cnx = source_cnx(source, special_privs=privilege)
     cursor = cnx.cursor()
     try:
         yield cursor
@@ -379,8 +384,8 @@
             except BaseException:
                 dbcnx.rollback()
                 raise
-        cnx = system_source_cnx(source, special_privs='CREATE LANGUAGE/SCHEMA',
-                                interactive=not automatic)
+        cnx = source_cnx(source, special_privs='CREATE LANGUAGE/SCHEMA',
+                         interactive=not automatic)
         cursor = cnx.cursor()
         helper.init_fti_extensions(cursor)
         namespace = source.get('db-namespace')
@@ -572,7 +577,7 @@
         config = ServerConfiguration.config_for(appid)
         source = config.system_source_config
         set_owner = self.config.set_owner
-        cnx = system_source_cnx(source, special_privs='GRANT')
+        cnx = source_cnx(source, special_privs='GRANT')
         cursor = cnx.cursor()
         schema = config.load_schema()
         try:
--- a/cubicweb/server/sources/native.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/server/sources/native.py	Tue Mar 17 13:34:54 2020 +0100
@@ -48,11 +48,10 @@
 from cubicweb.server import schema2sql as y2sql
 from cubicweb.server.utils import crypt_password, verify_and_update
 from cubicweb.server.sqlutils import SQL_PREFIX, SQLAdapterMixIn
-from cubicweb.server.rqlannotation import set_qdata
+from cubicweb.server.rqlannotation import RQLAnnotator, set_qdata
 from cubicweb.server.hook import CleanupDeletedEidsCacheOp
 from cubicweb.server.edition import EditedEntity
-from cubicweb.server.sources import AbstractSource, dbg_st_search, dbg_results
-from cubicweb.server.sources.rql2sql import SQLGenerator
+from cubicweb.server.sources import AbstractSource, dbg_st_search, dbg_results, rql2sql
 from cubicweb.misc.source_highlight import highlight_terminal
 from cubicweb.statsd_logger import statsd_timeit
 
@@ -260,7 +259,7 @@
 class NativeSQLSource(SQLAdapterMixIn, AbstractSource):
     """adapter for source using the native cubicweb schema (see below)
     """
-    sqlgen_class = SQLGenerator
+
     options = (
         ('db-driver',
          {'type': 'string',
@@ -333,8 +332,8 @@
             self.authentifiers.insert(0, EmailPasswordAuthentifier(self))
         AbstractSource.__init__(self, repo, source_config, *args, **kwargs)
         # sql generator
-        self._rql_sqlgen = self.sqlgen_class(self.schema, self.dbhelper,
-                                             ATTR_MAP.copy())
+        self._rql_sqlgen = rql2sql.SQLGenerator(self.schema, self.dbhelper,
+                                                ATTR_MAP.copy())
         # full text index helper
         self.do_fti = not repo.config['delay-full-text-indexation']
         # sql queries cache
@@ -473,7 +472,7 @@
         rqlst = self.repo.vreg.rqlhelper.parse(rql)
         rqlst.restricted_vars = ()
         rqlst.children[0].solutions = sols
-        self.repo.querier.sqlgen_annotate(rqlst)
+        RQLAnnotator(self.repo.querier.schema).annotate(rqlst)
         set_qdata(self.schema.rschema, rqlst, ())
         return rqlst
 
--- a/cubicweb/server/ssplanner.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/server/ssplanner.py	Tue Mar 17 13:34:54 2020 +0100
@@ -307,6 +307,11 @@
         return self.build_select_plan(plan, union)
 
 
+def prepare_plan(plan, schema, rqlhelper):
+    """Add steps to a plan to prepare it for execution"""
+    return SSPlanner(schema, rqlhelper).build_plan(plan)
+
+
 # execution steps and helper functions ########################################
 
 class Step(object):
--- a/cubicweb/server/test/unittest_rqlannotation.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/server/test/unittest_rqlannotation.py	Tue Mar 17 13:34:54 2020 +0100
@@ -22,14 +22,14 @@
 from cubicweb.devtools.repotest import BaseQuerierTC
 
 
-class SQLGenAnnotatorTC(BaseQuerierTC):
+class RQLAnnotatorTC(BaseQuerierTC):
 
     def setUp(self):
         handler = devtools.get_test_db_handler(devtools.TestServerConfiguration('data', __file__))
         handler.build_db_cache()
         repo, _cnx = handler.get_repo_and_cnx()
         self.__class__.repo = repo
-        super(SQLGenAnnotatorTC, self).setUp()
+        super(RQLAnnotatorTC, self).setUp()
 
     def test_0_1(self):
         with self.admin_access.cnx() as cnx:
--- a/cubicweb/server/test/unittest_server_security.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/server/test/unittest_server_security.py	Tue Mar 17 13:34:54 2020 +0100
@@ -44,7 +44,7 @@
         nom = self.repo.schema['Personne'].rdef('nom')
         with self.temporary_permissions((nom, {'read': ('users', 'managers')})):
             with self.admin_access.repo_cnx() as cnx:
-                self.repo.vreg.solutions(cnx, rqlst, None)
+                self.repo.vreg.compute_var_types(cnx, rqlst, None)
                 check_relations_read_access(cnx, rqlst, {})
             with self.new_access(u'anon').repo_cnx() as cnx:
                 self.assertRaises(Unauthorized,
@@ -57,7 +57,7 @@
         rqlst = self.repo.vreg.rqlhelper.parse(rql).children[0]
         with self.temporary_permissions(Personne={'read': ('users', 'managers')}):
             with self.admin_access.repo_cnx() as cnx:
-                self.repo.vreg.solutions(cnx, rqlst, None)
+                self.repo.vreg.compute_var_types(cnx, rqlst, None)
                 solution = rqlst.solutions[0]
                 localchecks = get_local_checks(cnx, rqlst, solution)
                 self.assertEqual({}, localchecks)
@@ -520,7 +520,7 @@
                                                            ERQLExpression('X owned_by U'))}):
             with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
                 rqlst = self.repo.vreg.rqlhelper.parse('Any X WHERE X is_instance_of Societe')
-                self.repo.vreg.solutions(cnx, rqlst, {})
+                self.repo.vreg.compute_var_types(cnx, rqlst, {})
                 self.repo.vreg.rqlhelper.annotate(rqlst)
                 plan = cnx.repo.querier.plan_factory(rqlst, {}, cnx)
                 plan.preprocess(rqlst)
--- a/cubicweb/test/unittest_predicates.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/test/unittest_predicates.py	Tue Mar 17 13:34:54 2020 +0100
@@ -29,7 +29,7 @@
                                  multi_lines_rset, score_entity, is_in_state,
                                  rql_condition, relation_possible, match_form_params,
                                  paginated_rset)
-from cubicweb.view import EntityAdapter
+from cubicweb.entity import EntityAdapter
 from cubicweb.web import action
 
 
--- a/cubicweb/test/unittest_rqlrewrite.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/test/unittest_rqlrewrite.py	Tue Mar 17 13:34:54 2020 +0100
@@ -58,7 +58,7 @@
         schema = schema
 
         @staticmethod
-        def solutions(sqlcursor, rqlst, kwargs):
+        def compute_var_types(sqlcursor, rqlst, kwargs):
             rqlhelper.compute_solutions(rqlst, {'eid': eid_func_map}, kwargs=kwargs)
 
         class rqlhelper:
@@ -602,7 +602,7 @@
             args = {}
         union = parse(rql)  # self.vreg.parse(rql, annotate=True)
         with self.admin_access.repo_cnx() as cnx:
-            self.vreg.solutions(cnx, union, args)
+            self.vreg.compute_var_types(cnx, union, args)
             self.vreg.rqlhelper.annotate(union)
             plan = self.repo.querier.plan_factory(union, args, cnx)
             plan.preprocess(union)
--- a/cubicweb/test/unittest_vregistry.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/test/unittest_vregistry.py	Tue Mar 17 13:34:54 2020 +0100
@@ -23,7 +23,7 @@
 from cubicweb import CW_SOFTWARE_ROOT as BASE, devtools
 from cubicweb.cwvreg import CWRegistryStore, UnknownProperty
 from cubicweb.devtools.testlib import CubicWebTC
-from cubicweb.view import EntityAdapter
+from cubicweb.entity import EntityAdapter
 
 
 class YesSchema:
--- a/cubicweb/view.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/view.py	Tue Mar 17 13:34:54 2020 +0100
@@ -554,18 +554,7 @@
     def domid(self):
         return '%sComponent' % domid(self.__regid__)
 
-
-class Adapter(AppObject):
-    """base class for adapters"""
-    __registry__ = 'adapters'
-
-
-class EntityAdapter(Adapter):
-    """base class for entity adapters (eg adapt an entity to an interface)"""
-    def __init__(self, _cw, **kwargs):
-        try:
-            self.entity = kwargs.pop('entity')
-        except KeyError:
-            self.entity = kwargs['rset'].get_entity(kwargs.get('row') or 0,
-                                                    kwargs.get('col') or 0)
-        Adapter.__init__(self, _cw, **kwargs)
+# EntityAdapter moved to cubicweb.entity ######################################
+from logilab.common.deprecation import class_moved
+from cubicweb import entity
+EntityAdapter = class_moved(entity.EntityAdapter) # cubicweb 3.28
--- a/cubicweb/web/action.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/web/action.py	Tue Mar 17 13:34:54 2020 +0100
@@ -32,6 +32,7 @@
 Many examples are available in :mod:`cubicweb.web.views.actions`.
 """
 
+from typing import Optional
 
 from cubicweb import _
 
@@ -48,7 +49,7 @@
     __registry__ = 'actions'
     __select__ = match_search_state('normal')
     order = 99
-    category = 'moreactions'
+    category: Optional[str] = 'moreactions'
     # actions in category 'moreactions' can specify a sub-menu in which they should be filed
     submenu = None
 
--- a/cubicweb/web/test/unittest_idownloadable.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/web/test/unittest_idownloadable.py	Tue Mar 17 13:34:54 2020 +0100
@@ -23,12 +23,12 @@
 from pytz import utc
 
 from cubicweb.devtools.testlib import CubicWebTC, real_error_handling
-from cubicweb import view
+from cubicweb.entity import EntityAdapter
 from cubicweb.predicates import is_instance
 from cubicweb.web import http_headers
 
 
-class IDownloadableUser(view.EntityAdapter):
+class IDownloadableUser(EntityAdapter):
     __regid__ = 'IDownloadable'
     __select__ = is_instance('CWUser')
 
--- a/cubicweb/web/views/basecontrollers.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/web/views/basecontrollers.py	Tue Mar 17 13:34:54 2020 +0100
@@ -23,7 +23,7 @@
 
 from cubicweb import (NoSelectableObject, ObjectNotFound, ValidationError,
                       AuthenticationError, UndoTransactionException,
-                      Forbidden)
+                      Forbidden, rdf)
 from cubicweb.utils import json_dumps
 from cubicweb.predicates import (authenticated_user, anonymous_user,
                                 match_form_params)
@@ -101,6 +101,10 @@
             else:
                 rset = None
         vid = req.form.get('vid') or vid_from_rset(req, rset, self._cw.vreg.schema)
+        if rset and len(rset) == 1:
+            for mimetype in rdf.RDF_MIMETYPE_TO_FORMAT:
+                req.headers_out.addRawHeader(
+                    'Link', "<%s>;rel=alternate;type=%s" % (rset.one().cwuri, mimetype))
         try:
             view = self._cw.vreg['views'].select(vid, req, rset=rset)
         except ObjectNotFound:
--- a/cubicweb/web/views/calendar.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/web/views/calendar.py	Tue Mar 17 13:34:54 2020 +0100
@@ -28,7 +28,8 @@
 
 from cubicweb.utils import json_dumps, make_uid
 from cubicweb.predicates import adaptable
-from cubicweb.view import EntityView, EntityAdapter
+from cubicweb.view import EntityView
+from cubicweb.entity import EntityAdapter
 
 # useful constants & functions ################################################
 
--- a/cubicweb/web/views/editcontroller.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/web/views/editcontroller.py	Tue Mar 17 13:34:54 2020 +0100
@@ -27,7 +27,7 @@
 from rql.utils import rqlvar_maker
 
 from cubicweb import _, ValidationError, UnknownEid
-from cubicweb.view import EntityAdapter
+from cubicweb.entity import EntityAdapter
 from cubicweb.predicates import is_instance
 from cubicweb.web import RequestError, NothingToEdit, ProcessFormError
 from cubicweb.web.views import basecontrollers, autoform
--- a/cubicweb/web/views/ibreadcrumbs.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/web/views/ibreadcrumbs.py	Tue Mar 17 13:34:54 2020 +0100
@@ -25,11 +25,11 @@
 from logilab.mtconverter import xml_escape
 
 from cubicweb import tags, uilib
-from cubicweb.entity import Entity
+from cubicweb.entity import Entity, EntityAdapter
 from cubicweb.predicates import (is_instance, one_line_rset, adaptable,
                                 one_etype_rset, multi_lines_rset, any_rset,
                                 match_form_params)
-from cubicweb.view import EntityView, EntityAdapter
+from cubicweb.view import EntityView
 from cubicweb.web.views import basecomponents
 # don't use AnyEntity since this may cause bug with isinstance() due to reloading
 
--- a/cubicweb/web/views/magicsearch.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/web/views/magicsearch.py	Tue Mar 17 13:34:54 2020 +0100
@@ -454,7 +454,7 @@
                 user_rql = variables
             select = parse(user_rql, print_errors=False).children[0]
             req.vreg.rqlhelper.annotate(select)
-            req.vreg.solutions(req, select, {})
+            req.vreg.compute_var_types(req, select, {})
             if restrictions:
                 return ['%s, %s' % (user_rql, suggestion)
                         for suggestion in self.rql_build_suggestions(select, incomplete_part)]
--- a/cubicweb/web/views/navigation.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/web/views/navigation.py	Tue Mar 17 13:34:54 2020 +0100
@@ -56,7 +56,7 @@
 
 from cubicweb.predicates import paginated_rset, sorted_rset, adaptable
 from cubicweb.uilib import cut
-from cubicweb.view import EntityAdapter
+from cubicweb.entity import EntityAdapter
 from cubicweb.web.component import EmptyComponent, EntityCtxComponent, NavigationComponent
 
 
--- a/cubicweb/web/views/xmlrss.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/cubicweb/web/views/xmlrss.py	Tue Mar 17 13:34:54 2020 +0100
@@ -26,7 +26,8 @@
 
 from cubicweb.predicates import (is_instance, non_final_entity, one_line_rset,
                                  appobject_selectable, adaptable)
-from cubicweb.view import EntityView, EntityAdapter, AnyRsetView, Component
+from cubicweb.view import EntityView, AnyRsetView, Component
+from cubicweb.entity import EntityAdapter
 from cubicweb.uilib import simple_sgml_tag
 from cubicweb.web import httpcache, component
 
--- a/doc/book/devrepo/datamodel/definition.rst	Tue Mar 17 13:31:50 2020 +0100
+++ b/doc/book/devrepo/datamodel/definition.rst	Tue Mar 17 13:34:54 2020 +0100
@@ -466,7 +466,7 @@
     __permissions__ = {'read': ('managers', 'users'),
                        'add': ('managers', RRQLExpression('U has_update_permission S')),
                        'delete': ('managers', RRQLExpression('U has_update_permission S'))
-		       }
+                       }
 
 In the above example, user will be allowed to add/delete `my_relation` if he has
 the `update` permission on the subject of the relation.
--- a/doc/book/devrepo/repo/hooks.rst	Tue Mar 17 13:31:50 2020 +0100
+++ b/doc/book/devrepo/repo/hooks.rst	Tue Mar 17 13:34:54 2020 +0100
@@ -35,11 +35,11 @@
         events = ('before_add_entity', 'before_update_entity')
 
         def __call__(self):
-	    if 'age' in self.entity.cw_edited:
+            if 'age' in self.entity.cw_edited:
                 if 0 <= self.entity.age <= 120:
                    return
-		msg = self._cw._('age must be between 0 and 120')
-		raise ValidationError(self.entity.eid, {'age': msg})
+                msg = self._cw._('age must be between 0 and 120')
+                raise ValidationError(self.entity.eid, {'age': msg})
 
 In our example the base `__select__` is augmented with an `is_instance` selector
 matching the desired entity type.
--- a/doc/book/devrepo/vreg.rst	Tue Mar 17 13:31:50 2020 +0100
+++ b/doc/book/devrepo/vreg.rst	Tue Mar 17 13:34:54 2020 +0100
@@ -300,18 +300,18 @@
 .. sourcecode:: python
 
     class UserLink(component.Component):
-	'''if the user is the anonymous user, build a link to login else a link
-	to the connected user object with a logout link
-	'''
-	__regid__ = 'loggeduserlink'
+        '''if the user is the anonymous user, build a link to login else a link
+        to the connected user object with a logout link
+        '''
+        __regid__ = 'loggeduserlink'
 
-	def call(self):
-	    if self._cw.session.anonymous_session:
-		# display login link
-		...
-	    else:
-		# display a link to the connected user object with a loggout link
-		...
+        def call(self):
+            if self._cw.session.anonymous_session:
+                # display login link
+                ...
+            else:
+                # display a link to the connected user object with a loggout link
+                ...
 
 The proper way to implement this with |cubicweb| is two have two different
 classes sharing the same identifier but with different selectors so you'll get
@@ -320,21 +320,21 @@
 .. sourcecode:: python
 
     class UserLink(component.Component):
-	'''display a link to the connected user object with a loggout link'''
-	__regid__ = 'loggeduserlink'
-	__select__ = component.Component.__select__ & authenticated_user()
+        '''display a link to the connected user object with a loggout link'''
+        __regid__ = 'loggeduserlink'
+        __select__ = component.Component.__select__ & authenticated_user()
 
-	def call(self):
+        def call(self):
             # display useractions and siteactions
-	    ...
+            ...
 
     class AnonUserLink(component.Component):
-	'''build a link to login'''
-	__regid__ = 'loggeduserlink'
-	__select__ = component.Component.__select__ & anonymous_user()
+        '''build a link to login'''
+        __regid__ = 'loggeduserlink'
+        __select__ = component.Component.__select__ & anonymous_user()
 
-	def call(self):
-	    # display login link
+        def call(self):
+            # display login link
             ...
 
 The big advantage, aside readability once you're familiar with the
--- a/doc/book/devweb/edition/examples.rst	Tue Mar 17 13:31:50 2020 +0100
+++ b/doc/book/devweb/edition/examples.rst	Tue Mar 17 13:34:54 2020 +0100
@@ -56,42 +56,42 @@
 .. sourcecode:: python
 
     def sender_value(form, field):
-	return '%s <%s>' % (form._cw.user.dc_title(), form._cw.user.get_email())
+        return '%s <%s>' % (form._cw.user.dc_title(), form._cw.user.get_email())
 
     def recipient_choices(form, field):
-	return [(e.get_email(), e.eid)
+        return [(e.get_email(), e.eid)
                  for e in form.cw_rset.entities()
-		 if e.get_email()]
+                 if e.get_email()]
 
     def recipient_value(form, field):
-	return [e.eid for e in form.cw_rset.entities()
+        return [e.eid for e in form.cw_rset.entities()
                 if e.get_email()]
 
     class MassMailingForm(forms.FieldsForm):
-	__regid__ = 'massmailing'
+        __regid__ = 'massmailing'
 
-	needs_js = ('cubicweb.widgets.js',)
-	domid = 'sendmail'
-	action = 'sendmail'
+        needs_js = ('cubicweb.widgets.js',)
+        domid = 'sendmail'
+        action = 'sendmail'
 
-	sender = ff.StringField(widget=TextInput({'disabled': 'disabled'}),
-				label=_('From:'),
-				value=sender_value)
+        sender = ff.StringField(widget=TextInput({'disabled': 'disabled'}),
+                                label=_('From:'),
+                                value=sender_value)
 
-	recipient = ff.StringField(widget=CheckBox(),
-	                           label=_('Recipients:'),
-				   choices=recipient_choices,
-				   value=recipients_value)
+        recipient = ff.StringField(widget=CheckBox(),
+                                   label=_('Recipients:'),
+                                   choices=recipient_choices,
+                                   value=recipients_value)
 
-	subject = ff.StringField(label=_('Subject:'), max_length=256)
+        subject = ff.StringField(label=_('Subject:'), max_length=256)
 
-	mailbody = ff.StringField(widget=AjaxWidget(wdgtype='TemplateTextField',
-						    inputid='mailbody'))
+        mailbody = ff.StringField(widget=AjaxWidget(wdgtype='TemplateTextField',
+                                                    inputid='mailbody'))
 
-	form_buttons = [ImgButton('sendbutton', "javascript: $('#sendmail').submit()",
-				  _('send email'), 'SEND_EMAIL_ICON'),
-			ImgButton('cancelbutton', "javascript: history.back()",
-				  stdmsgs.BUTTON_CANCEL, 'CANCEL_EMAIL_ICON')]
+        form_buttons = [ImgButton('sendbutton', "javascript: $('#sendmail').submit()",
+                                  _('send email'), 'SEND_EMAIL_ICON'),
+                        ImgButton('cancelbutton', "javascript: history.back()",
+                                  stdmsgs.BUTTON_CANCEL, 'CANCEL_EMAIL_ICON')]
 
 Let's detail what's going on up there. Our form will hold four fields:
 
@@ -125,13 +125,13 @@
 .. sourcecode:: python
 
     class MassMailingFormView(form.FormViewMixIn, EntityView):
-	__regid__ = 'massmailing'
-	__select__ = is_instance(IEmailable) & authenticated_user()
+        __regid__ = 'massmailing'
+        __select__ = is_instance(IEmailable) & authenticated_user()
 
-	def call(self):
-	    form = self._cw.vreg['forms'].select('massmailing', self._cw,
-	                                         rset=self.cw_rset)
-	    form.render(w=self.w)
+        def call(self):
+            form = self._cw.vreg['forms'].select('massmailing', self._cw,
+                                                 rset=self.cw_rset)
+            form.render(w=self.w)
 
 As you see, we simply define a view with proper selector so it only apply to a
 result set containing :class:`IEmailable` entities, and so that only users in the
--- a/doc/book/devweb/js.rst	Tue Mar 17 13:31:50 2020 +0100
+++ b/doc/book/devweb/js.rst	Tue Mar 17 13:34:54 2020 +0100
@@ -142,7 +142,7 @@
     function removeBookmark(beid) {
         d = asyncRemoteExec('delete_bookmark', beid);
         d.addCallback(function(boxcontent) {
-	    reloadComponent('bookmarks_box', '', 'boxes', 'bookmarks_box');
+            reloadComponent('bookmarks_box', '', 'boxes', 'bookmarks_box');
             document.location.hash = '#header';
             updateMessage(_("bookmark has been removed"));
          });
--- a/doc/book/devweb/views/reledit.rst	Tue Mar 17 13:31:50 2020 +0100
+++ b/doc/book/devweb/views/reledit.rst	Tue Mar 17 13:34:54 2020 +0100
@@ -136,14 +136,14 @@
     from cubicweb.web.views import reledit
 
     class DeactivatedAutoClickAndEditFormView(reledit.AutoClickAndEditFormView):
-	def _should_edit_attribute(self, rschema):
-	    return False
+        def _should_edit_attribute(self, rschema):
+            return False
 
-	def _should_edit_attribute(self, rschema, role):
-	    return False
+        def _should_edit_attribute(self, rschema, role):
+            return False
 
     def registration_callback(vreg):
-	vreg.register_and_replace(DeactivatedAutoClickAndEditFormView,
-				  reledit.AutoClickAndEditFormView)
+        vreg.register_and_replace(DeactivatedAutoClickAndEditFormView,
+                                  reledit.AutoClickAndEditFormView)
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/changes/3.28.rst	Tue Mar 17 13:34:54 2020 +0100
@@ -0,0 +1,8 @@
+3.28
+====
+
+Changes
+-------
+
+- the class cubicweb.view.EntityAdapter was moved to cubicweb.entity.EntityAdapter
+  a deprecation warning is in place, but please update your source code accordingly.
--- a/doc/changes/changelog.rst	Tue Mar 17 13:31:50 2020 +0100
+++ b/doc/changes/changelog.rst	Tue Mar 17 13:34:54 2020 +0100
@@ -2,6 +2,7 @@
  Changelog history
 ===================
 
+.. include:: 3.28.rst
 .. include:: 3.27.rst
 .. include:: 3.26.rst
 .. include:: 3.25.rst
--- a/doc/tutorials/advanced/part02_security.rst	Tue Mar 17 13:31:50 2020 +0100
+++ b/doc/tutorials/advanced/part02_security.rst	Tue Mar 17 13:34:54 2020 +0100
@@ -117,25 +117,25 @@
     from cubicweb.schema import ERQLExpression
 
     VISIBILITY_PERMISSIONS = {
-	'read':   ('managers',
-		   ERQLExpression('X visibility "public"'),
-		   ERQLExpression('X may_be_read_by U')),
-	'add':    ('managers',),
-	'update': ('managers', 'owners',),
-	'delete': ('managers', 'owners'),
-	}
+        'read':   ('managers',
+                   ERQLExpression('X visibility "public"'),
+                   ERQLExpression('X may_be_read_by U')),
+        'add':    ('managers',),
+        'update': ('managers', 'owners',),
+        'delete': ('managers', 'owners'),
+        }
     AUTH_ONLY_PERMISSIONS = {
-	    'read':   ('managers', 'users'),
-	    'add':    ('managers',),
-	    'update': ('managers', 'owners',),
-	    'delete': ('managers', 'owners'),
-	    }
+            'read':   ('managers', 'users'),
+            'add':    ('managers',),
+            'update': ('managers', 'owners',),
+            'delete': ('managers', 'owners'),
+            }
     CLASSIFIERS_PERMISSIONS = {
-	    'read':   ('managers', 'users', 'guests'),
-	    'add':    ('managers',),
-	    'update': ('managers', 'owners',),
-	    'delete': ('managers', 'owners'),
-	    }
+            'read':   ('managers', 'users', 'guests'),
+            'add':    ('managers',),
+            'update': ('managers', 'owners',),
+            'delete': ('managers', 'owners'),
+            }
 
     from cubicweb_folder.schema import Folder
     from cubicweb_file.schema import File
--- a/setup.py	Tue Mar 17 13:31:50 2020 +0100
+++ b/setup.py	Tue Mar 17 13:34:54 2020 +0100
@@ -73,6 +73,8 @@
         'pytz',
         'Markdown',
         'filelock',
+        'rdflib',
+        'rdflib-jsonld',
     ],
     entry_points={
         'console_scripts': [
@@ -102,9 +104,6 @@
             'pyramid_multiauth',
             'repoze.lru',
         ],
-        'rdf': [
-            'rdflib',
-        ],
         'sparql': [
             'fyzz >= 0.1.0',
         ],
@@ -113,5 +112,5 @@
         ],
     },
     zip_safe=False,
-    python_requires=">=3.4",
+    python_requires=">=3.6",
 )