storages.py 10.1 KB
Newer Older
1
# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2
3
4
5
6
7
8
9
10
# 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.
#
11
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
12
13
14
15
16
17
# 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/>.
18
"""custom storages for the system source"""
19

20
from os import unlink, path as osp
21
from contextlib import contextmanager
22

23
24
from yams.schema import role_name

25
from cubicweb import Binary, ValidationError
26
from cubicweb.server import hook
27
from cubicweb.server.edition import EditedEntity
28

29
30

def set_attribute_storage(repo, etype, attr, storage):
31
    repo.system_source.set_storage(etype, attr, storage)
32

33
def unset_attribute_storage(repo, etype, attr):
34
    repo.system_source.unset_storage(etype, attr)
35

36

37
class Storage(object):
38
39
40
    """abstract storage

    * If `source_callback` is true (by default), the callback will be run during
41
      query result process of fetched attribute's value and should have the
42
43
      following prototype::

44
        callback(self, source, session, value)
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

      where `value` is the value actually stored in the backend. None values
      will be skipped (eg callback won't be called).

    * if `source_callback` is false, the callback will be run during sql
      generation when some attribute with a custom storage is accessed and
      should have the following prototype::

        callback(self, generator, relation, linkedvar)

      where `generator` is the sql generator, `relation` the current rql syntax
      tree relation and linkedvar the principal syntax tree variable holding the
      attribute.
    """
    is_source_callback = True

    def callback(self, *args):
        """see docstring for prototype, which vary according to is_source_callback
63
64
65
66
67
68
69
70
71
72
73
74
        """
        raise NotImplementedError()

    def entity_added(self, entity, attr):
        """an entity using this storage for attr has been added"""
        raise NotImplementedError()
    def entity_updated(self, entity, attr):
        """an entity using this storage for attr has been updatded"""
        raise NotImplementedError()
    def entity_deleted(self, entity, attr):
        """an entity using this storage for attr has been deleted"""
        raise NotImplementedError()
75
76
77
    def migrate_entity(self, entity, attribute):
        """migrate an entity attribute to the storage"""
        raise NotImplementedError()
78
79
80
81

# TODO
# * make it configurable without code
# * better file path attribution
Sylvain Thénault's avatar
Sylvain Thénault committed
82
# * handle backup/restore
83

84
85
86
87
88
89
def uniquify_path(dirpath, basename):
    """return a unique file name for `basename` in `dirpath`, or None
    if all attemps failed.

    XXX subject to race condition.
    """
90
    path = osp.join(dirpath, basename.replace(osp.sep, '-'))
91
92
93
94
95
96
97
98
99
    if not osp.isfile(path):
        return path
    base, ext = osp.splitext(path)
    for i in xrange(1, 256):
        path = '%s%s%s' % (base, i, ext)
        if not osp.isfile(path):
            return path
    return None

100
101
102
103
104
105
106
107
108
109
110
@contextmanager
def fsimport(session):
    present = 'fs_importing' in session.transaction_data
    old_value = session.transaction_data.get('fs_importing')
    session.transaction_data['fs_importing'] = True
    yield
    if present:
        session.transaction_data['fs_importing'] = old_value
    else:
        del session.transaction_data['fs_importing']

Sylvain Thénault's avatar
Sylvain Thénault committed
111

112
113
class BytesFileSystemStorage(Storage):
    """store Bytes attribute value on the file system"""
114
    def __init__(self, defaultdir, fsencoding='utf-8'):
115
        self.default_directory = defaultdir
116
        self.fsencoding = fsencoding
117

118
    def callback(self, source, session, value):
119
120
121
        """sql generator callback when some attribute with a custom storage is
        accessed
        """
122
123
        fpath = source.binary_to_str(value)
        try:
124
            return Binary(file(fpath, 'rb').read())
125
        except EnvironmentError, ex:
126
127
            source.critical("can't open %s: %s", value, ex)
            return None
128
129
130

    def entity_added(self, entity, attr):
        """an entity using this storage for attr has been added"""
131
        if entity._cw.transaction_data.get('fs_importing'):
132
            binary = Binary(file(entity.cw_edited[attr].getvalue(), 'rb').read())
133
        else:
134
            binary = entity.cw_edited.pop(attr)
135
136
            fpath = self.new_fs_path(entity, attr)
            # bytes storage used to store file's path
137
            entity.cw_edited.edited_attribute(attr, Binary(fpath))
138
            file(fpath, 'wb').write(binary.getvalue())
139
            AddFileOp.get_instance(entity._cw).add_data(fpath)
140
        return binary
141
142
143

    def entity_updated(self, entity, attr):
        """an entity using this storage for attr has been updatded"""
144
        # get the name of the previous file containing the value
145
        oldpath = self.current_fs_path(entity, attr)
146
        if entity._cw.transaction_data.get('fs_importing'):
147
148
149
            # If we are importing from the filesystem, the file already exists.
            # We do not need to create it but we need to fetch the content of
            # the file as the actual content of the attribute
150
            fpath = entity.cw_edited[attr].getvalue()
151
            assert fpath is not None
152
            binary = Binary(file(fpath, 'rb').read())
153
        else:
154
155
156
157
158
159
160
161
162
            # We must store the content of the attributes
            # into a file to stay consistent with the behaviour of entity_add.
            # Moreover, the BytesFileSystemStorage expects to be able to
            # retrieve the current value of the attribute at anytime by reading
            # the file on disk. To be able to rollback things, use a new file
            # and keep the old one that will be removed on commit if everything
            # went ok.
            #
            # fetch the current attribute value in memory
163
            binary = entity.cw_edited.pop(attr)
164
165
166
167
168
169
170
171
172
173
174
            if binary is None:
                fpath = None
            else:
                # Get filename for it
                fpath = self.new_fs_path(entity, attr)
                assert not osp.exists(fpath)
                # write attribute value on disk
                file(fpath, 'wb').write(binary.getvalue())
                # Mark the new file as added during the transaction.
                # The file will be removed on rollback
                AddFileOp.get_instance(entity._cw).add_data(fpath)
175
        if oldpath != fpath:
176
            # register the new location for the file.
177
178
179
180
            if fpath is None:
                entity.cw_edited.edited_attribute(attr, None)
            else:
                entity.cw_edited.edited_attribute(attr, Binary(fpath))
181
182
            # Mark the old file as useless so the file will be removed at
            # commit.
183
184
            if oldpath is not None:
                DeleteFileOp.get_instance(entity._cw).add_data(oldpath)
185
        return binary
186
187
188

    def entity_deleted(self, entity, attr):
        """an entity using this storage for attr has been deleted"""
189
        fpath = self.current_fs_path(entity, attr)
190
191
        if fpath is not None:
            DeleteFileOp.get_instance(entity._cw).add_data(fpath)
192
193

    def new_fs_path(self, entity, attr):
194
195
196
197
198
        # We try to get some hint about how to name the file using attribute's
        # name metadata, so we use the real file name and extension when
        # available. Keeping the extension is useful for example in the case of
        # PIL processing that use filename extension to detect content-type, as
        # well as providing more understandable file names on the fs.
199
        basename = [str(entity.eid), attr]
200
        name = entity.cw_attr_metadata(attr, 'name')
201
        if name is not None:
202
            basename.append(name.encode(self.fsencoding))
203
204
205
        fspath = uniquify_path(self.default_directory, '_'.join(basename))
        if fspath is None:
            msg = entity._cw._('failed to uniquify path (%s, %s)') % (
206
                self.default_directory, '_'.join(basename))
207
            raise ValidationError(entity.eid, {role_name(attr, 'subject'): msg})
208
209
210
        return fspath

    def current_fs_path(self, entity, attr):
211
212
213
        """return the current fs_path of the tribute.

        Return None is the attr is not stored yet."""
214
        sysource = entity._cw.cnxset.source('system')
215
216
        cu = sysource.doexec(entity._cw,
                             'SELECT cw_%s FROM cw_%s WHERE cw_eid=%s' % (
217
                             attr, entity.__regid__, entity.eid))
218
219
        rawvalue = cu.fetchone()[0]
        if rawvalue is None: # no previous value
220
            return None
221
        return sysource._process_value(rawvalue, cu.description[0],
222
                                       binarywrap=str)
223

224
225
    def migrate_entity(self, entity, attribute):
        """migrate an entity attribute to the storage"""
226
        entity.cw_edited = EditedEntity(entity, **entity.cw_attr_cache)
227
228
229
230
231
232
233
        self.entity_added(entity, attribute)
        session = entity._cw
        source = session.repo.system_source
        attrs = source.preprocess_entity(entity)
        sql = source.sqlgen.update('cw_' + entity.__regid__, attrs,
                                   ['cw_eid'])
        source.doexec(session, sql, attrs)
234
        entity.cw_edited = None
235

236

237
class AddFileOp(hook.DataOperationMixIn, hook.Operation):
238
    def rollback_event(self):
239
        for filepath in self.get_data():
240
241
242
243
            try:
                unlink(filepath)
            except Exception, ex:
                self.error('cant remove %s: %s' % (filepath, ex))
244

245
class DeleteFileOp(hook.DataOperationMixIn, hook.Operation):
246
    def postcommit_event(self):
247
        for filepath in self.get_data():
248
249
250
251
            try:
                unlink(filepath)
            except Exception, ex:
                self.error('cant remove %s: %s' % (filepath, ex))