-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdb3.py
More file actions
618 lines (534 loc) · 22.2 KB
/
Copy pathdb3.py
File metadata and controls
618 lines (534 loc) · 22.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
"""
*Availability: 3.1+*
*Note:* This supersedes the ``SettingsDB`` object of 3.0. Within willie modules,
simmilar functionallity can be found using ``db.preferences``.
This class defines an interface for a semi-arbitrary database type. It is meant
to allow module writers to operate without regard to how the end user has
decided to set up the database.
"""
"""
Copyright 2012, Edward D. Powell, embolalia.net
Licensed under the Eiffel Forum License 2.
http://willie.dftba.net
"""
from collections import Iterable
from tools import deprecated
supported_types = set()
#Attempt to import possible db modules
try:
import MySQLdb
import MySQLdb.cursors
supported_types.add('mysql')
except ImportError:
pass
try:
import sqlite3
supported_types.add('sqlite')
except ImportError:
pass
class willieDB(object):
"""
Return a willieDB object configured with the options in the given Config
object. The exact settgins used vary depending on the type of database
chosen to back the SettingsDB, as determined by the ``userdb_type``
attribute of *config*.
Currently, two values for ``userdb_type`` are supported: ``sqlite`` and
``mysql``. The ``sqlite`` type requires that ``userdb_file`` be set in the
``db`` section of ``config`` (that is, under the ``[db]`` heading in the
config file), and refer to a writeable sqlite database. The ``mysql`` type
requires ``userdb_host``, ``userdb_user``, ``userdb_pass``, and
``userdb_name`` to be set, and provide the host and name of a MySQL database,
as well as a username and password for a user able to write to said database.
Upon creation of the object, the tables currently existing in the given
database will be registered, as though added through ``add_table``.
"""
def __init__(self, config):
self._none = Table(self, '_none', [], '_none')
self.tables = set()
if not config.parser.has_section('db'):
self.type = None
print 'No user settings database specified. Ignoring.'
return
self.type = config.db.userdb_type.lower()
if self.type not in supported_types:
self.type = None
print 'User settings database type is not supported. You may be missing the module for it. Ignoring.'
return
if self.type == 'mysql':
self.substitution = '%s'
self._mySQL(config)
elif self.type == 'sqlite':
self.substitution = '?'
self._sqlite(config)
def __getattr__(self, attr):
"""
Handle non-existant tables gracefully by returning a pseudo-table.
"""
return self._none
def __nonzero__(self):
"""Allow for testing if a db is set up through `if willie.db`."""
return bool(self.type)
def _mySQL(self, config):
try:
self._host = config.db.userdb_host
self._user = config.db.userdb_user
self._passwd = config.db.userdb_pass
self._dbname = config.db.userdb_name
except AttributeError as e:
print 'Some options are missing for your MySQL DB. The database will not be set up.'
return
try:
db = MySQLdb.connect(host=self._host,
user=self._user,
passwd=self._passwd,
db=self._dbname)
except:
print 'Error: Unable to connect to user settings DB.'
return
#Set up existing tables and columns
cur = MySQLdb.cursors.DictCursor(db)
cur.execute("SHOW tables;")
tables = cur.fetchall()
for table in tables:
name = table['Tables_in_%s' % self._dbname]
cur.execute("SHOW columns FROM %s;" % name)
result = cur.fetchall()
columns = []
key = []
for column in result:
columns.append(column['Field'])
if column['Key'].startswith('PRI'):
key.append(column['Field'])
setattr(self, name, Table(self, name, columns, key))
self.tables.add(name)
db.close()
def _sqlite(self, config):
try:
self._file = config.db.userdb_file
except AttributeError:
print 'No file specified for SQLite DB. The database will not be set up.'
return
try:
db = sqlite3.connect(self._file)
except:
print 'Error: Unable to connect to DB.'
print self._file
return
#Set up existing tables and columns
cur = db.cursor()
cur.execute("SELECT * FROM sqlite_master;")
tables = cur.fetchall()
for table in tables:
name = table[1]
if name.startswith('sqlite_'):
continue
cur.execute("PRAGMA table_info(%s);" % name)
result = cur.fetchall()
columns = []
key = []
for column in result:
columns.append(column[1])
if column[3]:
key.append(column[1])
setattr(self, name, Table(self, name, columns, key))
db.close()
def check_table(self, name, columns, key):
"""
Return ``True`` if the willieDB contains a table with the same ``name``
and ``key``, and which contains a column with the same name as each element
in the given list ``columns``.
"""
table = getattr(self, name)
return (isinstance(table, Table) and table.key == key and
all(c in table.columns for c in columns))
def _get_column_creation_text(self, columns, key=None):
cols = '('
for column in columns:
if isinstance(column, basestring):
if self.type == 'mysql':
cols = cols + column + ' VARCHAR(255)'
elif self.type == 'sqlite':
cols = cols + column + ' string'
elif isinstance(column, tuple):
cols += '%s %s' % column
if key and column in key:
cols += ' NOT NULL'
cols += ', '
if key:
if isinstance(key, basestring):
cols += 'PRIMARY KEY (%s)' % key
else:
cols += 'PRIMARY KEY (%s)' % ', '.join(key)
else:
cols = cols[:-2]
return cols + ')'
def add_table(self, name, columns, key):
"""
Add a column with the given ``name`` and ``key``, which has the given
``columns``. Each element in ``columns`` may be either a string giving
the name of the column, or a tuple containing the name of the column and
its type (using SQL type names). If the former, the type will be assumed
as string.
This will attempt to create the table within the database. If an error
is encountered while adding the table, it will not be added to the
willieDB object. If a table with the same name and key already exists,
the given columns will be added (if they don't already exist).
The given ``name`` can not be the same as any function or attribute
(with the exception of other tables) of the ``willieDB`` object, nor may
it start with ``'_'``. If it does not meet this requirement, or if the
``name`` matches that of an existing table with a different ``key``, a
``ValueError`` will be thrown.
When a table is created, the column ``key`` will be declared as the
primary key of the table. If it is desired that there be no primary key,
this can be achieved by creating the table manually, or with a custom
query, and then creating the willieDB object.
"""
# First, get the attribute with that name. It'll probably be a pseudo-
# table, but we want to know if the table already exists or if it's
# some other db attribute.
extant_table = getattr(self, name)
if name.startswith('_'): # exclude special names
raise ValueError('Invalid table name %s.' % name)
elif not isinstance(extant_table, Table):
#Conflict with a non-table value, probably a function
raise ValueError('Invalid table name %s.' % name)
elif not name in self.tables:
# We got a table, but it's not registered in the table list, so we
# create it.
cols = self._get_column_creation_text(columns, key)
db = self.connect()
cursor = db.cursor()
cursor.execute("CREATE TABLE %s %s;" % (name, cols))
db.close()
extant_table = Table(self, name, columns, key)
setattr(self, name, extant_table)
self.tables.add(name)
elif extant_table.key == key:
# We got an actual table. If the key on the table being created
# has the same key, it's safe to assume it's the one the user
# wanted, so if there are columns not already there, we add them.
if not all(c in extant_table.columns for c in columns):
db = self.connect()
cursor = db.cursor()
cursor.execute("ALTER TABLE %s ADD COLUMN %s;")
extant_table.colums.add(columns)
db.close()
else:
# There's already a different table with that name, which we can't
# fix, so raise an error.
raise ValueError('Table %s already exists with different key.'
% name)
def connect(self):
"""
Create a database connection object. This functions essentially the same
as the ``connect`` function of the appropriate database type, allowing
for custom queries to be executed.
"""
if self.type == 'mysql':
return MySQLdb.connect(host=self._host,
user=self._user,
passwd=self._passwd,
db=self._dbname)
elif self.type == 'sqlite':
return sqlite3.connect(self._file)
class Table(object):
"""
Return an object which represents a table in the given willieDB, with the
given attributes. This will not check if ``db`` already has a table with the
given ``name``; the ``db``'s ``add_table`` provides that functionality.
``key`` must be a string, which is in the list of strings ``columns``, or an
Exception will be thrown.
"""
def __init__(self, db, name, columns, key):
#This lets us have a pseudo-table to handle a non-existant table
if name is '_none':
self.db = db
self.columns = set()
self.name = name
self.key = '_none'
return
if not key:
key = columns[0]
if len(key) == 1:
key = key[0] # This catches strings, too, but without consequence.
self.db = db
self.columns = set(columns)
self.name = name
if isinstance(key, basestring):
if key not in columns:
raise Exception # TODO
self.key = key
else:
for k in key:
if k not in columns:
raise Exception # TODO
self.key = key
def __nonzero__(self):
return bool(self.columns)
def users(self):
"""
Returns the number of users (entries not starting with # or &) in the
table's ``key`` column.
"""
if not self.columns: # handle a non-existant table
return 0
db = self.db.connect()
cur = db.cursor()
cur.execute("SELECT COUNT(*) FROM " + self.name +
" WHERE " + self.key + " LIKE \"[^#&]%;")
result = int(cur.fetchone()[0])
db.close()
return result
def channels(self):
"""
Returns the number of users (entries starting with # or &) in the
table's ``key`` column.
"""
if not self.columns: # handle a non-existant table
return 0
db = self.db.connect()
cur = db.cursor()
cur.execute("SELECT COUNT(*) FROM " + self.name +
" WHERE " + self.key + " LIKE \"[#&]%;")
result = int(cur.fetchone()[0])
db.close()
return result
def size(self):
"""Returns the total number of rows in the table."""
if not self.columns: # handle a non-existant table
return 0
db = self.db.connect()
cur = db.cursor()
cur.execute("SELECT COUNT(*) FROM " + self.name + ";")
result = int(cur.fetchone()[0])
db.close()
return result
def _make_where_statement(self, key, row):
if isinstance(key, basestring):
key = [key]
where = []
for k in key:
where.append(k + ' = %s' % self.db.substitution)
return ' AND '.join(where) + ';'
def _get_one(self, row, value, key):
"""Implements get() for where values is a single string"""
if isinstance(row, basestring):
row = [row]
db = self.db.connect()
cur = db.cursor()
where = self._make_where_statement(key, row)
cur.execute(
'SELECT ' + value + ' FROM ' + self.name + ' WHERE ' + where, row)
result = cur.fetchone()
if result is None:
db.close()
raise KeyError(row + ' not in database')
db.close()
return result[0]
def _get_many(self, row, values, key):
"""Implements get() for where values is iterable"""
if isinstance(row, basestring):
row = [row]
db = self.db.connect()
cur = db.cursor()
values = ', '.join(values)
where = self._make_where_statement(key, row)
cur.execute(
'SELECT ' + values + ' FROM ' + self.name + ' WHERE ' + where, row)
row = cur.fetchone()
if row is None:
db.close()
raise KeyError(row + ' not in database')
db.close()
return row
def get(self, row, columns, key=None):
"""
Retrieve the value(s) in one or more ``columns`` in the row where the
``key`` column(s) match the value(s) given in ``row``. This is basically
equivalent to executing ``SELECT <columns> FROM <self> WHERE <key> =
<row>``.
The ``key`` can be either the name of one column as a string, or a tuple
of the names of multiple columns. ``row`` is the value or values of this
column or columns for which data will be retrieved. If multiple columns
are being used, the order in which the columns are presented should match
between ``row`` and ``key``. A ``KeyError`` will be raised if no have
values matching ``row`` in ``key``. If ``key`` is not passed, it will
default to the table's primary key.
``columns`` can either be a single column name, or a tuple of column
names. If one name is passed, a single string will be returned. If a
tuple of names is passed, the return value will be a tuple in the same
order.
""" # TODO this documentation could be better.
if not self.columns: # handle a non-existant table
return None
if not key:
key = self.key
if not (isinstance(row, basestring) and isinstance(key, basestring)):
if not len(row) == len(key):
raise ValueError('Unequal number of key and row columns.')
if isinstance(columns, basestring):
return self._get_one(row, columns, key)
elif isinstance(columns, Iterable):
return self._get_many(row, columns, key)
def update(self, row, values, key=None):
"""
Update the row where the values in ``row`` match the ``key`` columns.
If the row does not exist, it will be created. The same rules regarding
the type and length of ``key`` and ``row`` apply for ``update`` as for
``get``.
The given ``values`` must be a dict of column name to new value.
"""
if not self.columns: # handle a non-existant table
raise ValueError('Table is empty.')
if isinstance(row, basestring):
rowl = [row]
else:
rowl = row
if not key:
key = self.key
db = self.db.connect()
cur = db.cursor()
where = self._make_where_statement(key, row)
cur.execute('SELECT * FROM ' + self.name + ' WHERE ' + where, rowl)
if not cur.fetchone():
vals = '"' + row + '"'
for k in values:
key = key + ', ' + k
vals = vals + ', "' + values[k] + '"'
command = ('INSERT INTO ' + self.name + ' (' + key + ') VALUES (' +
vals + ');')
else:
command = 'UPDATE ' + self.name + ' SET '
for k in values:
command = command + k + '="' + values[k] + '", '
command = command[:-2] + ' WHERE ' + key + ' = "' + row + '";'
cur.execute(command)
db.commit()
db.close()
def delete(self, row, key=None):
"""Deletes the row for ``row`` in the database, removing its values in
all columns."""
if not self.columns: # handle a non-existant table
raise KeyError('Table is empty.')
if isinstance(row, basestring):
row = [row]
if not key:
key = self.key
db = self.db.connect()
cur = db.cursor()
where = self._make_where_statement(key, row)
cur.execute('SELECT * FROM ' + self.name + ' WHERE ' + where, row)
if not cur.fetchone():
db.close()
raise KeyError(key + ' not in database')
cur.execute('DELETE FROM ' + self.name + ' WHERE ' + where, row)
db.commit()
db.close()
def keys(self, key=None):
"""
Return an iterator over the keys and values in the table.
In a for each loop, you can use ``for key in table:``, where key will be
the value of the ``key`` column(s), which defaults to the primary key,
and table is the Table. This may be deprecated in future versions.
"""
if not self.columns: # handle a non-existant table
raise KeyError('Table is empty.')
if not key:
key = self.key
db = self.db.connect()
cur = db.cursor()
cur.execute('SELECT ' + key + ' FROM ' + self.name + '')
result = cur.fetchall()
db.close()
return result
def __iter__(self):
return self.keys()
def contains(self, row, key=None):
"""
Return ``True`` if this table has a row where the key value is equal to
``key``, else ``False``.
``key in db`` will also work, where db is your SettingsDB object.
"""
if not self.columns: # handle a non-existant table
return False
if not key:
key = self.key
db = self.db.connect()
cur = db.cursor()
where = self._make_where_statement(key, row)
cur.execute('SELECT * FROM ' + self.name + ' WHERE ' + where, [row])
result = cur.fetchone()
db.close()
if result:
return True
else:
return False
def __contains__(self, item):
return self.contains(item)
@deprecated
def hascolumn(self, column):
return self.has_columns(column)
@deprecated
def hascolumns(self, column):
return self.has_columns(column)
def has_columns(self, column):
"""
Each Table contains a cached list of its columns. ``hascolumn(column)``
checks this list, and returns True if it contains ``column``. If
``column`` is an iterable, this returns true if all of the values in
``column`` are in the column cache. Note that this will not check the
database itself; it's meant for speed, not accuracy. However, unless
you have multiple bots using the same database, or are adding columns
while the bot is running, you are unlikely to encounter errors.
"""
if not self.columns: # handle a non-existant table
return False
if isinstance(column, basestring):
return column in self.columns
elif isinstance(column, Iterable):
has = True
for col in column:
has = col in self.columns and has
return has
@deprecated
def addcolumns(self, columns):
return self.add_columns(columns)
def add_columns(self, columns):
"""
Insert a new column into the table, and add it to the column cache.
This is the preferred way to add new columns to the database.
"""
if not self.columns: # handle a non-existant table
raise ValueError('Table is empty.')
#I feel like adding one at a time is weird, but it works.
db = self.db.connect()
for column in columns:
cmd = 'ALTER TABLE ' + self.name + ' ADD '
if isinstance(column, tuple):
cmd = cmd + column[0] + ' ' + column[1] + ';'
else:
cmd = cmd + column + ' text;'
cur = db.cursor()
cur.execute(cmd)
db.commit()
db.close()
#Why a second loop? because I don't want clomuns to be added to self.columns if executing the SQL command fails
for column in columns:
self.columns.add(column)
def configure(config):
"""
Interactively create configuration options and add the attributes to
the Config object ``config``.
"""
config.add_section('db')
config.interactive_add('db', 'userdb_type',
'What type of database would you like to use? (mysql/sqlite)', 'mysql')
if config.db.userdb_type == 'sqlite':
config.interactive_add('db', 'userdb_file', 'Location for the database file')
elif config.db.userdb_type == 'mysql':
config.interactive_add('db', 'userdb_host', "Enter the MySQL hostname", 'localhost')
config.interactive_add('db', 'userdb_user', "Enter the MySQL username")
config.interactive_add('db', 'userdb_pass', "Enter the user's password", 'none')
config.interactive_add('db', 'userdb_name', "Enter the name of the database to use")
else:
print "This isn't currently supported. Aborting."