| 66 | | |
|---|
| 67 | | |
|---|
| 68 | | ########################################################################### |
|---|
| 69 | | ## ## |
|---|
| 70 | | ## Units ## |
|---|
| 71 | | ## ## |
|---|
| 72 | | ########################################################################### |
|---|
| 73 | | |
|---|
| 74 | | |
|---|
| 75 | | # All Units currently must possess an 'ID' UnitProperty. The sequencing of |
|---|
| 76 | | # IDs depends upon their type and the particular needs of the class. Pick |
|---|
| 77 | | # one of these UnitSequencers to fit your subclass. |
|---|
| 78 | | |
|---|
| 79 | | # At the moment, no ID sequences are allowed None as a value, since this |
|---|
| 80 | | # signals a Unit which needs to be sequenced when memorized. In addition, |
|---|
| 81 | | # you should aim to create new sequencers which generate IDs that obey |
|---|
| 82 | | # the builtin max() and min() functions. |
|---|
| 83 | | |
|---|
| 84 | | class UnitSequencerNull(object): |
|---|
| 85 | | """UnitSequencerNull(type=unicode). |
|---|
| 86 | | A null sequencer for Unit IDs. Sequencing will error. |
|---|
| 87 | | |
|---|
| 88 | | In many cases, ID values simply have no algorithmic sequence; |
|---|
| 89 | | for example, a set of Employee Units might use Social Security |
|---|
| 90 | | Numbers for IDs (which you should never, ever do ;). |
|---|
| 91 | | |
|---|
| 92 | | In other cases, sequencing will be best handled by custom algorithms |
|---|
| 93 | | within application code; that is, the job of abstracting the sequence |
|---|
| 94 | | logic would not be worth the effort. |
|---|
| 95 | | """ |
|---|
| 96 | | |
|---|
| 97 | | def __init__(self, type=unicode): |
|---|
| 98 | | self.type = type |
|---|
| 99 | | |
|---|
| 100 | | def next(self, sequence): |
|---|
| 101 | | raise StopIteration("No sequence defined.") |
|---|
| 102 | | |
|---|
| 103 | | |
|---|
| 104 | | class UnitSequencerInteger(object): |
|---|
| 105 | | """UnitSequencerInteger(type=int, initial=1) |
|---|
| 106 | | A sequencer for Unit IDs, where id[i+1] == id[i] + 1.""" |
|---|
| 107 | | |
|---|
| 108 | | def __init__(self, type=int, initial=1): |
|---|
| 109 | | self.type = type |
|---|
| 110 | | self.initial = initial |
|---|
| 111 | | |
|---|
| 112 | | def next(self, sequence): |
|---|
| 113 | | if sequence: |
|---|
| 114 | | m = max(sequence) |
|---|
| 115 | | if m is not None: |
|---|
| 116 | | return m + 1 |
|---|
| 117 | | return self.initial |
|---|
| 118 | | |
|---|
| 119 | | |
|---|
| 120 | | class UnitSequencerUnicode(object): |
|---|
| 121 | | """UnitSequencerUnicode(type=unicode, width=6, |
|---|
| 122 | | range="abcdefghijklmnopqrstuvwxyz") |
|---|
| 123 | | A sequencer for Unit IDs, where e.g. next(['abc']) == 'abd'.""" |
|---|
| 124 | | |
|---|
| 125 | | def __init__(self, type=unicode, width=6, |
|---|
| 126 | | range="abcdefghijklmnopqrstuvwxyz"): |
|---|
| 127 | | self.type = type |
|---|
| 128 | | self.width = width |
|---|
| 129 | | self.range = range |
|---|
| 130 | | |
|---|
| 131 | | def next(self, sequence): |
|---|
| 132 | | r = self.range |
|---|
| 133 | | if sequence: |
|---|
| 134 | | maxid = max(sequence) |
|---|
| 135 | | if len(maxid) != self.width: |
|---|
| 136 | | raise OverflowError("'%s' is not of width %s." % |
|---|
| 137 | | (maxid, self.width)) |
|---|
| 138 | | for i in range(self.width - 1, -1, -1): |
|---|
| 139 | | pos = r.index(maxid[i]) + 1 |
|---|
| 140 | | if pos >= len(r) or pos < 0: |
|---|
| 141 | | maxid = maxid[:i] + r[0] + maxid[i+1:] |
|---|
| 142 | | else: |
|---|
| 143 | | maxid = maxid[:i] + r[pos] + maxid[i+1:] |
|---|
| 144 | | break |
|---|
| 145 | | else: |
|---|
| 146 | | raise OverflowError("Next ID exceeds width %s." % self.width) |
|---|
| 147 | | return maxid |
|---|
| 148 | | return r[0] * self.width |
|---|
| 149 | | |
|---|
| 150 | | |
|---|
| 151 | | def _fix_fixedpoint_cmp(): |
|---|
| 152 | | """Add methods to fixedpoint to support pickling.""" |
|---|
| 153 | | import fixedpoint |
|---|
| 154 | | def __cmp__(self, other): |
|---|
| 155 | | if other is None: |
|---|
| 156 | | return 1 |
|---|
| 157 | | xn, yn, p = fixedpoint._norm(self, other, FixedPoint=type(self)) |
|---|
| 158 | | return cmp(xn, yn) |
|---|
| 159 | | fixedpoint.FixedPoint.__cmp__ = __cmp__ |
|---|
| 160 | | |
|---|
| 161 | | def _define_fixedpoint_states(): |
|---|
| 162 | | """Add methods to fixedpoint to support pickling.""" |
|---|
| 163 | | import fixedpoint |
|---|
| 164 | | |
|---|
| 165 | | if not hasattr(fixedpoint.FixedPoint, "__getstate__"): |
|---|
| 166 | | def __getstate__(self): |
|---|
| 167 | | return (self.n, self.p) |
|---|
| 168 | | fixedpoint.FixedPoint.__getstate__ = __getstate__ |
|---|
| 169 | | |
|---|
| 170 | | def __setstate__(self, v): |
|---|
| 171 | | self.n, self.p = v |
|---|
| 172 | | fixedpoint.FixedPoint.__setstate__ = __setstate__ |
|---|
| 173 | | |
|---|
| 174 | | |
|---|
| 175 | | class UnitProperty(object): |
|---|
| 176 | | """UnitProperty(type=unicode, index=False, hints=None, key=None) |
|---|
| 177 | | Data descriptor for Unit data which will persist in storage. |
|---|
| 178 | | |
|---|
| 179 | | hints: A dictionary which provides named hints to Storage Managers |
|---|
| 180 | | concerning the nature of the data. A common use, for example, |
|---|
| 181 | | is to inform Managers that would usually store unicode strings |
|---|
| 182 | | as strings of length 255, that a particular value should be |
|---|
| 183 | | a larger object; this is done with a 'bytes' mapping, such as: |
|---|
| 184 | | hints = {u'bytes': 0}, where 0 implies no limit. Canonical storage |
|---|
| 185 | | hint names and implementation details may be found in /storage |
|---|
| 186 | | documentation. |
|---|
| 187 | | """ |
|---|
| 188 | | |
|---|
| 189 | | def __init__(self, type=unicode, index=False, hints=None, key=None): |
|---|
| 190 | | if type.__name__ == 'FixedPoint': |
|---|
| 191 | | # fixedpoint can't handle "FixedPoint() != None" in Python 2.4 |
|---|
| 192 | | _fix_fixedpoint_cmp() |
|---|
| 193 | | |
|---|
| 194 | | # fixedpoint.Fixedpoint can't be pickled because it |
|---|
| 195 | | # defines __slots__ but not __getstate__. Provide it. |
|---|
| 196 | | _define_fixedpoint_states() |
|---|
| 197 | | |
|---|
| 198 | | self.type = type |
|---|
| 199 | | self.index = index |
|---|
| 200 | | if hints is None: hints = {} |
|---|
| 201 | | self.hints = hints |
|---|
| 202 | | self.key = key |
|---|
| 203 | | |
|---|
| 204 | | def __get__(self, unit, unitclass=None): |
|---|
| 205 | | if unit is None: |
|---|
| 206 | | # When calling on the class instead of an instance... |
|---|
| 207 | | return self |
|---|
| 208 | | else: |
|---|
| 209 | | return unit._properties[self.key] |
|---|
| 210 | | |
|---|
| 211 | | def __set__(self, unit, value): |
|---|
| 212 | | if self.coerce: |
|---|
| 213 | | value = self.coerce(unit, value) |
|---|
| 214 | | oldvalue = unit._properties[self.key] |
|---|
| 215 | | if oldvalue != value: |
|---|
| 216 | | unit._properties[self.key] = value |
|---|
| 217 | | |
|---|
| 218 | | def coerce(self, unit, value): |
|---|
| 219 | | if value is not None and not isinstance(value, self.type): |
|---|
| 220 | | # Try to coerce the value. |
|---|
| 221 | | try: |
|---|
| 222 | | value = self.type(value) |
|---|
| 223 | | except Exception, x: |
|---|
| 224 | | x.args += (value, type(value)) |
|---|
| 225 | | raise x |
|---|
| 226 | | return value |
|---|
| 227 | | |
|---|
| 228 | | def __delete__(self, unit): |
|---|
| 229 | | raise AttributeError("Unit Properties may not be deleted.") |
|---|
| 230 | | |
|---|
| 231 | | |
|---|
| 232 | | class MetaUnit(type): |
|---|
| 233 | | def __init__(cls, name, bases, dct): |
|---|
| 234 | | cls._associations = {} |
|---|
| 235 | | |
|---|
| 236 | | # Make a copy of the parent class' _properties keys, and store |
|---|
| 237 | | # it in the _properties attribute of this subclass. In this |
|---|
| 238 | | # manner, Unit Properties should propagate down to subclasses, |
|---|
| 239 | | # but not back up to superclasses. |
|---|
| 240 | | props = dict.fromkeys(cls._properties.keys()) |
|---|
| 241 | | |
|---|
| 242 | | # Now grab any new UnitProperties defined in this class. |
|---|
| 243 | | # Overwrite any properties defined in superclasses. |
|---|
| 244 | | for key, val in dct.iteritems(): |
|---|
| 245 | | if isinstance(val, UnitProperty): |
|---|
| 246 | | # If the UnitProperty.key is None, |
|---|
| 247 | | # supply it from the attribute name (key). |
|---|
| 248 | | if val.key is None: |
|---|
| 249 | | val.key = key |
|---|
| 250 | | props[key] = val |
|---|
| 251 | | |
|---|
| 252 | | cls._properties = props |
|---|
| 253 | | |
|---|
| 254 | | |
|---|
| 255 | | class Unit(object): |
|---|
| 256 | | """Unit(**kwarg properties). A generic, persistent object. |
|---|
| 257 | | |
|---|
| 258 | | Units are the building-block of Dejavu. They are purposefully lightweight, |
|---|
| 259 | | relying on Sandboxes to cache them, which in turn rely on Storage Managers |
|---|
| 260 | | to load and save them. |
|---|
| 261 | | |
|---|
| 262 | | They maintain their own "schema" via UnitProperty objects, so that the |
|---|
| 263 | | Storage Managers don't need to know every detail about every Unit. |
|---|
| 264 | | Storage Managers for simple databases, for example, will simply create |
|---|
| 265 | | a single flat table for each unit type. If you write a custom Storage |
|---|
| 266 | | Manager, you can do as you like; the only place you might run into a |
|---|
| 267 | | problem is if you write a custom Storage Manager for custom Unit types, |
|---|
| 268 | | because the knowledge between the two is indeterminate. For example, |
|---|
| 269 | | if we provide a standard StorageManagerForLotusNotes, and you create |
|---|
| 270 | | custom Units which interface with it, you should probably subclass and |
|---|
| 271 | | extend our StorageManagerForLotusNotes with some custom storage logic. |
|---|
| 272 | | |
|---|
| 273 | | sandbox: The sandbox in which the Unit "lives". Also serves as a flag |
|---|
| 274 | | indicating whether this Unit has finished the initial creation |
|---|
| 275 | | process. |
|---|
| 276 | | |
|---|
| 277 | | Sandboxes receive Units during recall() and memorize(); |
|---|
| 278 | | these processes should set the sandbox attribute. |
|---|
| 279 | | |
|---|
| 280 | | dirty: indicates whether elements in the _properties dictionary |
|---|
| 281 | | have been modified. This flag is used by Sandboxes to optimize |
|---|
| 282 | | forget(): they do not ask Storage Managers to save data for Units |
|---|
| 283 | | which have not been modified. Because SM's may cache Units, no code |
|---|
| 284 | | should set this flag other than UnitProperty.__set__ and SM's. |
|---|
| 285 | | """ |
|---|
| 286 | | |
|---|
| 287 | | __metaclass__ = MetaUnit |
|---|
| 288 | | _properties = {} |
|---|
| 289 | | |
|---|
| 290 | | # The default ID type is int. If you wish to use a different type for |
|---|
| 291 | | # the ID's of a subclass of Unit, just overwrite ID, e.g.: |
|---|
| 292 | | # ID = UnitProperty(unicode, index=True) |
|---|
| 293 | | # or |
|---|
| 294 | | # UnitSubclass.set_property('ID', unicode, index=True) |
|---|
| 295 | | # or even |
|---|
| 296 | | # UnitSubclass.ID.type = unicode |
|---|
| 297 | | # You will probably also want to override Unit.sequencer for the class. |
|---|
| 298 | | ID = UnitProperty(int, index=True) |
|---|
| 299 | | sequencer = UnitSequencerInteger() |
|---|
| 300 | | |
|---|
| 301 | | def __init__(self, **kwargs): |
|---|
| 302 | | # Copy the class _properties dict into self, setting |
|---|
| 303 | | # each value to None. |
|---|
| 304 | | self._properties = dict.fromkeys(self.__class__._properties.keys()) |
|---|
| 305 | | |
|---|
| 306 | | self.sandbox = None |
|---|
| 307 | | |
|---|
| 308 | | # Make sure we cleanse before assigning properties from kwargs, |
|---|
| 309 | | # or the new unit won't get saved if there are no further changes. |
|---|
| 310 | | self.cleanse() |
|---|
| 311 | | for k, v in kwargs.iteritems(): |
|---|
| 312 | | setattr(self, k, v) |
|---|
| 313 | | |
|---|
| 314 | | def _property_hash(self): |
|---|
| 315 | | try: |
|---|
| 316 | | return sha.new(pickle.dumps(self._properties)).digest() |
|---|
| 317 | | except TypeError, x: |
|---|
| 318 | | x.args += (self.__class__.__name__, self._properties.keys()) |
|---|
| 319 | | raise x |
|---|
| 320 | | |
|---|
| 321 | | def dirty(self): |
|---|
| 322 | | return self._initial_property_hash != self._property_hash() |
|---|
| 323 | | |
|---|
| 324 | | def cleanse(self): |
|---|
| 325 | | self._initial_property_hash = self._property_hash() |
|---|
| 326 | | |
|---|
| 327 | | def set_property(cls, key, type=unicode, index=False, |
|---|
| 328 | | descriptor=UnitProperty): |
|---|
| 329 | | """Set a Unit Property for cls.""" |
|---|
| 330 | | setattr(cls, key, descriptor(type, index, key=key)) |
|---|
| 331 | | cls._properties[key] = None |
|---|
| 332 | | set_property = classmethod(set_property) |
|---|
| 333 | | |
|---|
| 334 | | def set_properties(cls, types={}, descriptor=UnitProperty): |
|---|
| 335 | | """Set Unit Properties for cls.""" |
|---|
| 336 | | for key, typ in types.items(): |
|---|
| 337 | | cls.set_property(key, typ, False, descriptor) |
|---|
| 338 | | set_properties = classmethod(set_properties) |
|---|
| 339 | | |
|---|
| 340 | | def indices(cls): |
|---|
| 341 | | """cls.indices() -> tuple of names of indexed UnitProperties.""" |
|---|
| 342 | | product = [] |
|---|
| 343 | | for key in cls.properties(): |
|---|
| 344 | | try: |
|---|
| 345 | | if getattr(cls, key).index: |
|---|
| 346 | | product.append(key) |
|---|
| 347 | | except AttributeError, x: |
|---|
| 348 | | x.args += (cls, key) |
|---|
| 349 | | raise x |
|---|
| 350 | | return tuple(product) |
|---|
| 351 | | indices = classmethod(indices) |
|---|
| 352 | | |
|---|
| 353 | | def properties(cls): |
|---|
| 354 | | """cls.properties() -> list of UnitProperty names.""" |
|---|
| 355 | | return cls._properties.iterkeys() |
|---|
| 356 | | properties = classmethod(properties) |
|---|
| 357 | | |
|---|
| 358 | | def property_type(cls, key): |
|---|
| 359 | | """cls.property_type(key) -> type of the given UnitProperty.""" |
|---|
| 360 | | # Retrieving from the class gives us |
|---|
| 361 | | # the UnitProperty object, not its value. |
|---|
| 362 | | return getattr(cls, key).type |
|---|
| 363 | | property_type = classmethod(property_type) |
|---|
| 364 | | |
|---|
| 365 | | def adjust(self, **values): |
|---|
| 366 | | """adjust(**values) -> Set UnitProperties by key, value pairs.""" |
|---|
| 367 | | for key, val in values.iteritems(): |
|---|
| 368 | | setattr(self, key, val) |
|---|
| 369 | | |
|---|
| 370 | | def repress(self): |
|---|
| 371 | | """repress() -> Remove this Unit from memory (do not destroy).""" |
|---|
| 372 | | self.sandbox.repress(self) |
|---|
| 373 | | |
|---|
| 374 | | def forget(self): |
|---|
| 375 | | """forget() -> Destroy this Unit.""" |
|---|
| 376 | | self.sandbox.forget(self) |
|---|
| 377 | | |
|---|
| 378 | | def __copy__(self): |
|---|
| 379 | | newUnit = self.__class__() |
|---|
| 380 | | for key in self.__class__.properties(): |
|---|
| 381 | | if key != u'ID': |
|---|
| 382 | | newUnit._properties[key] = self._properties[key] |
|---|
| 383 | | newUnit.ID = None |
|---|
| 384 | | newUnit.sandbox = None |
|---|
| 385 | | return newUnit |
|---|
| 386 | | |
|---|
| 387 | | def __getstate__(self): |
|---|
| 388 | | return (self._properties, self._initial_property_hash) |
|---|
| 389 | | |
|---|
| 390 | | def __setstate__(self, state): |
|---|
| 391 | | self.sandbox = None |
|---|
| 392 | | self._properties, self._initial_property_hash = state |
|---|
| 393 | | |
|---|
| 394 | | |
|---|
| 395 | | # Associations # |
|---|
| 396 | | |
|---|
| 397 | | def first(self, farClass, **kwargs): |
|---|
| 398 | | """Return the first associated farClass Unit or None. |
|---|
| 399 | | |
|---|
| 400 | | Passes additional kwargs to sandbox.unit(). |
|---|
| 401 | | """ |
|---|
| 402 | | try: |
|---|
| 403 | | key, farKey = self.__class__._associations[farClass] |
|---|
| 404 | | except KeyError: |
|---|
| 405 | | raise AssociationError("'%s' is not associated with '%s'" |
|---|
| 406 | | % (self.__class__, farClass)) |
|---|
| 407 | | |
|---|
| 408 | | value = getattr(self, key) |
|---|
| 409 | | if value is None: |
|---|
| 410 | | return None |
|---|
| 411 | | |
|---|
| 412 | | # kwargs won't take unicode keys |
|---|
| 413 | | kwargs[str(farKey)] = value |
|---|
| 414 | | return self.sandbox.unit(farClass, **kwargs) |
|---|
| 415 | | |
|---|
| 416 | | def add(self, *units): |
|---|
| 417 | | """add(*units) -> Auto-create a relation between self and unit(s).""" |
|---|
| 418 | | cls = self.__class__ |
|---|
| 419 | | for unit in units: |
|---|
| 420 | | try: |
|---|
| 421 | | key, farKey = cls._associations[unit.__class__] |
|---|
| 422 | | except KeyError: |
|---|
| 423 | | raise AssociationError("'%s' is not associated with '%s'" |
|---|
| 424 | | % (cls, unit.__class__)) |
|---|
| 425 | | |
|---|
| 426 | | nearval = getattr(self, key) |
|---|
| 427 | | farval = getattr(unit, farKey) |
|---|
| 428 | | if nearval is None: |
|---|
| 429 | | if farval is None: |
|---|
| 430 | | raise AssociationError("At least one Unit key must be set.") |
|---|
| 431 | | else: |
|---|
| 432 | | setattr(self, key, farval) |
|---|
| 433 | | else: |
|---|
| 434 | | # If far key is already set, it will simply be overwritten. |
|---|
| 435 | | setattr(unit, farKey, nearval) |
|---|
| 436 | | |
|---|
| 437 | | |
|---|
| 438 | | def relation_factory(key, farClass, farKey): |
|---|
| 439 | | """Produce a new recaller method for a Unit subclass.""" |
|---|
| 440 | | def related_units(self, expr=None): |
|---|
| 441 | | value = getattr(self, key) |
|---|
| 442 | | if value is None: |
|---|
| 443 | | return iter([]) |
|---|
| 444 | | |
|---|
| 445 | | # kwargs won't take unicode keys |
|---|
| 446 | | f = logic.filter(**{str(farKey): value}) |
|---|
| 447 | | if expr is not None: |
|---|
| 448 | | f += expr |
|---|
| 449 | | return self.sandbox.recall(farClass, f) |
|---|
| 450 | | |
|---|
| 451 | | related_units.__doc__ = ( |
|---|
| 452 | | """Iterator over '%(farname)s' Units whose %(farkey)s matches self.%(key)s. |
|---|
| 453 | | If self.%(key)s is None, no Units will be recalled.""" |
|---|
| 454 | | % {'farname': farClass.__name__, |
|---|
| 455 | | 'farkey': farKey, |
|---|
| 456 | | 'key': key, |
|---|
| 457 | | }) |
|---|
| 458 | | return related_units |
|---|
| 459 | | |
|---|
| 460 | | def associate(cls, key, farClass, farKey, nearFactory=None, farFactory=None): |
|---|
| 461 | | """Associate one Unit class with another by relating attributes. |
|---|
| 462 | | |
|---|
| 463 | | cls, key: The 'near' class and its key. |
|---|
| 464 | | farClass, farKey: the 'far' class and its key. |
|---|
| 465 | | |
|---|
| 466 | | Far Units will be recalled if their farKey matches cls.key. |
|---|
| 467 | | However, if cls.key is empty or None, no Units will be recalled. |
|---|
| 468 | | """ |
|---|
| 469 | | |
|---|
| 470 | | # Add a method to cls which retrieves farClass Units |
|---|
| 471 | | if nearFactory is None: |
|---|
| 472 | | nearFactory = relation_factory |
|---|
| 473 | | func = nearFactory(key, farClass, farKey) |
|---|
| 474 | | setattr(cls, farClass.__name__, func) |
|---|
| 475 | | |
|---|
| 476 | | # Add the farClass to the association dictionary of cls. |
|---|
| 477 | | cls._associations[farClass] = (key, farKey) |
|---|
| 478 | | |
|---|
| 479 | | # Add a method to farClass which retrieves cls Units |
|---|
| 480 | | if farFactory is None: |
|---|
| 481 | | farFactory = relation_factory |
|---|
| 482 | | func = farFactory(farKey, cls, key) |
|---|
| 483 | | setattr(farClass, cls.__name__, func) |
|---|
| 484 | | |
|---|
| 485 | | # Add the cls to the association dictionary of farClass. |
|---|
| 486 | | farClass._associations[cls] = (farKey, key) |
|---|
| 487 | | |
|---|
| 488 | | |
|---|
| 489 | | ########################################################################### |
|---|
| 490 | | ## ## |
|---|
| 491 | | ## Arenas ## |
|---|
| 492 | | ## ## |
|---|
| 493 | | ########################################################################### |
|---|
| 494 | | |
|---|
| 495 | | |
|---|
| 496 | | # logging flags (see Arena.logflags) |
|---|
| 497 | | LOGSQL = 4 |
|---|
| 498 | | LOGCONN = 8 |
|---|
| 499 | | |
|---|
| 500 | | LOGMEMORIZE = 128 |
|---|
| 501 | | LOGRECALL = 256 |
|---|
| 502 | | LOGVIEW = 512 |
|---|
| 503 | | LOGREPRESS = 1024 |
|---|
| 504 | | LOGFORGET = 2048 |
|---|
| 505 | | LOGSANDBOX = LOGMEMORIZE | LOGRECALL | LOGVIEW | LOGREPRESS | LOGFORGET |
|---|
| 506 | | |
|---|
| 507 | | |
|---|
| 508 | | class Arena(object): |
|---|
| 509 | | """Arena(). A namespace/workspace for a Dejavu application.""" |
|---|
| 510 | | |
|---|
| 511 | | def __init__(self): |
|---|
| 512 | | self.defaultStore = None |
|---|
| 513 | | self.stores = {} |
|---|
| 514 | | self._registered_classes = {} |
|---|
| 515 | | self.associations = Graph() |
|---|
| 516 | | self.engine_functions = {} |
|---|
| 517 | | self.logflags = 0 |
|---|
| 518 | | |
|---|
| 519 | | def log(self, message, flag): |
|---|
| 520 | | """Default logger (writes to stdout). Feel free to replace.""" |
|---|
| 521 | | if flag & self.logflags: |
|---|
| 522 | | print message |
|---|
| 523 | | |
|---|
| 524 | | def load(self, configFileName): |
|---|
| 525 | | """Load StorageManagers.""" |
|---|
| 526 | | parser = ConfigParser.ConfigParser() |
|---|
| 527 | | # Make names case-sensitive by overriding optionxform. |
|---|
| 528 | | parser.optionxform = unicode |
|---|
| 529 | | parser.read(configFileName) |
|---|
| 530 | | |
|---|
| 531 | | stores = [] |
|---|
| 532 | | for section in parser.sections(): |
|---|
| 533 | | opts = dict(parser.items(section)) |
|---|
| 534 | | stores.append((int(opts.get("Load Order", "0")), section, opts)) |
|---|
| 535 | | stores.sort() |
|---|
| 536 | | |
|---|
| 537 | | for order, name, options in stores: |
|---|
| 538 | | self.add_store(name, options[u'Class'], options) |
|---|
| 539 | | |
|---|
| 540 | | def add_store(self, name, store, options=None): |
|---|
| 541 | | """add_store(name, store, options=None). Register a StorageManager. |
|---|
| 542 | | |
|---|
| 543 | | The 'store' argument may be the name of a Storage Manager class; |
|---|
| 544 | | if so, it must be importable (that is, it must have the full dotted |
|---|
| 545 | | package name). |
|---|
| 546 | | """ |
|---|
| 547 | | |
|---|
| 548 | | if isinstance(store, basestring): |
|---|
| 549 | | import xray |
|---|
| 550 | | store = xray.classes(store)(name, self, options or {}) |
|---|
| 551 | | |
|---|
| 552 | | self.stores[name] = store |
|---|
| 553 | | if not store.classnames: |
|---|
| 554 | | # This store has no "classnames" list, which signals that it |
|---|
| 555 | | # handles all classes which are not handled by other stores. |
|---|
| 556 | | self.defaultStore = store |
|---|
| 557 | | return store |
|---|
| 558 | | |
|---|
| 559 | | def remove_store(self, name): |
|---|
| 560 | | if name in self.stores: |
|---|
| 561 | | store = self.stores[name] |
|---|
| 562 | | |
|---|
| 563 | | # Disassociate all registered classes with this store. |
|---|
| 564 | | for c in self._registered_classes.keys(): |
|---|
| 565 | | if self._registered_classes[c] is store: |
|---|
| 566 | | self._registered_classes[c] = None |
|---|
| 567 | | |
|---|
| 568 | | del self.stores[name] |
|---|
| 569 | | |
|---|
| 570 | | def shutdown(self): |
|---|
| 571 | | """Shutdown the arena.""" |
|---|
| 572 | | # Tell all stores to shut down. |
|---|
| 573 | | stores = [(v.shutdownOrder, v, k) for k, v in self.stores.iteritems()] |
|---|
| 574 | | stores.sort() |
|---|
| 575 | | for order, store, name in stores: |
|---|
| 576 | | store.shutdown() |
|---|
| 577 | | |
|---|
| 578 | | def new_sandbox(self): |
|---|
| 579 | | return Sandbox(self) |
|---|
| 580 | | |
|---|
| 581 | | ########################################### |
|---|
| 582 | | ## Unit Class Registration ## |
|---|
| 583 | | ########################################### |
|---|
| 584 | | |
|---|
| 585 | | def register(self, cls): |
|---|
| 586 | | """register(cls) -> Assert that Units of class 'cls' will be handled.""" |
|---|
| 587 | | # We must allow modules to register classes before any stores have |
|---|
| 588 | | # been added, but not overwrite a store which has already been found. |
|---|
| 589 | | if cls not in self._registered_classes: |
|---|
| 590 | | self._registered_classes[cls] = None |
|---|
| 591 | | |
|---|
| 592 | | # Register any association(s) in an undirected graph. |
|---|
| 593 | | for farClass in cls._associations: |
|---|
| 594 | | self.associations.connect(cls, farClass) |
|---|
| 595 | | |
|---|
| 596 | | def register_all(self, globals): |
|---|
| 597 | | for obj in globals.itervalues(): |
|---|
| 598 | | if isinstance(obj, type) and issubclass(obj, Unit): |
|---|
| 599 | | self.register(obj) |
|---|
| 600 | | |
|---|
| 601 | | def class_by_name(self, classname): |
|---|
| 602 | | for cls in self._registered_classes: |
|---|
| 603 | | if cls.__name__ == classname: |
|---|
| 604 | | return cls |
|---|
| 605 | | raise KeyError("No registered class found for '%s'." % classname) |
|---|
| 606 | | |
|---|
| 607 | | def storage(self, cls): |
|---|
| 608 | | found = self._registered_classes.get(cls) |
|---|
| 609 | | |
|---|
| 610 | | if found: |
|---|
| 611 | | return found |
|---|
| 612 | | |
|---|
| 613 | | # Search all stores for the class name. |
|---|
| 614 | | clsname = cls.__name__ |
|---|
| 615 | | for store in self.stores.itervalues(): |
|---|
| 616 | | if clsname in store.classnames: |
|---|
| 617 | | found = store |
|---|
| 618 | | break |
|---|
| 619 | | found = found or self.defaultStore |
|---|
| 620 | | if found is None: |
|---|
| 621 | | raise KeyError("No store found for '%s' and no " |
|---|
| 622 | | "default store." % clsname) |
|---|
| 623 | | |
|---|
| 624 | | self._registered_classes[cls] = found |
|---|
| 625 | | return found |
|---|
| 626 | | |
|---|
| 627 | | def create_storage(self, cls): |
|---|
| 628 | | """create_storage(cls). Create storage space for cls.""" |
|---|
| 629 | | self.storage(cls).create_storage(cls) |
|---|
| 630 | | |
|---|
| 631 | | def migrate_class(self, cls, new_store): |
|---|
| 632 | | """migrate_class(cls, new_store). Copy all units of cls to new_store.""" |
|---|
| 633 | | new_store.create_storage(cls) |
|---|
| 634 | | for unit in self.new_sandbox().xrecall(cls): |
|---|
| 635 | | new_store.reserve(unit) |
|---|
| 636 | | new_store.save(unit, True) |
|---|
| 637 | | |
|---|
| 638 | | def migrate(self, new_store, old_store=None, copy_only=False): |
|---|
| 639 | | """migrate(new_store, old_store=None). Copy all units (of old_store) to new_store.""" |
|---|
| 640 | | for cls in self._registered_classes: |
|---|
| 641 | | store = self.storage(cls) |
|---|
| 642 | | if old_store is None or old_store is store: |
|---|
| 643 | | self.migrate_class(cls, new_store) |
|---|
| 644 | | if not copy_only: |
|---|
| 645 | | self._registered_classes[cls] = new_store |
|---|
| 651 | | |
|---|
| 652 | | |
|---|
| 653 | | ########################################################################### |
|---|
| 654 | | ## ## |
|---|
| 655 | | ## Sandboxes ## |
|---|
| 656 | | ## ## |
|---|
| 657 | | ########################################################################### |
|---|
| 658 | | |
|---|
| 659 | | |
|---|
| 660 | | class Sandbox(object): |
|---|
| 661 | | """Sandbox(arena). Data sandbox for Dejavu arenas. |
|---|
| 662 | | |
|---|
| 663 | | Each consumer (that is, each UI process) maintains a Sandbox for |
|---|
| 664 | | managing Units. Sandboxes populate themselves with Units on a lazy |
|---|
| 665 | | basis, allowing UI code to request data as it's needed. However, once |
|---|
| 666 | | obtained, such Units are persisted (usually for the lifetime of the |
|---|
| 667 | | thread); this important detail means that multiple requests for the |
|---|
| 668 | | same Units result in multiple references to the same objects, rather |
|---|
| 669 | | than multiple objects. Sandboxes are basically what Fowler calls |
|---|
| 670 | | Identity Maps. |
|---|
| 671 | | |
|---|
| 672 | | The *REALLY* important thing to understand if you're customizing this |
|---|
| 673 | | is that Sandboxes won't survive sharing across threads--DON'T TRY IT. |
|---|
| 674 | | If you need to share unit data across requests, use or make an SM which |
|---|
| 675 | | persists the data, and chain it with another, more normal SM. |
|---|
| 676 | | |
|---|
| 677 | | _cache(), _caches, and _stores are private for a reason--don't access |
|---|
| 678 | | them from interface code--tell the Sandbox to do it for you. |
|---|
| 679 | | """ |
|---|
| 680 | | |
|---|
| 681 | | def __init__(self, arena): |
|---|
| 682 | | self.arena = arena |
|---|
| 683 | | self._caches = {} |
|---|
| 684 | | |
|---|
| 685 | | def memorize(self, unit): |
|---|
| 686 | | """memorize(unit). Persist unit in storage.""" |
|---|
| 687 | | cls = unit.__class__ |
|---|
| 688 | | unit.sandbox = self |
|---|
| 689 | | |
|---|
| 690 | | # Ask the store to accept the unit, assigning it an ID if |
|---|
| 691 | | # necessary. The store should also call unit.cleanse() |
|---|
| 692 | | # if it saves the whole unit state on this call. |
|---|
| 693 | | self.arena.storage(cls).reserve(unit) |
|---|
| 694 | | |
|---|
| 695 | | # Insert the unit into the cache. |
|---|
| 696 | | self._cache(cls)[unit.ID] = unit |
|---|
| 697 | | self.arena.log("MEMORIZE %s: %s" % (cls.__name__, unit.ID), LOGMEMORIZE) |
|---|
| 698 | | |
|---|
| 699 | | # Do this at the end of the func, since most on_memorize |
|---|
| 700 | | # will want to have an ID when called. |
|---|
| 701 | | if hasattr(unit, "on_memorize"): |
|---|
| 702 | | unit.on_memorize() |
|---|
| 703 | | |
|---|
| 704 | | def forget(self, unit): |
|---|
| 705 | | """Destroy unit, both in the cache and storage.""" |
|---|
| 706 | | cls = unit.__class__ |
|---|
| 707 | | |
|---|
| 708 | | self.arena.log("FORGET %s: %s" % (cls.__name__, unit.ID), LOGFORGET) |
|---|
| 709 | | self.arena.storage(cls).destroy(unit) |
|---|
| 710 | | |
|---|
| 711 | | del self._cache(cls)[unit.ID] |
|---|
| 712 | | |
|---|
| 713 | | if hasattr(unit, "on_forget"): |
|---|
| 714 | | unit.on_forget() |
|---|
| 715 | | |
|---|
| 716 | | unit.sandbox = None |
|---|
| 717 | | |
|---|
| 718 | | def xrecall(self, cls, expr=None): |
|---|
| 719 | | """Iterator over units of cls which match expr.""" |
|---|
| 720 | | |
|---|
| 721 | | self.arena.log("RECALL %s: %s" % (cls.__name__, expr), LOGRECALL) |
|---|
| 722 | | |
|---|
| 723 | | cache = self._cache(cls) |
|---|
| 724 | | |
|---|
| 725 | | # Special-case the scenario where one Unit is expected and called |
|---|
| 726 | | # by ID. We should be able to save a database hit. |
|---|
| 727 | | if expr: |
|---|
| 728 | | fc = expr.func.func_code |
|---|
| 729 | | if (fc.co_code == '|\x00\x00i\x01\x00d\x01\x00j\x02\x00S' and |
|---|
| 730 | | fc.co_names[-1] == 'ID'): |
|---|
| 731 | | ID = fc.co_consts[-1] |
|---|
| 732 | | unit = cache.get(ID) |
|---|
| 733 | | if unit is not None: |
|---|
| 734 | | # Do NOT call on_recall here. That should be called |
|---|
| 735 | | # only at the Sandbox-SM boundary. |
|---|
| 736 | | yield unit |
|---|
| 737 | | raise StopIteration |
|---|
| 738 | | |
|---|
| 739 | | # Query Cache and Storage. |
|---|
| 740 | | for unit in self.arena.storage(cls).recall(cls, expr): |
|---|
| 741 | | ID = unit.ID |
|---|
| 742 | | # Very important that we check for existing unit, as its |
|---|
| 743 | | # state may have changed in memory but not in storage. |
|---|
| 744 | | # Make sure the cache lookup and get happens atomically. |
|---|
| 745 | | existing = cache.get(ID) |
|---|
| 746 | | if existing: |
|---|
| 747 | | yield existing |
|---|
| 748 | | else: |
|---|
| 749 | | unit.sandbox = self |
|---|
| 750 | | confirmed = True |
|---|
| 751 | | cache[ID] = unit |
|---|
| 752 | | if hasattr(unit, 'on_recall'): |
|---|
| 753 | | try: |
|---|
| 754 | | unit.on_recall() |
|---|
| 755 | | except UnrecallableError: |
|---|
| 756 | | confirmed = False |
|---|
| 757 | | if confirmed: |
|---|
| 758 | | yield unit |
|---|
| 759 | | |
|---|
| 760 | | def recall(self, cls, expr=None): |
|---|
| 761 | | """List of units of class 'cls' which match expr.""" |
|---|
| 762 | | return [x for x in self.xrecall(cls, expr)] |
|---|
| 763 | | |
|---|
| 764 | | def multirecall(self, *pairs): |
|---|
| 765 | | """multirecall((cls1, expr1), ...) -> [[unit, ...], [unit, ...], ...] |
|---|
| 766 | | Recall units of each cls which match each expr. |
|---|
| 767 | | |
|---|
| 768 | | Units of each additional cls/expr pair will be recalled; however, |
|---|
| 769 | | only those Units with associations to Units in the PRIMARY set will |
|---|
| 770 | | be returned. For you database guys, it's a set of inner joins, |
|---|
| 771 | | ALL of which are between the FIRST set and the subsequent set(s). |
|---|
| 772 | | |
|---|
| 773 | | Instead of single Units, each yielded value will be a tuple of |
|---|
| 774 | | Units, in the same order as the cls args were supplied. This |
|---|
| 775 | | facilitates unpacking in iterative consumer code like: |
|---|
| 776 | | |
|---|
| 777 | | for invoice, price in sandbox.multirecall(Invoice, f, Price, None): |
|---|
| 778 | | deal_with(invoice) |
|---|
| 779 | | deal_with(price) |
|---|
| 780 | | """ |
|---|
| 781 | | |
|---|
| 782 | | self.arena.log("RECALL %s" % ", ".join(["(%s: %s)" % (c.__name__, e) |
|---|
| 783 | | for c, e in pairs]), |
|---|
| 784 | | LOGRECALL) |
|---|
| 785 | | store = self.arena.storage(pairs[0][0]) |
|---|
| 786 | | for c, e in pairs: |
|---|
| 787 | | if self.arena.storage(c) is not store: |
|---|
| 788 | | raise ValueError(u"multirecall() does not support multiple" |
|---|
| 789 | | u" classes in disparate stores.") |
|---|
| 790 | | |
|---|
| 791 | | # This is broken. If a filter expr is supplied, then the store may |
|---|
| 792 | | # not return rows which our cache would, and those won't be included |
|---|
| 793 | | # in the resultset. If you're using multirecall with no expr's, or |
|---|
| 794 | | # in read-only scripts, it should be OK for now. But if you mutate |
|---|
| 795 | | # Units and then call multirecall, expect inconsistent results. |
|---|
| 796 | | for unitset in store.multirecall(*pairs): |
|---|
| 797 | | confirmed = True |
|---|
| 798 | | for index in xrange(len(unitset)): |
|---|
| 799 | | unit = unitset[index] |
|---|
| 800 | | ID = unit.ID |
|---|
| 801 | | cache = self._cache(unit.__class__) |
|---|
| 802 | | if ID in cache: |
|---|
| 803 | | # Keep the unit which is in our cache! |
|---|
| 804 | | unitset[index] = cache[ID] |
|---|
| 805 | | else: |
|---|
| 806 | | cache[ID] = unit |
|---|
| 807 | | unit.sandbox = self |
|---|
| 808 | | if hasattr(unit, 'on_recall'): |
|---|
| 809 | | try: |
|---|
| 810 | | unit.on_recall() |
|---|
| 811 | | except UnrecallableError: |
|---|
| 812 | | confirmed = False |
|---|
| 813 | | break |
|---|
| 814 | | if confirmed: |
|---|
| 815 | | yield unitset |
|---|
| 816 | | |
|---|
| 817 | | def unit(self, cls, **kwargs): |
|---|
| 818 | | """unit(cls, **kwargs) -> A single matching Unit, else None. |
|---|
| 819 | | |
|---|
| 820 | | **kwargs will be combined into an Expression via logic.filter. |
|---|
| 821 | | The first Unit matching that expression is returned; if no |
|---|
| 822 | | Units match, None is returned. |
|---|
| 823 | | |
|---|
| 824 | | If you need a single Unit which matches a more complex |
|---|
| 825 | | expression, use recall()[0] or xrecall().next(). |
|---|
| 826 | | """ |
|---|
| 827 | | expr = None |
|---|
| 828 | | if kwargs: |
|---|
| 829 | | expr = logic.filter(**kwargs) |
|---|
| 830 | | try: |
|---|
| 831 | | return self.xrecall(cls, expr).next() |
|---|
| 832 | | except StopIteration: |
|---|
| 833 | | return None |
|---|
| 834 | | |
|---|
| 835 | | def view(self, cls, attrs, expr=None): |
|---|
| 836 | | """view(cls, attrs, expr=None) -> Iterator of all Property tuples.""" |
|---|
| 837 | | self.arena.log("VIEW %s [%s]: %s" % (cls.__name__, attrs, expr), LOGVIEW) |
|---|
| 838 | | |
|---|
| 839 | | cache = self._cache(cls) |
|---|
| 840 | | |
|---|
| 841 | | for unit in cache.itervalues(): |
|---|
| 842 | | if expr is None or expr.evaluate(unit): |
|---|
| 843 | | yield tuple([getattr(unit, attr) for attr in attrs]) |
|---|
| 844 | | |
|---|
| 845 | | # Add the ID attribute if not present. This is necessary to |
|---|
| 846 | | # avoid duplicating objects which are already in our cache. |
|---|
| 847 | | fields = list(attrs) |
|---|
| 848 | | if "ID" not in fields: |
|---|
| 849 | | fields.append("ID") |
|---|
| 850 | | index_of_id = fields.index("ID") |
|---|
| 851 | | |
|---|
| 852 | | for row in self.arena.storage(cls).view(cls, fields, expr): |
|---|
| 853 | | if row[index_of_id] not in cache: |
|---|
| 854 | | if "ID" not in attrs: |
|---|
| 855 | | # Remove the ID column from the tuple. |
|---|
| 856 | | row = row[:len(row) - 1] |
|---|
| 857 | | yield row |
|---|
| 858 | | |
|---|
| 859 | | def distinct(self, cls, attrs, expr=None): |
|---|
| 860 | | """distinct(cls, attrs, expr=None) -> List of distinct Property tuples. |
|---|
| 861 | | |
|---|
| 862 | | If only one attribute is specified, a list of values will be returned. |
|---|
| 863 | | If more than one attribute is specified, a zipped list will be returned. |
|---|
| 864 | | |
|---|
| 865 | | Notice that you can also use this function as a count() function |
|---|
| 866 | | (in fact it's the only way to do it) by using attrs = ['ID']. |
|---|
| 867 | | """ |
|---|
| 868 | | self.arena.log("DISTINCT %s [%s]: %s" % (cls.__name__, attrs, expr), LOGVIEW) |
|---|
| 869 | | |
|---|
| 870 | | seen = {} |
|---|
| 871 | | cache = self._cache(cls) |
|---|
| 872 | | for unit in cache.itervalues(): |
|---|
| 873 | | if expr is None or expr.evaluate(unit): |
|---|
| 874 | | row = tuple([getattr(unit, attr) for attr in attrs]) |
|---|
| 875 | | if row not in seen: |
|---|
| 876 | | seen[row] = None |
|---|
| 877 | | |
|---|
| 878 | | for row in self.arena.storage(cls).distinct(cls, attrs, expr): |
|---|
| 879 | | if row not in seen: |
|---|
| 880 | | seen[row] = None |
|---|
| 881 | | |
|---|
| 882 | | seen = seen.keys() |
|---|
| 883 | | seen.sort() |
|---|
| 884 | | if len(attrs) == 1: |
|---|
| 885 | | seen = [x[0] for x in seen] |
|---|
| 886 | | return seen |
|---|
| 887 | | |
|---|
| 888 | | def count(self, cls, expr): |
|---|
| 889 | | """count(cls, expr) -> Number of Units of class 'cls'.""" |
|---|
| 890 | | return len(self.distinct(cls, ['ID'], expr)) |
|---|
| 891 | | |
|---|
| 892 | | #################################### |
|---|
| 893 | | ## Cache Management ## |
|---|
| 894 | | #################################### |
|---|
| 895 | | |
|---|
| 896 | | def _cache(self, cls): |
|---|
| 897 | | """cache(cls). Return the cache for the specified class. |
|---|
| 898 | | |
|---|
| 899 | | This base class creates a new cache for each cls per request. |
|---|
| 900 | | """ |
|---|
| 901 | | if cls not in self._caches: |
|---|
| 902 | | self._caches[cls] = {} |
|---|
| 903 | | return self._caches[cls] |
|---|
| 904 | | |
|---|
| 905 | | def purge(self, cls): |
|---|
| 906 | | """purge(cls). Drop all cached Units of class 'cls'. Do not save.""" |
|---|
| 907 | | del self._caches[cls] |
|---|
| 908 | | |
|---|
| 909 | | def repress(self, unit): |
|---|
| 910 | | """repress(unit). Remove unit from cache (but don't destroy).""" |
|---|
| 911 | | cls = unit.__class__ |
|---|
| 912 | | self.arena.log("REPRESS %s: %s" % (cls.__name__, unit.ID), LOGREPRESS) |
|---|
| 913 | | |
|---|
| 914 | | if hasattr(unit, "on_repress"): |
|---|
| 915 | | unit.on_repress() |
|---|
| 916 | | |
|---|
| 917 | | # Save after on_repress in case on_repress modified the unit. |
|---|
| 918 | | self.arena.storage(cls).save(unit) |
|---|
| 919 | | |
|---|
| 920 | | del self._cache(cls)[unit.ID] |
|---|
| 921 | | |
|---|
| 922 | | def flush_all(self): |
|---|
| 923 | | """flush_all(). Repress all units.""" |
|---|
| 924 | | |
|---|
| 925 | | for cls in self._caches.keys(): |
|---|
| 926 | | # Call all on_repress methods first! There are truly horrible |
|---|
| 927 | | # interdependency chains in most on_repress methods, and |
|---|
| 928 | | # it's best to resolve them all at once BEFORE flushing |
|---|
| 929 | | # any units from the cache. |
|---|
| 930 | | # Note we use values instead of itervalues, since the |
|---|
| 931 | | # cache may change size during iteration. |
|---|
| 932 | | for unit in self._cache(cls).values(): |
|---|
| 933 | | if hasattr(unit, "on_repress"): |
|---|
| 934 | | unit.on_repress() |
|---|
| 935 | | |
|---|
| 936 | | for cls in self._caches.keys(): |
|---|
| 937 | | cache = self._cache(cls) |
|---|
| 938 | | store = self.arena.storage(cls) |
|---|
| 939 | | while cache: |
|---|
| 940 | | unitid, unit = cache.popitem() |
|---|
| 941 | | self.arena.log("REPRESS %s: %s" % (cls.__name__, unitid), LOGREPRESS) |
|---|
| 942 | | store.save(unit) |
|---|
| 943 | | |
|---|
| 944 | | |
|---|
| 945 | | |
|---|
| 946 | | ########################################################################### |
|---|
| 947 | | ## ## |
|---|
| 948 | | ## Errors ## |
|---|
| 949 | | ## ## |
|---|
| 950 | | ########################################################################### |
|---|
| 951 | | |
|---|
| 952 | | |
|---|
| 953 | | class DejavuError(Exception): |
|---|
| 954 | | """Base class for errors which occur within Dejavu.""" |
|---|
| 955 | | def __init__(self, *args): |
|---|
| 956 | | Exception.__init__(self) |
|---|
| 957 | | self.args = args |
|---|
| 958 | | |
|---|
| 959 | | def __str__(self): |
|---|
| 960 | | return u'\n'.join([unicode(eachArg) for eachArg in self.args]) |
|---|
| 961 | | |
|---|
| 962 | | class AssociationError(DejavuError): |
|---|
| 963 | | """Exception raised when a Unit association fails.""" |
|---|
| 964 | | pass |
|---|
| 965 | | |
|---|
| 966 | | class UnrecallableError(DejavuError): |
|---|
| 967 | | """Exception raised when a Unit was sought but not recalled.""" |
|---|
| 968 | | pass |
|---|