Skip to content

Commit af38a92

Browse files
committed
Initial commit
0 parents  commit af38a92

File tree

6 files changed

+288
-0
lines changed

6 files changed

+288
-0
lines changed

Diff for: .gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.pyc
2+
.*.swp

Diff for: mongomap/__init__.py

Whitespace-only changes.

Diff for: mongomap/document.py

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import pymongo
2+
3+
import fields
4+
5+
class DocumentMetaclass(type):
6+
"""Metaclass for all documents.
7+
"""
8+
9+
def __new__(cls, name, bases, attrs):
10+
doc_fields = {}
11+
12+
# Include all fields present in superclasses
13+
for base in bases:
14+
if hasattr(base, '_fields'):
15+
doc_fields.update(base._fields)
16+
17+
# Add the document's fields to the _fields attribute
18+
for attr_name, attr_val in attrs.items():
19+
if issubclass(attr_val.__class__, fields.Field):
20+
if not attr_val.name:
21+
attr_val.name = attr_name
22+
doc_fields[attr_name] = attr_val
23+
attrs['_fields'] = doc_fields
24+
25+
return type.__new__(cls, name, bases, attrs)
26+
27+
28+
class TopLevelDocumentMetaclass(DocumentMetaclass):
29+
"""Metaclass for top-level documents (i.e. documents that have their own
30+
collection in the database.
31+
"""
32+
33+
def __new__(cls, name, bases, attrs):
34+
# Classes defined in this module are abstract and should not have
35+
# their own metadata with DB collection, etc.
36+
if attrs['__module__'] != __name__:
37+
collection = name.lower()
38+
# Subclassed documents inherit collection from superclass
39+
for base in bases:
40+
if hasattr(base, '_meta') and 'collection' in base._meta:
41+
collection = base._meta['collection']
42+
43+
meta = {
44+
'collection': collection,
45+
}
46+
meta.update(attrs.get('meta', {}))
47+
attrs['_meta'] = meta
48+
return DocumentMetaclass.__new__(cls, name, bases, attrs)
49+
50+
51+
class Document(object):
52+
53+
__metaclass__ = TopLevelDocumentMetaclass
54+
55+
def __init__(self, **values):
56+
self._data = {}
57+
# Assign initial values to instance
58+
for attr_name, attr_value in self._fields.items():
59+
if attr_name in values:
60+
setattr(self, attr_name, values.pop(attr_name))
61+
else:
62+
# Use default value
63+
setattr(self, attr_name, getattr(self, attr_name))
64+
65+
def __iter__(self):
66+
# Use _data rather than _fields as iterator only looks at names so
67+
# values don't need to be converted to Python types
68+
return iter(self._data)

Diff for: mongomap/fields.py

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import re
2+
3+
4+
__all__ = ['StringField', 'IntField', 'ValidationError']
5+
6+
7+
class ValidationError(Exception):
8+
pass
9+
10+
11+
class Field(object):
12+
"""A base class for fields in a MongoDB document. Instances of this class
13+
may be added to subclasses of `Document` to define a document's schema.
14+
"""
15+
16+
def __init__(self, name=None, default=None):
17+
self.name = name
18+
self.default = default
19+
20+
def __get__(self, instance, owner):
21+
"""Descriptor for retrieving a value from a field in a document. Do
22+
any necessary conversion between Python and MongoDB types.
23+
"""
24+
if instance is None:
25+
# Document class being used rather than a document object
26+
return self
27+
28+
# Get value from document instance if available, if not use default
29+
value = instance._data.get(self.name)
30+
if value is not None:
31+
value = self._to_python(value)
32+
elif self.default is not None:
33+
value = self.default
34+
if callable(value):
35+
value = value()
36+
return value
37+
38+
def __set__(self, instance, value):
39+
"""Descriptor for assigning a value to a field in a document. Do any
40+
necessary conversion between Python and MongoDB types.
41+
"""
42+
if value is not None:
43+
try:
44+
value = self._to_python(value)
45+
self._validate(value)
46+
value = self._to_mongo(value)
47+
except ValueError:
48+
raise ValidationError('Invalid value for field of type "' +
49+
self.__class__.__name__ + '"')
50+
instance._data[self.name] = value
51+
52+
def _to_python(self, value):
53+
"""Convert a MongoDB-compatible type to a Python type.
54+
"""
55+
return unicode(value)
56+
57+
def _to_mongo(self, value):
58+
"""Convert a Python type to a MongoDB-compatible type.
59+
"""
60+
return self._to_python(value)
61+
62+
def _validate(self, value):
63+
"""Perform validation on a value.
64+
"""
65+
return value
66+
67+
68+
class NestedDocumentField(Field):
69+
"""A nested document field. Only valid values are subclasses of
70+
NestedDocument.
71+
"""
72+
pass
73+
74+
75+
class StringField(Field):
76+
"""A unicode string field.
77+
"""
78+
79+
def __init__(self, regex=None, max_length=None, **kwargs):
80+
self.regex = re.compile(regex) if regex else None
81+
self.max_length = max_length
82+
Field.__init__(self, **kwargs)
83+
84+
def _validate(self, value):
85+
if self.max_length is not None and len(value) > self.max_length:
86+
raise ValidationError('String value is too long')
87+
88+
if self.regex is not None and self.regex.match(value) is None:
89+
message = 'String value did not match validation regex'
90+
raise ValidationError(message)
91+
92+
93+
class IntField(Field):
94+
"""An integer field.
95+
"""
96+
97+
def __init__(self, min_value=None, max_value=None, **kwargs):
98+
self.min_value, self.max_value = min_value, max_value
99+
Field.__init__(self, **kwargs)
100+
101+
def _to_python(self, value):
102+
return int(value)
103+
104+
def _validate(self, value):
105+
if self.min_value is not None and value < self.min_value:
106+
raise ValidationError('Integer value is too small')
107+
108+
if self.max_value is not None and value > self.max_value:
109+
raise ValidationError('Integer value is too large')

Diff for: tests/document.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import unittest
2+
3+
from mongomap.document import Document
4+
from mongomap.fields import StringField, IntField
5+
6+
7+
class DocumentTest(unittest.TestCase):
8+
9+
def test_definition(self):
10+
"""Ensure that document may be defined using fields.
11+
"""
12+
name_field = StringField()
13+
age_field = IntField()
14+
15+
class Person(Document):
16+
name = name_field
17+
age = age_field
18+
non_field = True
19+
20+
self.assertEqual(Person._fields['name'], name_field)
21+
self.assertEqual(Person._fields['age'], age_field)
22+
self.assertFalse('non_field' in Person._fields)
23+
# Test iteration over fields
24+
fields = list(Person())
25+
self.assertTrue('name' in fields and 'age' in fields)
26+
27+
def test_inheritance(self):
28+
"""Ensure that document may inherit fields from a superclass document.
29+
"""
30+
class Person(Document):
31+
name = StringField()
32+
33+
class Employee(Person):
34+
salary = IntField()
35+
36+
self.assertTrue('name' in Employee._fields)
37+
self.assertTrue('salary' in Employee._fields)
38+
self.assertEqual(Employee._meta['collection'],
39+
Person._meta['collection'])
40+
41+
def test_creation(self):
42+
"""Ensure that document may be created using keyword arguments.
43+
"""
44+
class Person(Document):
45+
name = StringField()
46+
age = IntField()
47+
48+
person = Person(name="Test User", age=30)
49+
self.assertEqual(person.name, "Test User")
50+
self.assertEqual(person.age, 30)
51+
52+
53+
if __name__ == '__main__':
54+
unittest.main()

Diff for: tests/fields.py

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import unittest
2+
3+
from mongomap.document import Document
4+
from mongomap.fields import *
5+
6+
7+
class FieldTest(unittest.TestCase):
8+
9+
def test_default_values(self):
10+
"""Ensure that default field values are used when creating a document.
11+
"""
12+
class Person(Document):
13+
name = StringField()
14+
age = IntField(default=30)
15+
userid = StringField(default=lambda: 'test')
16+
17+
person = Person(name='Test Person')
18+
self.assertEqual(person._data['age'], 30)
19+
self.assertEqual(person._data['userid'], 'test')
20+
21+
def test_string_validation(self):
22+
"""Ensure that invalid values cannot be assigned to string fields.
23+
"""
24+
class Person(Document):
25+
name = StringField(max_length=20)
26+
userid = StringField(r'[0-9a-z_]+$')
27+
28+
person = Person()
29+
# Test regex validation on userid
30+
self.assertRaises(ValidationError, person.__setattr__, 'userid',
31+
'test.User')
32+
person.userid = 'test_user'
33+
self.assertEqual(person.userid, 'test_user')
34+
35+
# Test max length validation on name
36+
self.assertRaises(ValidationError, person.__setattr__, 'name',
37+
'Name that is more than twenty characters')
38+
person.name = 'Shorter name'
39+
self.assertEqual(person.name, 'Shorter name')
40+
41+
def test_int_validation(self):
42+
"""Ensure that invalid values cannot be assigned to int fields.
43+
"""
44+
class Person(Document):
45+
age = IntField(min_value=0, max_value=110)
46+
47+
person = Person()
48+
person.age = 50
49+
self.assertRaises(ValidationError, person.__setattr__, 'age', -1)
50+
self.assertRaises(ValidationError, person.__setattr__, 'age', 120)
51+
self.assertRaises(ValidationError, person.__setattr__, 'age', 'ten')
52+
53+
54+
if __name__ == '__main__':
55+
unittest.main()

0 commit comments

Comments
 (0)