Mercurial > p > roundup > code
comparison roundup/backends/back_gadfly.py @ 968:07d8a4e296f8
Whee! It's not finished yet, but I can create a new instance...
...and play with it a little bit :)
| author | Richard Jones <richard@users.sourceforge.net> |
|---|---|
| date | Thu, 22 Aug 2002 07:56:51 +0000 |
| parents | |
| children | ca0a542b2d19 |
comparison
equal
deleted
inserted
replaced
| 967:dd35bab19dd9 | 968:07d8a4e296f8 |
|---|---|
| 1 # $Id: back_gadfly.py,v 1.1 2002-08-22 07:56:51 richard Exp $ | |
| 2 __doc__ = ''' | |
| 3 About Gadfly | |
| 4 ============ | |
| 5 | |
| 6 Gadfly is a collection of python modules that provides relational | |
| 7 database functionality entirely implemented in Python. It supports a | |
| 8 subset of the intergalactic standard RDBMS Structured Query Language | |
| 9 SQL. | |
| 10 | |
| 11 | |
| 12 Basic Structure | |
| 13 =============== | |
| 14 | |
| 15 We map roundup classes to relational tables. Automatically detect schema | |
| 16 changes and modify the gadfly table schemas appropriately. Multilinks | |
| 17 (which represent a many-to-many relationship) are handled through | |
| 18 intermediate tables. | |
| 19 | |
| 20 Journals are stored adjunct to the per-class tables. | |
| 21 | |
| 22 Table columns for properties have "_" prepended so the names can't | |
| 23 clash with restricted names (like "order"). Retirement is determined by the | |
| 24 __retired__ column being true. | |
| 25 | |
| 26 All columns are defined as VARCHAR, since it really doesn't matter what | |
| 27 type they're defined as. We stuff all kinds of data in there ;) [as long as | |
| 28 it's marshallable, gadfly doesn't care] | |
| 29 | |
| 30 | |
| 31 Additional Instance Requirements | |
| 32 ================================ | |
| 33 | |
| 34 The instance configuration must specify where the database is. It does this | |
| 35 with GADFLY_DATABASE, which is used as the arguments to the gadfly.gadfly() | |
| 36 method: | |
| 37 | |
| 38 Using an on-disk database directly (not a good idea): | |
| 39 GADFLY_DATABASE = (database name, directory) | |
| 40 | |
| 41 Using a network database (much better idea): | |
| 42 GADFLY_DATABASE = (policy, password, address, port) | |
| 43 | |
| 44 Because multiple accesses directly to a gadfly database aren't handled, but | |
| 45 multiple network accesses are, it's strongly advised that the latter setup be | |
| 46 used. | |
| 47 | |
| 48 ''' | |
| 49 | |
| 50 # standard python modules | |
| 51 import sys, os, time, re, errno, weakref | |
| 52 | |
| 53 # roundup modules | |
| 54 from roundup import hyperdb, date, password, roundupdb, security | |
| 55 from roundup.hyperdb import String, Password, Date, Interval, Link, \ | |
| 56 Multilink, DatabaseError, Boolean, Number | |
| 57 | |
| 58 # the all-important gadfly :) | |
| 59 import gadfly | |
| 60 from gadfly import client | |
| 61 | |
| 62 # support | |
| 63 from blobfiles import FileStorage | |
| 64 from roundup.indexer import Indexer | |
| 65 from sessions import Sessions | |
| 66 | |
| 67 class Database(FileStorage, hyperdb.Database, roundupdb.Database): | |
| 68 # flag to set on retired entries | |
| 69 RETIRED_FLAG = '__hyperdb_retired' | |
| 70 | |
| 71 def __init__(self, config, journaltag=None): | |
| 72 ''' Open the database and load the schema from it. | |
| 73 ''' | |
| 74 self.config, self.journaltag = config, journaltag | |
| 75 self.dir = config.DATABASE | |
| 76 self.classes = {} | |
| 77 self.indexer = Indexer(self.dir) | |
| 78 self.sessions = Sessions(self.config) | |
| 79 self.security = security.Security(self) | |
| 80 | |
| 81 db = config.GADFLY_DATABASE | |
| 82 if len(db) == 2: | |
| 83 # ensure files are group readable and writable | |
| 84 os.umask(0002) | |
| 85 try: | |
| 86 self.conn = gadfly.gadfly(*db) | |
| 87 except IOError, error: | |
| 88 if error.errno != errno.ENOENT: | |
| 89 raise | |
| 90 self.database_schema = {} | |
| 91 self.conn = gadfly.gadfly() | |
| 92 self.conn.startup(*db) | |
| 93 cursor = self.conn.cursor() | |
| 94 cursor.execute('create table schema (schema varchar)') | |
| 95 cursor.execute('create table ids (name varchar, num integer)') | |
| 96 else: | |
| 97 cursor = self.conn.cursor() | |
| 98 cursor.execute('select schema from schema') | |
| 99 self.database_schema = cursor.fetchone()[0] | |
| 100 else: | |
| 101 self.conn = client.gfclient(*db) | |
| 102 cursor = self.conn.cursor() | |
| 103 cursor.execute('select schema from schema') | |
| 104 self.database_schema = cursor.fetchone()[0] | |
| 105 | |
| 106 def post_init(self): | |
| 107 ''' Called once the schema initialisation has finished. | |
| 108 | |
| 109 We should now confirm that the schema defined by our "classes" | |
| 110 attribute actually matches the schema in the database. | |
| 111 ''' | |
| 112 for classname, spec in self.classes.items(): | |
| 113 if self.database_schema.has_key(classname): | |
| 114 dbspec = self.database_schema[classname] | |
| 115 self.update_class(spec.schema(), dbspec) | |
| 116 self.database_schema[classname] = dbspec | |
| 117 else: | |
| 118 self.create_class(spec) | |
| 119 self.database_schema[classname] = spec.schema() | |
| 120 | |
| 121 for classname in self.database_schema.keys(): | |
| 122 if not self.classes.has_key(classname): | |
| 123 self.drop_class(classname) | |
| 124 | |
| 125 # commit any changes | |
| 126 cursor = self.conn.cursor() | |
| 127 cursor.execute('delete from schema') | |
| 128 cursor.execute('insert into schema values (?)', (self.database_schema,)) | |
| 129 self.conn.commit() | |
| 130 | |
| 131 def determine_columns(self, spec): | |
| 132 ''' Figure the column names and multilink properties from the spec | |
| 133 ''' | |
| 134 cols = [] | |
| 135 mls = [] | |
| 136 # add the multilinks separately | |
| 137 for col, prop in spec.properties.items(): | |
| 138 if isinstance(prop, Multilink): | |
| 139 mls.append(col) | |
| 140 else: | |
| 141 cols.append('_'+col) | |
| 142 cols.sort() | |
| 143 return cols, mls | |
| 144 | |
| 145 def update_class(self, spec, dbspec): | |
| 146 ''' Determine the differences between the current spec and the | |
| 147 database version of the spec, and update where necessary | |
| 148 ''' | |
| 149 if spec == dbspec: | |
| 150 return | |
| 151 raise NotImplementedError | |
| 152 | |
| 153 def create_class(self, spec): | |
| 154 ''' Create a database table according to the given spec. | |
| 155 ''' | |
| 156 cols, mls = self.determine_columns(spec) | |
| 157 | |
| 158 # add on our special columns | |
| 159 cols.append('id') | |
| 160 cols.append('__retired__') | |
| 161 | |
| 162 cursor = self.conn.cursor() | |
| 163 | |
| 164 # create the base table | |
| 165 cols = ','.join(['%s varchar'%x for x in cols]) | |
| 166 sql = 'create table %s (%s)'%(spec.classname, cols) | |
| 167 if __debug__: | |
| 168 print >>hyperdb.DEBUG, 'create_class', (self, sql) | |
| 169 cursor.execute(sql) | |
| 170 | |
| 171 # journal table | |
| 172 cols = ','.join(['%s varchar'%x | |
| 173 for x in 'nodeid date tag action params'.split()]) | |
| 174 sql = 'create table %s__journal (%s)'%(spec.classname, cols) | |
| 175 if __debug__: | |
| 176 print >>hyperdb.DEBUG, 'create_class', (self, sql) | |
| 177 cursor.execute(sql) | |
| 178 | |
| 179 # now create the multilink tables | |
| 180 for ml in mls: | |
| 181 sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%( | |
| 182 spec.classname, ml) | |
| 183 if __debug__: | |
| 184 print >>hyperdb.DEBUG, 'create_class', (self, sql) | |
| 185 cursor.execute(sql) | |
| 186 | |
| 187 # ID counter | |
| 188 sql = 'insert into ids (name, num) values (?,?)' | |
| 189 vals = (spec.classname, 1) | |
| 190 if __debug__: | |
| 191 print >>hyperdb.DEBUG, 'create_class', (self, sql, vals) | |
| 192 cursor.execute(sql, vals) | |
| 193 | |
| 194 def drop_class(self, spec): | |
| 195 ''' Drop the given table from the database. | |
| 196 | |
| 197 Drop the journal and multilink tables too. | |
| 198 ''' | |
| 199 # figure the multilinks | |
| 200 mls = [] | |
| 201 for col, prop in spec.properties.items(): | |
| 202 if isinstance(prop, Multilink): | |
| 203 mls.append(col) | |
| 204 cursor = self.conn.cursor() | |
| 205 | |
| 206 sql = 'drop table %s'%spec.classname | |
| 207 if __debug__: | |
| 208 print >>hyperdb.DEBUG, 'drop_class', (self, sql) | |
| 209 cursor.execute(sql) | |
| 210 | |
| 211 sql = 'drop table %s__journal'%spec.classname | |
| 212 if __debug__: | |
| 213 print >>hyperdb.DEBUG, 'drop_class', (self, sql) | |
| 214 cursor.execute(sql) | |
| 215 | |
| 216 for ml in mls: | |
| 217 sql = 'drop table %s_%s'%(spec.classname, ml) | |
| 218 if __debug__: | |
| 219 print >>hyperdb.DEBUG, 'drop_class', (self, sql) | |
| 220 cursor.execute(sql) | |
| 221 | |
| 222 # | |
| 223 # Classes | |
| 224 # | |
| 225 def __getattr__(self, classname): | |
| 226 ''' A convenient way of calling self.getclass(classname). | |
| 227 ''' | |
| 228 if self.classes.has_key(classname): | |
| 229 if __debug__: | |
| 230 print >>hyperdb.DEBUG, '__getattr__', (self, classname) | |
| 231 return self.classes[classname] | |
| 232 raise AttributeError, classname | |
| 233 | |
| 234 def addclass(self, cl): | |
| 235 ''' Add a Class to the hyperdatabase. | |
| 236 ''' | |
| 237 if __debug__: | |
| 238 print >>hyperdb.DEBUG, 'addclass', (self, cl) | |
| 239 cn = cl.classname | |
| 240 if self.classes.has_key(cn): | |
| 241 raise ValueError, cn | |
| 242 self.classes[cn] = cl | |
| 243 | |
| 244 def getclasses(self): | |
| 245 ''' Return a list of the names of all existing classes. | |
| 246 ''' | |
| 247 if __debug__: | |
| 248 print >>hyperdb.DEBUG, 'getclasses', (self,) | |
| 249 l = self.classes.keys() | |
| 250 l.sort() | |
| 251 return l | |
| 252 | |
| 253 def getclass(self, classname): | |
| 254 '''Get the Class object representing a particular class. | |
| 255 | |
| 256 If 'classname' is not a valid class name, a KeyError is raised. | |
| 257 ''' | |
| 258 if __debug__: | |
| 259 print >>hyperdb.DEBUG, 'getclass', (self, classname) | |
| 260 return self.classes[classname] | |
| 261 | |
| 262 def clear(self): | |
| 263 ''' Delete all database contents. | |
| 264 | |
| 265 Note: I don't commit here, which is different behaviour to the | |
| 266 "nuke from orbit" behaviour in the *dbms. | |
| 267 ''' | |
| 268 if __debug__: | |
| 269 print >>hyperdb.DEBUG, 'clear', (self,) | |
| 270 cursor = self.conn.cursor() | |
| 271 for cn in self.classes.keys(): | |
| 272 sql = 'delete from %s'%cn | |
| 273 if __debug__: | |
| 274 print >>hyperdb.DEBUG, 'clear', (self, sql) | |
| 275 cursor.execute(sql) | |
| 276 | |
| 277 # | |
| 278 # Node IDs | |
| 279 # | |
| 280 def newid(self, classname): | |
| 281 ''' Generate a new id for the given class | |
| 282 ''' | |
| 283 # get the next ID | |
| 284 cursor = self.conn.cursor() | |
| 285 sql = 'select num from ids where name=?' | |
| 286 if __debug__: | |
| 287 print >>hyperdb.DEBUG, 'newid', (self, sql, classname) | |
| 288 cursor.execute(sql, (classname, )) | |
| 289 newid = cursor.fetchone()[0] | |
| 290 | |
| 291 # update the counter | |
| 292 sql = 'update ids set num=? where name=?' | |
| 293 vals = (newid+1, classname) | |
| 294 if __debug__: | |
| 295 print >>hyperdb.DEBUG, 'newid', (self, sql, vals) | |
| 296 cursor.execute(sql, vals) | |
| 297 | |
| 298 # return as string | |
| 299 return str(newid) | |
| 300 | |
| 301 def setid(self, classname, setid): | |
| 302 ''' Set the id counter: used during import of database | |
| 303 ''' | |
| 304 cursor = self.conn.cursor() | |
| 305 sql = 'update ids set num=? where name=?' | |
| 306 vals = (setid, spec.classname) | |
| 307 if __debug__: | |
| 308 print >>hyperdb.DEBUG, 'setid', (self, sql, vals) | |
| 309 cursor.execute(sql, vals) | |
| 310 | |
| 311 # | |
| 312 # Nodes | |
| 313 # | |
| 314 | |
| 315 def addnode(self, classname, nodeid, node): | |
| 316 ''' Add the specified node to its class's db. | |
| 317 ''' | |
| 318 # gadfly requires values for all non-multilink columns | |
| 319 cl = self.classes[classname] | |
| 320 cols, mls = self.determine_columns(cl) | |
| 321 | |
| 322 # default the non-multilink columns | |
| 323 for col, prop in cl.properties.items(): | |
| 324 if not isinstance(col, Multilink): | |
| 325 if not node.has_key(col): | |
| 326 node[col] = None | |
| 327 | |
| 328 node = self.serialise(classname, node) | |
| 329 | |
| 330 # make sure the ordering is correct for column name -> column value | |
| 331 vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0) | |
| 332 s = ','.join(['?' for x in cols]) + ',?,?' | |
| 333 cols = ','.join(cols) + ',id,__retired__' | |
| 334 | |
| 335 # perform the inserts | |
| 336 cursor = self.conn.cursor() | |
| 337 sql = 'insert into %s (%s) values (%s)'%(classname, cols, s) | |
| 338 if __debug__: | |
| 339 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals) | |
| 340 cursor.execute(sql, vals) | |
| 341 | |
| 342 # insert the multilink rows | |
| 343 for col in mls: | |
| 344 t = '%s_%s'%(classname, col) | |
| 345 for entry in node[col]: | |
| 346 sql = 'insert into %s (linkid, nodeid) values (?,?)'%t | |
| 347 vals = (entry, nodeid) | |
| 348 if __debug__: | |
| 349 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals) | |
| 350 cursor.execute(sql, vals) | |
| 351 | |
| 352 def setnode(self, classname, nodeid, node): | |
| 353 ''' Change the specified node. | |
| 354 ''' | |
| 355 node = self.serialise(classname, node) | |
| 356 | |
| 357 cl = self.classes[classname] | |
| 358 cols = [] | |
| 359 mls = [] | |
| 360 # add the multilinks separately | |
| 361 for col in node.keys(): | |
| 362 prop = cl.properties[col] | |
| 363 if isinstance(prop, Multilink): | |
| 364 mls.append(col) | |
| 365 else: | |
| 366 cols.append('_'+col) | |
| 367 cols.sort() | |
| 368 | |
| 369 # make sure the ordering is correct for column name -> column value | |
| 370 vals = tuple([node[col[1:]] for col in cols]) | |
| 371 s = ','.join(['?' for x in cols]) | |
| 372 cols = ','.join(cols) | |
| 373 | |
| 374 # perform the update | |
| 375 cursor = self.conn.cursor() | |
| 376 sql = 'update %s (%s) values (%s)'%(classname, cols, s) | |
| 377 if __debug__: | |
| 378 print >>hyperdb.DEBUG, 'setnode', (self, sql, vals) | |
| 379 cursor.execute(sql, vals) | |
| 380 | |
| 381 # now the fun bit, updating the multilinks ;) | |
| 382 # XXX TODO XXX | |
| 383 | |
| 384 def getnode(self, classname, nodeid): | |
| 385 ''' Get a node from the database. | |
| 386 ''' | |
| 387 # figure the columns we're fetching | |
| 388 cl = self.classes[classname] | |
| 389 cols, mls = self.determine_columns(cl) | |
| 390 cols = ','.join(cols) | |
| 391 | |
| 392 # perform the basic property fetch | |
| 393 cursor = self.conn.cursor() | |
| 394 sql = 'select %s from %s where id=?'%(cols, classname) | |
| 395 if __debug__: | |
| 396 print >>hyperdb.DEBUG, 'getnode', (self, sql, nodeid) | |
| 397 cursor.execute(sql, (nodeid,)) | |
| 398 values = cursor.fetchone() | |
| 399 | |
| 400 # make up the node | |
| 401 node = {} | |
| 402 for col in range(len(cols)): | |
| 403 node[col] = values[col] | |
| 404 | |
| 405 # now the multilinks | |
| 406 for col in mls: | |
| 407 # get the link ids | |
| 408 sql = 'select linkid from %s_%s where nodeid=?'%(classname, col) | |
| 409 if __debug__: | |
| 410 print >>hyperdb.DEBUG, 'getnode', (self, sql, nodeid) | |
| 411 cursor.execute(sql, (nodeid,)) | |
| 412 # extract the first column from the result | |
| 413 node[col] = [x[0] for x in cursor.fetchall()] | |
| 414 | |
| 415 return self.unserialise(classname, node) | |
| 416 | |
| 417 def serialise(self, classname, node): | |
| 418 '''Copy the node contents, converting non-marshallable data into | |
| 419 marshallable data. | |
| 420 ''' | |
| 421 if __debug__: | |
| 422 print >>hyperdb.DEBUG, 'serialise', classname, node | |
| 423 properties = self.getclass(classname).getprops() | |
| 424 d = {} | |
| 425 for k, v in node.items(): | |
| 426 # if the property doesn't exist, or is the "retired" flag then | |
| 427 # it won't be in the properties dict | |
| 428 if not properties.has_key(k): | |
| 429 d[k] = v | |
| 430 continue | |
| 431 | |
| 432 # get the property spec | |
| 433 prop = properties[k] | |
| 434 | |
| 435 if isinstance(prop, Password): | |
| 436 d[k] = str(v) | |
| 437 elif isinstance(prop, Date) and v is not None: | |
| 438 d[k] = v.serialise() | |
| 439 elif isinstance(prop, Interval) and v is not None: | |
| 440 d[k] = v.serialise() | |
| 441 else: | |
| 442 d[k] = v | |
| 443 return d | |
| 444 | |
| 445 def unserialise(self, classname, node): | |
| 446 '''Decode the marshalled node data | |
| 447 ''' | |
| 448 if __debug__: | |
| 449 print >>hyperdb.DEBUG, 'unserialise', classname, node | |
| 450 properties = self.getclass(classname).getprops() | |
| 451 d = {} | |
| 452 for k, v in node.items(): | |
| 453 # if the property doesn't exist, or is the "retired" flag then | |
| 454 # it won't be in the properties dict | |
| 455 if not properties.has_key(k): | |
| 456 d[k] = v | |
| 457 continue | |
| 458 | |
| 459 # get the property spec | |
| 460 prop = properties[k] | |
| 461 | |
| 462 if isinstance(prop, Date) and v is not None: | |
| 463 d[k] = date.Date(v) | |
| 464 elif isinstance(prop, Interval) and v is not None: | |
| 465 d[k] = date.Interval(v) | |
| 466 elif isinstance(prop, Password): | |
| 467 p = password.Password() | |
| 468 p.unpack(v) | |
| 469 d[k] = p | |
| 470 else: | |
| 471 d[k] = v | |
| 472 return d | |
| 473 | |
| 474 def hasnode(self, classname, nodeid): | |
| 475 ''' Determine if the database has a given node. | |
| 476 ''' | |
| 477 cursor = self.conn.cursor() | |
| 478 sql = 'select count(*) from %s where nodeid=?'%classname | |
| 479 if __debug__: | |
| 480 print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid) | |
| 481 cursor.execute(sql, (nodeid,)) | |
| 482 return cursor.fetchone()[0] | |
| 483 | |
| 484 def countnodes(self, classname): | |
| 485 ''' Count the number of nodes that exist for a particular Class. | |
| 486 ''' | |
| 487 cursor = self.conn.cursor() | |
| 488 sql = 'select count(*) from %s'%classname | |
| 489 if __debug__: | |
| 490 print >>hyperdb.DEBUG, 'countnodes', (self, sql) | |
| 491 cursor.execute(sql) | |
| 492 return cursor.fetchone()[0] | |
| 493 | |
| 494 def getnodeids(self, classname): | |
| 495 ''' Retrieve all the ids of the nodes for a particular Class. | |
| 496 ''' | |
| 497 cursor = self.conn.cursor() | |
| 498 sql = 'select id from %s'%classname | |
| 499 if __debug__: | |
| 500 print >>hyperdb.DEBUG, 'getnodeids', (self, sql) | |
| 501 cursor.execute(sql) | |
| 502 return [x[0] for x in cursor.fetchall()] | |
| 503 | |
| 504 def addjournal(self, classname, nodeid, action, params): | |
| 505 ''' Journal the Action | |
| 506 'action' may be: | |
| 507 | |
| 508 'create' or 'set' -- 'params' is a dictionary of property values | |
| 509 'link' or 'unlink' -- 'params' is (classname, nodeid, propname) | |
| 510 'retire' -- 'params' is None | |
| 511 ''' | |
| 512 if isinstance(params, type({})): | |
| 513 if params.has_key('creator'): | |
| 514 journaltag = self.user.get(params['creator'], 'username') | |
| 515 del params['creator'] | |
| 516 else: | |
| 517 journaltag = self.journaltag | |
| 518 if params.has_key('created'): | |
| 519 journaldate = params['created'].serialise() | |
| 520 del params['created'] | |
| 521 else: | |
| 522 journaldate = date.Date().serialise() | |
| 523 if params.has_key('activity'): | |
| 524 del params['activity'] | |
| 525 | |
| 526 # serialise the parameters now | |
| 527 if action in ('set', 'create'): | |
| 528 params = self.serialise(classname, params) | |
| 529 else: | |
| 530 journaltag = self.journaltag | |
| 531 journaldate = date.Date().serialise() | |
| 532 | |
| 533 # create the journal entry | |
| 534 cols = ','.join('nodeid date tag action params'.split()) | |
| 535 entry = (nodeid, journaldate, journaltag, action, params) | |
| 536 | |
| 537 if __debug__: | |
| 538 print >>hyperdb.DEBUG, 'doSaveJournal', entry | |
| 539 | |
| 540 # do the insert | |
| 541 cursor = self.conn.cursor() | |
| 542 sql = 'insert into %s__journal (%s) values (?,?,?,?,?)'%(classname, | |
| 543 cols) | |
| 544 if __debug__: | |
| 545 print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry) | |
| 546 cursor.execute(sql, entry) | |
| 547 | |
| 548 def getjournal(self, classname, nodeid): | |
| 549 ''' get the journal for id | |
| 550 ''' | |
| 551 cols = ','.join('nodeid date tag action params'.split()) | |
| 552 cursor = self.conn.cursor() | |
| 553 sql = 'select %s from %s__journal where nodeid=?'%(cols, classname) | |
| 554 if __debug__: | |
| 555 print >>hyperdb.DEBUG, 'getjournal', (self, sql, nodeid) | |
| 556 cursor.execute(sql, (nodeid,)) | |
| 557 res = [] | |
| 558 for nodeid, date_stamp, user, action, params in cursor.fetchall(): | |
| 559 res.append((nodeid, date.Date(date_stamp), user, action, params)) | |
| 560 return res | |
| 561 | |
| 562 def pack(self, pack_before): | |
| 563 ''' Pack the database, removing all journal entries before the | |
| 564 "pack_before" date. | |
| 565 ''' | |
| 566 # get a 'yyyymmddhhmmss' version of the date | |
| 567 date_stamp = pack_before.serialise() | |
| 568 | |
| 569 # do the delete | |
| 570 cursor = self.conn.cursor() | |
| 571 for classname in self.classes.keys(): | |
| 572 sql = 'delete from %s__journal where date<?'%classname | |
| 573 if __debug__: | |
| 574 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp) | |
| 575 cursor.execute(sql, (date_stamp,)) | |
| 576 | |
| 577 def commit(self): | |
| 578 ''' Commit the current transactions. | |
| 579 | |
| 580 Save all data changed since the database was opened or since the | |
| 581 last commit() or rollback(). | |
| 582 ''' | |
| 583 self.conn.commit() | |
| 584 | |
| 585 def rollback(self): | |
| 586 ''' Reverse all actions from the current transaction. | |
| 587 | |
| 588 Undo all the changes made since the database was opened or the last | |
| 589 commit() or rollback() was performed. | |
| 590 ''' | |
| 591 self.conn.rollback() | |
| 592 | |
| 593 # | |
| 594 # The base Class class | |
| 595 # | |
| 596 class Class(hyperdb.Class): | |
| 597 ''' The handle to a particular class of nodes in a hyperdatabase. | |
| 598 | |
| 599 All methods except __repr__ and getnode must be implemented by a | |
| 600 concrete backend Class. | |
| 601 ''' | |
| 602 | |
| 603 def __init__(self, db, classname, **properties): | |
| 604 '''Create a new class with a given name and property specification. | |
| 605 | |
| 606 'classname' must not collide with the name of an existing class, | |
| 607 or a ValueError is raised. The keyword arguments in 'properties' | |
| 608 must map names to property objects, or a TypeError is raised. | |
| 609 ''' | |
| 610 if (properties.has_key('creation') or properties.has_key('activity') | |
| 611 or properties.has_key('creator')): | |
| 612 raise ValueError, '"creation", "activity" and "creator" are '\ | |
| 613 'reserved' | |
| 614 | |
| 615 self.classname = classname | |
| 616 self.properties = properties | |
| 617 self.db = weakref.proxy(db) # use a weak ref to avoid circularity | |
| 618 self.key = '' | |
| 619 | |
| 620 # should we journal changes (default yes) | |
| 621 self.do_journal = 1 | |
| 622 | |
| 623 # do the db-related init stuff | |
| 624 db.addclass(self) | |
| 625 | |
| 626 self.auditors = {'create': [], 'set': [], 'retire': []} | |
| 627 self.reactors = {'create': [], 'set': [], 'retire': []} | |
| 628 | |
| 629 def schema(self): | |
| 630 ''' A dumpable version of the schema that we can store in the | |
| 631 database | |
| 632 ''' | |
| 633 return (self.key, [(x, repr(y)) for x,y in self.properties.items()]) | |
| 634 | |
| 635 def enableJournalling(self): | |
| 636 '''Turn journalling on for this class | |
| 637 ''' | |
| 638 self.do_journal = 1 | |
| 639 | |
| 640 def disableJournalling(self): | |
| 641 '''Turn journalling off for this class | |
| 642 ''' | |
| 643 self.do_journal = 0 | |
| 644 | |
| 645 # Editing nodes: | |
| 646 def create(self, **propvalues): | |
| 647 ''' Create a new node of this class and return its id. | |
| 648 | |
| 649 The keyword arguments in 'propvalues' map property names to values. | |
| 650 | |
| 651 The values of arguments must be acceptable for the types of their | |
| 652 corresponding properties or a TypeError is raised. | |
| 653 | |
| 654 If this class has a key property, it must be present and its value | |
| 655 must not collide with other key strings or a ValueError is raised. | |
| 656 | |
| 657 Any other properties on this class that are missing from the | |
| 658 'propvalues' dictionary are set to None. | |
| 659 | |
| 660 If an id in a link or multilink property does not refer to a valid | |
| 661 node, an IndexError is raised. | |
| 662 ''' | |
| 663 if propvalues.has_key('id'): | |
| 664 raise KeyError, '"id" is reserved' | |
| 665 | |
| 666 if self.db.journaltag is None: | |
| 667 raise DatabaseError, 'Database open read-only' | |
| 668 | |
| 669 if propvalues.has_key('creation') or propvalues.has_key('activity'): | |
| 670 raise KeyError, '"creation" and "activity" are reserved' | |
| 671 | |
| 672 self.fireAuditors('create', None, propvalues) | |
| 673 | |
| 674 # new node's id | |
| 675 newid = self.db.newid(self.classname) | |
| 676 | |
| 677 # validate propvalues | |
| 678 num_re = re.compile('^\d+$') | |
| 679 for key, value in propvalues.items(): | |
| 680 if key == self.key: | |
| 681 try: | |
| 682 self.lookup(value) | |
| 683 except KeyError: | |
| 684 pass | |
| 685 else: | |
| 686 raise ValueError, 'node with key "%s" exists'%value | |
| 687 | |
| 688 # try to handle this property | |
| 689 try: | |
| 690 prop = self.properties[key] | |
| 691 except KeyError: | |
| 692 raise KeyError, '"%s" has no property "%s"'%(self.classname, | |
| 693 key) | |
| 694 | |
| 695 if value is not None and isinstance(prop, Link): | |
| 696 if type(value) != type(''): | |
| 697 raise ValueError, 'link value must be String' | |
| 698 link_class = self.properties[key].classname | |
| 699 # if it isn't a number, it's a key | |
| 700 if not num_re.match(value): | |
| 701 try: | |
| 702 value = self.db.classes[link_class].lookup(value) | |
| 703 except (TypeError, KeyError): | |
| 704 raise IndexError, 'new property "%s": %s not a %s'%( | |
| 705 key, value, link_class) | |
| 706 elif not self.db.getclass(link_class).hasnode(value): | |
| 707 raise IndexError, '%s has no node %s'%(link_class, value) | |
| 708 | |
| 709 # save off the value | |
| 710 propvalues[key] = value | |
| 711 | |
| 712 # register the link with the newly linked node | |
| 713 if self.do_journal and self.properties[key].do_journal: | |
| 714 self.db.addjournal(link_class, value, 'link', | |
| 715 (self.classname, newid, key)) | |
| 716 | |
| 717 elif isinstance(prop, Multilink): | |
| 718 if type(value) != type([]): | |
| 719 raise TypeError, 'new property "%s" not a list of ids'%key | |
| 720 | |
| 721 # clean up and validate the list of links | |
| 722 link_class = self.properties[key].classname | |
| 723 l = [] | |
| 724 for entry in value: | |
| 725 if type(entry) != type(''): | |
| 726 raise ValueError, '"%s" link value (%s) must be '\ | |
| 727 'String'%(key, value) | |
| 728 # if it isn't a number, it's a key | |
| 729 if not num_re.match(entry): | |
| 730 try: | |
| 731 entry = self.db.classes[link_class].lookup(entry) | |
| 732 except (TypeError, KeyError): | |
| 733 raise IndexError, 'new property "%s": %s not a %s'%( | |
| 734 key, entry, self.properties[key].classname) | |
| 735 l.append(entry) | |
| 736 value = l | |
| 737 propvalues[key] = value | |
| 738 | |
| 739 # handle additions | |
| 740 for nodeid in value: | |
| 741 if not self.db.getclass(link_class).hasnode(nodeid): | |
| 742 raise IndexError, '%s has no node %s'%(link_class, | |
| 743 nodeid) | |
| 744 # register the link with the newly linked node | |
| 745 if self.do_journal and self.properties[key].do_journal: | |
| 746 self.db.addjournal(link_class, nodeid, 'link', | |
| 747 (self.classname, newid, key)) | |
| 748 | |
| 749 elif isinstance(prop, String): | |
| 750 if type(value) != type(''): | |
| 751 raise TypeError, 'new property "%s" not a string'%key | |
| 752 | |
| 753 elif isinstance(prop, Password): | |
| 754 if not isinstance(value, password.Password): | |
| 755 raise TypeError, 'new property "%s" not a Password'%key | |
| 756 | |
| 757 elif isinstance(prop, Date): | |
| 758 if value is not None and not isinstance(value, date.Date): | |
| 759 raise TypeError, 'new property "%s" not a Date'%key | |
| 760 | |
| 761 elif isinstance(prop, Interval): | |
| 762 if value is not None and not isinstance(value, date.Interval): | |
| 763 raise TypeError, 'new property "%s" not an Interval'%key | |
| 764 | |
| 765 elif value is not None and isinstance(prop, Number): | |
| 766 try: | |
| 767 float(value) | |
| 768 except ValueError: | |
| 769 raise TypeError, 'new property "%s" not numeric'%key | |
| 770 | |
| 771 elif value is not None and isinstance(prop, Boolean): | |
| 772 try: | |
| 773 int(value) | |
| 774 except ValueError: | |
| 775 raise TypeError, 'new property "%s" not boolean'%key | |
| 776 | |
| 777 # make sure there's data where there needs to be | |
| 778 for key, prop in self.properties.items(): | |
| 779 if propvalues.has_key(key): | |
| 780 continue | |
| 781 if key == self.key: | |
| 782 raise ValueError, 'key property "%s" is required'%key | |
| 783 if isinstance(prop, Multilink): | |
| 784 propvalues[key] = [] | |
| 785 else: | |
| 786 propvalues[key] = None | |
| 787 | |
| 788 # done | |
| 789 self.db.addnode(self.classname, newid, propvalues) | |
| 790 if self.do_journal: | |
| 791 self.db.addjournal(self.classname, newid, 'create', propvalues) | |
| 792 | |
| 793 self.fireReactors('create', newid, None) | |
| 794 | |
| 795 return newid | |
| 796 | |
| 797 _marker = [] | |
| 798 def get(self, nodeid, propname, default=_marker, cache=1): | |
| 799 '''Get the value of a property on an existing node of this class. | |
| 800 | |
| 801 'nodeid' must be the id of an existing node of this class or an | |
| 802 IndexError is raised. 'propname' must be the name of a property | |
| 803 of this class or a KeyError is raised. | |
| 804 | |
| 805 'cache' indicates whether the transaction cache should be queried | |
| 806 for the node. If the node has been modified and you need to | |
| 807 determine what its values prior to modification are, you need to | |
| 808 set cache=0. | |
| 809 ''' | |
| 810 if propname == 'id': | |
| 811 return nodeid | |
| 812 | |
| 813 if propname == 'creation': | |
| 814 if not self.do_journal: | |
| 815 raise ValueError, 'Journalling is disabled for this class' | |
| 816 journal = self.db.getjournal(self.classname, nodeid) | |
| 817 if journal: | |
| 818 return self.db.getjournal(self.classname, nodeid)[0][1] | |
| 819 else: | |
| 820 # on the strange chance that there's no journal | |
| 821 return date.Date() | |
| 822 if propname == 'activity': | |
| 823 if not self.do_journal: | |
| 824 raise ValueError, 'Journalling is disabled for this class' | |
| 825 journal = self.db.getjournal(self.classname, nodeid) | |
| 826 if journal: | |
| 827 return self.db.getjournal(self.classname, nodeid)[-1][1] | |
| 828 else: | |
| 829 # on the strange chance that there's no journal | |
| 830 return date.Date() | |
| 831 if propname == 'creator': | |
| 832 if not self.do_journal: | |
| 833 raise ValueError, 'Journalling is disabled for this class' | |
| 834 journal = self.db.getjournal(self.classname, nodeid) | |
| 835 if journal: | |
| 836 name = self.db.getjournal(self.classname, nodeid)[0][2] | |
| 837 else: | |
| 838 return None | |
| 839 return self.db.user.lookup(name) | |
| 840 | |
| 841 # get the property (raises KeyErorr if invalid) | |
| 842 prop = self.properties[propname] | |
| 843 | |
| 844 # get the node's dict | |
| 845 d = self.db.getnode(self.classname, nodeid) #, cache=cache) | |
| 846 | |
| 847 if not d.has_key(propname): | |
| 848 if default is _marker: | |
| 849 if isinstance(prop, Multilink): | |
| 850 return [] | |
| 851 else: | |
| 852 return None | |
| 853 else: | |
| 854 return default | |
| 855 | |
| 856 return d[propname] | |
| 857 | |
| 858 def getnode(self, nodeid, cache=1): | |
| 859 ''' Return a convenience wrapper for the node. | |
| 860 | |
| 861 'nodeid' must be the id of an existing node of this class or an | |
| 862 IndexError is raised. | |
| 863 | |
| 864 'cache' indicates whether the transaction cache should be queried | |
| 865 for the node. If the node has been modified and you need to | |
| 866 determine what its values prior to modification are, you need to | |
| 867 set cache=0. | |
| 868 ''' | |
| 869 return Node(self, nodeid, cache=cache) | |
| 870 | |
| 871 def set(self, nodeid, **propvalues): | |
| 872 '''Modify a property on an existing node of this class. | |
| 873 | |
| 874 'nodeid' must be the id of an existing node of this class or an | |
| 875 IndexError is raised. | |
| 876 | |
| 877 Each key in 'propvalues' must be the name of a property of this | |
| 878 class or a KeyError is raised. | |
| 879 | |
| 880 All values in 'propvalues' must be acceptable types for their | |
| 881 corresponding properties or a TypeError is raised. | |
| 882 | |
| 883 If the value of the key property is set, it must not collide with | |
| 884 other key strings or a ValueError is raised. | |
| 885 | |
| 886 If the value of a Link or Multilink property contains an invalid | |
| 887 node id, a ValueError is raised. | |
| 888 ''' | |
| 889 raise NotImplementedError | |
| 890 | |
| 891 def retire(self, nodeid): | |
| 892 '''Retire a node. | |
| 893 | |
| 894 The properties on the node remain available from the get() method, | |
| 895 and the node's id is never reused. | |
| 896 | |
| 897 Retired nodes are not returned by the find(), list(), or lookup() | |
| 898 methods, and other nodes may reuse the values of their key properties. | |
| 899 ''' | |
| 900 cursor = self.db.conn.cursor() | |
| 901 sql = 'update %s set __retired__=1 where id=?'%self.classname | |
| 902 if __debug__: | |
| 903 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid) | |
| 904 cursor.execute(sql, (nodeid,)) | |
| 905 | |
| 906 def is_retired(self, nodeid): | |
| 907 '''Return true if the node is rerired | |
| 908 ''' | |
| 909 cursor = self.db.conn.cursor() | |
| 910 sql = 'select __retired__ from %s where id=?'%self.classname | |
| 911 if __debug__: | |
| 912 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid) | |
| 913 cursor.execute(sql, (nodeid,)) | |
| 914 return cursor.fetchone()[0] | |
| 915 | |
| 916 def destroy(self, nodeid): | |
| 917 '''Destroy a node. | |
| 918 | |
| 919 WARNING: this method should never be used except in extremely rare | |
| 920 situations where there could never be links to the node being | |
| 921 deleted | |
| 922 WARNING: use retire() instead | |
| 923 WARNING: the properties of this node will not be available ever again | |
| 924 WARNING: really, use retire() instead | |
| 925 | |
| 926 Well, I think that's enough warnings. This method exists mostly to | |
| 927 support the session storage of the cgi interface. | |
| 928 | |
| 929 The node is completely removed from the hyperdb, including all journal | |
| 930 entries. It will no longer be available, and will generally break code | |
| 931 if there are any references to the node. | |
| 932 ''' | |
| 933 raise NotImplementedError | |
| 934 | |
| 935 def history(self, nodeid): | |
| 936 '''Retrieve the journal of edits on a particular node. | |
| 937 | |
| 938 'nodeid' must be the id of an existing node of this class or an | |
| 939 IndexError is raised. | |
| 940 | |
| 941 The returned list contains tuples of the form | |
| 942 | |
| 943 (date, tag, action, params) | |
| 944 | |
| 945 'date' is a Timestamp object specifying the time of the change and | |
| 946 'tag' is the journaltag specified when the database was opened. | |
| 947 ''' | |
| 948 raise NotImplementedError | |
| 949 | |
| 950 # Locating nodes: | |
| 951 def hasnode(self, nodeid): | |
| 952 '''Determine if the given nodeid actually exists | |
| 953 ''' | |
| 954 return self.db.hasnode(self.classname, nodeid) | |
| 955 | |
| 956 def setkey(self, propname): | |
| 957 '''Select a String property of this class to be the key property. | |
| 958 | |
| 959 'propname' must be the name of a String property of this class or | |
| 960 None, or a TypeError is raised. The values of the key property on | |
| 961 all existing nodes must be unique or a ValueError is raised. | |
| 962 ''' | |
| 963 # XXX create an index on the key prop column | |
| 964 prop = self.getprops()[propname] | |
| 965 if not isinstance(prop, String): | |
| 966 raise TypeError, 'key properties must be String' | |
| 967 self.key = propname | |
| 968 | |
| 969 def getkey(self): | |
| 970 '''Return the name of the key property for this class or None.''' | |
| 971 return self.key | |
| 972 | |
| 973 def labelprop(self, default_to_id=0): | |
| 974 ''' Return the property name for a label for the given node. | |
| 975 | |
| 976 This method attempts to generate a consistent label for the node. | |
| 977 It tries the following in order: | |
| 978 1. key property | |
| 979 2. "name" property | |
| 980 3. "title" property | |
| 981 4. first property from the sorted property name list | |
| 982 ''' | |
| 983 raise NotImplementedError | |
| 984 | |
| 985 def lookup(self, keyvalue): | |
| 986 '''Locate a particular node by its key property and return its id. | |
| 987 | |
| 988 If this class has no key property, a TypeError is raised. If the | |
| 989 'keyvalue' matches one of the values for the key property among | |
| 990 the nodes in this class, the matching node's id is returned; | |
| 991 otherwise a KeyError is raised. | |
| 992 ''' | |
| 993 if not self.key: | |
| 994 raise TypeError, 'No key property set' | |
| 995 | |
| 996 cursor = self.db.conn.cursor() | |
| 997 sql = 'select id from %s where _%s=?'%(self.classname, self.key) | |
| 998 if __debug__: | |
| 999 print >>hyperdb.DEBUG, 'lookup', (self, sql, keyvalue) | |
| 1000 cursor.execute(sql, (keyvalue,)) | |
| 1001 | |
| 1002 # see if there was a result | |
| 1003 l = cursor.fetchall() | |
| 1004 if not l: | |
| 1005 raise KeyError, keyvalue | |
| 1006 | |
| 1007 # return the id | |
| 1008 return l[0][0] | |
| 1009 | |
| 1010 def find(self, **propspec): | |
| 1011 '''Get the ids of nodes in this class which link to the given nodes. | |
| 1012 | |
| 1013 'propspec' consists of keyword args propname={nodeid:1,} | |
| 1014 'propname' must be the name of a property in this class, or a | |
| 1015 KeyError is raised. That property must be a Link or Multilink | |
| 1016 property, or a TypeError is raised. | |
| 1017 | |
| 1018 Any node in this class whose 'propname' property links to any of the | |
| 1019 nodeids will be returned. Used by the full text indexing, which knows | |
| 1020 that "foo" occurs in msg1, msg3 and file7, so we have hits on these | |
| 1021 issues: | |
| 1022 | |
| 1023 db.issue.find(messages={'1':1,'3':1}, files={'7':1}) | |
| 1024 ''' | |
| 1025 raise NotImplementedError | |
| 1026 | |
| 1027 def filter(self, search_matches, filterspec, sort, group, | |
| 1028 num_re = re.compile('^\d+$')): | |
| 1029 ''' Return a list of the ids of the active nodes in this class that | |
| 1030 match the 'filter' spec, sorted by the group spec and then the | |
| 1031 sort spec | |
| 1032 ''' | |
| 1033 raise NotImplementedError | |
| 1034 | |
| 1035 def count(self): | |
| 1036 '''Get the number of nodes in this class. | |
| 1037 | |
| 1038 If the returned integer is 'numnodes', the ids of all the nodes | |
| 1039 in this class run from 1 to numnodes, and numnodes+1 will be the | |
| 1040 id of the next node to be created in this class. | |
| 1041 ''' | |
| 1042 return self.db.countnodes(self.classname) | |
| 1043 | |
| 1044 # Manipulating properties: | |
| 1045 def getprops(self, protected=1): | |
| 1046 '''Return a dictionary mapping property names to property objects. | |
| 1047 If the "protected" flag is true, we include protected properties - | |
| 1048 those which may not be modified. | |
| 1049 ''' | |
| 1050 d = self.properties.copy() | |
| 1051 if protected: | |
| 1052 d['id'] = String() | |
| 1053 d['creation'] = hyperdb.Date() | |
| 1054 d['activity'] = hyperdb.Date() | |
| 1055 d['creator'] = hyperdb.Link("user") | |
| 1056 return d | |
| 1057 | |
| 1058 def addprop(self, **properties): | |
| 1059 '''Add properties to this class. | |
| 1060 | |
| 1061 The keyword arguments in 'properties' must map names to property | |
| 1062 objects, or a TypeError is raised. None of the keys in 'properties' | |
| 1063 may collide with the names of existing properties, or a ValueError | |
| 1064 is raised before any properties have been added. | |
| 1065 ''' | |
| 1066 for key in properties.keys(): | |
| 1067 if self.properties.has_key(key): | |
| 1068 raise ValueError, key | |
| 1069 self.properties.update(properties) | |
| 1070 | |
| 1071 def index(self, nodeid): | |
| 1072 '''Add (or refresh) the node to search indexes | |
| 1073 ''' | |
| 1074 # find all the String properties that have indexme | |
| 1075 for prop, propclass in self.getprops().items(): | |
| 1076 if isinstance(propclass, String) and propclass.indexme: | |
| 1077 try: | |
| 1078 value = str(self.get(nodeid, prop)) | |
| 1079 except IndexError: | |
| 1080 # node no longer exists - entry should be removed | |
| 1081 self.db.indexer.purge_entry((self.classname, nodeid, prop)) | |
| 1082 else: | |
| 1083 # and index them under (classname, nodeid, property) | |
| 1084 self.db.indexer.add_text((self.classname, nodeid, prop), | |
| 1085 value) | |
| 1086 | |
| 1087 | |
| 1088 # | |
| 1089 # Detector interface | |
| 1090 # | |
| 1091 def audit(self, event, detector): | |
| 1092 '''Register a detector | |
| 1093 ''' | |
| 1094 l = self.auditors[event] | |
| 1095 if detector not in l: | |
| 1096 self.auditors[event].append(detector) | |
| 1097 | |
| 1098 def fireAuditors(self, action, nodeid, newvalues): | |
| 1099 '''Fire all registered auditors. | |
| 1100 ''' | |
| 1101 for audit in self.auditors[action]: | |
| 1102 audit(self.db, self, nodeid, newvalues) | |
| 1103 | |
| 1104 def react(self, event, detector): | |
| 1105 '''Register a detector | |
| 1106 ''' | |
| 1107 l = self.reactors[event] | |
| 1108 if detector not in l: | |
| 1109 self.reactors[event].append(detector) | |
| 1110 | |
| 1111 def fireReactors(self, action, nodeid, oldvalues): | |
| 1112 '''Fire all registered reactors. | |
| 1113 ''' | |
| 1114 for react in self.reactors[action]: | |
| 1115 react(self.db, self, nodeid, oldvalues) | |
| 1116 | |
| 1117 class FileClass(Class): | |
| 1118 '''This class defines a large chunk of data. To support this, it has a | |
| 1119 mandatory String property "content" which is typically saved off | |
| 1120 externally to the hyperdb. | |
| 1121 | |
| 1122 The default MIME type of this data is defined by the | |
| 1123 "default_mime_type" class attribute, which may be overridden by each | |
| 1124 node if the class defines a "type" String property. | |
| 1125 ''' | |
| 1126 default_mime_type = 'text/plain' | |
| 1127 | |
| 1128 def create(self, **propvalues): | |
| 1129 ''' snaffle the file propvalue and store in a file | |
| 1130 ''' | |
| 1131 content = propvalues['content'] | |
| 1132 del propvalues['content'] | |
| 1133 newid = Class.create(self, **propvalues) | |
| 1134 self.db.storefile(self.classname, newid, None, content) | |
| 1135 return newid | |
| 1136 | |
| 1137 def import_list(self, propnames, proplist): | |
| 1138 ''' Trap the "content" property... | |
| 1139 ''' | |
| 1140 # dupe this list so we don't affect others | |
| 1141 propnames = propnames[:] | |
| 1142 | |
| 1143 # extract the "content" property from the proplist | |
| 1144 i = propnames.index('content') | |
| 1145 content = proplist[i] | |
| 1146 del propnames[i] | |
| 1147 del proplist[i] | |
| 1148 | |
| 1149 # do the normal import | |
| 1150 newid = Class.import_list(self, propnames, proplist) | |
| 1151 | |
| 1152 # save off the "content" file | |
| 1153 self.db.storefile(self.classname, newid, None, content) | |
| 1154 return newid | |
| 1155 | |
| 1156 _marker = [] | |
| 1157 def get(self, nodeid, propname, default=_marker, cache=1): | |
| 1158 ''' trap the content propname and get it from the file | |
| 1159 ''' | |
| 1160 | |
| 1161 poss_msg = 'Possibly a access right configuration problem.' | |
| 1162 if propname == 'content': | |
| 1163 try: | |
| 1164 return self.db.getfile(self.classname, nodeid, None) | |
| 1165 except IOError, (strerror): | |
| 1166 # BUG: by catching this we donot see an error in the log. | |
| 1167 return 'ERROR reading file: %s%s\n%s\n%s'%( | |
| 1168 self.classname, nodeid, poss_msg, strerror) | |
| 1169 if default is not _marker: | |
| 1170 return Class.get(self, nodeid, propname, default, cache=cache) | |
| 1171 else: | |
| 1172 return Class.get(self, nodeid, propname, cache=cache) | |
| 1173 | |
| 1174 def getprops(self, protected=1): | |
| 1175 ''' In addition to the actual properties on the node, these methods | |
| 1176 provide the "content" property. If the "protected" flag is true, | |
| 1177 we include protected properties - those which may not be | |
| 1178 modified. | |
| 1179 ''' | |
| 1180 d = Class.getprops(self, protected=protected).copy() | |
| 1181 if protected: | |
| 1182 d['content'] = hyperdb.String() | |
| 1183 return d | |
| 1184 | |
| 1185 def index(self, nodeid): | |
| 1186 ''' Index the node in the search index. | |
| 1187 | |
| 1188 We want to index the content in addition to the normal String | |
| 1189 property indexing. | |
| 1190 ''' | |
| 1191 # perform normal indexing | |
| 1192 Class.index(self, nodeid) | |
| 1193 | |
| 1194 # get the content to index | |
| 1195 content = self.get(nodeid, 'content') | |
| 1196 | |
| 1197 # figure the mime type | |
| 1198 if self.properties.has_key('type'): | |
| 1199 mime_type = self.get(nodeid, 'type') | |
| 1200 else: | |
| 1201 mime_type = self.default_mime_type | |
| 1202 | |
| 1203 # and index! | |
| 1204 self.db.indexer.add_text((self.classname, nodeid, 'content'), content, | |
| 1205 mime_type) | |
| 1206 | |
| 1207 # XXX deviation from spec - was called ItemClass | |
| 1208 class IssueClass(Class, roundupdb.IssueClass): | |
| 1209 # Overridden methods: | |
| 1210 def __init__(self, db, classname, **properties): | |
| 1211 '''The newly-created class automatically includes the "messages", | |
| 1212 "files", "nosy", and "superseder" properties. If the 'properties' | |
| 1213 dictionary attempts to specify any of these properties or a | |
| 1214 "creation" or "activity" property, a ValueError is raised. | |
| 1215 ''' | |
| 1216 if not properties.has_key('title'): | |
| 1217 properties['title'] = hyperdb.String(indexme='yes') | |
| 1218 if not properties.has_key('messages'): | |
| 1219 properties['messages'] = hyperdb.Multilink("msg") | |
| 1220 if not properties.has_key('files'): | |
| 1221 properties['files'] = hyperdb.Multilink("file") | |
| 1222 if not properties.has_key('nosy'): | |
| 1223 properties['nosy'] = hyperdb.Multilink("user") | |
| 1224 if not properties.has_key('superseder'): | |
| 1225 properties['superseder'] = hyperdb.Multilink(classname) | |
| 1226 Class.__init__(self, db, classname, **properties) | |
| 1227 | |
| 1228 # | |
| 1229 # $Log: not supported by cvs2svn $ | |
| 1230 # Revision 1.80 2002/08/16 04:28:13 richard | |
| 1231 # added is_retired query to Class | |
| 1232 # | |
| 1233 # Revision 1.79 2002/07/29 23:30:14 richard | |
| 1234 # documentation reorg post-new-security | |
| 1235 # | |
| 1236 # Revision 1.78 2002/07/21 03:26:37 richard | |
| 1237 # Gordon, does this help? | |
| 1238 # | |
| 1239 # Revision 1.77 2002/07/18 11:27:47 richard | |
| 1240 # ws | |
| 1241 # | |
| 1242 # Revision 1.76 2002/07/18 11:17:30 gmcm | |
| 1243 # Add Number and Boolean types to hyperdb. | |
| 1244 # Add conversion cases to web, mail & admin interfaces. | |
| 1245 # Add storage/serialization cases to back_anydbm & back_metakit. | |
| 1246 # | |
| 1247 # Revision 1.75 2002/07/14 02:05:53 richard | |
| 1248 # . all storage-specific code (ie. backend) is now implemented by the backends | |
| 1249 # | |
| 1250 # Revision 1.74 2002/07/10 00:24:10 richard | |
| 1251 # braino | |
| 1252 # | |
| 1253 # Revision 1.73 2002/07/10 00:19:48 richard | |
| 1254 # Added explicit closing of backend database handles. | |
| 1255 # | |
| 1256 # Revision 1.72 2002/07/09 21:53:38 gmcm | |
| 1257 # Optimize Class.find so that the propspec can contain a set of ids to match. | |
| 1258 # This is used by indexer.search so it can do just one find for all the index matches. | |
| 1259 # This was already confusing code, but for common terms (lots of index matches), | |
| 1260 # it is enormously faster. | |
| 1261 # | |
| 1262 # Revision 1.71 2002/07/09 03:02:52 richard | |
| 1263 # More indexer work: | |
| 1264 # - all String properties may now be indexed too. Currently there's a bit of | |
| 1265 # "issue" specific code in the actual searching which needs to be | |
| 1266 # addressed. In a nutshell: | |
| 1267 # + pass 'indexme="yes"' as a String() property initialisation arg, eg: | |
| 1268 # file = FileClass(db, "file", name=String(), type=String(), | |
| 1269 # comment=String(indexme="yes")) | |
| 1270 # + the comment will then be indexed and be searchable, with the results | |
| 1271 # related back to the issue that the file is linked to | |
| 1272 # - as a result of this work, the FileClass has a default MIME type that may | |
| 1273 # be overridden in a subclass, or by the use of a "type" property as is | |
| 1274 # done in the default templates. | |
| 1275 # - the regeneration of the indexes (if necessary) is done once the schema is | |
| 1276 # set up in the dbinit. | |
| 1277 # | |
| 1278 # Revision 1.70 2002/06/27 12:06:20 gmcm | |
| 1279 # Improve an error message. | |
| 1280 # | |
| 1281 # Revision 1.69 2002/06/17 23:15:29 richard | |
| 1282 # Can debug to stdout now | |
| 1283 # | |
| 1284 # Revision 1.68 2002/06/11 06:52:03 richard | |
| 1285 # . #564271 ] find() and new properties | |
| 1286 # | |
| 1287 # Revision 1.67 2002/06/11 05:02:37 richard | |
| 1288 # . #565979 ] code error in hyperdb.Class.find | |
| 1289 # | |
| 1290 # Revision 1.66 2002/05/25 07:16:24 rochecompaan | |
| 1291 # Merged search_indexing-branch with HEAD | |
| 1292 # | |
| 1293 # Revision 1.65 2002/05/22 04:12:05 richard | |
| 1294 # . applied patch #558876 ] cgi client customization | |
| 1295 # ... with significant additions and modifications ;) | |
| 1296 # - extended handling of ML assignedto to all places it's handled | |
| 1297 # - added more NotFound info | |
| 1298 # | |
| 1299 # Revision 1.64 2002/05/15 06:21:21 richard | |
| 1300 # . node caching now works, and gives a small boost in performance | |
| 1301 # | |
| 1302 # As a part of this, I cleaned up the DEBUG output and implemented TRACE | |
| 1303 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of | |
| 1304 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff | |
| 1305 # (using if __debug__ which is compiled out with -O) | |
| 1306 # | |
| 1307 # Revision 1.63 2002/04/15 23:25:15 richard | |
| 1308 # . node ids are now generated from a lockable store - no more race conditions | |
| 1309 # | |
| 1310 # We're using the portalocker code by Jonathan Feinberg that was contributed | |
| 1311 # to the ASPN Python cookbook. This gives us locking across Unix and Windows. | |
| 1312 # | |
| 1313 # Revision 1.62 2002/04/03 07:05:50 richard | |
| 1314 # d'oh! killed retirement of nodes :( | |
| 1315 # all better now... | |
| 1316 # | |
| 1317 # Revision 1.61 2002/04/03 06:11:51 richard | |
| 1318 # Fix for old databases that contain properties that don't exist any more. | |
| 1319 # | |
| 1320 # Revision 1.60 2002/04/03 05:54:31 richard | |
| 1321 # Fixed serialisation problem by moving the serialisation step out of the | |
| 1322 # hyperdb.Class (get, set) into the hyperdb.Database. | |
| 1323 # | |
| 1324 # Also fixed htmltemplate after the showid changes I made yesterday. | |
| 1325 # | |
| 1326 # Unit tests for all of the above written. | |
| 1327 # | |
| 1328 # Revision 1.59.2.2 2002/04/20 13:23:33 rochecompaan | |
| 1329 # We now have a separate search page for nodes. Search links for | |
| 1330 # different classes can be customized in instance_config similar to | |
| 1331 # index links. | |
| 1332 # | |
| 1333 # Revision 1.59.2.1 2002/04/19 19:54:42 rochecompaan | |
| 1334 # cgi_client.py | |
| 1335 # removed search link for the time being | |
| 1336 # moved rendering of matches to htmltemplate | |
| 1337 # hyperdb.py | |
| 1338 # filtering of nodes on full text search incorporated in filter method | |
| 1339 # roundupdb.py | |
| 1340 # added paramater to call of filter method | |
| 1341 # roundup_indexer.py | |
| 1342 # added search method to RoundupIndexer class | |
| 1343 # | |
| 1344 # Revision 1.59 2002/03/12 22:52:26 richard | |
| 1345 # more pychecker warnings removed | |
| 1346 # | |
| 1347 # Revision 1.58 2002/02/27 03:23:16 richard | |
| 1348 # Ran it through pychecker, made fixes | |
| 1349 # | |
| 1350 # Revision 1.57 2002/02/20 05:23:24 richard | |
| 1351 # Didn't accomodate new values for new properties | |
| 1352 # | |
| 1353 # Revision 1.56 2002/02/20 05:05:28 richard | |
| 1354 # . Added simple editing for classes that don't define a templated interface. | |
| 1355 # - access using the admin "class list" interface | |
| 1356 # - limited to admin-only | |
| 1357 # - requires the csv module from object-craft (url given if it's missing) | |
| 1358 # | |
| 1359 # Revision 1.55 2002/02/15 07:27:12 richard | |
| 1360 # Oops, precedences around the way w0rng. | |
| 1361 # | |
| 1362 # Revision 1.54 2002/02/15 07:08:44 richard | |
| 1363 # . Alternate email addresses are now available for users. See the MIGRATION | |
| 1364 # file for info on how to activate the feature. | |
| 1365 # | |
| 1366 # Revision 1.53 2002/01/22 07:21:13 richard | |
| 1367 # . fixed back_bsddb so it passed the journal tests | |
| 1368 # | |
| 1369 # ... it didn't seem happy using the back_anydbm _open method, which is odd. | |
| 1370 # Yet another occurrance of whichdb not being able to recognise older bsddb | |
| 1371 # databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the | |
| 1372 # process. | |
| 1373 # | |
| 1374 # Revision 1.52 2002/01/21 16:33:19 rochecompaan | |
| 1375 # You can now use the roundup-admin tool to pack the database | |
| 1376 # | |
| 1377 # Revision 1.51 2002/01/21 03:01:29 richard | |
| 1378 # brief docco on the do_journal argument | |
| 1379 # | |
| 1380 # Revision 1.50 2002/01/19 13:16:04 rochecompaan | |
| 1381 # Journal entries for link and multilink properties can now be switched on | |
| 1382 # or off. | |
| 1383 # | |
| 1384 # Revision 1.49 2002/01/16 07:02:57 richard | |
| 1385 # . lots of date/interval related changes: | |
| 1386 # - more relaxed date format for input | |
| 1387 # | |
| 1388 # Revision 1.48 2002/01/14 06:32:34 richard | |
| 1389 # . #502951 ] adding new properties to old database | |
| 1390 # | |
| 1391 # Revision 1.47 2002/01/14 02:20:15 richard | |
| 1392 # . changed all config accesses so they access either the instance or the | |
| 1393 # config attriubute on the db. This means that all config is obtained from | |
| 1394 # instance_config instead of the mish-mash of classes. This will make | |
| 1395 # switching to a ConfigParser setup easier too, I hope. | |
| 1396 # | |
| 1397 # At a minimum, this makes migration a _little_ easier (a lot easier in the | |
| 1398 # 0.5.0 switch, I hope!) | |
| 1399 # | |
| 1400 # Revision 1.46 2002/01/07 10:42:23 richard | |
| 1401 # oops | |
| 1402 # | |
| 1403 # Revision 1.45 2002/01/02 04:18:17 richard | |
| 1404 # hyperdb docstrings | |
| 1405 # | |
| 1406 # Revision 1.44 2002/01/02 02:31:38 richard | |
| 1407 # Sorry for the huge checkin message - I was only intending to implement #496356 | |
| 1408 # but I found a number of places where things had been broken by transactions: | |
| 1409 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename | |
| 1410 # for _all_ roundup-generated smtp messages to be sent to. | |
| 1411 # . the transaction cache had broken the roundupdb.Class set() reactors | |
| 1412 # . newly-created author users in the mailgw weren't being committed to the db | |
| 1413 # | |
| 1414 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working | |
| 1415 # on when I found that stuff :): | |
| 1416 # . #496356 ] Use threading in messages | |
| 1417 # . detectors were being registered multiple times | |
| 1418 # . added tests for mailgw | |
| 1419 # . much better attaching of erroneous messages in the mail gateway | |
| 1420 # | |
| 1421 # Revision 1.43 2001/12/20 06:13:24 rochecompaan | |
| 1422 # Bugs fixed: | |
| 1423 # . Exception handling in hyperdb for strings-that-look-like numbers got | |
| 1424 # lost somewhere | |
| 1425 # . Internet Explorer submits full path for filename - we now strip away | |
| 1426 # the path | |
| 1427 # Features added: | |
| 1428 # . Link and multilink properties are now displayed sorted in the cgi | |
| 1429 # interface | |
| 1430 # | |
| 1431 # Revision 1.42 2001/12/16 10:53:37 richard | |
| 1432 # take a copy of the node dict so that the subsequent set | |
| 1433 # operation doesn't modify the oldvalues structure | |
| 1434 # | |
| 1435 # Revision 1.41 2001/12/15 23:47:47 richard | |
| 1436 # Cleaned up some bare except statements | |
| 1437 # | |
| 1438 # Revision 1.40 2001/12/14 23:42:57 richard | |
| 1439 # yuck, a gdbm instance tests false :( | |
| 1440 # I've left the debugging code in - it should be removed one day if we're ever | |
| 1441 # _really_ anal about performace :) | |
| 1442 # | |
| 1443 # Revision 1.39 2001/12/02 05:06:16 richard | |
| 1444 # . We now use weakrefs in the Classes to keep the database reference, so | |
| 1445 # the close() method on the database is no longer needed. | |
| 1446 # I bumped the minimum python requirement up to 2.1 accordingly. | |
| 1447 # . #487480 ] roundup-server | |
| 1448 # . #487476 ] INSTALL.txt | |
| 1449 # | |
| 1450 # I also cleaned up the change message / post-edit stuff in the cgi client. | |
| 1451 # There's now a clearly marked "TODO: append the change note" where I believe | |
| 1452 # the change note should be added there. The "changes" list will obviously | |
| 1453 # have to be modified to be a dict of the changes, or somesuch. | |
| 1454 # | |
| 1455 # More testing needed. | |
| 1456 # | |
| 1457 # Revision 1.38 2001/12/01 07:17:50 richard | |
| 1458 # . We now have basic transaction support! Information is only written to | |
| 1459 # the database when the commit() method is called. Only the anydbm | |
| 1460 # backend is modified in this way - neither of the bsddb backends have been. | |
| 1461 # The mail, admin and cgi interfaces all use commit (except the admin tool | |
| 1462 # doesn't have a commit command, so interactive users can't commit...) | |
| 1463 # . Fixed login/registration forwarding the user to the right page (or not, | |
| 1464 # on a failure) | |
| 1465 # | |
| 1466 # Revision 1.37 2001/11/28 21:55:35 richard | |
| 1467 # . login_action and newuser_action return values were being ignored | |
| 1468 # . Woohoo! Found that bloody re-login bug that was killing the mail | |
| 1469 # gateway. | |
| 1470 # (also a minor cleanup in hyperdb) | |
| 1471 # | |
| 1472 # Revision 1.36 2001/11/27 03:16:09 richard | |
| 1473 # Another place that wasn't handling missing properties. | |
| 1474 # | |
| 1475 # Revision 1.35 2001/11/22 15:46:42 jhermann | |
| 1476 # Added module docstrings to all modules. | |
| 1477 # | |
| 1478 # Revision 1.34 2001/11/21 04:04:43 richard | |
| 1479 # *sigh* more missing value handling | |
| 1480 # | |
| 1481 # Revision 1.33 2001/11/21 03:40:54 richard | |
| 1482 # more new property handling | |
| 1483 # | |
| 1484 # Revision 1.32 2001/11/21 03:11:28 richard | |
| 1485 # Better handling of new properties. | |
| 1486 # | |
| 1487 # Revision 1.31 2001/11/12 22:01:06 richard | |
| 1488 # Fixed issues with nosy reaction and author copies. | |
| 1489 # | |
| 1490 # Revision 1.30 2001/11/09 10:11:08 richard | |
| 1491 # . roundup-admin now handles all hyperdb exceptions | |
| 1492 # | |
| 1493 # Revision 1.29 2001/10/27 00:17:41 richard | |
| 1494 # Made Class.stringFind() do caseless matching. | |
| 1495 # | |
| 1496 # Revision 1.28 2001/10/21 04:44:50 richard | |
| 1497 # bug #473124: UI inconsistency with Link fields. | |
| 1498 # This also prompted me to fix a fairly long-standing usability issue - | |
| 1499 # that of being able to turn off certain filters. | |
| 1500 # | |
| 1501 # Revision 1.27 2001/10/20 23:44:27 richard | |
| 1502 # Hyperdatabase sorts strings-that-look-like-numbers as numbers now. | |
| 1503 # | |
| 1504 # Revision 1.26 2001/10/16 03:48:01 richard | |
| 1505 # admin tool now complains if a "find" is attempted with a non-link property. | |
| 1506 # | |
| 1507 # Revision 1.25 2001/10/11 00:17:51 richard | |
| 1508 # Reverted a change in hyperdb so the default value for missing property | |
| 1509 # values in a create() is None and not '' (the empty string.) This obviously | |
| 1510 # breaks CSV import/export - the string 'None' will be created in an | |
| 1511 # export/import operation. | |
| 1512 # | |
| 1513 # Revision 1.24 2001/10/10 03:54:57 richard | |
| 1514 # Added database importing and exporting through CSV files. | |
| 1515 # Uses the csv module from object-craft for exporting if it's available. | |
| 1516 # Requires the csv module for importing. | |
| 1517 # | |
| 1518 # Revision 1.23 2001/10/09 23:58:10 richard | |
| 1519 # Moved the data stringification up into the hyperdb.Class class' get, set | |
| 1520 # and create methods. This means that the data is also stringified for the | |
| 1521 # journal call, and removes duplication of code from the backends. The | |
| 1522 # backend code now only sees strings. | |
| 1523 # | |
| 1524 # Revision 1.22 2001/10/09 07:25:59 richard | |
| 1525 # Added the Password property type. See "pydoc roundup.password" for | |
| 1526 # implementation details. Have updated some of the documentation too. | |
| 1527 # | |
| 1528 # Revision 1.21 2001/10/05 02:23:24 richard | |
| 1529 # . roundup-admin create now prompts for property info if none is supplied | |
| 1530 # on the command-line. | |
| 1531 # . hyperdb Class getprops() method may now return only the mutable | |
| 1532 # properties. | |
| 1533 # . Login now uses cookies, which makes it a whole lot more flexible. We can | |
| 1534 # now support anonymous user access (read-only, unless there's an | |
| 1535 # "anonymous" user, in which case write access is permitted). Login | |
| 1536 # handling has been moved into cgi_client.Client.main() | |
| 1537 # . The "extended" schema is now the default in roundup init. | |
| 1538 # . The schemas have had their page headings modified to cope with the new | |
| 1539 # login handling. Existing installations should copy the interfaces.py | |
| 1540 # file from the roundup lib directory to their instance home. | |
| 1541 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from | |
| 1542 # Ping - has been removed. | |
| 1543 # . Fixed a whole bunch of places in the CGI interface where we should have | |
| 1544 # been returning Not Found instead of throwing an exception. | |
| 1545 # . Fixed a deviation from the spec: trying to modify the 'id' property of | |
| 1546 # an item now throws an exception. | |
| 1547 # | |
| 1548 # Revision 1.20 2001/10/04 02:12:42 richard | |
| 1549 # Added nicer command-line item adding: passing no arguments will enter an | |
| 1550 # interactive more which asks for each property in turn. While I was at it, I | |
| 1551 # fixed an implementation problem WRT the spec - I wasn't raising a | |
| 1552 # ValueError if the key property was missing from a create(). Also added a | |
| 1553 # protected=boolean argument to getprops() so we can list only the mutable | |
| 1554 # properties (defaults to yes, which lists the immutables). | |
| 1555 # | |
| 1556 # Revision 1.19 2001/08/29 04:47:18 richard | |
| 1557 # Fixed CGI client change messages so they actually include the properties | |
| 1558 # changed (again). | |
| 1559 # | |
| 1560 # Revision 1.18 2001/08/16 07:34:59 richard | |
| 1561 # better CGI text searching - but hidden filter fields are disappearing... | |
| 1562 # | |
| 1563 # Revision 1.17 2001/08/16 06:59:58 richard | |
| 1564 # all searches use re now - and they're all case insensitive | |
| 1565 # | |
| 1566 # Revision 1.16 2001/08/15 23:43:18 richard | |
| 1567 # Fixed some isFooTypes that I missed. | |
| 1568 # Refactored some code in the CGI code. | |
| 1569 # | |
| 1570 # Revision 1.15 2001/08/12 06:32:36 richard | |
| 1571 # using isinstance(blah, Foo) now instead of isFooType | |
| 1572 # | |
| 1573 # Revision 1.14 2001/08/07 00:24:42 richard | |
| 1574 # stupid typo | |
| 1575 # | |
| 1576 # Revision 1.13 2001/08/07 00:15:51 richard | |
| 1577 # Added the copyright/license notice to (nearly) all files at request of | |
| 1578 # Bizar Software. | |
| 1579 # | |
| 1580 # Revision 1.12 2001/08/02 06:38:17 richard | |
| 1581 # Roundupdb now appends "mailing list" information to its messages which | |
| 1582 # include the e-mail address and web interface address. Templates may | |
| 1583 # override this in their db classes to include specific information (support | |
| 1584 # instructions, etc). | |
| 1585 # | |
| 1586 # Revision 1.11 2001/08/01 04:24:21 richard | |
| 1587 # mailgw was assuming certain properties existed on the issues being created. | |
| 1588 # | |
| 1589 # Revision 1.10 2001/07/30 02:38:31 richard | |
| 1590 # get() now has a default arg - for migration only. | |
| 1591 # | |
| 1592 # Revision 1.9 2001/07/29 09:28:23 richard | |
| 1593 # Fixed sorting by clicking on column headings. | |
| 1594 # | |
| 1595 # Revision 1.8 2001/07/29 08:27:40 richard | |
| 1596 # Fixed handling of passed-in values in form elements (ie. during a | |
| 1597 # drill-down) | |
| 1598 # | |
| 1599 # Revision 1.7 2001/07/29 07:01:39 richard | |
| 1600 # Added vim command to all source so that we don't get no steenkin' tabs :) | |
| 1601 # | |
| 1602 # Revision 1.6 2001/07/29 05:36:14 richard | |
| 1603 # Cleanup of the link label generation. | |
| 1604 # | |
| 1605 # Revision 1.5 2001/07/29 04:05:37 richard | |
| 1606 # Added the fabricated property "id". | |
| 1607 # | |
| 1608 # Revision 1.4 2001/07/27 06:25:35 richard | |
| 1609 # Fixed some of the exceptions so they're the right type. | |
| 1610 # Removed the str()-ification of node ids so we don't mask oopsy errors any | |
| 1611 # more. | |
| 1612 # | |
| 1613 # Revision 1.3 2001/07/27 05:17:14 richard | |
| 1614 # just some comments | |
| 1615 # | |
| 1616 # Revision 1.2 2001/07/22 12:09:32 richard | |
| 1617 # Final commit of Grande Splite | |
| 1618 # | |
| 1619 # Revision 1.1 2001/07/22 11:58:35 richard | |
| 1620 # More Grande Splite | |
| 1621 # | |
| 1622 # | |
| 1623 # vim: set filetype=python ts=4 sw=4 et si |
