diff --git a/CHANGES.md b/CHANGES.md index 363262c..96e416d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # Release notes +## 0.2.2 (2016-05-15) + +- fields with default values are properly passed to __new__()/__init__() +- added support for coercion of tuples for Struct-typed fields +- added support for `__getitem__` and `__setitem__` +- testing a Struct for equality with itself succeeds quickly + ## 0.2.1 (2014-12-20) - changed type checking keyword argument names: `opt` -> `or_none` diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..d1f3526 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Jon Brandvein - Author, maintainer +- Bo Lin - Struct indexing, tuple coercion for Struct-typed fields diff --git a/LICENSE b/LICENSE index bd86437..cc7ccba 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013-2014 Jonathan Brandvein +Copyright (c) 2013-2015 Jonathan Brandvein Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.md b/README.md index c2c1b2b..643b099 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# SimpleStruct +# SimpleStruct # *(Supports Python 3.3 and up)* @@ -9,7 +9,7 @@ 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 +## Example ## Writing struct classes by hand is tedious and error prone. Consider a simple point class. The bare minimum we can write in Python is @@ -93,7 +93,7 @@ File | Purpose [vector.py](examples/vector.py) | advanced features [abstract.py](examples/abstract.py) | mixing structs and metaclasses -## Comparison and feature matrix +## Comparison and feature matrix ## The most important problems mentioned above are solved by using `namedtuple`, but this approach begins to break down when you @@ -114,7 +114,7 @@ the boilerplate methods so they recognize the new fields. This can be done using multiple inheritance: ```python -BaseEmployee = namedtuple('BaseEmployee', Employee._fields + ('salary',)) +BaseEmployee = namedtuple('BaseEmployee', BasePerson._fields + ('salary',)) class Employee(BaseEmployee, Person): pass ``` @@ -138,16 +138,17 @@ optional mutability | | ✗ hashing (if immutable) | `__hash__()` | ✓ pickling / deep-copying | | ✓ tuple decomposition | `__len__`, `__iter__` | ✓ +indexing | `__getitem__`, `__setitem__` | `__getitem__` only optional type checking | `__init__()`, `@property` | ✗ `_asdict()` / `_replace()` | | ✓ -[MacroPy][2]'s case classes provide similar functionality, but is +[MacroPy][2]'s "case classes" provide similar functionality, but are implemented in a very different way. Instead of metaclass hacking -or source code templating, it relies on syntactic transformation +or source code templating, MacroPy 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 +a library. Case classes are subject to limitations on inheritance and class members. ## Installation ## diff --git a/TODO.md b/TODO.md index 5ab9543..b7584f7 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,19 @@ +# Bugs # +- equality testing of cyclic objects can cause infinite recursion. + +```python +class A(Struct): + x = Field() +a1 = A(None) +a2 = A(None) +a1.x = a2 +a2.x = a1 +a1 == a2 +``` +The proper behavior in this case should probably be to allow `a1 == a2` +to return `False` for simplicity, even though `a1` and `a2` are actually +isomorphic. + # Wishlist # - add support for `__slots__` - make exceptions appear to be raised from the stack frame of user code diff --git a/examples/typed.py b/examples/typed.py index 23b1bf4..56f6ae5 100644 --- a/examples/typed.py +++ b/examples/typed.py @@ -108,3 +108,14 @@ class NullablePoint(Struct): z = myfield p = NullablePoint(1, 2.0, None) + + +# Struct-typed fields can be coerced from tuple values. + +class Line(Struct): + a = TypedField(TypedPoint) + b = TypedField(TypedPoint) + +line1 = Line(TypedPoint(1, 2), TypedPoint(3, 4)) +line2 = Line((1, 2), (3, 4)) +assert line1 == line2 diff --git a/examples/vector.py b/examples/vector.py index e3d315c..6406a43 100644 --- a/examples/vector.py +++ b/examples/vector.py @@ -83,18 +83,19 @@ class MutablePoint(Struct): # 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__()). +# __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. +# in the constructor argument list is meaningless, as they will always +# be overridden by the defaults from the field's declaration. class DoublingVector2D(Struct): - x = Field - y = Field + x = Field(default=0) + y = Field(default=0) def __new__(cls, x, y): print('Vector2D.__new__() has been called') diff --git a/setup.py b/setup.py index 5ce1fa3..e2e6228 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name = 'SimpleStruct', - version = '0.2.1', + version = '0.2.2', url = '/~https://github.com/brandjon/simplestruct', author = 'Jon Brandvein', diff --git a/simplestruct/__init__.py b/simplestruct/__init__.py index d0d7ccf..be4846e 100644 --- a/simplestruct/__init__.py +++ b/simplestruct/__init__.py @@ -3,7 +3,7 @@ checking, mutability, and inheritance. """ -__version__ = '0.2.1' +__version__ = '0.2.2' from .struct import * from .fields import * diff --git a/simplestruct/fields.py b/simplestruct/fields.py index 44d0a8c..12feed8 100644 --- a/simplestruct/fields.py +++ b/simplestruct/fields.py @@ -6,7 +6,7 @@ ] -from .struct import Field +from .struct import Field, Struct from .type import TypeChecker @@ -22,6 +22,9 @@ class TypedField(Field, TypeChecker): determined by kind.__eq__()). If or_none is True, None is a valid value. + + If the kind is a Struct and seq is False, allow the value to + be a tuple and coerce it to an instance of the Struct. """ def __init__(self, kind, *, @@ -64,6 +67,15 @@ def normalize(self, inst, value): return value def __set__(self, inst, value): + # Special case: If our type is a non-sequence Struct, allow + # coercion of a tuple value to the Struct. This is done + # prior to the type check and normalization. + if (not self.seq and len(self.kind) == 1 and + isinstance(self.kind[0], type) and + issubclass(self.kind[0], Struct) and + isinstance(value, tuple)): + value = self.kind[0](*value) + self.check(inst, value) value = self.normalize(inst, value) super().__set__(inst, value) diff --git a/simplestruct/struct.py b/simplestruct/struct.py index 139ad82..ed0275b 100644 --- a/simplestruct/struct.py +++ b/simplestruct/struct.py @@ -95,11 +95,12 @@ class MetaStruct(type): Upon instantiation of a Struct subtype, set the instance's _initialized attribute to True after __init__() returns. + Preprocess its __new__/__init__() arguments as well. """ # Use OrderedDict to preserve Field declaration order. @classmethod - def __prepare__(mcls, name, bases, **kargs): + def __prepare__(cls, name, bases, **kargs): return OrderedDict() # Construct the _struct attribute on the new class. @@ -144,9 +145,23 @@ def __new__(mcls, clsname, bases, namespace, **kargs): return cls + def get_boundargs(cls, *args, **kargs): + """Return an inspect.BoundArguments object for the application + of this Struct's signature to its arguments. Add missing values + for default fields as keyword arguments. + """ + 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 + return boundargs + # Mark the class as _initialized after construction. - def __call__(mcls, *args, **kargs): - inst = super().__call__(*args, **kargs) + def __call__(cls, *args, **kargs): + boundargs = cls.get_boundargs(*args, **kargs) + inst = super().__call__(*boundargs.args, **boundargs.kwargs) inst._initialized = True return inst @@ -193,12 +208,7 @@ def __new__(cls, *args, **kargs): f = None 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 + boundargs = cls.get_boundargs(*args, **kargs) for f in cls._struct: setattr(inst, f.name, boundargs.arguments[f.name]) f = None @@ -231,6 +241,13 @@ def __repr__(self): return self._fmt_helper(repr) def __eq__(self, other): + # Succeed immediately if we're being tested against ourselves + # (identical object in memory). This avoids an unnecessary + # walk over the fields, which can be expensive if the field + # values are themselves Structs, and so on. + if self is other: + return True + # Two struct instances are equal if they have the same # type and same field values. if type(self) != type(other): @@ -257,6 +274,26 @@ def __len__(self): def __iter__(self): return (getattr(self, f.name) for f in self._struct) + def __getitem__(self, index): + # Index may also be a slice. + return tuple(getattr(self, f.name) for f in self._struct)[index] + + def __setitem__(self, index, value): + if isinstance(index, slice): + fnames = [f.name for f in self._struct][index] + values = list(value) + if len(values) < len(fnames): + word = 'value' if len(values) == 1 else 'values' + raise ValueError('need more than {} {} to ' + 'unpack'.format(len(fnames), word)) + elif len(values) > len(fnames): + raise ValueError('too many values to unpack (expected ' + '{})'.format(len(fnames))) + for fname, v in zip(fnames, values): + setattr(self, fname, v) + else: + setattr(self, self._struct[index].name, value) + def __reduce_ex__(self, protocol): # We use __reduce_ex__() rather than __getnewargs__() so that # the metaclass's __call__() will still run. This is needed to diff --git a/tests/test_fields.py b/tests/test_fields.py index dcf26be..2bece9a 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -40,5 +40,19 @@ class Foo(Struct): bar = TypedField(int, or_none=True) f1 = Foo(None) + def test_nestedstructs(self): + class Bar(Struct): + a = Field + class Foo(Struct): + b = Field + c = TypedField(Bar) + f = Foo(1, (2,)) + self.assertEqual(f.c.a, 2) + self.assertEqual(f.b, 1) + with self.assertRaises(TypeError): + f = Foo(1, 2) + with self.assertRaises(TypeError): + f = Foo(1, (2, 3)) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_struct.py b/tests/test_struct.py index 19e5990..5a0a1ce 100644 --- a/tests/test_struct.py +++ b/tests/test_struct.py @@ -84,6 +84,38 @@ class Foo(Struct): self.assertEqual(len(f), 2) self.assertEqual((a, b), (1, 2)) + def test_indexing(self): + class Bar(Struct): + a = Field + b = Field + f = Bar(5, 6) + self.assertEqual(f[0], 5) + self.assertEqual(f[1], 6) + self.assertEqual(f[0:2], (5, 6)) + self.assertEqual(f[0:0], ()) + self.assertEqual(f[0:2:2], (5,)) + self.assertEqual(f[1:-1], ()) + self.assertEqual(f[-1:10], (6,)) + with self.assertRaises(IndexError): + f[2] + with self.assertRaises(AttributeError): + f[0] = 4 + + class Bar(Struct): + _immutable = False + a = Field() + b = Field() + f = Bar(5, 6) + f[0] = 4 + self.assertEqual(f.a, 4) + f[::-1] = (1, 2) + self.assertEqual(f.a, 2) + self.assertEqual(f.b, 1) + with self.assertRaises(ValueError): + f[:] = (1,) + with self.assertRaises(ValueError): + f[:] = (1, 2, 3) + def test_construct(self): # Construction by keyword. class Foo(Struct): @@ -101,10 +133,13 @@ class Foo(Struct): class Foo(Struct): a = Field() b = Field(default='b') + # Make sure default field values are passed to __init__() too. + def __init__(self, a, b): + self.c = b f = Foo(1, 2) - self.assertEqual((f.a, f.b), (1, 2)) + self.assertEqual((f.a, f.b, f.c), (1, 2, 2)) f = Foo(1) - self.assertEqual((f.a, f.b), (1, 'b')) + self.assertEqual((f.a, f.b, f.c), (1, 'b', 'b')) # Parentheses-less shorthand. class Foo(Struct):