Use python logging in install / log cherrypy at right severity
[cascardo/ipsilon.git] / ipsilon / util / data.py
1 # Copyright (C) 2013  Simo Sorce <simo@redhat.com>
2 #
3 # see file 'COPYING' for use and warranty information
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 import cherrypy
19 from ipsilon.util.log import Log
20 from sqlalchemy import create_engine
21 from sqlalchemy import MetaData, Table, Column, Text
22 from sqlalchemy.pool import QueuePool, SingletonThreadPool
23 from sqlalchemy.sql import select
24 import ConfigParser
25 import os
26 import uuid
27 import logging
28
29
30 OPTIONS_COLUMNS = ['name', 'option', 'value']
31 UNIQUE_DATA_COLUMNS = ['uuid', 'name', 'value']
32
33
34 class SqlStore(Log):
35     __instances = {}
36
37     @classmethod
38     def get_connection(cls, name):
39         if name not in cls.__instances.keys():
40             logging.debug('SqlStore new: %s', name)
41             cls.__instances[name] = SqlStore(name)
42         return cls.__instances[name]
43
44     def __init__(self, name):
45         self.debug('SqlStore init: %s' % name)
46         self.name = name
47         engine_name = name
48         if '://' not in engine_name:
49             engine_name = 'sqlite:///' + engine_name
50         # This pool size is per configured database. The minimum needed,
51         #  determined by binary search, is 23. We're using 25 so we have a bit
52         #  more playroom, and then the overflow should make sure things don't
53         #  break when we suddenly need more.
54         pool_args = {'poolclass': QueuePool,
55                      'pool_size': 25,
56                      'max_overflow': 50}
57         if engine_name.startswith('sqlite://'):
58             # It's not possible to share connections for SQLite between
59             #  threads, so let's use the SingletonThreadPool for them
60             pool_args = {'poolclass': SingletonThreadPool}
61         # pylint: disable=star-args
62         self._dbengine = create_engine(engine_name, **pool_args)
63         self.is_readonly = False
64
65     def engine(self):
66         return self._dbengine
67
68     def connection(self):
69         self.debug('SqlStore connect: %s' % self.name)
70         conn = self._dbengine.connect()
71
72         def cleanup_connection():
73             self.debug('SqlStore cleanup: %s' % self.name)
74             conn.close()
75         cherrypy.request.hooks.attach('on_end_request', cleanup_connection)
76         return conn
77
78
79 def SqlAutotable(f):
80     def at(self, *args, **kwargs):
81         self.create()
82         return f(self, *args, **kwargs)
83     return at
84
85
86 class SqlQuery(Log):
87
88     def __init__(self, db_obj, table, columns, trans=True):
89         self._db = db_obj
90         self._con = self._db.connection()
91         self._trans = self._con.begin() if trans else None
92         self._table = self._get_table(table, columns)
93
94     def _get_table(self, name, columns):
95         table = Table(name, MetaData(self._db.engine()))
96         for c in columns:
97             table.append_column(Column(c, Text()))
98         return table
99
100     def _where(self, kvfilter):
101         where = None
102         if kvfilter is not None:
103             for k in kvfilter:
104                 w = self._table.columns[k] == kvfilter[k]
105                 if where is None:
106                     where = w
107                 else:
108                     where = where & w
109         return where
110
111     def _columns(self, columns=None):
112         cols = None
113         if columns is not None:
114             cols = []
115             for c in columns:
116                 cols.append(self._table.columns[c])
117         else:
118             cols = self._table.columns
119         return cols
120
121     def rollback(self):
122         self._trans.rollback()
123
124     def commit(self):
125         self._trans.commit()
126
127     def create(self):
128         self._table.create(checkfirst=True)
129
130     def drop(self):
131         self._table.drop(checkfirst=True)
132
133     @SqlAutotable
134     def select(self, kvfilter=None, columns=None):
135         return self._con.execute(select(self._columns(columns),
136                                         self._where(kvfilter)))
137
138     @SqlAutotable
139     def insert(self, values):
140         self._con.execute(self._table.insert(values))
141
142     @SqlAutotable
143     def update(self, values, kvfilter):
144         self._con.execute(self._table.update(self._where(kvfilter), values))
145
146     @SqlAutotable
147     def delete(self, kvfilter):
148         self._con.execute(self._table.delete(self._where(kvfilter)))
149
150
151 class FileStore(Log):
152
153     def __init__(self, name):
154         self._filename = name
155         self.is_readonly = True
156         self._timestamp = None
157         self._config = None
158
159     def get_config(self):
160         try:
161             stat = os.stat(self._filename)
162         except OSError, e:
163             self.error("Unable to check config file %s: [%s]" % (
164                 self._filename, e))
165             self._config = None
166             raise
167         timestamp = stat.st_mtime
168         if self._config is None or timestamp > self._timestamp:
169             self._config = ConfigParser.RawConfigParser()
170             self._config.optionxform = str
171             self._config.read(self._filename)
172         return self._config
173
174
175 class FileQuery(Log):
176
177     def __init__(self, fstore, table, columns, trans=True):
178         self._fstore = fstore
179         self._config = fstore.get_config()
180         self._section = table
181         if len(columns) > 3 or columns[-1] != 'value':
182             raise ValueError('Unsupported configuration format')
183         self._columns = columns
184
185     def rollback(self):
186         return
187
188     def commit(self):
189         return
190
191     def create(self):
192         raise NotImplementedError
193
194     def drop(self):
195         raise NotImplementedError
196
197     def select(self, kvfilter=None, columns=None):
198         if self._section not in self._config.sections():
199             return []
200
201         opts = self._config.options(self._section)
202
203         prefix = None
204         prefix_ = ''
205         if self._columns[0] in kvfilter:
206             prefix = kvfilter[self._columns[0]]
207             prefix_ = prefix + ' '
208
209         name = None
210         if len(self._columns) == 3 and self._columns[1] in kvfilter:
211             name = kvfilter[self._columns[1]]
212
213         value = None
214         if self._columns[-1] in kvfilter:
215             value = kvfilter[self._columns[-1]]
216
217         res = []
218         for o in opts:
219             if len(self._columns) == 3:
220                 # 3 cols
221                 if prefix and not o.startswith(prefix_):
222                     continue
223
224                 col1, col2 = o.split(' ', 1)
225                 if name and col2 != name:
226                     continue
227
228                 col3 = self._config.get(self._section, o)
229                 if value and col3 != value:
230                     continue
231
232                 r = [col1, col2, col3]
233             else:
234                 # 2 cols
235                 if prefix and o != prefix:
236                     continue
237                 r = [o, self._config.get(self._section, o)]
238
239             if columns:
240                 s = []
241                 for c in columns:
242                     s.append(r[self._columns.index(c)])
243                 res.append(s)
244             else:
245                 res.append(r)
246
247         self.debug('SELECT(%s, %s, %s) -> %s' % (self._section,
248                                                  repr(kvfilter),
249                                                  repr(columns),
250                                                  repr(res)))
251         return res
252
253     def insert(self, values):
254         raise NotImplementedError
255
256     def update(self, values, kvfilter):
257         raise NotImplementedError
258
259     def delete(self, kvfilter):
260         raise NotImplementedError
261
262
263 class Store(Log):
264     def __init__(self, config_name=None, database_url=None):
265         if config_name is None and database_url is None:
266             raise ValueError('config_name or database_url must be provided')
267         if config_name:
268             if config_name not in cherrypy.config:
269                 raise NameError('Unknown database %s' % config_name)
270             name = cherrypy.config[config_name]
271         else:
272             name = database_url
273         if name.startswith('configfile://'):
274             _, filename = name.split('://')
275             self._db = FileStore(filename)
276             self._query = FileQuery
277         else:
278             self._db = SqlStore.get_connection(name)
279             self._query = SqlQuery
280
281     @property
282     def is_readonly(self):
283         return self._db.is_readonly
284
285     def _row_to_dict_tree(self, data, row):
286         name = row[0]
287         if len(row) > 2:
288             if name not in data:
289                 data[name] = dict()
290             d2 = data[name]
291             self._row_to_dict_tree(d2, row[1:])
292         else:
293             value = row[1]
294             if name in data:
295                 if data[name] is list:
296                     data[name].append(value)
297                 else:
298                     v = data[name]
299                     data[name] = [v, value]
300             else:
301                 data[name] = value
302
303     def _rows_to_dict_tree(self, rows):
304         data = dict()
305         for r in rows:
306             self._row_to_dict_tree(data, r)
307         return data
308
309     def _load_data(self, table, columns, kvfilter=None):
310         rows = []
311         try:
312             q = self._query(self._db, table, columns, trans=False)
313             rows = q.select(kvfilter)
314         except Exception, e:  # pylint: disable=broad-except
315             self.error("Failed to load data for table %s: [%s]" % (table, e))
316         return self._rows_to_dict_tree(rows)
317
318     def load_config(self):
319         table = 'config'
320         columns = ['name', 'value']
321         return self._load_data(table, columns)
322
323     def load_options(self, table, name=None):
324         kvfilter = dict()
325         if name:
326             kvfilter['name'] = name
327         options = self._load_data(table, OPTIONS_COLUMNS, kvfilter)
328         if name and name in options:
329             return options[name]
330         return options
331
332     def save_options(self, table, name, options):
333         curvals = dict()
334         q = None
335         try:
336             q = self._query(self._db, table, OPTIONS_COLUMNS)
337             rows = q.select({'name': name}, ['option', 'value'])
338             for row in rows:
339                 curvals[row[0]] = row[1]
340
341             for opt in options:
342                 if opt in curvals:
343                     q.update({'value': options[opt]},
344                              {'name': name, 'option': opt})
345                 else:
346                     q.insert((name, opt, options[opt]))
347
348             q.commit()
349         except Exception, e:  # pylint: disable=broad-except
350             if q:
351                 q.rollback()
352             self.error("Failed to save options: [%s]" % e)
353             raise
354
355     def delete_options(self, table, name, options=None):
356         kvfilter = {'name': name}
357         q = None
358         try:
359             q = self._query(self._db, table, OPTIONS_COLUMNS)
360             if options is None:
361                 q.delete(kvfilter)
362             else:
363                 for opt in options:
364                     kvfilter['option'] = opt
365                     q.delete(kvfilter)
366             q.commit()
367         except Exception, e:  # pylint: disable=broad-except
368             if q:
369                 q.rollback()
370             self.error("Failed to delete from %s: [%s]" % (table, e))
371             raise
372
373     def new_unique_data(self, table, data):
374         newid = str(uuid.uuid4())
375         q = None
376         try:
377             q = self._query(self._db, table, UNIQUE_DATA_COLUMNS)
378             for name in data:
379                 q.insert((newid, name, data[name]))
380             q.commit()
381         except Exception, e:  # pylint: disable=broad-except
382             if q:
383                 q.rollback()
384             self.error("Failed to store %s data: [%s]" % (table, e))
385             raise
386         return newid
387
388     def get_unique_data(self, table, uuidval=None, name=None, value=None):
389         kvfilter = dict()
390         if uuidval:
391             kvfilter['uuid'] = uuidval
392         if name:
393             kvfilter['name'] = name
394         if value:
395             kvfilter['value'] = value
396         return self._load_data(table, UNIQUE_DATA_COLUMNS, kvfilter)
397
398     def save_unique_data(self, table, data):
399         q = None
400         try:
401             q = self._query(self._db, table, UNIQUE_DATA_COLUMNS)
402             for uid in data:
403                 curvals = dict()
404                 rows = q.select({'uuid': uid}, ['name', 'value'])
405                 for r in rows:
406                     curvals[r[0]] = r[1]
407
408                 datum = data[uid]
409                 for name in datum:
410                     if name in curvals:
411                         if datum[name] is None:
412                             q.delete({'uuid': uid, 'name': name})
413                         else:
414                             q.update({'value': datum[name]},
415                                      {'uuid': uid, 'name': name})
416                     else:
417                         if datum[name] is not None:
418                             q.insert((uid, name, datum[name]))
419
420             q.commit()
421         except Exception, e:  # pylint: disable=broad-except
422             if q:
423                 q.rollback()
424             self.error("Failed to store data in %s: [%s]" % (table, e))
425             raise
426
427     def del_unique_data(self, table, uuidval):
428         kvfilter = {'uuid': uuidval}
429         try:
430             q = self._query(self._db, table, UNIQUE_DATA_COLUMNS, trans=False)
431             q.delete(kvfilter)
432         except Exception, e:  # pylint: disable=broad-except
433             self.error("Failed to delete data from %s: [%s]" % (table, e))
434
435     def _reset_data(self, table):
436         q = None
437         try:
438             q = self._query(self._db, table, UNIQUE_DATA_COLUMNS)
439             q.drop()
440             q.create()
441             q.commit()
442         except Exception, e:  # pylint: disable=broad-except
443             if q:
444                 q.rollback()
445             self.error("Failed to erase all data from %s: [%s]" % (table, e))
446
447
448 class AdminStore(Store):
449
450     def __init__(self):
451         super(AdminStore, self).__init__('admin.config.db')
452
453     def get_data(self, plugin, idval=None, name=None, value=None):
454         return self.get_unique_data(plugin+"_data", idval, name, value)
455
456     def save_data(self, plugin, data):
457         return self.save_unique_data(plugin+"_data", data)
458
459     def new_datum(self, plugin, datum):
460         table = plugin+"_data"
461         return self.new_unique_data(table, datum)
462
463     def del_datum(self, plugin, idval):
464         table = plugin+"_data"
465         return self.del_unique_data(table, idval)
466
467     def wipe_data(self, plugin):
468         table = plugin+"_data"
469         self._reset_data(table)
470
471
472 class UserStore(Store):
473
474     def __init__(self, path=None):
475         super(UserStore, self).__init__('user.prefs.db')
476
477     def save_user_preferences(self, user, options):
478         self.save_options('users', user, options)
479
480     def load_user_preferences(self, user):
481         return self.load_options('users', user)
482
483     def save_plugin_data(self, plugin, user, options):
484         self.save_options(plugin+"_data", user, options)
485
486     def load_plugin_data(self, plugin, user):
487         return self.load_options(plugin+"_data", user)
488
489
490 class TranStore(Store):
491
492     def __init__(self, path=None):
493         super(TranStore, self).__init__('transactions.db')