Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
Initial commit
- Loading branch information
0 parents
commit af38a92
Showing
6 changed files
with
288 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
*.pyc | ||
.*.swp |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'): | ||
doc_fields.update(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_val.name: | ||
attr_val.name = 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)) | ||
else: | ||
# 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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import re | ||
|
||
|
||
__all__ = ['StringField', 'IntField', 'ValidationError'] | ||
|
||
|
||
class ValidationError(Exception): | ||
pass | ||
|
||
|
||
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): | ||
self.name = 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(self.name) | ||
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: | ||
try: | ||
value = self._to_python(value) | ||
self._validate(value) | ||
value = self._to_mongo(value) | ||
except ValueError: | ||
raise ValidationError('Invalid value for field of type "' + | ||
self.__class__.__name__ + '"') | ||
instance._data[self.name] = 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 | ||
NestedDocument. | ||
""" | ||
pass | ||
|
||
|
||
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') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
self.assertEqual(Employee._meta['collection'], | ||
Person._meta['collection']) | ||
|
||
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(person.name, "Test User") | ||
self.assertEqual(person.age, 30) | ||
|
||
|
||
if __name__ == '__main__': | ||
unittest.main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
'test.User') | ||
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') | ||
person.name = 'Shorter name' | ||
self.assertEqual(person.name, '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__': | ||
unittest.main() |