Skip to content

Instantly share code, notes, and snippets.

@akuhn
Created August 13, 2018 19:53
Show Gist options
  • Save akuhn/9ae9021446dd4ebaa11ef1acf912ca5e to your computer and use it in GitHub Desktop.
Save akuhn/9ae9021446dd4ebaa11ef1acf912ca5e to your computer and use it in GitHub Desktop.
from contrib import debug
# Defines a little language for schema-enforced models.
#
# - Entities are described by models
# - Models are described by metamodels
# - Models have fields, derived fields and constraints
# - Entities can have custom fields
# - Entities can be created even if data is invalid
# - There are functions to validate and get all errors
# - Validation checks presence and type of fields
# - Validation checks all constraints
# - Derived fields are memoized
#
# Example
#
# class Example(Model):
#
# @schema
# def metamodel(self, m):
# m.field('name', str)
# m.field('subject', str)
# m.field('percent_exposed', int, default=100)
#
# @derived_field
# def is_miscellanous(self):
# return self.subject not in ['user', 'visitor']
#
# @constraint("expected percent_exposed to not exceed 100, got {}")
# def constraint(self):
# if self.percent_exposed > 100:
# return self.percent_exposed
#
# Conventions
#
# - Define metamodel function first
# - Then define derived field functions
# - Then define all the constraint functions
# - Constraints are named "constraint," that is intentional
#
# Usage
#
# See code examples in test__model.py
most_recent_metamodel = None
class Metamodel(object):
def __init__(self, function):
global most_recent_metamodel
assert function.__name__ == 'metamodel'
self.initializer = function
self.constraints = []
most_recent_metamodel = self
def __get__(self, entity, model):
if self.initializer: self.initialize_metamodel_once(model)
entity.__dict__['metamodel'] = self
return self
def initialize_metamodel_once(self, model):
self.name = model.__class__.__name__
self.fields = {}
self.initializer(None, self)
self.derived_fields = {
name: each
for name, each in model.__dict__.items()
if isinstance(each, DerivedField)
}
self.initializer = None
def field(self, field_name, field_type, **options):
self.fields[field_name] = Field(field_name, field_type, **options)
def get_field_value(self, entity, field_name, strict):
if field_name in self.fields: return self.fields[field_name].get_value(entity)
if field_name in self.derived_fields: return self.derived_fields[field_name].get_value(entity)
if strict: object.__getattribute__(entity, field_name) # raises AttributeError
return entity.data.get(field_name)
def error_messages(self, entity):
for field in self.fields.values():
value = entity.data.get(field.name)
if not field.match(value):
yield "expected field '{}'' to be an {}, got {}".format(field.name, field.type, value)
for constraint in self.constraints:
error_message = constraint.error_message(entity)
if error_message: yield error_message
class Model(object):
def __init__(self, **data):
self.data = dict(data)
def __getattr__(self, field_name):
return self.metamodel.get_field_value(self, field_name, strict=True)
def __getitem__(self, field_name):
return self.metamodel.get_field_value(self, field_name, strict=False)
def is_valid(self):
return not any(self.error_messages())
def error_messages(self):
return self.metamodel.error_messages(self)
@property
def metamodel(self, m):
raise NotImplementedError, "subclass must override metamodel"
class Field(object):
def __init__(self, name, type, default=None, **options):
self.name = name
self.type = type
self.default = default
self.options = options
def match(self, value):
if value is None: return self.default is not None
return isinstance(value, self.type)
def get_value(self, entity):
value = entity.data.get(self.name)
return self.default if value is None else value
class DerivedField(object):
def __init__(self, function):
self.name = function.__name__
self.initializer = function
def __get__(self, entity, model):
value = self.get(entity)
entity.__dict__[self.name] = value
return value
def get_value(self, entity):
if self.name not in entity.data:
value = self.initializer(entity)
entity.data[self.name] = value
return entity.data[self.name]
class Constraint(object):
def __init__(self, message):
self.message = message
def __call__(self, function):
assert function.__name__ == 'constraint'
self.function = function
most_recent_metamodel.constraints.append(self)
# Bind the attribute named 'constraint' to this class in order to make
# sure we don't shadow the imported decorator named 'constraint'
return Constraint
def error_message(self, entity):
values = self.function(entity)
if values is None: return
if not isinstance(values, tuple): values = (values,)
return self.message.format(*values)
# Alias decorator classes to lowercase names
constraint = Constraint
derived_field = DerivedField
schema = Metamodel
from expects import *
from metamodel import *
class Example(Model):
@schema
def metamodel(self, m):
m.field('name', str)
m.field('subject', str)
m.field('percent_exposed', int, default=100)
@derived_field
def is_miscellanous(self):
return self.subject not in ['user', 'visitor']
@derived_field
def counter(self):
if not hasattr(self, 'count'): self.count = 0
self.count = self.count + 1
return self.count
@constraint("expected percent_exposed to not exceed 100, got {}")
def constraint(self):
if self.percent_exposed > 100:
return self.percent_exposed
class Broken(Model):
@schema
def metamodel(self, m):
pass
@constraint("exepected to not return {}")
def constraint(self):
return False
@constraint("exepected to not return {} and {}")
def constraint(self):
return 'foo', 'bar'
def test____should_all_have_same_metamodel():
m1 = Example()
m2 = Example()
expect(m1).to_not(be(m2))
expect(m1.metamodel).to(be(m2.metamodel))
def test____should_have_one_constraint():
m = Example()
expect(m.metamodel.constraints).to(have_length(1))
expect(m.metamodel.fields).to(have_length(3))
expect(m.metamodel.derived_fields).to(have_length(2))
def test____should_validate_model():
m = Example(name='button_color', subject='user', whatnot='gibberish')
expect(m.is_valid()).to(be_true)
expect(m.error_messages()).to(be_empty)
def test____should_get_fields_as_attributes():
m = Example(name='button_color', subject='user', whatnot='gibberish')
expect(m.name).to(equal('button_color'))
expect(m.subject).to(equal('user'))
expect(m.percent_exposed).to(equal(100))
expect(lambda: m.whatnot).to(raise_error(AttributeError))
expect(lambda: m.covfefe).to(raise_error(AttributeError))
def test____should_get_fields_as_items():
m = Example(name='button_color', subject='user', whatnot='gibberish')
expect(m['name']).to(equal('button_color'))
expect(m['subject']).to(equal('user'))
expect(m['percent_exposed']).to(equal(100))
expect(m['whatnot']).to(equal('gibberish'))
expect(m['covfefe']).to(be_none)
def test___should_not_validate():
m = Example(name='button_color', percent_exposed=200)
errors = list(m.error_messages())
expect(m.is_valid()).to_not(be_true)
expect(errors).to(contain("expected percent_exposed to not exceed 100, got 200"))
expect(errors).to(contain("expected field 'subject'' to be an <type 'str'>, got None"))
expect(errors).to(have_length(2))
def test____should_get_dervied_fields_as_attributes():
m = Example(name='button_color', subject='user')
expect(m.subject).to(equal('user'))
expect(m.is_miscellanous).to(equal(False))
def test____should_get_dervied_fields_as_items():
m = Example(name='button_color', subject='user')
expect(m['subject']).to(equal('user'))
expect(m['is_miscellanous']).to(equal(False))
def test____should_memoize_derived_fields():
m = Example()
expect(m.counter).to(be(1))
expect(m['counter']).to(be(1))
expect(m['counter']).to(be(1))
m = Example()
expect(m['counter']).to(be(1))
expect(m.counter).to(be(1))
expect(m.counter).to(be(1))
def test____should_be_broken():
m = Broken()
errors = list(m.error_messages())
expect(m.metamodel.constraints).to(have_length(2))
expect(errors).to(contain("exepected to not return False"))
expect(errors).to(contain("exepected to not return foo and bar"))
expect(errors).to(have_length(2))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment