diff --git a/.gitignore b/.gitignore index bee8a64..14d50a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ __pycache__ +.tox +SimpleStruct.egg-info +dist diff --git a/README.md b/README.md index 3bbc795..14a284a 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,104 @@ -SimpleStruct -============ - -*(Requires Python 3)* - -This is a small utility for making it easier to write simple struct -classes in Python, without having to write boilerplate code. Structs -are similar to the standard library's namedtuple, but support type -checking, mutability, and derived data (i.e. cached properties). - -A struct is a class defining one or more named fields. The constructor -takes in the non-derived fields, in the order they were declared. -Structs can be compared for equality -- two instances of the same -struct compare equal if their fields are equal. The struct may be -declared as immutable or mutable; if immutable, modification is not -allowed after `__init__()` finishes, and the struct becomes hashable. -Structs are pretty-printed by `str()` and `repr()`. - -Each field is declared with an optional type and modifiers. Types -are checked dynamically upon assignment (or reassignment). Modifiers -allow for lists of values, automatic type coersion, and for marking -fields as derived (computed by a user-defined `__init__()`). - -This is a small toy project, so no backwards compatability guarantees -are made. - - -### To use ### - -For the simplest case, just use +# SimpleStruct + +*(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. + +## Example + +Writing struct classes by hand is tedious and error prone. Consider a +simple Point2D class. The bare minimum we can write is + +```python +class Point2D: + def __init__(self, x, y): + self.x = x + self.y = y +``` + +but for it to be of any use, we'll need structural equality semantics +and perhaps some pretty printing for debugging. + +```python +class Point2D: + def __init__(self, x, y): + self.x = x + self.y = y + def __repr__(self): + print('Point2D({}, {})'.format(self.x, self.y)) + __str__ = __repr__ + def __eq__(self, other): + # Nevermind type-checking and subtyping. + return self.x == other.x and self.y == other.y + def __hash__(self): + 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. + +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. + +`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. For the above case, +we just write from simplestruct import Struct, Field - class Point(Struct): - x = Field(int) - y = Field(int) + class Point2D(Struct): + x = Field + y = Field + +## Feature matrix + +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 | | ✗ -to get a simple Point class. No need to define `__init__()`, `__str__()`, -`__eq__()`, `__hash__()`, etc. See the examples/ directory for more. +The `_asdict()` and `_replace()` methods from `namedtuple` are also +provided. +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. -### Comparison to namedtuple ### -The standard library's [namedtuple](http://docs.python.org/3/library/collections#collections.namedtuple) -feature can generate classes similar to what this library produces. -Specifically, namedtuple classes automatically get constructors, pretty- -printing, equality, and hashing, as well as sequential access (so you can use -decomposing assignment such as `x, y = mypoint`). They do *not* support type -checks and mutability, nor can you define auxiliary attributes on the object -since it is constructed all-at-once. +## To use ### -Namedtuples are implemented by specializing and then `eval()`ing a source code -template that describes the desired class. In contrast, SimpleStruct uses -inheritance and metaclasses to implement all struct's behavior in a generic -way. There is a performance penalty to this, since each operation results in -more function calls. An application that requires top performance out of each -struct operation should go with namedtuple if possible, especially because -much of its functionality is provided by the built-in Python tuple type. +See the `examples/` directory. -### TODO ### +## TODO ### Features TODO: - add support for `__slots__` -- support iteration of fields (like namedtuple) - 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) -- possibly make it so the same Field object can be used to declare multiple - structs, and the metaclass replaces this Field object with a copy so they - can have different "name" attributes. This would allow defining a reusable - kind of field without repeating kind/mods each time. Packaging TODO: -- make usage examples - fix up setup.py, make installable diff --git a/examples/abstract.py b/examples/abstract.py index d496303..09fdd34 100644 --- a/examples/abstract.py +++ b/examples/abstract.py @@ -1,4 +1,4 @@ -# Illustrates how to combine Struct with abstract base classes. +"""Demonstrates how to combine Struct with abstract base classes.""" from abc import ABCMeta, abstractmethod from simplestruct import Struct, Field, MetaStruct @@ -11,9 +11,9 @@ def foo(self): # If we ran this code # # class Concrete(Abstract, Struct): -# f = Field(int) +# f = Field # def foo(self): -# return self.f +# return self.f ** 2 # # we would get the following error: # @@ -26,12 +26,12 @@ class ABCMetaStruct(MetaStruct, ABCMeta): pass class Concrete(Abstract, Struct, metaclass=ABCMetaStruct): - f = Field(int) + f = Field def foo(self): return self.f ** 2 c = Concrete(5) -print(c.foo()) +print(c.foo()) # 25 # For convenience we can also do @@ -41,9 +41,9 @@ class ABCStruct(Struct, metaclass=ABCMetaStruct): # and then class Concrete(Abstract, ABCStruct): - f = Field(int) + f = Field def foo(self): return self.f ** 2 c = Concrete(5) -print(c.foo()) +print(c.foo()) # 25 diff --git a/examples/point.py b/examples/point.py index 56213b8..7bc22db 100644 --- a/examples/point.py +++ b/examples/point.py @@ -1,33 +1,53 @@ -# Illustrates use of Struct to define a simple point class. +"""Illustrates the use of Struct classes and their differences +from normal classes. +""" -from simplestruct import Struct, Field +from simplestruct import Struct, Field, TypedField +# Standard Python class. class PointA: def __init__(self, x, y): self.x = x self.y = y -# The constructor is implicitly defined. +# Struct class. class PointB(Struct): - x = Field(int) - y = Field(int) + # Field declaration order matters. + 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, 2) - -pb1 = PointB(1, 2) -pb2 = PointB(1, 2) +pa2 = PointA(1, y=2) +pb1 = PointB(*[1, 2]) +pb2 = PointB(**{'x': 1, 'y': 2}) # Structs have pretty-printing. -print((pa1, pa2)) -print((pb1, pb2)) -print() +print('==== Printing ====') +print(pa1) # <__main__.PointA object at ...> +print(pb1) # PointB(x=1, y=2) -# Structs have structural equality (for like-typed objects). -print(pa1 == pa2) -print(pb1 == pb2) -print() +# Structs have structural equality (for like-typed objects)... +print('\n==== Equality ====') +print(pa1 == pa2) # False +print(pb1 == pb2) # True +print(pa1 == pb1) # False # ... with a corresponding hash function. -print((hash(pa1) == hash(pa2))) -print((hash(pb1) == hash(pb2))) +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') diff --git a/examples/vector.py b/examples/vector.py index 9f2577f..82db621 100644 --- a/examples/vector.py +++ b/examples/vector.py @@ -1,12 +1,59 @@ -# Vector class that stores its magnitude. +"""Illustrates inheritance, non-field data, and mutability.""" from simplestruct import Struct, Field -class Vector(Struct): - vals = Field(int, 'seq') - mag = Field(float, '!') - def __init__(self, vals): - self.mag = sum(v ** 2 for v in vals) ** .5 +class Point2D(Struct): + x = Field + y = Field -v = Vector([1, 2, 5, 10]) -print(v.mag) +# Derived class that adds a computed magnitude data. +class Vector2D(Point2D): + # Special flag to inherit x and y fields without + # needing to redeclare. + _inherit_fields = True + + # 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 + + # 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. + + # 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 + +# 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 + +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) + +try: + hash(p2) +except TypeError: + print('Exception') diff --git a/setup.py b/setup.py index a3e2d93..f949d18 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,24 @@ from setuptools import setup -import simplestruct - setup( - name='SimpleStruct', - version=simplestruct.__version__, + name = 'SimpleStruct', + version = '0.2.0', + url = '/~https://github.com/brandjon/simplestruct', + + author = 'Jon Brandvein', + author_email = 'jon.brandvein@gmail.com', + license = 'MIT License', + description = 'A library for defining struct-like classes', + + classifiers = [ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Topic :: Software Development :: Libraries :: Python Modules', + ], - author='Jon Brandvein', - license='MIT License', - description='A Python library for defining struct-like classes', + packages = ['simplestruct'], - packages=['simplestruct'], + test_suite = 'tests', ) diff --git a/simplestruct/__init__.py b/simplestruct/__init__.py index 6aaf80c..7c30fbb 100644 --- a/simplestruct/__init__.py +++ b/simplestruct/__init__.py @@ -1,8 +1,9 @@ -"""Provides a mechanism for defining classes with a fixed number of -fields, possibly type-checked and immutable. Methods are provided for -pretty-printing, equality testing, and hashing. +"""Provides a mechanism for defining struct-like classes. These are +similar to collections.namedtuple classes but support optional type- +checking, mutability, and inheritance. """ -__version__ = '0.1.0' +__version__ = '0.2.0' from .struct import * +from .fields import * diff --git a/simplestruct/fields.py b/simplestruct/fields.py new file mode 100644 index 0000000..3cbd7d2 --- /dev/null +++ b/simplestruct/fields.py @@ -0,0 +1,41 @@ +"""Fancier field subclasses.""" + + +__all__ = [ + 'TypedField', +] + + +from .struct import Field +from .type import normalize_kind, checktype, checktype_seq + + +class TypedField(Field): + + """A field with dynamically-checked type constraints. + + kind is a class or tuple of classes, as described in type.py. + 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 + determined by kind.__eq__()). + """ + + def __init__(self, kind, *, seq=False, nodups=False): + super().__init__() + self.kind = normalize_kind(kind) + self.seq = seq + self.nodups = nodups + + def copy(self): + return type(self)(self.kind, seq=self.seq, nodups=self.nodups) + + def __set__(self, inst, value): + 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/simplestruct/struct.py b/simplestruct/struct.py index ef6efee..9b76bce 100644 --- a/simplestruct/struct.py +++ b/simplestruct/struct.py @@ -1,4 +1,4 @@ -"""Provides field descriptors and the base and meta classes for struct.""" +"""Core framework for Struct, its metaclass, and field descriptors.""" __all__ = [ @@ -8,13 +8,10 @@ ] -from collections import OrderedDict +from collections import OrderedDict, Counter from functools import reduce from inspect import Signature, Parameter -from simplestruct.type import (str_valtype, check_spec, - normalize_kind, normalize_mods) - def hash_seq(seq): """Given a sequence of hash values, return a combined xor'd hash.""" @@ -23,33 +20,26 @@ def hash_seq(seq): class Field: - """Descriptor for declaring struct fields. Fields have a name - and type spec (kind and mods). In addition to those specified - in types.py, mods may include: - - '!': this field is derived data that should not be passed as - an argument to the struct's constructor or consulted for - equality/hashing - - The 'seq' mod will additionally convert the value to a tuple. + """Descriptor for declaring fields on Structs. - All writes are type-checked according to the type spec. - Writing to a field will fail with AttributeError if the struct + Writing to a field will fail with AttributeError if the Struct is immutable and has finished initializing. - To use custom equality/hashing semantics, subclass Field and - override eq() and hash(). Note that in the case of list-valued - fields, these functions are called element-wise. + Subclasses may override __set__() to implement type restrictions + or coercion, and may override eq() and hash() to implement custom + equality semantics. """ - # TODO: It would be a pretty sweet/evil metacircularity to define - # Field itself as a Struct. + def __init__(self): + # name is the attribute name through which this field is + # accessed from the Struct. This will be set automatically + # by MetaStruct. + self.name = None - # The attribute "name" is assigned by MetaStruct. - - def __init__(self, kind=None, mods=()): - self.kind = normalize_kind(kind) - self.mods = normalize_mods(mods) + def copy(self): + # This is used by MetaStruct to get a fresh instance + # of the field for each of its occurrences. + return type(self)() def __get__(self, inst, value): if inst is None: @@ -59,36 +49,14 @@ def __get__(self, inst, value): def __set__(self, inst, value): if inst._immutable and inst._initialized: raise AttributeError('Struct is immutable') - - check_spec(value, self.kind, self.mods) - - if 'seq' in self.mods: - value = tuple(value) - inst.__dict__[self.name] = value - def _field_eq(self, val1, val2): - """Compare two field values for equality.""" - if 'seq' in self.mods: - if len(val1) != len(val2): - return False - return all(self.eq(e1, e2) for e1, e2 in zip(val1, val2)) - else: - return self.eq(val1, val2) - - def _field_hash(self, val): - """Hash a field value.""" - if 'seq' in self.mods: - return hash_seq(self.hash(e) for e in val) - else: - return self.hash(val) - def eq(self, val1, val2): - """Compare two values of this field's kind.""" + """Compare two values for this field.""" return val1 == val2 def hash(self, val): - """Hash a value of this field's kind.""" + """Hash a value for this field.""" return hash(val) @@ -96,21 +64,18 @@ class MetaStruct(type): """Metaclass for Structs. - Upon class definition (of a new Struct subtype), set class attribute - _struct to be a tuple of the Field descriptors, in declaration - order. + Upon class definition (of a new Struct subtype), set the class + attribute _struct to be a tuple of the Field descriptors, in + declaration order. If the class has attribute _inherit_fields + and it evaluates to true, also include fields of base classes. + (Names of inherited fields must not collide with other inherited + fields or this class's fields.) Set class attribute _signature + to be an inspect.Signature object to facilitate instantiation. Upon instantiation of a Struct subtype, set the instance's _initialized attribute to True after __init__() returns. """ - @property - def _primary_fields(cls): - """Non-derived fields, i.e. fields that don't have a '!' - modified. - """ - return [f for f in cls._struct if '!' not in f.mods] - # Use OrderedDict to preserve Field declaration order. @classmethod def __prepare__(mcls, name, bases, **kargs): @@ -125,12 +90,25 @@ def __new__(mcls, clsname, bases, namespace, **kargs): if isinstance(b, MetaStruct): fields += b._struct # Gather fields from this class's namespace. - for fname, f in namespace.copy().items(): - if not isinstance(f, Field): - continue - - f.name = fname - fields.append(f) + for fname, f in namespace.items(): + # Using the Field class directly (or one of its subclasses) + # is shorthand for making a Field instance with no args. + if isinstance(f, type) and issubclass(f, Field): + f = f() + if isinstance(f, Field): + # Fields need to be copied in case they're used + # in multiple places (in this class or others). + f = f.copy() + f.name = fname + fields.append(f) + namespace[fname] = f + # Ensure no name collisions. + fnames = Counter(f.name for f in fields) + collided = [k for k in fnames if fnames[k] > 1] + if len(collided) > 0: + raise AttributeError( + 'Struct {} has colliding field name(s): {}'.format( + clsname, ', '.join(collided))) cls = super().__new__(mcls, clsname, bases, dict(namespace), **kargs) @@ -138,7 +116,7 @@ def __new__(mcls, clsname, bases, namespace, **kargs): cls._signature = Signature( parameters=[Parameter(f.name, Parameter.POSITIONAL_OR_KEYWORD) - for f in cls._primary_fields]) + for f in cls._struct]) return cls @@ -151,37 +129,47 @@ def __call__(mcls, *args, **kargs): class Struct(metaclass=MetaStruct): - """A fixed structure class that supports default constructors, - type-checking and coersion, immutable fields, pretty-printing, - equality, and hashing. + """Base class for Structs. - By default, __new__() will initialize the non-derived fields. - If immutable, fields may still be written to until __init__() - (of the last subclass) returns. + Declare fields by assigning class attributes to an instance of + the descriptor Field or one of its subclasses. As a convenience, + assigning to the Field (sub)class itself is also permitted. + The fields become the positional arguments to the class's + constructor. Construction via keyword argument is also allowed, + following normal Python parameter passing rules. - Subclasses are not required to call super().__init__() if this - is the only base class. - """ + If class attribute _inherit_fields is defined and evaluates to + true, the fields of each base class are prepended to this class's + list of fields in left-to-right order. + + A subclass may define __init__() to customize how fields are + initialized, or to set other non-field attributes. If the class + attribute _immutable evaluates to true, assigning to fields is + disallowed once the last subclass's __init__() finishes. - @property - def _primary_fields(self): - return self.__class__._primary_fields + Structs may be pickled. Upon unpickling, __init__() will be + called. + + Structs support structural equality. Hashing is allowed only + for immutable Structs and after they are initialized. + + The methods _asdict() and _replace() behave as they do for + collections.namedtuple. + """ _immutable = True """Flag for whether to allow reassignment to fields after construction. Override with False in subclass to allow. """ - # We expect there to be one constructor argument for each - # non-derived field (i.e. a field without the '!' modifier), - # in field declaration order. def __new__(cls, *args, **kargs): inst = super().__new__(cls) + # _initialized is read during field initialization. inst._initialized = False try: boundargs = cls._signature.bind(*args, **kargs) - for f in cls._primary_fields: + for f in cls._struct: setattr(inst, f.name, boundargs.arguments[f.name]) except TypeError as exc: raise TypeError('Error constructing ' + cls.__name__) from exc @@ -192,7 +180,7 @@ def _fmt_helper(self, fmt): return '{}({})'.format( self.__class__.__name__, ', '.join('{}={}'.format(f.name, fmt(getattr(self, f.name))) - for f in self._primary_fields)) + for f in self._struct)) def __str__(self): return self._fmt_helper(str) @@ -200,43 +188,50 @@ def __repr__(self): return self._fmt_helper(repr) def __eq__(self, other): - if not isinstance(self, other.__class__): + # Two struct instances are equal if they have the same + # type and same field values. + if type(self) != type(other): + # But leave the door open to subclasses providing + # alternative equality semantics. return NotImplemented - return all(f._field_eq(getattr(self, f.name), getattr(other, f.name)) - for f in self._primary_fields) - - def __neq__(self, other): - return not (self == other) + return all(f.eq(getattr(self, f.name), getattr(other, f.name)) + for f in self._struct) def __hash__(self): if not self._immutable: raise TypeError('Cannot hash mutable Struct {}'.format( - str_valtype(self))) + self.__class__.__name__)) if not self._initialized: raise TypeError('Cannot hash uninitialized Struct {}'.format( - str_valtype(self))) - return hash_seq(f._field_hash(getattr(self, f.name)) - for f in self._primary_fields) + self.__class__.__name__)) + return hash_seq(f.hash(getattr(self, f.name)) + for f in self._struct) + + def __len__(self): + return len(self._struct) + + def __iter__(self): + return (getattr(self, f.name) for f in self._struct) 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 - # trigger the user-defined __init__() (which may compute - # derived field values) and to set _immutable to false. + # trigger the user-defined __init__() and to set _immutable to + # False. return (self.__class__, tuple(getattr(self, f.name) - for f in self._primary_fields)) + for f in self._struct)) def _asdict(self): """Return an OrderedDict of the fields.""" return OrderedDict((f.name, getattr(self, f.name)) - for f in self._primary_fields) + for f in self._struct) def _replace(self, **kargs): - """Return a copy of this struct with the same fields except + """Return a copy of this Struct with the same fields except with the changes specified by kargs. """ fields = {f.name: getattr(self, f.name) - for f in self._primary_fields} + for f in self._struct} fields.update(kargs) return type(self)(**fields) diff --git a/simplestruct/test_util.py b/simplestruct/test_util.py deleted file mode 100644 index 3d73363..0000000 --- a/simplestruct/test_util.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Unit tests for util.py.""" - - -import unittest - -from simplestruct.util import * - - -class UtilCase(unittest.TestCase): - - def testTrim(self): - text1 = trim(''' - for x in foo: - print(x) - ''') - exp_text1 = 'for x in foo:\n print(x)' - - self.assertEqual(text1, exp_text1) - - text2 = trim('') - exp_text2 = '' - - self.assertEqual(text2, exp_text2) - - def testFrozendict(self): - d = frozendict({1:2, 3:4}) - with self.assertRaises(TypeError): - d[3] = 5 - hash(d) - - def testFreeze(self): - val = make_frozen([{1: 2}, {3}]) - exp_val = (frozendict({1: 2}), frozenset({3})) - self.assertEqual(val, exp_val) - - -if __name__ == '__main__': - unittest.main() diff --git a/simplestruct/type.py b/simplestruct/type.py index cd9c0c8..a6edb7d 100644 --- a/simplestruct/type.py +++ b/simplestruct/type.py @@ -1,34 +1,26 @@ -"""Type checking and type coercion.""" +"""Type checking and type coercion. -# 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. +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. -# 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,)). - -# A type specification is a kind along with a tuple of modifier strings, -# which may include: -# -# 'seq': sequence of elements satisfying the kind -# 'nodups': with 'seq', no duplicate elements allowed -# -# The modifier strings may also be given as another sequence or as a -# space-delimited string. +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', - 'check_spec', ] def str_valtype(val): """Get a string describing the type of val.""" - return type(val).__name__ + return val.__class__.__name__ def normalize_kind(kind): """Make a proper kind out of one of the alternative forms.""" @@ -39,13 +31,6 @@ def normalize_kind(kind): else: return tuple(kind) -def normalize_mods(mods): - """Make a modifier list out of space-delimited string.""" - if isinstance(mods, str): - return tuple(mods.split()) - else: - return tuple(mods) - def str_kind(kind): """Get a string describing a kind.""" if len(kind) == 0: @@ -107,15 +92,3 @@ def checktype_seq(seq, kind, nodups=False): raise TypeError('Duplicate element {} at position {}'.format( repr(item), i)) seen.append(item) - - -def check_spec(val, kind, mods): - """Raise TypeError if val does not match the type specification - given by kind and mods. - """ - mods = normalize_mods(mods) - if 'seq' in mods: - nodups = 'nodups' in mods - checktype_seq(val, kind, nodups=nodups) - else: - checktype(val, kind) diff --git a/simplestruct/util.py b/simplestruct/util.py deleted file mode 100644 index fea3549..0000000 --- a/simplestruct/util.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Useful, small utilities. Some of these are not used within -simplestruct, but by other projects that depend on simplestruct. -""" - - -__all__ = [ - 'trim', - 'frozendict', - 'make_frozen', -] - - -from collections import Mapping -from functools import reduce - - -def trim(text): - """Like textwrap.dedent, but also eliminate leading and trailing - lines if they are whitespace or empty. - - This is useful for writing code as triple-quoted multi-line - strings. - """ - from textwrap import dedent - - lines = text.split('\n') - if len(lines) > 0: - if len(lines[0]) == 0 or lines[0].isspace(): - lines = lines[1 : ] - if len(lines) > 0: - if len(lines[-1]) == 0 or lines[-1].isspace(): - lines = lines[ : -1] - - return dedent('\n'.join(lines)) - - -# Inspired by a Stack Overflow answer by Mike Graham. -# http://stackoverflow.com/questions/2703599/what-would-be-a-frozen-dict - -class frozendict(Mapping): - - """Analogous to frozenset.""" - - def __init__(self, *args, **kargs): - self.d = dict(*args, **kargs) - self.hash = reduce(lambda a, b: a ^ hash(b), self.items(), 0) - - def __iter__(self): - return iter(self.d) - - def __len__(self): - return len(self.d) - - def __getitem__(self, key): - return self.d[key] - - def __hash__(self): - return self.hash - - -def make_frozen(v): - """Normalize mutable dicts to frozendicts and lists to tuples, - recursively. - """ - if isinstance(v, (dict, frozendict)): - return frozendict({make_frozen(k): make_frozen(v) - for k, v in v.items()}) - elif isinstance(v, (list, tuple)): - return tuple(make_frozen(e) for e in v) - else: - return v diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_fields.py b/tests/test_fields.py new file mode 100644 index 0000000..963507d --- /dev/null +++ b/tests/test_fields.py @@ -0,0 +1,36 @@ +"""Unit tests for fields.py.""" + + +import unittest + +from simplestruct.struct import * +from simplestruct.fields import * + + +class FieldsCase(unittest.TestCase): + + def test_TypedField(self): + # Non-sequence case. + class Foo(Struct): + bar = TypedField(int) + f = Foo(1) + with self.assertRaises(TypeError): + Foo('a') + + # Sequence case. + class Foo(Struct): + bar = TypedField(int, seq=True) + f = Foo([1, 2]) + self.assertEqual(f.bar, ((1, 2))) + with self.assertRaises(TypeError): + Foo([1, 'a']) + + # Nodups sequence. + class Foo(Struct): + bar = TypedField(int, seq=True, nodups=True) + Foo([1, 2]) + with self.assertRaises(TypeError): + Foo([1, 2, 1]) + +if __name__ == '__main__': + unittest.main() diff --git a/simplestruct/test_struct.py b/tests/test_struct.py similarity index 51% rename from simplestruct/test_struct.py rename to tests/test_struct.py index a0fc3cf..b25dc3b 100644 --- a/simplestruct/test_struct.py +++ b/tests/test_struct.py @@ -16,37 +16,63 @@ class PickleFoo(Struct): class StructCase(unittest.TestCase): - def testField(self): - # Since we're just testing Field and not MetaStruct, - # we need to bypass some of the meta machinery and be - # more verbose. - barfield = Field(int, 'seq') + def test_Field(self): + # We're just testing the behavior of Field as a descriptor, + # and not Struct/MetaStruct, so we'll mock the behavior of + # the meta machinery. + barfield = Field() barfield.name = 'bar' + + # Normal read/write access. class Foo: + _immutable = False bar = barfield - def __init__(self, b): - self._immutable = False - self.bar = b - - # Instantiation. - f = Foo([5]) - self.assertEqual(f.bar, (5,)) - - # Type checking. - with self.assertRaises(TypeError): - f.bar = 'b' + f = Foo() + f.bar = 5 + self.assertEqual(f.bar, 5) - # Immutability. - f._immutable = True + # Immutable access, pre- and post-initialization. + class Foo: + _immutable = True + bar = barfield + f = Foo() + f._initialized = False + f.bar = 5 + f._initialized = True with self.assertRaises(AttributeError): - f.bar = [6] + f.bar = 6 + + # Equality and hashing. + self.assertTrue(barfield.eq(5, 5)) + self.assertFalse(barfield.eq(5, 6)) + self.assertEqual(barfield.hash(5), hash(5)) - def testStruct(self): - # Basic functionality. + def test_Struct(self): + # Basic instantiation and pretty printing. class Foo(Struct): - bar = Field(int) + bar = Field() f = Foo(5) self.assertEqual(f.bar, 5) + self.assertEqual(str(f), 'Foo(bar=5)') + + # Equality and hashing. + class Foo(Struct): + bar = Field() + f1 = Foo(5) + f2 = Foo(5) + f3 = Foo(6) + self.assertEqual(f1, f2) + self.assertNotEqual(f1, f3) + self.assertEqual(hash(f1), hash(f2)) + # hash(f1) == hash(f3) is unlikely but valid. + + # No hashing for mutable structs. + class Foo(Struct): + _immutable = False + bar = Field() + f = Foo(5) + with self.assertRaises(TypeError): + hash(f) # Construction by keyword. class Foo(Struct): @@ -56,65 +82,67 @@ class Foo(Struct): f1 = Foo(1, b=2, **{'c': 3}) f2 = Foo(1, 2, 3) self.assertEqual(f1, f2) + # _struct attribute. + names = [f.name for f in Foo._struct] + self.assertEqual(names, ['a', 'b', 'c']) + + # Parentheses-less shorthand. + class Foo(Struct): + bar = Field + f = Foo(5) + self.assertEqual(f.bar, 5) - # Mutability. + # Distinct uses of Field instances are cloned. + barfield = Field() + class Foo1(Struct): + bar1 = barfield + bar2 = barfield + class Foo2(Struct): + bar3 = barfield + # Check for distinct instances. Although if there's + # 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. class Foo(Struct): _immutable = False - bar = Field(int) + bar = Field() f = Foo(5) f.bar = 6 + with self.assertRaises(TypeError): + hash(f) - # Immutability. + # Immutable and hashable after initialization is done. class Foo(Struct): - bar = Field(int) + bar = Field() def __init__(self, *_): self.bar += 1 f = Foo(5) self.assertEqual(f.bar, 6) + hash(f) with self.assertRaises(AttributeError): f.bar = 7 - # Pretty-printing. - self.assertEqual(str(f), 'Foo(bar=6)') - - # Derived data. - class Foo(Struct): - bar = Field(int) - baz = Field(int, '!') - def __init__(self, bar): - self.baz = bar + 1 - f = Foo(5) - self.assertEqual(f.baz, 6) - - # Equality and hashing. - class Foo(Struct): - bar = Field(int) - f1 = Foo(5) - f2 = Foo(5) - f3 = Foo(6) - self.assertEqual(f1, f2) - self.assertNotEqual(f1, f3) - self.assertEqual(hash(f1), hash(f2)) - # hash(f1) == hash(f3) is unlikely but valid. - - # No hashing for mutable structs. - class Foo(Struct): - _immutable = False - bar = Field(int) - f = Foo(5) - with self.assertRaises(TypeError): - hash(f) - - # Or for structs that aren't yet constructed. + # Unhashable during initialization. class Foo(Struct): - bar = Field(int) + bar = Field() def __init__(self, bar): hash(self) - hash(self) with self.assertRaises(TypeError): f = Foo(5) - def testCustomEqHash(self): + def test_custom_eq_hash(self): class CustomField(Field): def eq(self, val1, val2): return val1 * val2 > 0 @@ -122,21 +150,21 @@ def hash(self, val): return int(val) * 2 class FooA(Struct): - bar = Field(float, 'seq') + bar = Field() class FooB(Struct): - bar = CustomField(float, 'seq') + bar = CustomField() - fa1 = FooA([5.0]) - fa2 = FooA([6.0]) - fb1 = FooB([5.0]) - fb2 = FooB([6.0]) + fa1 = FooA(5) + fa2 = FooA(6) + fb1 = FooB(5) + fb2 = FooB(6) self.assertNotEqual(fa1, fa2) - self.assertNotEqual(hash(fa1), 10) + self.assertEqual(hash(fa1), hash(5)) self.assertEqual(fb1, fb2) self.assertEqual(hash(fb1), 10) - def testDict(self): + def test_asdict(self): class Foo(Struct): a = Field() b = Field() @@ -146,7 +174,7 @@ class Foo(Struct): exp_d = OrderedDict([('a', 1), ('b', 2), ('c', 3)]) self.assertEqual(d, exp_d) - def testReplace(self): + def test_replace(self): class Foo(Struct): a = Field() b = Field() @@ -156,25 +184,41 @@ class Foo(Struct): f3 = Foo(1, 4, 3) self.assertEqual(f2, f3) - def testPickleability(self): + def test_pickleability(self): + # Pickle dump/load. f1 = PickleFoo(1) buf = pickle.dumps(f1) f2 = pickle.loads(buf) self.assertEqual(f2, f1) + # Deep copy. f3 = copy.deepcopy(f1) self.assertEqual(f3, f1) - def testInheritFields(self): + def test_inheritance(self): + # Normal case. class Foo(Struct): a = Field() class Bar(Foo): _inherit_fields = True b = Field() - bar = Bar(1, 2) self.assertEqual(bar.a, 1) self.assertEqual(bar.b, 2) + + # Name collision. + with self.assertRaises(AttributeError): + class Baz(Foo): + _inherit_fields = True + a = Field() + + # Instances are different classes are never equal, + # even with inheritance. + class Bar(Foo): + _inherit_fields = True + foo = Foo(1) + bar = Bar(1) + self.assertNotEqual(foo, bar) if __name__ == '__main__': diff --git a/simplestruct/test_type.py b/tests/test_type.py similarity index 86% rename from simplestruct/test_type.py rename to tests/test_type.py index 117b2e3..5ec2571 100644 --- a/simplestruct/test_type.py +++ b/tests/test_type.py @@ -33,7 +33,7 @@ def test_checktype_seq(self): checktype_seq(True, bool) with self.assertRaisesRegex( TypeError, 'Expected sequence of str; ' - 'got single str*'): + 'got single str'): checktype_seq('abc', str) with self.assertRaisesRegex( TypeError, 'Expected sequence of int; ' @@ -44,12 +44,6 @@ def test_checktype_seq(self): with self.assertRaisesRegex( TypeError, 'Duplicate element 5 at position 2'): checktype_seq([5, 3, 5, 8], int, nodups=True) - - def test_check_spec(self): - check_spec(3, int, []) - with self.assertRaisesRegex( - TypeError, 'Duplicate element 5 at position 2'): - check_spec([5, 3, 5, 8], int, 'seq nodups') if __name__ == '__main__': diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..9600c93 --- /dev/null +++ b/tox.ini @@ -0,0 +1,5 @@ +[tox] +envlist = py33, py34 + +[testenv] +commands = python setup.py test