From 52a2093f5f6b620a5d0004e3540fa8360e3fde33 Mon Sep 17 00:00:00 2001 From: Dave Willmer Date: Sun, 13 Aug 2017 01:47:50 +0100 Subject: [PATCH 1/2] Initial NamedStruct support --- README.md | 48 +++++- privates/__init__.py | 2 + privates/named_struct.py | 164 +++++++++++++++++++ tests/named_struct/__init__.py | 0 tests/named_struct/test_basic_sanity.py | 69 ++++++++ tests/named_struct/test_inheritance.py | 60 +++++++ tests/named_struct/test_numba.py | 73 +++++++++ tests/named_struct/test_numba_inheritance.py | 1 + tests/named_struct/test_overrides.py | 45 +++++ 9 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 privates/named_struct.py create mode 100644 tests/named_struct/__init__.py create mode 100644 tests/named_struct/test_basic_sanity.py create mode 100644 tests/named_struct/test_inheritance.py create mode 100644 tests/named_struct/test_numba.py create mode 100644 tests/named_struct/test_numba_inheritance.py create mode 100644 tests/named_struct/test_overrides.py diff --git a/README.md b/README.md index 1e2454d..a55ddae 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![Build Status](https://travis-ci.org/fastats/privates.svg?branch=master)](https://travis-ci.org/fastats/privates) [![Coverage Status](https://coveralls.io/repos/github/fastats/privates/badge.svg?branch=master)](https://coveralls.io/github/fastats/privates?branch=master) - - +[![Documentation Status](https://readthedocs.org/projects/privates/badge/?version=latest)](http://privates.readthedocs.io/en/latest/?badge=latest) A python library using private/hidden python language features +[Click here for the documentation](http://privates.readthedocs.io/en/latest/) ## features @@ -30,9 +30,49 @@ with no_mutations(x): assert 'c' in x ``` +- A `NamedStruct` to facilitate calling external native/jitted APIs, which +allows inheritance of attributes, among other behaviours. This also features +as a better `namedtuple`/`typing.NamedTuple`, without the errors of the existing +implementations. + +```python +from privates import NamedStruct + + +class Point(NamedStruct): + x: int + y: int + + +class Rectangle(Point): + height: int + width: int + + def area(self): + return self.height * self.width + + +# This creates a `numba` jitclass. +r = Rectangle.create(x=0, y=0, height=3, width=4) +assert r.area() == 12 +``` + +## development/contributing + +- To report a bug, please open a PR with a new (failing) unit-test showing the +problem. +- To request a feature, please open a PR with a new (failing) unit-test showing +the preferred API. +- To make a contribution, please open a PR with new (passing) unit-tests, +inline doctest examples and documentation updates. + ## requirements - Python 3.6 or later -- Py.test -- coverage \ No newline at end of file +- py.test +- coverage + +### optional requirements + +- numba >= 0.33 diff --git a/privates/__init__.py b/privates/__init__.py index 0a53cf4..d9b2ade 100644 --- a/privates/__init__.py +++ b/privates/__init__.py @@ -4,9 +4,11 @@ from privates.core.errors import MutationError from privates.mutations import no_mutations +from privates.named_struct import NamedStruct __all__ = [ MutationError, + NamedStruct, no_mutations ] \ No newline at end of file diff --git a/privates/named_struct.py b/privates/named_struct.py new file mode 100644 index 0000000..0e8bba0 --- /dev/null +++ b/privates/named_struct.py @@ -0,0 +1,164 @@ + +from typing import Optional, Any, Iterator + + +class NamedStruct: + """ + ``NamedStruct`` is a class which facilitates interactions + between raw python code and native/jitted code. + + A single ``NamedStruct`` definition can be used as an + argument for: + - a `numba` jitclass. + - TODO: a native function which takes a `ctypes` struct. + - TODO: a `cffi` function which requires a native C pointer. + + As a side effect, this is also a better NamedTuple, without + all the problems of ``namedtuple`` or ``typing.NamedTuple`` + + Examples + -------- + A `numba` jitclass. + >>> from privates import NamedStruct + >>> class MyStruct(NamedStruct): + ... a: int + ... b: int + ... c: float + >>> m = MyStruct(1, 2, 3.0) + >>> m.a + 1 + >>> m.b + 2 + >>> m.c + 3.0 + + """ + def __init__(self, *args): + fields = self._fields() + assert len(fields) == len(args), "Invalid Arguments" + for (name, typ), value in zip(fields.items(), args): + assert isinstance(value, typ), f"{name} is not a {typ}" + setattr(self, name, value) + + @classmethod + def _fields(cls) -> dict: + init = {} + for base in reversed(cls.mro()): + try: + init.update(base.__annotations__ or {}) + except AttributeError: # no annotations + continue + init.update(cls.__annotations__) + return init + + def __iter__(self) -> Iterator: + """ + Implementing this makes the NamedStruct iterable. + + This means that looping over the struct object (such + as in an OrderedDict constructor) will visit this + method. + + The implementation returns an iterator over the + `__annotations__` mapping which is inherited from + all base classes. + + >>> from privates import NamedStruct + >>> class MyStruct(NamedStruct): + ... a: int + >>> m = MyStruct(30_000) + >>> list(m) + [('a', )] + """ + return iter(self._fields().items()) + + def __getitem__(self, key) -> Optional[Any]: + """ + Implementing this overrides the default behaviour + for square-bracket-indexing, ie, my_obj[0]. + + In this case we accept integer indices to represent + the struct attributes, with the order being the + order they are defined on this class. + + >>> from privates import NamedStruct + >>> class Test(NamedStruct): + ... a: int + ... b: float + >>> t = Test(1, 2.0) + >>> t[0] + 1 + >>> t[1] + 2.0 + """ + item = list(self._fields())[key] + name = item[0] + return getattr(self, name) + + def __len__(self) -> int: + """ + Implementing this allows us to call the builtin `len` + on our objects. + + The value returned is an integer count of the number + of fields defined on the struct. + + >>> from privates import NamedStruct + >>> class First(NamedStruct): + ... a: int + ... b: int + >>> class Second(First): + ... c: float + >>> f = First(1, 2) + >>> len(f) + 2 + >>> s = Second(1, 2, 3.0) + >>> len(s) + 3 + """ + return sum(1 for _ in iter(self)) + + @classmethod + def items(cls): + """ + This mimics the `dict` API, which is required for + `numba` support. + + When passing an object to the `jitclass` decorator in + `numba`, `numba` does `OrderedDict(my_obj)` to get the + attribute and type specifications, which requires us to + have this implemented. + + >>> from privates import NamedStruct + >>> class Test(NamedStruct): + ... a: int + ... b: int + >>> t = Test(1, 2) + >>> t.items() + dict_items([('a', ), ('b', )]) + """ + return cls._fields().items() + + @classmethod + def create(cls, **kwargs): + jit_cls = cls._gen_type() + args = [] + for k, v in cls.items(): + args.append(kwargs[k]) + args = (kwargs[k] for k, _ in cls.items()) + return jit_cls(*args) + + @classmethod + def _gen_type(cls): + from numba import jitclass # Lazy import + latest = object + fields = cls._fields() + for base in reversed(cls.mro()): + if base is not NamedStruct: + # TODO: generate __init__ for annotations + items = {'__init__': base.__dict__.get('__init__')} + meths = {k: v for k, v in base.__dict__.items() + if not k.startswith('__')} + items.update(meths) + latest = type(str(base), (latest,), items) + return jitclass(fields)(latest) diff --git a/tests/named_struct/__init__.py b/tests/named_struct/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/named_struct/test_basic_sanity.py b/tests/named_struct/test_basic_sanity.py new file mode 100644 index 0000000..788fe31 --- /dev/null +++ b/tests/named_struct/test_basic_sanity.py @@ -0,0 +1,69 @@ + +from collections import OrderedDict + +import pytest + +from privates import NamedStruct + + +class MyStruct(NamedStruct): + a: int + b: int + c: float + + +def test_iter_support(): + m = MyStruct(1, 2, 3.0) + fields = list(m) + + assert isinstance(fields[0], tuple) + assert fields[0][0] == 'a' + assert fields[0][1] == int + assert fields[1][0] == 'b' + assert fields[1][1] == int + assert fields[2][0] == 'c' + assert fields[2][1] == float + + with pytest.raises(IndexError): + _ = fields[3] + +def test_ordered_dict_support(): + m = MyStruct(1, 2, 3.0) + od = OrderedDict(m) + + assert od['a'] == int + assert od['b'] == int + assert od['c'] == float + + with pytest.raises(KeyError): + _ = od['d'] + + +def test_tuple_indexing(): + m = MyStruct(2, 3, 4.0) + + assert m[0] == 2 + assert m[1] == 3 + assert m[2] == 4.0 + + with pytest.raises(IndexError): + _ = m[3] + + +def test_insufficient_args(): + with pytest.raises(AssertionError): + _ = MyStruct(1) + + +def test_too_many_args(): + with pytest.raises(AssertionError): + _ = MyStruct(1, 2, 3.0, 4) + + +def test_items(): + view = MyStruct.items() + items = dict(view) + + assert 'a' in items + assert 'b' in items + assert 'c' in items diff --git a/tests/named_struct/test_inheritance.py b/tests/named_struct/test_inheritance.py new file mode 100644 index 0000000..3cb6d0a --- /dev/null +++ b/tests/named_struct/test_inheritance.py @@ -0,0 +1,60 @@ + +import pytest + +from privates import NamedStruct + + +class First(NamedStruct): + a: int + b: str + + +class Second(First): + c: float + d: str + + +class Third(Second): + e: int + f: str + + +class Fourth(Third): + g: str + h: int + + +def test_attribute_order(): + f = Fourth(1, 'a', 2.0, 'b', 3, 'c', 'test', 30_000) + + assert f.a == 1 + assert f[0] == 1 + + assert f.b == 'a' + assert f[1] == 'a' + + assert f.c == 2.0 + assert f[2] == 2.0 + + assert f.d == 'b' + assert f[3] == 'b' + + assert f.e == 3 + assert f[4] == 3 + + assert f.f == 'c' + assert f[5] == 'c' + + assert f.g == 'test' + assert f[6] == 'test' + + assert f.h == 30000 + assert f[7] == 30000 + + with pytest.raises(IndexError): + _ = f[8] + + +def test_types_are_validated(): + with pytest.raises(AssertionError): + f = Fourth('1', 'a', 2.0, 'b', 3, 'c', 'test', 30_000) \ No newline at end of file diff --git a/tests/named_struct/test_numba.py b/tests/named_struct/test_numba.py new file mode 100644 index 0000000..1f0df5e --- /dev/null +++ b/tests/named_struct/test_numba.py @@ -0,0 +1,73 @@ + +import sys +from unittest import skipUnless as skip_unless + +import numpy as np +try: + import numba + from numba import jitclass + + from numba.types import ( + intp, float64 + ) +except ImportError: + numba = None + +from privates import NamedStruct + +FAIL_MSG = "Numba not found" + + +class Point(NamedStruct): + x: float64 + y: float64 + + def distance_from_origin(self): + return np.sqrt(self.x**2 + self.y**2) + + +class Rectangle(Point): + width: float64 + height: float64 + + def __init__(self, x, y, width, height): + self.x = x + self.y = y + self.width = width + self.height = height + + def area(self): + return self.width * self.height + + +@skip_unless(numba, FAIL_MSG) +def test_create_api(): + global Rectangle + + norm = Rectangle(x=0, y=1, height=5, width=6) + assert norm.x == 0 + assert norm[0] == 0 + assert norm.y == 1 + assert norm[1] == 1 + assert norm.height == 5 + # assert norm[2] == 5 # TODO : need Struct.init + assert norm.width == 6 + # assert norm[3] == 6 + assert norm.distance_from_origin() == 1 + assert norm.area() == 30 + + r = Rectangle.create(x=0, y=1, height=5, width=6) + assert r.x == 0 + assert r.y == 1 + assert r.height == 5 + assert r.width == 6 + assert r.area() == 30 + assert r.distance_from_origin() == 1.0 + + r2 = Rectangle.create(x=0, y=1, height=6, width=7) + assert r2.area() == 42 + assert r2.distance_from_origin() == 1.0 + + r3 = Rectangle.create(x=3.0, y=4.0, height=3, width=4) + assert r3.area() == 12 + assert r3.distance_from_origin() == 5.0 \ No newline at end of file diff --git a/tests/named_struct/test_numba_inheritance.py b/tests/named_struct/test_numba_inheritance.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/named_struct/test_numba_inheritance.py @@ -0,0 +1 @@ + diff --git a/tests/named_struct/test_overrides.py b/tests/named_struct/test_overrides.py new file mode 100644 index 0000000..eb35bed --- /dev/null +++ b/tests/named_struct/test_overrides.py @@ -0,0 +1,45 @@ + +import pytest + +from privates import NamedStruct + + +class First(NamedStruct): + a: int + b: float + + +class Second(First): + b: int + c: str + + +def test_overridden_attribute(): + s = Second(1, 2, 'test') + + assert len(s) == 3 + + assert s.a == 1 + assert s[0] == 1 + + assert s.b == 2 + assert s[1] == 2 + + assert s.c == 'test' + assert s[2] == 'test' + + with pytest.raises(IndexError): + _ = s[3] + + +def test_previous_attribute_type_fails(): + f = First(1, 2.0) + + assert len(f) == 2 + assert f.a == 1 + assert f[0] == 1 + assert f.b == 2.0 + assert f[1] == 2.0 + + with pytest.raises(AssertionError): + _ = Second(1, 2.0, 'test') From bec8feaceb682ce555749ebe0527dcf408d766ed Mon Sep 17 00:00:00 2001 From: Dave Willmer Date: Tue, 3 Apr 2018 21:50:13 +0100 Subject: [PATCH 2/2] General cleanup - remove unused code + require numba --- privates/named_struct.py | 3 --- tests/named_struct/test_numba.py | 20 +++----------------- tests/named_struct/test_numba_inheritance.py | 1 - 3 files changed, 3 insertions(+), 21 deletions(-) delete mode 100644 tests/named_struct/test_numba_inheritance.py diff --git a/privates/named_struct.py b/privates/named_struct.py index 0e8bba0..49cd2dd 100644 --- a/privates/named_struct.py +++ b/privates/named_struct.py @@ -142,9 +142,6 @@ def items(cls): @classmethod def create(cls, **kwargs): jit_cls = cls._gen_type() - args = [] - for k, v in cls.items(): - args.append(kwargs[k]) args = (kwargs[k] for k, _ in cls.items()) return jit_cls(*args) diff --git a/tests/named_struct/test_numba.py b/tests/named_struct/test_numba.py index 1f0df5e..749f951 100644 --- a/tests/named_struct/test_numba.py +++ b/tests/named_struct/test_numba.py @@ -1,22 +1,9 @@ -import sys -from unittest import skipUnless as skip_unless - +from numba.types import float64 import numpy as np -try: - import numba - from numba import jitclass - - from numba.types import ( - intp, float64 - ) -except ImportError: - numba = None from privates import NamedStruct -FAIL_MSG = "Numba not found" - class Point(NamedStruct): x: float64 @@ -40,7 +27,6 @@ def area(self): return self.width * self.height -@skip_unless(numba, FAIL_MSG) def test_create_api(): global Rectangle @@ -50,7 +36,7 @@ def test_create_api(): assert norm.y == 1 assert norm[1] == 1 assert norm.height == 5 - # assert norm[2] == 5 # TODO : need Struct.init + # assert norm[2] == 5 # TODO : needs Struct.init assert norm.width == 6 # assert norm[3] == 6 assert norm.distance_from_origin() == 1 @@ -70,4 +56,4 @@ def test_create_api(): r3 = Rectangle.create(x=3.0, y=4.0, height=3, width=4) assert r3.area() == 12 - assert r3.distance_from_origin() == 5.0 \ No newline at end of file + assert r3.distance_from_origin() == 5.0 diff --git a/tests/named_struct/test_numba_inheritance.py b/tests/named_struct/test_numba_inheritance.py deleted file mode 100644 index 8b13789..0000000 --- a/tests/named_struct/test_numba_inheritance.py +++ /dev/null @@ -1 +0,0 @@ -