Skip to content
This repository was archived by the owner on Aug 19, 2025. It is now read-only.

Commit 013007f

Browse files
Schemas as instances, not classes
1 parent 6f25af5 commit 013007f

6 files changed

Lines changed: 168 additions & 18 deletions

File tree

tests/test_forms.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Contact(typesystem.Schema):
1414

1515

1616
def test_form_rendering():
17-
form = forms.Form(Contact)
17+
form = forms.create_form(Contact)
1818

1919
html = str(form)
2020

@@ -28,13 +28,14 @@ def test_password_rendering():
2828
class PasswordForm(typesystem.Schema):
2929
password = typesystem.String(format="password")
3030

31-
form = forms.Form(PasswordForm, values={"password": "secret"})
31+
form = forms.create_form(PasswordForm)
32+
form.validate(data={"password": "secret"})
3233
html = str(form)
3334
assert "secret" not in html
3435

3536

3637
def test_form_html():
37-
form = forms.Form(Contact)
38+
form = forms.create_form(Contact)
3839

3940
markup = form.__html__()
4041

tests/test_schemas.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,54 @@
66

77
import typesystem
88
import typesystem.formats
9+
from typesystem import Integer, Scheme
10+
11+
12+
def test_scheme():
13+
validator = Scheme(fields={})
14+
value, error = validator.validate_or_error({})
15+
assert value == {}
16+
17+
validator = Scheme(fields={})
18+
value, error = validator.validate_or_error(None)
19+
assert dict(error) == {"": "May not be null."}
20+
21+
validator = Scheme(fields={})
22+
value, error = validator.validate_or_error(123)
23+
assert dict(error) == {"": "Must be an object."}
24+
25+
validator = Scheme(fields={})
26+
value, error = validator.validate_or_error({1: 123})
27+
assert dict(error) == {1: "All object keys must be strings."}
28+
29+
validator = Scheme(fields={}, allow_null=True)
30+
value, error = validator.validate_or_error(None)
31+
assert value is None
32+
assert error is None
33+
34+
validator = Scheme(fields={"example": Integer()})
35+
value, error = validator.validate_or_error({"example": "123"})
36+
assert value == {"example": 123}
37+
38+
validator = Scheme(fields={"example": Integer()})
39+
value, error = validator.validate_or_error({"example": "abc"})
40+
assert dict(error) == {"example": "Must be a number."}
41+
42+
validator = Scheme(fields={"example": Integer(default=0)})
43+
value, error = validator.validate_or_error({"example": "123"})
44+
assert value == {"example": 123}
45+
46+
validator = Scheme(fields={"example": Integer(default=0)})
47+
value, error = validator.validate_or_error({})
48+
assert value == {"example": 0}
49+
50+
validator = Scheme(fields={"example": Integer()})
51+
value, error = validator.validate_or_error({"example": "abc"})
52+
assert dict(error) == {"example": "Must be a number."}
53+
54+
validator = Scheme(fields={"example": Integer(read_only=True)})
55+
value, error = validator.validate_or_error({"example": "123"})
56+
assert value == {}
957

1058

1159
class Person(typesystem.Schema):

typesystem/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Number,
1414
Object,
1515
String,
16+
Scheme,
1617
Text,
1718
Time,
1819
Union,

typesystem/fields.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def __init__(
2828
description: str = "",
2929
default: typing.Any = NO_DEFAULT,
3030
allow_null: bool = False,
31+
read_only: bool = False
3132
):
3233
assert isinstance(title, str)
3334
assert isinstance(description, str)
@@ -41,6 +42,7 @@ def __init__(
4142
self.title = title
4243
self.description = description
4344
self.allow_null = allow_null
45+
self.read_only = read_only
4446

4547
# We need this global counter to determine what order fields have
4648
# been declared in when used with `Schema`.
@@ -680,6 +682,85 @@ def serialize(self, obj: typing.Any) -> typing.Any:
680682
return [self.items.serialize(value) for value in obj]
681683

682684

685+
class Scheme(Field):
686+
errors = {
687+
"type": "Must be an object.",
688+
"null": "May not be null.",
689+
"invalid_key": "All object keys must be strings.",
690+
"required": "This field is required.",
691+
}
692+
693+
def __init__(
694+
self,
695+
fields: typing.Dict[str, Field],
696+
**kwargs: typing.Any,
697+
) -> None:
698+
super().__init__(**kwargs)
699+
self.fields = fields
700+
self.required = [key for key, field in fields.items() if not (field.read_only or field.has_default())]
701+
702+
def validate(self, value: typing.Any) -> typing.Any:
703+
if value is None and self.allow_null:
704+
return None
705+
elif value is None:
706+
raise self.validation_error("null")
707+
elif not isinstance(value, (dict, typing.Mapping)):
708+
raise self.validation_error("type")
709+
710+
validated = {}
711+
error_messages = []
712+
713+
# Ensure all property keys are strings.
714+
for key in value.keys():
715+
if not isinstance(key, str):
716+
text = self.get_error_text("invalid_key")
717+
message = Message(text=text, code="invalid_key", index=[key])
718+
error_messages.append(message)
719+
720+
# Required properties
721+
for key in self.required:
722+
if key not in value:
723+
text = self.get_error_text("required")
724+
message = Message(text=text, code="required", index=[key])
725+
error_messages.append(message)
726+
727+
# Properties
728+
for key, child_schema in self.fields.items():
729+
if child_schema.read_only:
730+
continue
731+
732+
if key not in value:
733+
if child_schema.has_default():
734+
validated[key] = child_schema.get_default_value()
735+
continue
736+
item = value[key]
737+
child_value, error = child_schema.validate_or_error(item)
738+
if not error:
739+
validated[key] = child_value
740+
else:
741+
error_messages += error.messages(add_prefix=key)
742+
743+
if error_messages:
744+
raise ValidationError(messages=error_messages)
745+
746+
return validated
747+
748+
def serialize(self, obj: typing.Any) -> typing.Any:
749+
if obj is None:
750+
return None
751+
752+
is_mapping = isinstance(obj, dict)
753+
754+
ret = {}
755+
for key, field in self.fields.items():
756+
try:
757+
value = obj[key] if is_mapping else getattr(obj, key)
758+
except (KeyError, AttributeError):
759+
continue
760+
ret[key] = field.serialize(value)
761+
return ret
762+
763+
683764
class Text(String):
684765
def __init__(self, **kwargs: typing.Any) -> None:
685766
super().__init__(format="text", **kwargs)

typesystem/formats.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def validate(self, value: typing.Any) -> datetime.datetime:
118118
raise self.validation_error("format")
119119

120120
groups = match.groupdict()
121-
if groups["microsecond"]:
121+
if groups["microsecond"] is not None:
122122
groups["microsecond"] = groups["microsecond"].ljust(6, "0")
123123

124124
tzinfo_str = groups.pop("tzinfo")

typesystem/forms.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,40 @@ def __init__(
3535
*,
3636
env: "jinja2.Environment",
3737
schema: typing.Type[Schema],
38-
values: dict = None,
39-
errors: ValidationError = None,
38+
instance: typing.Any = None
4039
) -> None:
4140
self.env = env
4241
self.schema = schema
43-
self.values = values
44-
self.errors = errors
42+
self.instance = instance
43+
self.values = None if instance is None else self.schema.serialize(instance)
44+
self.errors = None
45+
self._validate_called = False
46+
47+
def validate(self, data: dict = None):
48+
assert not self._validate_called, 'validate() has already been called.'
49+
self.data = data
50+
self.values, self.errors = self.schema.validate_or_error(data)
51+
self._validate_called = True
52+
53+
@property
54+
def is_valid(self):
55+
assert self._validate_called, 'validate() has not been called.'
56+
return self.errors is None
57+
58+
@property
59+
def validated_data(self):
60+
return self.values
4561

4662
def render_fields(self) -> str:
63+
values = self.data if self.errors else self.values
64+
errors = self.errors
65+
4766
html = ""
4867
for field_name, field in self.schema.fields.items():
49-
value = None if self.values is None else self.values.get(field_name)
50-
error = None if self.errors is None else self.errors.get(field_name)
68+
if field.read_only:
69+
continue
70+
value = None if values is None else values.get(field_name)
71+
error = None if errors is None else errors.get(field_name)
5172
html += self.render_field(
5273
field_name=field_name, field=field, value=value, error=error
5374
)
@@ -61,8 +82,8 @@ def render_field(
6182
value: typing.Any = None,
6283
error: str = None,
6384
) -> str:
64-
field_id_prefix = "form-" + self.schema.__name__.lower() + "-"
65-
field_id = field_id_prefix + field_name.replace("_", "-")
85+
# field_id_prefix = "form-" + self.schema.__name__.lower() + "-"
86+
# field_id = field_id_prefix + field_name.replace("_", "-")
6687
label = field.title or field_name
6788
allow_empty = field.allow_null or getattr(field, "allow_blank", False)
6889
required = not field.has_default() and not allow_empty
@@ -72,7 +93,7 @@ def render_field(
7293
value = "" if input_type == "password" else value
7394
return template.render(
7495
{
75-
"field_id": field_id,
96+
# "field_id": field_id,
7697
"field_name": field_name,
7798
"field": field,
7899
"label": label,
@@ -135,11 +156,9 @@ def load_template_env(
135156
)
136157
return jinja2.Environment(loader=loader, autoescape=True)
137158

138-
def Form(
159+
def create_form(
139160
self,
140161
schema: typing.Type[Schema],
141-
*,
142-
values: dict = None,
143-
errors: ValidationError = None,
162+
instance: typing.Any = None
144163
) -> Form: # type: ignore
145-
return Form(env=self.env, schema=schema, values=values, errors=errors)
164+
return Form(env=self.env, schema=schema, instance=instance)

0 commit comments

Comments
 (0)