Pystachio
type-checked dictionary templating library for python
Install / Use
/learn @wickman/PystachioREADME
Pystachio
tl;dr
Pystachio is a type-checked dictionary templating library.
why?
Its primary use is for the construction of miniature domain-specific configuration languages. Schemas defined by Pystachio can themselves be serialized and reconstructed into other Python interpreters. Pystachio objects are tailored via Mustache templates, as explained in the section on templating.
Similar projects
This project is unrelated to the defunct Javascript Python interpreter.
Notable related projects:
- dictshield
- remoteobjects
- Django's model.Model
Requirements
Tested and works in CPython3 and PyPy3.
Overview
You can define a structured type through the 'Struct' type:
from pystachio import (
Integer,
String,
Struct)
class Employee(Struct):
first = String
last = String
age = Integer
By default all fields are optional:
>>> Employee().check()
TypeCheck(OK)
>>> Employee(first = 'brian')
Employee(first=brian)
>>> Employee(first = 'brian').check()
TypeCheck(OK)
But it is possible to make certain fields required:
from pystachio import Required
class Employee(Struct):
first = Required(String)
last = Required(String)
age = Integer
We can still instantiate objects with empty fields:
>>> Employee()
Employee()
But they will fail type checks:
>>> Employee().check()
TypeCheck(FAILED): Employee[last] is required.
Struct objects are purely functional and hence immutable after constructed, however they are composable like functors:
>>> brian = Employee(first = 'brian')
>>> brian(last = 'wickman')
Employee(last=wickman, first=brian)
>>> brian
Employee(first=brian)
>>> brian = brian(last='wickman')
>>> brian.check()
TypeCheck(OK)
Object fields may also acquire defaults:
class Employee(Struct):
first = Required(String)
last = Required(String)
age = Integer
location = Default(String, "San Francisco")
>>> Employee()
Employee(location=San Francisco)
Schemas wouldn't be terribly useful without the ability to be hierarchical:
class Location(Struct):
city = String
state = String
country = String
class Employee(Struct):
first = Required(String)
last = Required(String)
age = Integer
location = Default(Location, Location(city = "San Francisco"))
>>> Employee(first="brian", last="wickman")
Employee(last=wickman, location=Location(city=San Francisco), first=brian)
>>> Employee(first="brian", last="wickman").check()
TypeCheck(OK)
The type system
There are five basic types, two basic container types and then the Struct and Choice types.
Basic Types
There are five basic types: String, Integer, Float, Boolean and Enum. The first four behave as expected:
>>> Float(1.0).check()
TypeCheck(OK)
>>> String("1.0").check()
TypeCheck(OK)
>>> Integer(1).check()
TypeCheck(OK)
>>> Boolean(False).check()
TypeCheck(OK)
They also make a best effort to coerce into the appropriate type:
>>> Float("1.0")
Float(1.0)
>>> String(1.0)
String(1.0)
>>> Integer("1")
Integer(1)
>>> Boolean("true")
Boolean(True)
Though the same gotchas apply as standard coercion in Python:
>>> int("1.0")
ValueError: invalid literal for int() with base 10: '1.0'
>>> Integer("1.0")
pystachio.objects.CoercionError: Cannot coerce '1.0' to Integer
with the exception of Boolean which accepts "false" as falsy.
Enum is a factory that produces new enumeration types:
>>> Enum('Red', 'Green', 'Blue')
<class 'pystachio.typing.Enum_Red_Green_Blue'>
>>> Color = Enum('Red', 'Green', 'Blue')
>>> Color('Red')
Enum_Red_Green_Blue(Red)
>>> Color('Brown')
Traceback (most recent call last):
File "<console>", line 1, in <module>
File "/Users/wickman/clients/pystachio/pystachio/basic.py", line 208, in __init__
self.__class__.__name__, ', '.join(self.VALUES)))
ValueError: Enum_Red_Green_Blue only accepts the following values: Red, Green, Blue
Enums can also be constructed using namedtuple syntax to generate more illustrative class names:
>>> Enum('Color', ('Red', 'Green', 'Blue'))
<class 'pystachio.typing.Color'>
>>> Color = Enum('Color', ('Red', 'Green', 'Blue'))
>>> Color('Red')
Color(Red)
Choices
Choice types represent alternatives - values that can have one of some set of values.
>>> C = Choice([Integer, String])
>>> c1 = C("abc")
>>> c2 = C(343)
Container types
There are two container types: the List type and the Map type. Lists
are parameterized by the type they contain, and Maps are parameterized from
a key type to a value type.
Lists
You construct a List by specifying its type (it actually behaves like a
metaclass, since it produces a type):
>>> List(String)
<class 'pystachio.container.StringList'>
>>> List(String)([])
StringList()
>>> List(String)(["a", "b", "c"])
StringList(a, b, c)
They compose like expected:
>>> li = List(Integer)
>>> li
<class 'pystachio.container.IntegerList'>
>>> List(li)
<class 'pystachio.container.IntegerListList'>
>>> List(li)([li([1,"2",3]), li([' 2', '3 ', 4])])
IntegerListList(IntegerList(1, 2, 3), IntegerList(2, 3, 4))
Type checking is done recursively:
>> List(li)([li([1,"2",3]), li([' 2', '3 ', 4])]).check()
TypeCheck(OK)
Maps
You construct a Map by specifying the source and destination types:
>>> ages = Map(String, Integer)({
... 'brian': 30,
... 'ian': 15,
... 'robey': 5000
... })
>>> ages
StringIntegerMap(brian => 28, ian => 15, robey => 5000)
>>> ages.check()
TypeCheck(OK)
Much like all other types, these types are immutable. The only way to "mutate" would be to create a whole new Map. Technically speaking these types are hashable as well, so you can construct stranger composite types (added indentation for clarity.)
>>> fake_ages = Map(String, Integer)({
... 'brian': 28,
... 'ian': 15,
... 'robey': 5000
... })
>>> real_ages = Map(String, Integer)({
... 'brian': 30,
... 'ian': 21,
... 'robey': 35
... })
>>> believability = Map(Map(String, Integer), Float)({
... fake_ages: 0.2,
... real_ages: 0.9
... })
>>> believability
StringIntegerMapFloatMap(
StringIntegerMap(brian => 28, ian => 15, robey => 5000) => 0.2,
StringIntegerMap(brian => 30, ian => 21, robey => 35) => 0.9)
Object scopes
Objects have "environments": a set of bound scopes that follow the Object
around. Objects are still immutable. The act of binding a variable to an
Object just creates a new object with an additional variable scope. You can
print the scopes by using the scopes function:
>>> String("hello").scopes()
()
You can bind variables to that object with the bind function:
>>> String("hello").bind(herp = "derp")
String(hello)
The environment variables of an object do not alter equality, for example:
>>> String("hello") == String("hello")
True
>>> String("hello").bind(foo = "bar") == String("hello")
True
The object appears to be the same but it carries that scope around with it:
>>> String("hello").bind(herp = "derp").scopes()
(Environment({Ref(herp): 'derp'}),)
Furthermore you can bind multiple times:
>>> String("hello").bind(herp = "derp").bind(herp = "extra derp").scopes()
(Environment({Ref(herp): 'extra derp'}), Environment({Ref(herp): 'derp'}))
You can use keyword arguments, but you can also pass dictionaries directly:
>>> String("hello").bind({"herp": "derp"}).scopes()
(Environment({Ref(herp): 'derp'}),)
Think of this as a "mount table" for mounting objects at particular points in a namespace. This namespace is hierarchical:
>>> String("hello").bind(herp = "derp", metaherp = {"a": 1, "b": {"c": 2}}).scopes()
(Environment({Ref(herp): 'derp', Ref(metaherp.b.c): '2', Ref(metaherp.a): '1'}),)
In fact, you can bind any Namable object, including List, Map, and
Struct types directly:
>>> class Person(Struct)
... first = String
... last = String
...
>>> String("hello").bind(Person(first="brian")).scopes()
(Person(first=brian),)
The Environment object is simply a mechanism to bind arbitrary strings
into a namespace compatible with Namable objects.
Because you can bind multiple times, scopes just form a name-resolution order:
>>> (String("hello").bind(Person(first="brian"), first="john")
.bind({'first': "jake"}, Person(first="jane"))).scopes()
(Person(first=jane),
Environment({Ref(first): 'jake'}),
Environment({Ref(first): 'john'}),
Person(first=brian))
The later a variable is bound, the "higher priority" its name resolution
becomes. Binding to an object is to achieve the effect of local overriding.
But you can also do a lower-priority "global" bindings via in_scope:
>>> env = Environment(globalvar = "global variable", sharedvar = "global shared variable")
>>> obj = String("hello").bind(localvar = "local variable", sharedvar = "local shared variable")
>>> obj.scopes()
(Environment({Ref(localvar): 'local variable', Ref(sharedvar): 'local shared variable'}),)
Now we can bind env directly into obj as if they were local variables using bind:
>>> obj.bind(env).scopes()
(Environment({Ref(globalvar): 'global variable', Ref(shared
