From 0a4ed9a619125829f6338ab4ddb1e3c3b8dbb4ef Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Tue, 16 Dec 2014 14:44:37 -0500 Subject: [PATCH 01/30] Bump version. --- setup.py | 2 +- simplestruct/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index f949d18..5ce1fa3 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name = 'SimpleStruct', - version = '0.2.0', + version = '0.2.1', url = '/~https://github.com/brandjon/simplestruct', author = 'Jon Brandvein', diff --git a/simplestruct/__init__.py b/simplestruct/__init__.py index 7c30fbb..d0d7ccf 100644 --- a/simplestruct/__init__.py +++ b/simplestruct/__init__.py @@ -3,7 +3,7 @@ checking, mutability, and inheritance. """ -__version__ = '0.2.0' +__version__ = '0.2.1' from .struct import * from .fields import * From c8ab0b1c10a4b0741d4aa39622e150b2ef28ba2c Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Tue, 16 Dec 2014 14:51:19 -0500 Subject: [PATCH 02/30] Removed old todo note, added info on running tests. --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 14a284a..b90a045 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,21 @@ overhead of descriptor function calls. See the `examples/` directory. +## Developers ## + +Tests can be run with + +``` +python setup.py test +``` +or alternatively by installing [Tox](http://testrun.org/tox/latest/) and +running +``` +python -m tox +``` +in the project root. Tox has the advantage of automatically testing both +Python 3.3 and 3.4. + ## TODO ### Features TODO: @@ -99,6 +114,3 @@ Features TODO: - make exceptions appear to be raised from the stack frame of user code where the type error occurred, rather than inside this library (with a flag to disable, for debugging) - -Packaging TODO: -- fix up setup.py, make installable From d78f27a59d28e4495f4a50a30deaafa15423b221 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Tue, 16 Dec 2014 15:40:22 -0500 Subject: [PATCH 03/30] Added see-also and setuptools-git to readme. Added note on possibly copy() method. --- README.md | 25 +++++++++++++------------ simplestruct/struct.py | 7 +++++++ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b90a045..1782249 100644 --- a/README.md +++ b/README.md @@ -92,20 +92,21 @@ overhead of descriptor function calls. See the `examples/` directory. -## Developers ## +## See also ## -Tests can be run with +* The standard library's [namedtuple](https://docs.python.org/3/library/collections.html#collections.namedtuple) -``` -python setup.py test -``` -or alternatively by installing [Tox](http://testrun.org/tox/latest/) and -running -``` -python -m tox -``` -in the project root. Tox has the advantage of automatically testing both -Python 3.3 and 3.4. +* Li Haoyi's MacroPy, specifically [case classes](/~https://github.com/lihaoyi/macropy#case-classes) + + +## Developers ## + +Tests can be run with `python setup.py test`, or alternatively by +installing [Tox](http://testrun.org/tox/latest/) and running +`python -m tox` in the project root. Tox has the advantage of automatically +testing both for Python 3.3 and 3.4. Building a source distribution +(`python setup.py sdist`) requires the setuptools extension package +[setuptools-git](/~https://github.com/wichert/setuptools-git). ## TODO ### diff --git a/simplestruct/struct.py b/simplestruct/struct.py index 9b76bce..d2de343 100644 --- a/simplestruct/struct.py +++ b/simplestruct/struct.py @@ -235,3 +235,10 @@ def _replace(self, **kargs): for f in self._struct} fields.update(kargs) return type(self)(**fields) + + # XXX: We could provide a copy() method as well, analogous to + # list, dict, and other collections. Unlike the above methods, + # it would not have an underscore prefix, and potentially clash + # with a user-defined field named "copy". But in this case, + # the user field should simply take precedence and shadow + # this feature. From c4d51fbe355ecea775646846b959dcb5b2b44645 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Wed, 17 Dec 2014 15:46:39 -0500 Subject: [PATCH 04/30] Fixed __repr__() for recursive structs. --- simplestruct/struct.py | 4 ++-- tests/test_struct.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/simplestruct/struct.py b/simplestruct/struct.py index d2de343..9bb7194 100644 --- a/simplestruct/struct.py +++ b/simplestruct/struct.py @@ -11,6 +11,7 @@ from collections import OrderedDict, Counter from functools import reduce from inspect import Signature, Parameter +from reprlib import recursive_repr def hash_seq(seq): @@ -182,8 +183,7 @@ def _fmt_helper(self, fmt): ', '.join('{}={}'.format(f.name, fmt(getattr(self, f.name))) for f in self._struct)) - def __str__(self): - return self._fmt_helper(str) + @recursive_repr() def __repr__(self): return self._fmt_helper(repr) diff --git a/tests/test_struct.py b/tests/test_struct.py index b25dc3b..cf3c0ab 100644 --- a/tests/test_struct.py +++ b/tests/test_struct.py @@ -219,6 +219,17 @@ class Bar(Foo): foo = Foo(1) bar = Bar(1) self.assertNotEqual(foo, bar) + + def test_recur(self): + # __repr__ for recursive objects. + class Foo(Struct): + _immutable = False + a = Field() + f = Foo(None) + f.a = f + s = repr(f) + exp_s = 'Foo(a=...)' + self.assertEqual(s, exp_s) if __name__ == '__main__': From bd9ca2729ef1e0774857a1a9369e2e4e6fbc6557 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Wed, 17 Dec 2014 16:09:36 -0500 Subject: [PATCH 05/30] Added CHANGES.md. --- CHANGES.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..9322993 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,9 @@ +# Release notes + +## 0.2.1 (unreleased) + +- fix `__repr__()` on recursive Structs + +## 0.2.0 (2014-12-15) + +- initial release From fe48f34eb946f58554934eb912d4df0fdf359f11 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Thu, 18 Dec 2014 12:28:23 -0500 Subject: [PATCH 06/30] Added optional default values for fields. --- simplestruct/struct.py | 25 ++++++++++++++++++++----- tests/test_struct.py | 28 +++++++++++++++++++--------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/simplestruct/struct.py b/simplestruct/struct.py index 9bb7194..cce54ed 100644 --- a/simplestruct/struct.py +++ b/simplestruct/struct.py @@ -31,16 +31,23 @@ class Field: equality semantics. """ - def __init__(self): + NO_DEFAULT = object() + + def __init__(self, default=NO_DEFAULT): # name is the attribute name through which this field is # accessed from the Struct. This will be set automatically # by MetaStruct. self.name = None + self.default = default def copy(self): # This is used by MetaStruct to get a fresh instance # of the field for each of its occurrences. - return type(self)() + return type(self)(default=self.default) + + @property + def has_default(self): + return self.default is not self.NO_DEFAULT def __get__(self, inst, value): if inst is None: @@ -115,9 +122,12 @@ def __new__(mcls, clsname, bases, namespace, **kargs): cls._struct = tuple(fields) - cls._signature = Signature( - parameters=[Parameter(f.name, Parameter.POSITIONAL_OR_KEYWORD) - for f in cls._struct]) + params = [] + for f in cls._struct: + default = f.default if f.has_default else Parameter.empty + params.append(Parameter(f.name, Parameter.POSITIONAL_OR_KEYWORD, + default=default)) + cls._signature = Signature(params) return cls @@ -170,6 +180,11 @@ def __new__(cls, *args, **kargs): try: boundargs = cls._signature.bind(*args, **kargs) + # Include default arguments. + for param in cls._signature.parameters.values(): + if (param.name not in boundargs.arguments and + param.default is not param.empty): + boundargs.arguments[param.name] = param.default for f in cls._struct: setattr(inst, f.name, boundargs.arguments[f.name]) except TypeError as exc: diff --git a/tests/test_struct.py b/tests/test_struct.py index cf3c0ab..949b8b4 100644 --- a/tests/test_struct.py +++ b/tests/test_struct.py @@ -74,6 +74,16 @@ class Foo(Struct): with self.assertRaises(TypeError): hash(f) + # Tuple decomposition. + class Foo(Struct): + a = Field() + b = Field() + f = Foo(1, 2) + a, b = f + self.assertEqual(len(f), 2) + self.assertEqual((a, b), (1, 2)) + + def test_construct(self): # Construction by keyword. class Foo(Struct): a = Field() @@ -86,6 +96,15 @@ class Foo(Struct): names = [f.name for f in Foo._struct] self.assertEqual(names, ['a', 'b', 'c']) + # Construction with defaults. + class Foo(Struct): + a = Field() + b = Field(default='b') + f = Foo(1, 2) + self.assertEqual((f.a, f.b), (1, 2)) + f = Foo(1) + self.assertEqual((f.a, f.b), (1, 'b')) + # Parentheses-less shorthand. class Foo(Struct): bar = Field @@ -103,15 +122,6 @@ class Foo2(Struct): # overlap, there'd be a name collision anyway. ids = {id(f) for f in Foo1._struct + Foo2._struct} self.assertTrue(len(ids) == 3) - - # Tuple decomposition. - class Foo(Struct): - a = Field() - b = Field() - f = Foo(1, 2) - a, b = f - self.assertEqual(len(f), 2) - self.assertEqual((a, b), (1, 2)) def test_mutability(self): # Mutable, unhashable. From 3c4e303c7bd5a205057d659117d97303f4b69e86 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Thu, 18 Dec 2014 12:33:15 -0500 Subject: [PATCH 07/30] Added opt flag to TypedField. --- simplestruct/fields.py | 23 +++++++++++++++-------- tests/test_fields.py | 9 +++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/simplestruct/fields.py b/simplestruct/fields.py index 3cbd7d2..2858cb7 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -20,22 +20,29 @@ class TypedField(Field): The sequence gets converted to a tuple if it isn't already. If nodup is also True, the elements must be distinct (as determined by kind.__eq__()). + + If opt is True, None is a valid value and the default if no + value is given. """ - def __init__(self, kind, *, seq=False, nodups=False): - super().__init__() + def __init__(self, kind, *, seq=False, nodups=False, opt=False): + default = None if opt else self.NO_DEFAULT + super().__init__(default=default) self.kind = normalize_kind(kind) self.seq = seq self.nodups = nodups + self.opt = opt def copy(self): - return type(self)(self.kind, seq=self.seq, nodups=self.nodups) + return type(self)(self.kind, seq=self.seq, nodups=self.nodups, + opt=self.opt) def __set__(self, inst, value): - if self.seq: - checktype_seq(value, self.kind, self.nodups) - value = tuple(value) - else: - checktype(value, self.kind) + if not (self.opt and value is None): + if self.seq: + checktype_seq(value, self.kind, self.nodups) + value = tuple(value) + else: + checktype(value, self.kind) super().__set__(inst, value) diff --git a/tests/test_fields.py b/tests/test_fields.py index 963507d..a61c5a8 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -31,6 +31,15 @@ class Foo(Struct): Foo([1, 2]) with self.assertRaises(TypeError): Foo([1, 2, 1]) + + # Optional case. + class Foo(Struct): + _immutable = False + bar = TypedField(int, opt=True) + f1 = Foo() + f2 = Foo(5) + f2.bar = None + self.assertEqual(f1, f2) if __name__ == '__main__': unittest.main() From 5738a4765da50a150abff61ed5ce87512417baf9 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Thu, 18 Dec 2014 13:07:28 -0500 Subject: [PATCH 08/30] Updated changelog, vector.py example. --- CHANGES.md | 4 ++- examples/vector.py | 85 +++++++++++++++++++++++++++++++++------------- 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 9322993..aa27346 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,9 @@ ## 0.2.1 (unreleased) -- fix `__repr__()` on recursive Structs +- added support for default values in general, and optional values + for type-checked fields +- fixed `__repr__()` on recursive Structs ## 0.2.0 (2014-12-15) diff --git a/examples/vector.py b/examples/vector.py index 82db621..8441571 100644 --- a/examples/vector.py +++ b/examples/vector.py @@ -1,12 +1,63 @@ -"""Illustrates inheritance, non-field data, and mutability.""" +"""Illustrates more advanced features like type-checking, +inheritance, non-field data, and mutability. +""" -from simplestruct import Struct, Field +from simplestruct import Struct, Field, TypedField + + +print('==== Default values ====') + +class AxisPoint(Struct): + x = Field(default=0) + y = Field(default=0) + +p1 = AxisPoint(x=2) +print(p1) # AxisPoint(x=2, y=0) +p2 = AxisPoint(y=3) +print(p2) # AxisPoint(x=0, y=3) + + +print('\n==== Type checking ====') class Point2D(Struct): + x = TypedField(int) + y = TypedField(int) + +p1 = Point2D(2, 3) +try: + Point2D('a', 'b') +except TypeError: + print('Exception') + + +print('\n==== Mutability ====') + +# Structs are immutable by default. +try: + p1.x = 7 +except AttributeError: + print('Exception') + +class Point3D(Point2D): + _immutable = False x = Field y = Field + z = Field + +p2 = Point3D(3, 4, 5) +print(p2) # Point3D(x=3, y=4, z=5) +p2.x = 7 +print(p2) # Point3D(x=7, y=4, z=5) + +# Mutable structs can't be hashed (analogous to Python lists, dicts, sets). +try: + hash(p2) +except TypeError: + print('Exception') + + +print('\n==== Subclassing and non-field data ====') -# Derived class that adds a computed magnitude data. class Vector2D(Point2D): # Special flag to inherit x and y fields without # needing to redeclare. @@ -28,32 +79,20 @@ def __init__(self, x, y): p1 = Point2D(3, 4) v1 = Vector2D(3, 4) - print(p1) # Point2D(x=3, y=4) print(v1) # Vector2D(x=3, y=4) print(v1.mag) # 5.0 - # Equality does not hold between different types. print(p1 == v1) # False -# Structs are immutable by default. -try: - p1.x = 7 -except AttributeError: - print('Exception') -# Let's make a mutable 3D point. -class Point3D(Point2D): - _inherit_fields = True - _immutable = False - z = Field +print('\n==== More advanced types ====') -p2 = Point3D(3, 4, 5) -print(p2) # Point3D(x=3, y=4, z=5) -p2.x = 7 -print(p2) # Point3D(x=7, y=4, z=5) +# n-dimensional vector +class Vector(Struct): + # 'seq' is for sequence types. The value gets normalized + # to a tuple. + vals = TypedField(int, seq=True) -try: - hash(p2) -except TypeError: - print('Exception') +v1 = Vector([1, 2, 3, 4]) +print(v1.vals) # (1, 2, 3, 4) From b6411566640a10e317fe2f13d123a027cb7a99fe Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Thu, 18 Dec 2014 18:25:18 -0500 Subject: [PATCH 09/30] Fixed regression in Struct.__str__() using Struct.__repr__(). --- simplestruct/struct.py | 8 ++++++++ tests/test_struct.py | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/simplestruct/struct.py b/simplestruct/struct.py index cce54ed..21c8e5d 100644 --- a/simplestruct/struct.py +++ b/simplestruct/struct.py @@ -192,12 +192,20 @@ def __new__(cls, *args, **kargs): return inst + # str() and repr() both recurse over their fields with + # whichever function was used initially. Both are protected + # from recursive cycles with the help of reprlib. + def _fmt_helper(self, fmt): return '{}({})'.format( self.__class__.__name__, ', '.join('{}={}'.format(f.name, fmt(getattr(self, f.name))) for f in self._struct)) + @recursive_repr() + def __str__(self): + return self._fmt_helper(str) + @recursive_repr() def __repr__(self): return self._fmt_helper(repr) diff --git a/tests/test_struct.py b/tests/test_struct.py index 949b8b4..19e5990 100644 --- a/tests/test_struct.py +++ b/tests/test_struct.py @@ -51,9 +51,10 @@ def test_Struct(self): # Basic instantiation and pretty printing. class Foo(Struct): bar = Field() - f = Foo(5) - self.assertEqual(f.bar, 5) - self.assertEqual(str(f), 'Foo(bar=5)') + f = Foo('a') + self.assertEqual(f.bar, 'a') + self.assertEqual(str(f), 'Foo(bar=a)') + self.assertEqual(repr(f), "Foo(bar='a')") # Equality and hashing. class Foo(Struct): From 8d6892ea847d151948af68b259ed1ea428f7c5db Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Thu, 18 Dec 2014 19:55:52 -0500 Subject: [PATCH 10/30] Allow retrieval of field descriptor from class. --- CHANGES.md | 1 + simplestruct/struct.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index aa27346..3c11c0d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ ## 0.2.1 (unreleased) +- accessing fields descriptors from classes is now permissible - added support for default values in general, and optional values for type-checked fields - fixed `__repr__()` on recursive Structs diff --git a/simplestruct/struct.py b/simplestruct/struct.py index 21c8e5d..4328766 100644 --- a/simplestruct/struct.py +++ b/simplestruct/struct.py @@ -51,7 +51,7 @@ def has_default(self): def __get__(self, inst, value): if inst is None: - raise AttributeError('Cannot retrieve field from class') + return self return inst.__dict__[self.name] def __set__(self, inst, value): From 87dc79bfb9e8f4cdaa8280e36aef3e31d6019727 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Fri, 19 Dec 2014 19:40:43 -0500 Subject: [PATCH 11/30] Split off hooks for TypedField subclasses. --- CHANGES.md | 1 + simplestruct/fields.py | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 3c11c0d..bb9691f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ ## 0.2.1 (unreleased) +- added check() and normalize() hooks to TypedField - accessing fields descriptors from classes is now permissible - added support for default values in general, and optional values for type-checked fields diff --git a/simplestruct/fields.py b/simplestruct/fields.py index 2858cb7..89911b5 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -37,12 +37,27 @@ def copy(self): return type(self)(self.kind, seq=self.seq, nodups=self.nodups, opt=self.opt) - def __set__(self, inst, value): + def check(self, inst, value): + """Raise TypeError if value doesn't satisfy the constraints + for use on instance inst. + """ if not (self.opt and value is None): if self.seq: checktype_seq(value, self.kind, self.nodups) value = tuple(value) else: checktype(value, self.kind) - + + def normalize(self, inst, value): + """Return value or a normalized form of it for use on + instance inst. + """ + if (not (self.opt and value is None) and + self.seq): + value = tuple(value) + return value + + def __set__(self, inst, value): + self.check(inst, value) + value = self.normalize(inst, value) super().__set__(inst, value) From 6dcacb86927d75acb2be6754e881aa1bc64065f4 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Fri, 19 Dec 2014 21:50:35 -0500 Subject: [PATCH 12/30] Refactor type.py into a mixin class from which TypedField inherits. --- simplestruct/fields.py | 11 ++- simplestruct/type.py | 157 +++++++++++++++++++++-------------------- tests/test_type.py | 54 ++++++++++---- 3 files changed, 125 insertions(+), 97 deletions(-) diff --git a/simplestruct/fields.py b/simplestruct/fields.py index 89911b5..df4e1c1 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -7,10 +7,10 @@ from .struct import Field -from .type import normalize_kind, checktype, checktype_seq +from .type import TypeChecker -class TypedField(Field): +class TypedField(Field, TypeChecker): """A field with dynamically-checked type constraints. @@ -28,7 +28,7 @@ class TypedField(Field): def __init__(self, kind, *, seq=False, nodups=False, opt=False): default = None if opt else self.NO_DEFAULT super().__init__(default=default) - self.kind = normalize_kind(kind) + self.kind = self.normalize_kind(kind) self.seq = seq self.nodups = nodups self.opt = opt @@ -43,10 +43,9 @@ def check(self, inst, value): """ if not (self.opt and value is None): if self.seq: - checktype_seq(value, self.kind, self.nodups) - value = tuple(value) + self.checktype_seq(value, self.kind, self.nodups) else: - checktype(value, self.kind) + self.checktype(value, self.kind) def normalize(self, inst, value): """Return value or a normalized form of it for use on diff --git a/simplestruct/type.py b/simplestruct/type.py index a6edb7d..3ffce66 100644 --- a/simplestruct/type.py +++ b/simplestruct/type.py @@ -3,92 +3,97 @@ Spare me the "It's not the Python way" lectures. I've lost too much time to type errors in places where I never had any intention of allowing duck-typed alternative values. - -For our purposes, a "kind" is a tuple of types. A value satisfies -a kind if it is an instance of any of the types. For convenience, -kinds may also be specified as a single type, a sequence other than -a tuple, and as None (equivalent to (object,)). """ __all__ = [ - 'normalize_kind', - 'checktype', - 'checktype_seq', + 'TypeChecker', ] -def str_valtype(val): - """Get a string describing the type of val.""" - return val.__class__.__name__ - -def normalize_kind(kind): - """Make a proper kind out of one of the alternative forms.""" - if kind is None: - return (object,) - elif isinstance(kind, type): - return (kind,) - else: - return tuple(kind) - -def str_kind(kind): - """Get a string describing a kind.""" - if len(kind) == 0: - return '()' - elif len(kind) == 1: - return kind[0].__name__ - elif len(kind) == 2: - return kind[0].__name__ + ' or ' + kind[1].__name__ - else: - return 'one of {' + ', '.join(t.__name__ for t in kind) + '}' - - -def checktype(val, kind): - """Raise TypeError if val does not satisfy kind.""" - kind = normalize_kind(kind) - if not isinstance(val, kind): - raise TypeError('Expected {}; got {}'.format( - str_kind(kind), str_valtype(val))) - -def checktype_seq(seq, kind, nodups=False): - """Raise TypeError if seq is not a sequence of elements satisfying - kind. Optionally require elements to be unique. +class TypeChecker: - As a special case, a string is considered to be an atomic value - rather than a sequence of single-character strings. (Thus, - checktype_seq('foo', str) will fail.) + """A simple type checker supporting sequences and unions. + Suitable for use as a mixin. + + A "kind" is a tuple of types. A value satisfies a kind if it is + an instance of any of the types. """ - kind = normalize_kind(kind) - exp = str_kind(kind) - # Make sure we have a sequence. - try: - iterator = iter(seq) - # Generators aren't sequences. This avoids a confusing bug - # where we consume a generator by type-checking it, and leave - # only an exhausted iterator for the user code. - len(seq) - except TypeError: - got = str_valtype(seq) - raise TypeError('Expected sequence of {}; ' - 'got {} instead of sequence'.format(exp, got)) + def str_valtype(self, val): + """Get a string describing the type of val.""" + if val is None: + return 'None' + return val.__class__.__name__ + + def normalize_kind(self, kindlike): + """Make a kind out of a possible shorthand. If the given + argument is a sequence of types or a singular type, it becomes + a kind that accepts exactly those types. If the given argument + is None, it becomes a type that accepts anything. + """ + if kindlike is None: + return (object,) + elif isinstance(kindlike, type): + return (kindlike,) + else: + return tuple(kindlike) - if isinstance(seq, str): - raise TypeError('Expected sequence of {}; got single str ' - '(strings do not count as character ' - 'sequences)'.format(exp)) + def str_kind(self, kind): + """Get a string describing a kind.""" + if len(kind) == 0: + return 'Nothing' + elif len(kind) == 1: + return kind[0].__name__ + elif len(kind) == 2: + return kind[0].__name__ + ' or ' + kind[1].__name__ + else: + return 'one of {' + ', '.join(t.__name__ for t in kind) + '}' + + def checktype(self, val, kind): + """Raise TypeError if val does not satisfy kind.""" + if not isinstance(val, kind): + raise TypeError('Expected {}; got {}'.format( + self.str_kind(kind), self.str_valtype(val))) + + def checktype_seq(self, seq, kind, nodups=False): + """Raise TypeError if seq is not a sequence of elements satisfying + kind. Optionally require elements to be unique. - for i, item in enumerate(iterator): - if not isinstance(item, kind): - got = str_valtype(item) + As a special case, a string is considered to be an atomic value + rather than a sequence of single-character strings. (Thus, + checktype_seq('foo', str) will fail.) + """ + exp = self.str_kind(kind) + + # Make sure we have a sequence. + try: + iterator = iter(seq) + # Generators aren't sequences. This avoids a confusing bug + # where we consume a generator by type-checking it, and leave + # only an exhausted iterator for the user code. + len(seq) + except TypeError: + got = self.str_valtype(seq) raise TypeError('Expected sequence of {}; ' - 'got sequence with {} at position {}'.format( - exp, got, i)) - - if nodups: - seen = [] - for i, item in enumerate(seq): - if item in seen: - raise TypeError('Duplicate element {} at position {}'.format( - repr(item), i)) - seen.append(item) + 'got {} instead of sequence'.format(exp, got)) + + if isinstance(seq, str): + raise TypeError('Expected sequence of {}; got single str ' + '(strings do not count as character ' + 'sequences)'.format(exp)) + + for i, item in enumerate(iterator): + if not isinstance(item, kind): + got = self.str_valtype(item) + raise TypeError('Expected sequence of {}; ' + 'got sequence with {} at position {}'.format( + exp, got, i)) + + if nodups: + seen = [] + for i, item in enumerate(seq): + if item in seen: + raise TypeError('Duplicate element {} at ' + 'position {}'.format(repr(item), i)) + seen.append(item) diff --git a/tests/test_type.py b/tests/test_type.py index 5ec2571..1421d6f 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -8,42 +8,66 @@ class ChecktypeCase(unittest.TestCase): + def setUp(self): + self.checker = TypeChecker() + + def test_strs(self): + c = self.checker + self.assertEqual(c.str_valtype(None), 'None') + self.assertEqual(c.str_valtype(5), 'int') + self.assertEqual(c.str_kind(()), 'Nothing') + self.assertEqual(c.str_kind((int,)), 'int') + self.assertEqual(c.str_kind((int, str)), 'int or str') + self.assertEqual(c.str_kind((int, str, bool)), + 'one of {int, str, bool}') + + def test_normalize(self): + c = self.checker + self.assertEqual(c.normalize_kind((int,)), (int,)) + self.assertEqual(c.normalize_kind([int,]), (int,)) + self.assertEqual(c.normalize_kind(int), (int,)) + self.assertEqual(c.normalize_kind(None), (object,)) + def test_checktype(self): - checktype('a', str) - checktype(True, int) # This is correct, bool subtypes int - checktype(5, (str, int)) + c = self.checker + + c.checktype('a', (str,)) + c.checktype(True, (int,)) # This is correct, bool subtypes int + c.checktype(5, (str, int)) with self.assertRaisesRegex( - TypeError, 'Expected int; got NoneType'): - checktype(None, int) + TypeError, 'Expected int; got None'): + c.checktype(None, (int,)) with self.assertRaisesRegex( - TypeError, 'Expected str or int; got NoneType'): - checktype(None, (str, int)) + TypeError, 'Expected str or int; got None'): + c.checktype(None, (str, int)) def test_checktype_seq(self): - checktype_seq([], str) - checktype_seq([3, True], int) + c = self.checker + + c.checktype_seq([], (str,)) + c.checktype_seq([3, True], (int,)) with self.assertRaisesRegex( TypeError, 'Expected sequence of bool; got sequence with ' 'int at position 0'): - checktype_seq([3, True], bool) + c.checktype_seq([3, True], (bool,)) with self.assertRaisesRegex( TypeError, 'Expected sequence of bool; ' 'got bool instead of sequence'): - checktype_seq(True, bool) + c.checktype_seq(True, (bool,)) with self.assertRaisesRegex( TypeError, 'Expected sequence of str; ' 'got single str'): - checktype_seq('abc', str) + c.checktype_seq('abc', (str,)) with self.assertRaisesRegex( TypeError, 'Expected sequence of int; ' 'got generator instead of sequence'): - checktype_seq((i for i in range(3)), int) + c.checktype_seq((i for i in range(3)), (int,)) - checktype_seq([5, 3, 5, 8], int) + c.checktype_seq([5, 3, 5, 8], (int,)) with self.assertRaisesRegex( TypeError, 'Duplicate element 5 at position 2'): - checktype_seq([5, 3, 5, 8], int, nodups=True) + c.checktype_seq([5, 3, 5, 8], (int,), nodups=True) if __name__ == '__main__': From 790eaf99b6ed2026b5ea5d54febe31ac01785c4e Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Fri, 19 Dec 2014 21:55:02 -0500 Subject: [PATCH 13/30] Make checktype_seq() use checktype(). --- simplestruct/type.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/simplestruct/type.py b/simplestruct/type.py index 3ffce66..6e3b26d 100644 --- a/simplestruct/type.py +++ b/simplestruct/type.py @@ -84,7 +84,12 @@ def checktype_seq(self, seq, kind, nodups=False): 'sequences)'.format(exp)) for i, item in enumerate(iterator): - if not isinstance(item, kind): + # Depend on checktype() to check individual elements, + # but generate an error message that includes the position + # of the failure. + try: + self.checktype(item, kind) + except TypeError: got = self.str_valtype(item) raise TypeError('Expected sequence of {}; ' 'got sequence with {} at position {}'.format( From 14948eb5d5e801632b9cca0560be9e0b0758e2fc Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Fri, 19 Dec 2014 22:06:18 -0500 Subject: [PATCH 14/30] Star-arg'd checktype() and checktype_seq(). This allows TypedField to pass in instance information, in case a subclass wants to override checktype to use it. --- simplestruct/fields.py | 4 ++-- simplestruct/type.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/simplestruct/fields.py b/simplestruct/fields.py index df4e1c1..e3f82f5 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -43,9 +43,9 @@ def check(self, inst, value): """ if not (self.opt and value is None): if self.seq: - self.checktype_seq(value, self.kind, self.nodups) + self.checktype_seq(value, self.kind, self.nodups, inst=inst) else: - self.checktype(value, self.kind) + self.checktype(value, self.kind, inst=inst) def normalize(self, inst, value): """Return value or a normalized form of it for use on diff --git a/simplestruct/type.py b/simplestruct/type.py index 6e3b26d..75995eb 100644 --- a/simplestruct/type.py +++ b/simplestruct/type.py @@ -50,13 +50,13 @@ def str_kind(self, kind): else: return 'one of {' + ', '.join(t.__name__ for t in kind) + '}' - def checktype(self, val, kind): + def checktype(self, val, kind, **kargs): """Raise TypeError if val does not satisfy kind.""" if not isinstance(val, kind): raise TypeError('Expected {}; got {}'.format( self.str_kind(kind), self.str_valtype(val))) - def checktype_seq(self, seq, kind, nodups=False): + def checktype_seq(self, seq, kind, nodups=False, **kargs): """Raise TypeError if seq is not a sequence of elements satisfying kind. Optionally require elements to be unique. @@ -88,7 +88,7 @@ def checktype_seq(self, seq, kind, nodups=False): # but generate an error message that includes the position # of the failure. try: - self.checktype(item, kind) + self.checktype(item, kind, **kargs) except TypeError: got = self.str_valtype(item) raise TypeError('Expected sequence of {}; ' From 061870fa4adb99cb08ffffde8049708d030f4f75 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Fri, 19 Dec 2014 22:27:53 -0500 Subject: [PATCH 15/30] Added convenience methods to type.py. --- simplestruct/type.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/simplestruct/type.py b/simplestruct/type.py index 75995eb..724e9c4 100644 --- a/simplestruct/type.py +++ b/simplestruct/type.py @@ -8,6 +8,8 @@ __all__ = [ 'TypeChecker', + 'checktype', + 'checktype_seq', ] @@ -102,3 +104,17 @@ def checktype_seq(self, seq, kind, nodups=False, **kargs): raise TypeError('Duplicate element {} at ' 'position {}'.format(repr(item), i)) seen.append(item) + + +# We export some convenience methods so the caller doesn't have to +# instantiate TypeChecker. These methods automatically normalize kind. + +checker = TypeChecker() + +def checktype(kind, val): + kind = checker.normalize_kind(kind) + checker.checktype(kind, val) + +def checktype_seq(kind, val): + kind = checker.normalize_kind(kind) + checker.checktype_seq(kind, val) From aed7819bfd7bcf3b8b6b1a484ef4a84deb3e4461 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Fri, 19 Dec 2014 23:35:15 -0500 Subject: [PATCH 16/30] Passing opt=True to TypedField no longer implies a default value. Updated changelog. --- CHANGES.md | 3 +++ simplestruct/fields.py | 8 +++----- tests/test_fields.py | 7 +++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index bb9691f..83ee20b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,9 @@ ## 0.2.1 (unreleased) +- using opt=True on TypedField no longer implies that None is + the default value +- made mixin version of checktype() and checktype_seq() - added check() and normalize() hooks to TypedField - accessing fields descriptors from classes is now permissible - added support for default values in general, and optional values diff --git a/simplestruct/fields.py b/simplestruct/fields.py index e3f82f5..f7f909c 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -21,13 +21,11 @@ class TypedField(Field, TypeChecker): If nodup is also True, the elements must be distinct (as determined by kind.__eq__()). - If opt is True, None is a valid value and the default if no - value is given. + If opt is True, None is a valid value. """ - def __init__(self, kind, *, seq=False, nodups=False, opt=False): - default = None if opt else self.NO_DEFAULT - super().__init__(default=default) + def __init__(self, kind, *, seq=False, nodups=False, opt=False, **kargs): + super().__init__(**kargs) self.kind = self.normalize_kind(kind) self.seq = seq self.nodups = nodups diff --git a/tests/test_fields.py b/tests/test_fields.py index a61c5a8..5c832d3 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -16,6 +16,8 @@ class Foo(Struct): f = Foo(1) with self.assertRaises(TypeError): Foo('a') + with self.assertRaises(TypeError): + Foo(None) # Sequence case. class Foo(Struct): @@ -36,10 +38,7 @@ class Foo(Struct): class Foo(Struct): _immutable = False bar = TypedField(int, opt=True) - f1 = Foo() - f2 = Foo(5) - f2.bar = None - self.assertEqual(f1, f2) + f1 = Foo(None) if __name__ == '__main__': unittest.main() From 696c7ba4fb282b5ae40c54ab5dc595de6540bfe5 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Fri, 19 Dec 2014 23:47:02 -0500 Subject: [PATCH 17/30] TypedField.copy() should pass in default= as well. --- simplestruct/fields.py | 2 +- simplestruct/struct.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/simplestruct/fields.py b/simplestruct/fields.py index f7f909c..c0b0c2b 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -33,7 +33,7 @@ def __init__(self, kind, *, seq=False, nodups=False, opt=False, **kargs): def copy(self): return type(self)(self.kind, seq=self.seq, nodups=self.nodups, - opt=self.opt) + opt=self.opt, default=self.default) def check(self, inst, value): """Raise TypeError if value doesn't satisfy the constraints diff --git a/simplestruct/struct.py b/simplestruct/struct.py index 4328766..78836e4 100644 --- a/simplestruct/struct.py +++ b/simplestruct/struct.py @@ -31,6 +31,19 @@ class Field: equality semantics. """ + # TODO: The current copy() is not ideal because a subclass that + # overrides it needs to know about the fields of the base class, + # so that it can pass those to the newly constructed object. + # For instance, TypedField needs to know to pass in + # default=self.default. + # + # One possible solution is to get meta: make Field itself be a + # Struct, and let attributes like default be Struct fields. + # Then the use of copy() becomes _replace(name=new_name), + # and subclasses simply set _inherit_fields = True. + # This solution would require a new non-Struct BaseField class + # for bootstrapping. + NO_DEFAULT = object() def __init__(self, default=NO_DEFAULT): From 51686d36091af93a75c388702e5da83ba5f14529 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Fri, 19 Dec 2014 23:51:34 -0500 Subject: [PATCH 18/30] Made TypedField.kind a property. This ensures that it gets normalized when it's overwritten outside of the constructor. --- simplestruct/fields.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/simplestruct/fields.py b/simplestruct/fields.py index c0b0c2b..24dd482 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -26,7 +26,7 @@ class TypedField(Field, TypeChecker): def __init__(self, kind, *, seq=False, nodups=False, opt=False, **kargs): super().__init__(**kargs) - self.kind = self.normalize_kind(kind) + self.kind = kind self.seq = seq self.nodups = nodups self.opt = opt @@ -35,6 +35,13 @@ def copy(self): return type(self)(self.kind, seq=self.seq, nodups=self.nodups, opt=self.opt, default=self.default) + @property + def kind(self): + return self._kind + @kind.setter + def kind(self, k): + self._kind = self.normalize_kind(k) + def check(self, inst, value): """Raise TypeError if value doesn't satisfy the constraints for use on instance inst. From d8d37363319ff220a1766a65bd556c94193c8130 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 00:22:32 -0500 Subject: [PATCH 19/30] Clearer error message. --- simplestruct/type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplestruct/type.py b/simplestruct/type.py index 724e9c4..db702b0 100644 --- a/simplestruct/type.py +++ b/simplestruct/type.py @@ -95,7 +95,7 @@ def checktype_seq(self, seq, kind, nodups=False, **kargs): got = self.str_valtype(item) raise TypeError('Expected sequence of {}; ' 'got sequence with {} at position {}'.format( - exp, got, i)) + exp, got, i)) from None if nodups: seen = [] From 990c4f806a17d9dc67fb7d6a33cebfa29e890362 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 00:40:12 -0500 Subject: [PATCH 20/30] Bugfix nodups kwarg. --- simplestruct/fields.py | 3 ++- simplestruct/type.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/simplestruct/fields.py b/simplestruct/fields.py index 24dd482..16719a0 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -48,7 +48,8 @@ def check(self, inst, value): """ if not (self.opt and value is None): if self.seq: - self.checktype_seq(value, self.kind, self.nodups, inst=inst) + self.checktype_seq(value, self.kind, + nodups=self.nodups, inst=inst) else: self.checktype(value, self.kind, inst=inst) diff --git a/simplestruct/type.py b/simplestruct/type.py index db702b0..705838f 100644 --- a/simplestruct/type.py +++ b/simplestruct/type.py @@ -58,7 +58,7 @@ def checktype(self, val, kind, **kargs): raise TypeError('Expected {}; got {}'.format( self.str_kind(kind), self.str_valtype(val))) - def checktype_seq(self, seq, kind, nodups=False, **kargs): + def checktype_seq(self, seq, kind, *, nodups=False, **kargs): """Raise TypeError if seq is not a sequence of elements satisfying kind. Optionally require elements to be unique. From c0b9830389d6c6dfd3402a4b2e1eb212cfe054e6 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 00:46:35 -0500 Subject: [PATCH 21/30] Bugfix checktype()/checktype_seq(). --- simplestruct/type.py | 8 ++++---- tests/test_type.py | 37 +++++++++++++++---------------------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/simplestruct/type.py b/simplestruct/type.py index 705838f..7ea7e04 100644 --- a/simplestruct/type.py +++ b/simplestruct/type.py @@ -111,10 +111,10 @@ def checktype_seq(self, seq, kind, *, nodups=False, **kargs): checker = TypeChecker() -def checktype(kind, val): +def checktype(val, kind): kind = checker.normalize_kind(kind) - checker.checktype(kind, val) + checker.checktype(val, kind) -def checktype_seq(kind, val): +def checktype_seq(val, kind, *, nodups=False): kind = checker.normalize_kind(kind) - checker.checktype_seq(kind, val) + checker.checktype_seq(val, kind, nodups=nodups) diff --git a/tests/test_type.py b/tests/test_type.py index 1421d6f..24e252f 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -8,11 +8,8 @@ class ChecktypeCase(unittest.TestCase): - def setUp(self): - self.checker = TypeChecker() - def test_strs(self): - c = self.checker + c = TypeChecker() self.assertEqual(c.str_valtype(None), 'None') self.assertEqual(c.str_valtype(5), 'int') self.assertEqual(c.str_kind(()), 'Nothing') @@ -22,52 +19,48 @@ def test_strs(self): 'one of {int, str, bool}') def test_normalize(self): - c = self.checker + c = TypeChecker() self.assertEqual(c.normalize_kind((int,)), (int,)) self.assertEqual(c.normalize_kind([int,]), (int,)) self.assertEqual(c.normalize_kind(int), (int,)) self.assertEqual(c.normalize_kind(None), (object,)) def test_checktype(self): - c = self.checker - - c.checktype('a', (str,)) - c.checktype(True, (int,)) # This is correct, bool subtypes int - c.checktype(5, (str, int)) + checktype('a', str) + checktype(True, int) # This is correct, bool subtypes int + checktype(5, (str, int)) with self.assertRaisesRegex( TypeError, 'Expected int; got None'): - c.checktype(None, (int,)) + checktype(None, int) with self.assertRaisesRegex( TypeError, 'Expected str or int; got None'): - c.checktype(None, (str, int)) + checktype(None, (str, int)) def test_checktype_seq(self): - c = self.checker - - c.checktype_seq([], (str,)) - c.checktype_seq([3, True], (int,)) + checktype_seq([], str) + checktype_seq([3, True], int) with self.assertRaisesRegex( TypeError, 'Expected sequence of bool; got sequence with ' 'int at position 0'): - c.checktype_seq([3, True], (bool,)) + checktype_seq([3, True], bool) with self.assertRaisesRegex( TypeError, 'Expected sequence of bool; ' 'got bool instead of sequence'): - c.checktype_seq(True, (bool,)) + checktype_seq(True, bool) with self.assertRaisesRegex( TypeError, 'Expected sequence of str; ' 'got single str'): - c.checktype_seq('abc', (str,)) + checktype_seq('abc', str) with self.assertRaisesRegex( TypeError, 'Expected sequence of int; ' 'got generator instead of sequence'): - c.checktype_seq((i for i in range(3)), (int,)) + checktype_seq((i for i in range(3)), int) - c.checktype_seq([5, 3, 5, 8], (int,)) + checktype_seq([5, 3, 5, 8], int) with self.assertRaisesRegex( TypeError, 'Duplicate element 5 at position 2'): - c.checktype_seq([5, 3, 5, 8], (int,), nodups=True) + checktype_seq([5, 3, 5, 8], int, nodups=True) if __name__ == '__main__': From e341fa309e9060356ddd159e6ebb5cd26227ef5a Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 17:50:40 -0500 Subject: [PATCH 22/30] Big readme updates, moved wishlist to separate todo file. --- README.md | 175 +++++++++++++++++++++++++++++++++++------------------- TODO.md | 5 ++ 2 files changed, 118 insertions(+), 62 deletions(-) create mode 100644 TODO.md diff --git a/README.md b/README.md index 1782249..ede3d09 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ *(Supports Python 3.3 and up)* -This is a small utility for making it easier to create "struct" classes -in Python without writing boilerplate code. Structs are similar to the -standard library's `collections.namedtuple` but are more flexible, -relying on an inheritance-based approach instead of `eval()`ing a code -template. +This small library makes it easier to create "struct" classes in Python +without writing boilerplate code. Structs are similar to the standard +library's [`collections.namedtuple`][1] but are more flexible, relying on an +inheritance-based approach instead of `eval()`ing a code template. If +you like using `namedtuple` classes but wish they were more composable +and extensible, this project is for you. ## Example Writing struct classes by hand is tedious and error prone. Consider a -simple Point2D class. The bare minimum we can write is +simple point class. The bare minimum we can write in Python is ```python class Point2D: @@ -20,8 +21,8 @@ class Point2D: self.y = y ``` -but for it to be of any use, we'll need structural equality semantics -and perhaps some pretty printing for debugging. +We'll likely want to compare points for equality and pretty-print them +for debugging. ```python class Point2D: @@ -29,89 +30,139 @@ class Point2D: self.x = x self.y = y def __repr__(self): - print('Point2D({}, {})'.format(self.x, self.y)) - __str__ = __repr__ + # Separate __str__() would be nice too + return 'Point2D({!r}, {!r})'.format(self.x, self.y) def __eq__(self, other): - # Nevermind type-checking and subtyping. + # Should check other's type too return self.x == other.x and self.y == other.y def __hash__(self): + # Required because we're overriding __eq__(). return hash(self.x) ^ hash(self.y) ``` -If you're the sort of heathen who likes to use dynamic type checks -in Python code, you'll want to add extra argument checking to the -constructor. And we'll probably want to disallow inadvertently -reassigning to x and y after construction, or else the hash value -could become inconsistent -- a big problem if the point is stored -in a hash-based collection. +Already the code is becoming pretty verbose for such a simple concept. +Worse, it violates the [DRY principle](http://en.wikipedia.org/wiki/Don%27t_repeat_yourself) +in that the `x` and `y` fields appear many times. This isn't very +robust. If we decide to turn this into a `Point3D` class, we'll have +to upgrade each method to accommodate a new z coordinate. We could be +in for an infuriating bug if we forget to update `__eq__()` or +`__hash__()`. Adding more utilities like a copy/replace method will +exacerbate the situation. -Even if we do all that, the code isn't robust to change. If we decide -to make this a Point3D class, we'll have to update each method to -accommodate the new z coordinate. One oversight and we're in for a -potentially hard-to-find bug. +Then there's the added code for consistency checking. Maybe you're the +sort of heathen who prefers dynamic type checking over blindly trusting +Mama Ducktype. Or maybe you want to disallow overwriting `x` and `y` so +as to avoid changing its hash value. Either way you'd need to use +descriptors or properties to intercept writes. -`namedtuple` takes care of many of these problems, but it's not -extensible. You can't easily derive a new class from a namedtuple -class without implementing much of this boilerplate. It also forces -immutability, which may be inappropriate for your use case. +SimpleStruct provides a simple alternative. Here is a `Point2D` class +that provides everything described above. -SimpleStruct provides a simple alternative. For the above case, -we just write +```python +from numbers import Number # standard library abstract base class +from simplestruct import Struct, Field, TypedField - from simplestruct import Struct, Field - - class Point2D(Struct): - x = Field - y = Field +class Point2D(Struct): + x = TypedField(Number) + y = TypedField(Number) +``` -## Feature matrix +Of course, customizations are possible. Type checking is by no means +required, objects may be mutable so long as they are not hashed, +and you can add your own non-Field attributes and properties. -Feature | Avoids boilerplate for | Supported by `namedtuple`? ----|:---:|:---: -construction | `__init__()` | ✓ -extra attributes on self | | ✗ -pretty printing | `__str()__`, `__repr()__` | ✓ -structural equality | `__eq__()` | ✓ -inheritance | | ✗ -optional mutability | | ✗ -hashing (if immutable) | `__hash__()` | ✓ -pickling / deep-copying | | ✓ -tuple decomposition | `__len__`, `__iter__` | ✓ -optional type checking | | ✗ +```python +class Point2D(Struct): + _immutable = False + x = Field + y = Field + + # magnitude won't be considered when hashing or testing equality + @property + def magnitude(self): + return (self.x**2 + self.y**2) ** .5 +``` -The `_asdict()` and `_replace()` methods from `namedtuple` are also -provided. +For more usage examples, see the sample files: -One advantage that `namedtuple` does have is speed. It is based on -the built-in Python tuple type, whereas SimpleStruct has the added -overhead of descriptor function calls. +- [point.py](examples/point.py): Basic usage +- [vector.py](examples/vector.py): More advanced cases +- [abstract.py](examples/abstract.py): Composing Structs with + metaclasses from other libraries +## Comparison and feature matrix -## To use ### +The most important problems mentioned above are solved by using +`namedtuple`, but this approach begins to break down when you +start to customize classes. To add a property to a `namedtuple`, +you must define a subclass: -See the `examples/` directory. +```python +BasePerson = namedtuple('BasePerson', 'fname lname age') +class Person(BasePerson): + @property + def full_name(self): + return self.fname + ' ' + self.lname +``` +If on the other hand you want to extend an existing `namedtuple` with +new fields, it's a bit harder. You need to regenerate (not inherit) +the boilerplate methods so they recognize the new fields. This can be +done using multiple inheritance: -## See also ## +```python +BaseEmployee = namedtuple('BaseEmployee', Employee._fields + ('salary',)) +class Employee(BaseEmployee, Person): + pass +``` -* The standard library's [namedtuple](https://docs.python.org/3/library/collections.html#collections.namedtuple) +Implementation wise, `namedtuple` works by dynamically evaluating +a templated class definition based on the built-in `tuple` type. +This gives it a speed advantage, but is also the main reason why +it is less extensible (and unable to handle mutable values). -* Li Haoyi's MacroPy, specifically [case classes](/~https://github.com/lihaoyi/macropy#case-classes) +In contrast, SimpleStruct is based on metaclasses, descriptors, and +dynamic dispatch. The below matrix summarizes the feature comparison. +Feature | Avoids boilerplate for | Supported by `namedtuple`? +---|:---:|:---: +easy construction | `__init__()` | ✓ +extra attributes on self | | subclasses only +pretty printing | `__str()__`, `__repr()__` | ✓ +structural equality | `__eq__()` | ✓ +easy inheritance | | ✗ +optional mutability | | ✗ +hashing (if immutable) | `__hash__()` | ✓ +pickling / deep-copying | | ✓ +tuple decomposition | `__len__`, `__iter__` | ✓ +optional type checking | `__init__()`, `@property` | ✗ +`_asdict()` / `_replace()` | | ✓ + +[MacroPy][2]'s case classes provide similar functionality, but is +implemented in a very different way. Instead of metaclass hacking +or source code templating, it relies on syntactic transformation +of the module's AST. This allows for a syntax that's very different +from what we've seen above. So different, in fact, that we might view +MacroPy as an extension to the Python language rather than as just +a library. MacroPy case classes are subject to limitations on +inheritance and class members. ## Developers ## Tests can be run with `python setup.py test`, or alternatively by installing [Tox](http://testrun.org/tox/latest/) and running `python -m tox` in the project root. Tox has the advantage of automatically -testing both for Python 3.3 and 3.4. Building a source distribution +testing under both Python 3.3 and 3.4. Building a source distribution (`python setup.py sdist`) requires the setuptools extension package [setuptools-git](/~https://github.com/wichert/setuptools-git). -## TODO ### +## References ## + +[1]: https://docs.python.org/3/library/collections.html#collections.namedtuple +[[1]] The standard library's `namedtuple` feature + +[2]: /~https://github.com/lihaoyi/macropy#case-classes +[[2]] Li Haoyi's case classes (part of MacroPy) -Features TODO: -- add support for `__slots__` -- make exceptions appear to be raised from the stack frame of user code - where the type error occurred, rather than inside this library (with - a flag to disable, for debugging) +[3]: http://harts.net/reece/2013/06/02/using-namedtuples-with-method-and-instance-variable-inheritance/ +[[3]] Reece Hart's blog post on inheriting from `namedtuple` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..5ab9543 --- /dev/null +++ b/TODO.md @@ -0,0 +1,5 @@ +# Wishlist # +- add support for `__slots__` +- make exceptions appear to be raised from the stack frame of user code + where the type error occurred, rather than inside this library (with + a flag to disable, for debugging) From af8416cd8b7a5abd42a03149acbcc65344a3edff Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 18:05:54 -0500 Subject: [PATCH 23/30] Installation instructions for readme. --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index ede3d09..79af79e 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,22 @@ MacroPy as an extension to the Python language rather than as just a library. MacroPy case classes are subject to limitations on inheritance and class members. +## Installation ## + +As with most Python packages, SimpleStruct is available on PyPI: + +``` +python -m pip install simplestruct +``` + +Or grab a development version if you're so inclined: + +``` +python -m pip install /~https://github.com/brandjon/simplestruct/tree/tarball/develop +``` + +Python 3.3 and 3.4 are supported. There are no additional dependencies. + ## Developers ## Tests can be run with `python setup.py test`, or alternatively by From 49d9435016be2aab123aaf47c04c9e9b7fae5654 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 19:22:02 -0500 Subject: [PATCH 24/30] Revamped point.py example. --- README.md | 10 ++-- examples/point.py | 122 +++++++++++++++++++++++++++++++++------------- examples/typed.py | 11 +++++ 3 files changed, 105 insertions(+), 38 deletions(-) create mode 100644 examples/typed.py diff --git a/README.md b/README.md index 79af79e..3797845 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ from numbers import Number # standard library abstract base class from simplestruct import Struct, Field, TypedField class Point2D(Struct): + # Note that field declaration order matters. x = TypedField(Number) y = TypedField(Number) ``` @@ -85,10 +86,11 @@ class Point2D(Struct): For more usage examples, see the sample files: -- [point.py](examples/point.py): Basic usage -- [vector.py](examples/vector.py): More advanced cases -- [abstract.py](examples/abstract.py): Composing Structs with - metaclasses from other libraries +File | Purpose +---|--- +[point.py](examples/point.py) | introduction, basic use +[vector.py](examples/vector.py) | advanced features +[abstract.py](examples/abstract.py) | mixing structs and metaclasses ## Comparison and feature matrix diff --git a/examples/point.py b/examples/point.py index 7bc22db..d54aeeb 100644 --- a/examples/point.py +++ b/examples/point.py @@ -1,53 +1,107 @@ -"""Illustrates the use of Struct classes and their differences -from normal classes. +"""Illustrates the use of Struct classes, and their differences +from normal classes and namedtuples. """ -from simplestruct import Struct, Field, TypedField +from collections import namedtuple +from simplestruct import Struct, Field + + +############################################################################### +# Definition # +############################################################################### # Standard Python class. -class PointA: +class PyPoint: def __init__(self, x, y): self.x = x self.y = y # Struct class. -class PointB(Struct): +class SPoint(Struct): # Field declaration order matters. - x = Field + x = Field # shorthand for "x = Field()" y = Field # The constructor is implicitly defined. -# Initialization is the same for both. -# Keywords, *args, and *kargs are allowed. -pa1 = PointA(1, 2) -pa2 = PointA(1, y=2) -pb1 = PointB(*[1, 2]) -pb2 = PointB(**{'x': 1, 'y': 2}) +# namedtuple class +NTPoint = namedtuple('NTPoint', 'x y') + + +############################################################################### +# Construction and pretty-printing # +############################################################################### -# Structs have pretty-printing. +# Initialization is the same for all three classes. +py_point = PyPoint(1, 2) +struct_point = SPoint(1, 2) +tuple_point = NTPoint(1, 2) + +# Structs and namedtuples both have pretty-printing. print('==== Printing ====') -print(pa1) # <__main__.PointA object at ...> -print(pb1) # PointB(x=1, y=2) +print(py_point) # <__main__.Pypoint object at ...> +print(struct_point) # SPoint(x=1, y=2) +print(tuple_point) # NTPoint(x=1, y=2) + +# Structs print their contents using whichever formatting method +# was called originally. namedtuples always use repr. +struct_point2 = SPoint('a', 'b') +tuple_point2 = NTPoint('a', 'b') +print(str(struct_point2)) # SPoint(a, b) +print(repr(struct_point2)) # SPoint('a', 'b') +print(str(tuple_point2)) # NTPoint('a', 'b') +print(repr(tuple_point2)) # NTPoint('a', 'b') + +# All three classes can also be constructed using +# keywords, *args, and **kargs. +py_point2 = PyPoint(1, y=2) +struct_point2 = SPoint(*[1, 2]) +tuple_point2 = NTPoint(**{'x': 1, 'y': 2}) -# Structs have structural equality (for like-typed objects)... + +############################################################################### +# Equality and hashing # +############################################################################### + +# Structs and namedtuples both have structural equality. print('\n==== Equality ====') -print(pa1 == pa2) # False -print(pb1 == pb2) # True -print(pa1 == pb1) # False +print(py_point == py_point2) # False +print(struct_point == struct_point2) # True +print(tuple_point == tuple_point2) # True -# ... with a corresponding hash function. +# Structs, unlike namedtuple, are only equal to other +# instances of the same class. +class OtherSPoint(Struct): + x, y = Field, Field +OtherNTPoint = namedtuple('OtherNTPoint', 'x y') +struct_point2 = OtherSPoint(1, 2) +tuple_point2 = OtherNTPoint(1, 2) +print(struct_point == struct_point2) # False +print(tuple_point == tuple_point2) # True + +# Structs and namedtuples have hash functions based on +# structural value. print('\n==== Hashing ====') -print((hash(pa1) == hash(pa2))) # False (almost certainly) -print((hash(pb1) == hash(pb2))) # True - -# Struct with typed fields. -class TypedPoint(Struct): - x = TypedField(int) - y = TypedField(int) - -print('\n==== Type checking ====') -tp1 = TypedPoint(1, 2) -try: - tp2 = TypedPoint(1, 'b') -except TypeError: - print('Exception') +print(hash(py_point) == hash(py_point2)) # False (almost certainly) +print(hash(struct_point) == hash(struct_point)) # True +print(hash(tuple_point) == hash(tuple_point2)) # True + + +############################################################################### +# Other features # +############################################################################### + +# Structs implement some of the same convenience methods as namedtuples. +print('\n==== Convenience methods ====') +print(struct_point._asdict()) # OrderedDict([('x', 1), ('y', 2)]) +print(tuple_point._asdict()) # OrderedDict([('x', 1), ('y', 2)]) +print(struct_point._replace(x=3)) # SPoint(x=3, y=2) +print(tuple_point._replace(x=3)) # NTPoint(x=3, y=2) +# Note that _replace() creates a copy without modifying the original object. + +# Both can be iterated over and decomposed into their components. +print(len(struct_point)) # 2 +x, y = struct_point +print((x, y)) # (1, 2) +print(len(tuple_point)) # 2 +x, y = tuple_point +print((x, y)) # (1, 2) diff --git a/examples/typed.py b/examples/typed.py new file mode 100644 index 0000000..c729b3b --- /dev/null +++ b/examples/typed.py @@ -0,0 +1,11 @@ +## Struct with typed fields. +#class TypedPoint(Struct): +# x = TypedField(int) +# y = TypedField(int) +# +#print('\n==== Type checking ====') +#tp1 = TypedPoint(1, 2) +#try: +# tp2 = TypedPoint(1, 'b') +#except TypeError: +# print('Exception') From 6a31a8f17c6cb767851d6ca3af38a9d3c1e3b07f Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 20:00:32 -0500 Subject: [PATCH 25/30] Improved error messages. --- CHANGES.md | 2 ++ simplestruct/struct.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 83ee20b..bc45972 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## 0.2.1 (unreleased) +- improved error messages for constructing Structs +- significant updates to readme and examples - using opt=True on TypedField no longer implies that None is the default value - made mixin version of checktype() and checktype_seq() diff --git a/simplestruct/struct.py b/simplestruct/struct.py index 78836e4..139ad82 100644 --- a/simplestruct/struct.py +++ b/simplestruct/struct.py @@ -191,6 +191,7 @@ def __new__(cls, *args, **kargs): # _initialized is read during field initialization. inst._initialized = False + f = None try: boundargs = cls._signature.bind(*args, **kargs) # Include default arguments. @@ -200,8 +201,14 @@ def __new__(cls, *args, **kargs): boundargs.arguments[param.name] = param.default for f in cls._struct: setattr(inst, f.name, boundargs.arguments[f.name]) + f = None except TypeError as exc: - raise TypeError('Error constructing ' + cls.__name__) from exc + if f is not None: + where = "{} (field '{}')".format(cls.__name__, f.name) + else: + where = cls.__name__ + raise TypeError('Error constructing {}: {}'.format( + where, exc)) from exc return inst From 848fd754516a8bc082c7457d84d618002ca720ff Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 20:03:09 -0500 Subject: [PATCH 26/30] Changed keyword names for type-checking functions. --- CHANGES.md | 2 ++ simplestruct/fields.py | 21 +++++++++++---------- simplestruct/type.py | 8 ++++---- tests/test_fields.py | 6 +++--- tests/test_type.py | 2 +- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index bc45972..51dfc5c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## 0.2.1 (unreleased) +- changed type checking keyword argument names: 'opt' -> 'or_none' + and 'nodups' -> 'unique' - improved error messages for constructing Structs - significant updates to readme and examples - using opt=True on TypedField no longer implies that None is diff --git a/simplestruct/fields.py b/simplestruct/fields.py index 16719a0..44d0a8c 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -18,22 +18,23 @@ class TypedField(Field, TypeChecker): If seq is False, the field value must satisfy kind. Otherwise, the field value must be a sequence of elements that satisfy kind. The sequence gets converted to a tuple if it isn't already. - If nodup is also True, the elements must be distinct (as + If unique is also True, the elements must be distinct (as determined by kind.__eq__()). - If opt is True, None is a valid value. + If or_none is True, None is a valid value. """ - def __init__(self, kind, *, seq=False, nodups=False, opt=False, **kargs): + def __init__(self, kind, *, + seq=False, unique=False, or_none=False, **kargs): super().__init__(**kargs) self.kind = kind self.seq = seq - self.nodups = nodups - self.opt = opt + self.unique = unique + self.or_none = or_none def copy(self): - return type(self)(self.kind, seq=self.seq, nodups=self.nodups, - opt=self.opt, default=self.default) + return type(self)(self.kind, seq=self.seq, unique=self.unique, + or_none=self.or_none, default=self.default) @property def kind(self): @@ -46,10 +47,10 @@ def check(self, inst, value): """Raise TypeError if value doesn't satisfy the constraints for use on instance inst. """ - if not (self.opt and value is None): + if not (self.or_none and value is None): if self.seq: self.checktype_seq(value, self.kind, - nodups=self.nodups, inst=inst) + unique=self.unique, inst=inst) else: self.checktype(value, self.kind, inst=inst) @@ -57,7 +58,7 @@ def normalize(self, inst, value): """Return value or a normalized form of it for use on instance inst. """ - if (not (self.opt and value is None) and + if (not (self.or_none and value is None) and self.seq): value = tuple(value) return value diff --git a/simplestruct/type.py b/simplestruct/type.py index 7ea7e04..aa4a556 100644 --- a/simplestruct/type.py +++ b/simplestruct/type.py @@ -58,7 +58,7 @@ def checktype(self, val, kind, **kargs): raise TypeError('Expected {}; got {}'.format( self.str_kind(kind), self.str_valtype(val))) - def checktype_seq(self, seq, kind, *, nodups=False, **kargs): + def checktype_seq(self, seq, kind, *, unique=False, **kargs): """Raise TypeError if seq is not a sequence of elements satisfying kind. Optionally require elements to be unique. @@ -97,7 +97,7 @@ def checktype_seq(self, seq, kind, *, nodups=False, **kargs): 'got sequence with {} at position {}'.format( exp, got, i)) from None - if nodups: + if unique: seen = [] for i, item in enumerate(seq): if item in seen: @@ -115,6 +115,6 @@ def checktype(val, kind): kind = checker.normalize_kind(kind) checker.checktype(val, kind) -def checktype_seq(val, kind, *, nodups=False): +def checktype_seq(val, kind, *, unique=False): kind = checker.normalize_kind(kind) - checker.checktype_seq(val, kind, nodups=nodups) + checker.checktype_seq(val, kind, unique=unique) diff --git a/tests/test_fields.py b/tests/test_fields.py index 5c832d3..dcf26be 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -27,9 +27,9 @@ class Foo(Struct): with self.assertRaises(TypeError): Foo([1, 'a']) - # Nodups sequence. + # Sequence without duplicates. class Foo(Struct): - bar = TypedField(int, seq=True, nodups=True) + bar = TypedField(int, seq=True, unique=True) Foo([1, 2]) with self.assertRaises(TypeError): Foo([1, 2, 1]) @@ -37,7 +37,7 @@ class Foo(Struct): # Optional case. class Foo(Struct): _immutable = False - bar = TypedField(int, opt=True) + bar = TypedField(int, or_none=True) f1 = Foo(None) if __name__ == '__main__': diff --git a/tests/test_type.py b/tests/test_type.py index 24e252f..6f2a412 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -60,7 +60,7 @@ def test_checktype_seq(self): checktype_seq([5, 3, 5, 8], int) with self.assertRaisesRegex( TypeError, 'Duplicate element 5 at position 2'): - checktype_seq([5, 3, 5, 8], int, nodups=True) + checktype_seq([5, 3, 5, 8], int, unique=True) if __name__ == '__main__': From 416f8b220fe3819168e4d158607223ec27153357 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 20:12:12 -0500 Subject: [PATCH 27/30] Added typed.py example file. --- README.md | 1 + examples/typed.py | 121 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3797845..c2c1b2b 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ For more usage examples, see the sample files: File | Purpose ---|--- [point.py](examples/point.py) | introduction, basic use +[typed.py](examples/typed.py) | type-checked fields [vector.py](examples/vector.py) | advanced features [abstract.py](examples/abstract.py) | mixing structs and metaclasses diff --git a/examples/typed.py b/examples/typed.py index c729b3b..23b1bf4 100644 --- a/examples/typed.py +++ b/examples/typed.py @@ -1,11 +1,110 @@ -## Struct with typed fields. -#class TypedPoint(Struct): -# x = TypedField(int) -# y = TypedField(int) -# -#print('\n==== Type checking ====') -#tp1 = TypedPoint(1, 2) -#try: -# tp2 = TypedPoint(1, 'b') -#except TypeError: -# print('Exception') +"""Shows use of type-checked fields.""" + +from numbers import Number +from simplestruct import Struct, Field, TypedField + + +# The standard abstract base class Number is handy because it +# lets us restrict TypedPoint to any of int, float, complex, etc. + +class TypedPoint(Struct): + x = TypedField(Number) + y = TypedField(Number) + +p = TypedPoint(1, 2.0) +try: + p = TypedPoint('a', 'b') +except TypeError as e: + print(e) + + +# We can enumerate specific allowed classes (like isinstance()). + +class IntFloatPoint(Struct): + x = TypedField((int, float)) + y = TypedField((int, float)) + +p = IntFloatPoint(1, 2.0) +try: + p = IntFloatPoint(1j, 2 + 3j) +except TypeError as e: + print(e) + + +# We can take a sequence of values, all of which satisfy the +# type specification. + +class Vector(Struct): + vals = TypedField(Number, seq=True) + +v = Vector([1, 2, 3, 4]) +# The sequence is converted to a tuple to help ensure immutability. +print(v) +try: + v = Vector(5) +except TypeError as e: + print(e) +try: + v = Vector([1, 'b', 3, 4]) +except TypeError as e: + print(e) +# Construction from non-sequence iterables like generators is disallowed. +try: + v = Vector((x for x in range(1, 5))) +except TypeError as e: + print(e) + + +# Sequences may be checked for uniqueness. +# (This is implemented naively in O(n^2) time.) + +class Ids(Struct): + vals = TypedField(int, seq=True, unique=True) + +try: + ids = Ids([1, 2, 3, 2]) +except TypeError as e: + print(e) + + +# If None is passed as the first argument of TypedField, +# any type is admitted. + +class Array(Struct): + vals = TypedField(None, seq=True) + +a = Array([1, 'b', False]) +# It still must be a sequence. +try: + a = Array(True) +except TypeError as e: + print(e) + + +# Typed fields can be set to allow None. + +class Person(Struct): + name = Field + salary = TypedField(int, or_none=True) + +a = Person('Alice', 100000) +b = Person('Bob', None) + +# This is different from adding NoneType to the sequence of +# allowed types, as that would mean the elements could be +# any type. Also note that or_none=True does not make passing +# in the field value to the constructor optional. + + +# The same Field instance can be used as a descriptor multiple +# times. (Each occurrence automatically gets a copy.) This can +# help shorten definitions. + +myfield = TypedField((int, float), or_none=True) + +class NullablePoint(Struct): + x = myfield + y = myfield + z = myfield + +p = NullablePoint(1, 2.0, None) From bb223c6011a008d16d9a68e747af0aec3bef66ba Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 21:07:18 -0500 Subject: [PATCH 28/30] Updated vector.py example. --- examples/vector.py | 175 ++++++++++++++++++++++++++++----------------- 1 file changed, 111 insertions(+), 64 deletions(-) diff --git a/examples/vector.py b/examples/vector.py index 8441571..e3d315c 100644 --- a/examples/vector.py +++ b/examples/vector.py @@ -1,98 +1,145 @@ -"""Illustrates more advanced features like type-checking, -inheritance, non-field data, and mutability. +"""Illustrates more advanced features like inheritance, mutability, +and user-supplied constructors. """ -from simplestruct import Struct, Field, TypedField +from simplestruct import Struct, Field -print('==== Default values ====') +# Default values on fields work exactly like default values for +# constructor arguments. This includes the restriction that +# a non-default argument cannot follow a default argument. class AxisPoint(Struct): x = Field(default=0) y = Field(default=0) +print('==== Default values ====') p1 = AxisPoint(x=2) print(p1) # AxisPoint(x=2, y=0) p2 = AxisPoint(y=3) print(p2) # AxisPoint(x=0, y=3) -print('\n==== Type checking ====') +# Subclasses by default do not inherit fields, but this can +# be enabled with a class-level flag. class Point2D(Struct): - x = TypedField(int) - y = TypedField(int) + x = Field + y = Field -p1 = Point2D(2, 3) -try: - Point2D('a', 'b') -except TypeError: - print('Exception') +class Point3D(Point2D): + _inherit_fields = True + z = Field +print('\n==== Inheritance ====') +p = Point3D(1, 2, 3) +print(p) # Point3D(x=1, y=2, z=3) -print('\n==== Mutability ====') +# The flag must be redefined on each subclass that wants to +# inherit fields. -# Structs are immutable by default. -try: - p1.x = 7 -except AttributeError: - print('Exception') +# The list of fields can be programmatically accessed via the +# _struct attribute. -class Point3D(Point2D): +print(p._struct) # (, , ) +print([f.name for f in p._struct]) # ['x', 'y', 'z'] + +# Equality does not hold on different types, even if they are +# in the same class hierarchy and share the same fields. + +class Point3D_2(Point3D): + _inherit_fields = True + +p2 = Point3D_2(1, 2, 3) +print(p == p2) # False + + +# Structs are immutable by default, but this can be disabled +# with a class-level flag. + +class MutablePoint(Struct): _immutable = False x = Field y = Field - z = Field -p2 = Point3D(3, 4, 5) -print(p2) # Point3D(x=3, y=4, z=5) -p2.x = 7 -print(p2) # Point3D(x=7, y=4, z=5) +print('\n==== Mutability ====') +p = Point2D(1, 2) +try: + p.x = 3 +except AttributeError as e: + print(e) +p = MutablePoint(1, 2) +p.x = 3 +print(p) # MutablePoint(3, 2) # Mutable structs can't be hashed (analogous to Python lists, dicts, sets). try: - hash(p2) -except TypeError: - print('Exception') - - -print('\n==== Subclassing and non-field data ====') - -class Vector2D(Point2D): - # Special flag to inherit x and y fields without - # needing to redeclare. - _inherit_fields = True + hash(p) +except TypeError as e: + print(e) + + +# Like other classes, a Struct is free to define its own constructor. +# The arguments are the declared fields, in order of their declaration. +# +# Fields are initialized in __new__(). A subclass that overrides +# __new__() must call super.__new__() (not type.__new__()). +# __init__() does not need to call super().__init__() or do any work +# on behalf of the Struct system. +# +# If the fields have default values, these are substituted in before +# calling the constructor. Thus providing default parameter values +# in the constructor argument list is meaningless. + +class DoublingVector2D(Struct): + + x = Field + y = Field + + def __new__(cls, x, y): + print('Vector2D.__new__() has been called') + return super().__new__(cls, x, y) - # Constructor takes in the field values. def __init__(self, x, y): - # mag is not a field for the purposes of pretty printing, - # equality comparison, etc. It could alternatively be - # implemented as a @property. - self.mag = (x**2 + y**2) ** .5 + # There is no need to call super().__init__(). - # self.x and self.y are already automatically initialized, - # but can be modified in __init__(), even though this - # Struct is immutable. Be careful not to hash self until - # after __init__() is done. + # The field values self.x and self.y have already been + # initialized by __new__(). - # No need to call super().__init__(). - -p1 = Point2D(3, 4) -v1 = Vector2D(3, 4) -print(p1) # Point2D(x=3, y=4) -print(v1) # Vector2D(x=3, y=4) -print(v1.mag) # 5.0 -# Equality does not hold between different types. -print(p1 == v1) # False - - -print('\n==== More advanced types ====') - -# n-dimensional vector -class Vector(Struct): - # 'seq' is for sequence types. The value gets normalized - # to a tuple. - vals = TypedField(int, seq=True) + # Before the call to __init__(), the instance attribute + # _initialized is set to False. It is changed to True + # once __init__() has finished executing. If there are + # multiple __init__() calls chained via super(), it is + # changed once the outermost call returns. + + assert not self._initialized + + # Despite the fact that this Struct is immutable, we + # are free to reassign fields until the flag is set. + # Likewise, we may not hash this instance until the + # flag is set. + + self.x *= 2 + self.y *= 2 + try: + hash(self) + except TypeError as e: + print(e) + + # We can create additional non-field attributes. + self.magnitude = (self.x**2 + self.y**2) ** .5 + # Since magnitude is not declared as a field, it is not + # considered during equality comparison, hashing, pretty + # printing, etc. Non-field attributes are generally + # incidental to the value of the Struct, or else can be + # deterministically derived from the fields. They can + # be overwritten at any time, whether or not the Struct + # is immutable. + + # Alternatively, We could define magnitude as a @property, + # but then it would be recomputed each time it is used. -v1 = Vector([1, 2, 3, 4]) -print(v1.vals) # (1, 2, 3, 4) +print('\n==== Custom constructor ====') +v = DoublingVector2D(1.5, 2) +print(v) # DoublingVector2D(x=3, y=4) +print(v.magnitude) # 5.0 From 7755c0530b3f5d7856f7849a4d0a15d45c3ec232 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 21:12:22 -0500 Subject: [PATCH 29/30] Updated abstract.py example. --- examples/abstract.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/examples/abstract.py b/examples/abstract.py index 09fdd34..0a4fd6d 100644 --- a/examples/abstract.py +++ b/examples/abstract.py @@ -3,25 +3,26 @@ from abc import ABCMeta, abstractmethod from simplestruct import Struct, Field, MetaStruct + +# A simple ABC. Subclasses must provide an override for foo(). class Abstract(metaclass=ABCMeta): @abstractmethod def foo(self): pass -# If we ran this code -# -# class Concrete(Abstract, Struct): -# f = Field -# def foo(self): -# return self.f ** 2 -# -# we would get the following error: -# -# TypeError: metaclass conflict: the metaclass of a derived class -# must be a (non-strict) subclass of the metaclasses of all its bases -# -# So let's make a trivial subclass of ABCMeta and MetaStruct. +# ABCs rely on a metaclass that conflicts with Struct's metaclass. +try: + class Concrete(Abstract, Struct): + f = Field + def foo(self): + return self.f ** 2 + +except TypeError as e: + print(e) + # metaclass conflict: the metaclass of a derived class must + # be a (non-strict) subclass of the metaclasses of all its bases +# So let's make a trivial subclass of ABCMeta and MetaStruct. class ABCMetaStruct(MetaStruct, ABCMeta): pass @@ -33,13 +34,13 @@ def foo(self): c = Concrete(5) print(c.foo()) # 25 -# For convenience we can also do +# For convenience we can make a version of Struct that +# incorporates the common metaclass. class ABCStruct(Struct, metaclass=ABCMetaStruct): pass -# and then - +# Now we only have to do: class Concrete(Abstract, ABCStruct): f = Field def foo(self): From b005101ebd87ce734840100e4837eee9e21f4440 Mon Sep 17 00:00:00 2001 From: Jon Brandvein Date: Sat, 20 Dec 2014 21:46:47 -0500 Subject: [PATCH 30/30] Set release date, fix formatting. --- CHANGES.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 51dfc5c..363262c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,15 +1,15 @@ # Release notes -## 0.2.1 (unreleased) +## 0.2.1 (2014-12-20) -- changed type checking keyword argument names: 'opt' -> 'or_none' - and 'nodups' -> 'unique' +- changed type checking keyword argument names: `opt` -> `or_none` + and `nodups` -> `unique` - improved error messages for constructing Structs - significant updates to readme and examples -- using opt=True on TypedField no longer implies that None is +- using `opt=True` on `TypedField` no longer implies that `None` is the default value -- made mixin version of checktype() and checktype_seq() -- added check() and normalize() hooks to TypedField +- made mixin version of `checktype()` and `checktype_seq()` +- added `check()` and `normalize()` hooks to `TypedField` - accessing fields descriptors from classes is now permissible - added support for default values in general, and optional values for type-checked fields