Skip to content


Browse files Browse the repository at this point in the history
Initial commit
  • Loading branch information
hmarr committed Nov 15, 2009
0 parents commit af38a92
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
@@ -0,0 +1,2 @@
Empty file added mongomap/
Empty file.
68 changes: 68 additions & 0 deletions mongomap/
@@ -0,0 +1,68 @@
import pymongo

import fields

class DocumentMetaclass(type):
"""Metaclass for all documents.

def __new__(cls, name, bases, attrs):
doc_fields = {}

# Include all fields present in superclasses
for base in bases:
if hasattr(base, '_fields'):

# Add the document's fields to the _fields attribute
for attr_name, attr_val in attrs.items():
if issubclass(attr_val.__class__, fields.Field):
if not = attr_name
doc_fields[attr_name] = attr_val
attrs['_fields'] = doc_fields

return type.__new__(cls, name, bases, attrs)

class TopLevelDocumentMetaclass(DocumentMetaclass):
"""Metaclass for top-level documents (i.e. documents that have their own
collection in the database.

def __new__(cls, name, bases, attrs):
# Classes defined in this module are abstract and should not have
# their own metadata with DB collection, etc.
if attrs['__module__'] != __name__:
collection = name.lower()
# Subclassed documents inherit collection from superclass
for base in bases:
if hasattr(base, '_meta') and 'collection' in base._meta:
collection = base._meta['collection']

meta = {
'collection': collection,
meta.update(attrs.get('meta', {}))
attrs['_meta'] = meta
return DocumentMetaclass.__new__(cls, name, bases, attrs)

class Document(object):

__metaclass__ = TopLevelDocumentMetaclass

def __init__(self, **values):
self._data = {}
# Assign initial values to instance
for attr_name, attr_value in self._fields.items():
if attr_name in values:
setattr(self, attr_name, values.pop(attr_name))
# Use default value
setattr(self, attr_name, getattr(self, attr_name))

def __iter__(self):
# Use _data rather than _fields as iterator only looks at names so
# values don't need to be converted to Python types
return iter(self._data)
109 changes: 109 additions & 0 deletions mongomap/
@@ -0,0 +1,109 @@
import re

__all__ = ['StringField', 'IntField', 'ValidationError']

class ValidationError(Exception):

class Field(object):
"""A base class for fields in a MongoDB document. Instances of this class
may be added to subclasses of `Document` to define a document's schema.

def __init__(self, name=None, default=None): = name
self.default = default

def __get__(self, instance, owner):
"""Descriptor for retrieving a value from a field in a document. Do
any necessary conversion between Python and MongoDB types.
if instance is None:
# Document class being used rather than a document object
return self

# Get value from document instance if available, if not use default
value = instance._data.get(
if value is not None:
value = self._to_python(value)
elif self.default is not None:
value = self.default
if callable(value):
value = value()
return value

def __set__(self, instance, value):
"""Descriptor for assigning a value to a field in a document. Do any
necessary conversion between Python and MongoDB types.
if value is not None:
value = self._to_python(value)
value = self._to_mongo(value)
except ValueError:
raise ValidationError('Invalid value for field of type "' +
self.__class__.__name__ + '"')
instance._data[] = value

def _to_python(self, value):
"""Convert a MongoDB-compatible type to a Python type.
return unicode(value)

def _to_mongo(self, value):
"""Convert a Python type to a MongoDB-compatible type.
return self._to_python(value)

def _validate(self, value):
"""Perform validation on a value.
return value

class NestedDocumentField(Field):
"""A nested document field. Only valid values are subclasses of

class StringField(Field):
"""A unicode string field.

def __init__(self, regex=None, max_length=None, **kwargs):
self.regex = re.compile(regex) if regex else None
self.max_length = max_length
Field.__init__(self, **kwargs)

def _validate(self, value):
if self.max_length is not None and len(value) > self.max_length:
raise ValidationError('String value is too long')

if self.regex is not None and self.regex.match(value) is None:
message = 'String value did not match validation regex'
raise ValidationError(message)

class IntField(Field):
"""An integer field.

def __init__(self, min_value=None, max_value=None, **kwargs):
self.min_value, self.max_value = min_value, max_value
Field.__init__(self, **kwargs)

def _to_python(self, value):
return int(value)

def _validate(self, value):
if self.min_value is not None and value < self.min_value:
raise ValidationError('Integer value is too small')

if self.max_value is not None and value > self.max_value:
raise ValidationError('Integer value is too large')
54 changes: 54 additions & 0 deletions tests/
@@ -0,0 +1,54 @@
import unittest

from mongomap.document import Document
from mongomap.fields import StringField, IntField

class DocumentTest(unittest.TestCase):

def test_definition(self):
"""Ensure that document may be defined using fields.
name_field = StringField()
age_field = IntField()

class Person(Document):
name = name_field
age = age_field
non_field = True

self.assertEqual(Person._fields['name'], name_field)
self.assertEqual(Person._fields['age'], age_field)
self.assertFalse('non_field' in Person._fields)
# Test iteration over fields
fields = list(Person())
self.assertTrue('name' in fields and 'age' in fields)

def test_inheritance(self):
"""Ensure that document may inherit fields from a superclass document.
class Person(Document):
name = StringField()

class Employee(Person):
salary = IntField()

self.assertTrue('name' in Employee._fields)
self.assertTrue('salary' in Employee._fields)

def test_creation(self):
"""Ensure that document may be created using keyword arguments.
class Person(Document):
name = StringField()
age = IntField()

person = Person(name="Test User", age=30)
self.assertEqual(, "Test User")
self.assertEqual(person.age, 30)

if __name__ == '__main__':
55 changes: 55 additions & 0 deletions tests/
@@ -0,0 +1,55 @@
import unittest

from mongomap.document import Document
from mongomap.fields import *

class FieldTest(unittest.TestCase):

def test_default_values(self):
"""Ensure that default field values are used when creating a document.
class Person(Document):
name = StringField()
age = IntField(default=30)
userid = StringField(default=lambda: 'test')

person = Person(name='Test Person')
self.assertEqual(person._data['age'], 30)
self.assertEqual(person._data['userid'], 'test')

def test_string_validation(self):
"""Ensure that invalid values cannot be assigned to string fields.
class Person(Document):
name = StringField(max_length=20)
userid = StringField(r'[0-9a-z_]+$')

person = Person()
# Test regex validation on userid
self.assertRaises(ValidationError, person.__setattr__, 'userid',
person.userid = 'test_user'
self.assertEqual(person.userid, 'test_user')

# Test max length validation on name
self.assertRaises(ValidationError, person.__setattr__, 'name',
'Name that is more than twenty characters') = 'Shorter name'
self.assertEqual(, 'Shorter name')

def test_int_validation(self):
"""Ensure that invalid values cannot be assigned to int fields.
class Person(Document):
age = IntField(min_value=0, max_value=110)

person = Person()
person.age = 50
self.assertRaises(ValidationError, person.__setattr__, 'age', -1)
self.assertRaises(ValidationError, person.__setattr__, 'age', 120)
self.assertRaises(ValidationError, person.__setattr__, 'age', 'ten')

if __name__ == '__main__':

0 comments on commit af38a92

Please sign in to comment.