Created
August 13, 2018 19:53
-
-
Save akuhn/9ae9021446dd4ebaa11ef1acf912ca5e to your computer and use it in GitHub Desktop.
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
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 |
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
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