diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 96082e3..e313430 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,46 @@ Changelog ========= +0.4 (2015-01-15) +================ + +- **Breaking Change:** The ``Schema.dump`` method no longer supports the + ``many`` argument. This makes ``many`` consistent with ``ordered`` and + simplifies internals. + +- Improve support for serializing linked data: + + - Add new field type ``fields.Reference`` for references to linked objects. + + - Add new name for ``fields.Nested``: ``fields.Embed``. Deprecate + ``fields.Nested`` in favour of ``fields.Embed``. + +- Add read-only properties ``many`` and ``ordered`` for schema objects. + +- Don't generate docs for internal modules any more - those did clutter up the + documentation of the actual API (the docstrings remain though). + +- Implement lazy evaluation and caching of some attributes (affects methods: + ``Schema.dump``, ``Embed.pack`` and ``Reference.pack``). This means stuff is + only evaluated if and when really needed, but it also means: + + - The very first time data is dumped/packed by a Schema/Embed/Reference + object, there will be a tiny delay. Keep objects around to mitigate this + effect. + + - Some errors might surface at a later time. lima mentions this when + raising exceptions though. + +- Allow quotes in field names. + +- Small speed improvement when serializing collections. + +- Remove deprecated field ``fields.type_mapping``. Use ``fields.TYPE_MAPPING`` + instead. + +- Overall cleanup, improvements and bug fixes. + + 0.3.1 (2014-11-11) ================== diff --git a/LICENSE b/LICENSE index 7af9a76..7257704 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2014, Bernhard Weitzhofer +Copyright (c) 2014-2015, Bernhard Weitzhofer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/advanced.rst b/docs/advanced.rst index 6e5ddef..f31b55d 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -65,6 +65,8 @@ like. exotic column types. There is still work to be done. +.. _field_name_mangling: + Field Name Mangling =================== @@ -73,8 +75,8 @@ Fields provided via class attributes have a drawback: class attribute names have to be valid Python identifiers. lima implements a simple name mangling mechanism to allow the specification of -some common non-Python-identifier field names (like JSON-LD's ``"@id"``) as -class attributes. +some common non-Python-identifier field names (like `JSON-LD +`_'s ``"@id"``) as class attributes. The following table shows how name prefixes will be replaced by lima when specifying fields as class attributes (note that every one of those prefixes @@ -110,9 +112,6 @@ This enables us to do the following: explicitly how the data for these fields should be determined (see :ref:`field_data_sources`). - Also, quotes in field names are currently not allowed in lima, regardless - of how they are specified. - Advanced Topics Recap ===================== diff --git a/docs/api.rst b/docs/api.rst index 7a38229..076953a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -35,27 +35,11 @@ lima.fields .. automodule:: lima.fields :members: - :exclude-members: TYPE_MAPPING, type_mapping + :exclude-members: TYPE_MAPPING .. autodata:: lima.fields.TYPE_MAPPING :annotation: =dict(...) - .. autodata:: lima.fields.type_mapping - :annotation: =dict(...) - - -.. _api_registry: - -lima.registry -============= - -.. automodule:: lima.registry - :members: - :exclude-members: global_registry - - .. autodata:: lima.registry.global_registry - :annotation: =lima.registry.Registry() - .. _api_schema: @@ -64,12 +48,3 @@ lima.schema .. automodule:: lima.schema :members: - - -.. _api_util: - -lima.util -========= - -.. automodule:: lima.util - :members: diff --git a/docs/fields.rst b/docs/fields.rst index 80f42e6..2e21fa0 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -209,7 +209,7 @@ Or we can change how already supported data types are marshalled: Also, don't try to override an existing instance method with a static method. Have a look at the source if in doubt (currently only - :class:`lima.fields.Nested` implements :meth:`pack` as an instance method. + :class:`lima.fields.Embed` implements :meth:`pack` as an instance method. .. _data_validation: diff --git a/docs/index.rst b/docs/index.rst index 518d0c4..61fb228 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -69,7 +69,7 @@ Documentation first_steps schemas fields - nested_data + linked_data advanced api project_info diff --git a/docs/linked_data.rst b/docs/linked_data.rst new file mode 100644 index 0000000..aec0438 --- /dev/null +++ b/docs/linked_data.rst @@ -0,0 +1,389 @@ +=========== +Linked Data +=========== + +Lets model a relationship between a book and a book review: + +.. code-block:: python + :emphasize-lines: 16 + + class Book: + def __init__(self, isbn, author, title): + self.isbn = isbn + self.author = author + self.title = title + + # A review links to a book via the "book" attribute + class Review: + def __init__(self, rating, text, book=None): + self.rating = rating + self.text = text + self.book = book + + book = Book('0-684-80122-1', 'Hemingway', 'The Old Man and the Sea') + review = Review(10, 'Has lots of sharks.') + review.book = book + +To serialize this construct, we have to tell lima that a :class:`Review` object +links to a :class:`Book` object via the :attr:`book` attribute (many ORMs +represent related objects in a similar way). + + +Embedding linked Objects +======================== + +We can use a field of type :class:`lima.fields.Embed` to *embed* the serialized +book into the serialization of the review. For this to work we have to tell the +:class:`~lima.fields.Embed` field what to expect by providing the ``schema`` +parameter: + +.. code-block:: python + :emphasize-lines: 9,15-17 + + from lima import fields, Schema + + class BookSchema(Schema): + isbn = fields.String() + author = fields.String() + title = fields.String() + + class ReviewSchema(Schema): + book = fields.Embed(schema=BookSchema) + rating = fields.Integer() + text = fields.String() + + review_schema = ReviewSchema() + review_schema.dump(review) + # {'book': {'author': 'Hemingway', + # 'isbn': '0-684-80122-1', + # 'title': 'The Old Man and the Sea'}, + # 'rating': 10, + # 'text': 'Has lots of sharks.'} + +Along with the mandatory keyword-only argument ``schema``, +:class:`~lima.fields.Embed` accepts the optional keyword-only-arguments we +already know (``attr``, ``get``, ``val``). All other keyword arguments provided +to :class:`~lima.fields.Embed` get passed through to the constructor of the +associated schema. This allows us to do stuff like the following: + +.. code-block:: python + :emphasize-lines: 4-6,10-11 + + class ReviewSchemaPartialBook(Schema): + rating = fields.Integer() + text = fields.String() + partial_book = fields.Embed(attr='book', + schema=BookSchema, + exclude='isbn') + + review_schema_partial_book = ReviewSchemaPartialBook() + review_schema_partial_book.dump(review) + # {'partial_book': {'author': 'Hemingway', + # 'title': 'The Old Man and the Sea'}, + # 'rating': 10, + # 'text': 'Has lots of sharks.'} + + +Referencing linked Objects +========================== + +Embedding linked objects is not always what we want. If we just want to +reference linked objects, we can use a field of type +:class:`lima.fields.Reference`. This field type yields the value of a single +field of the linked object's serialization. + +Referencing is similar to embedding save one key difference: In addition to the +schema of the linked object we also provide the name of the field that acts as +our reference to the linked object. We may, for example, reference a book via +its ISBN like this: + +.. code-block:: python + :emphasize-lines: 2,8 + + class ReferencingReviewSchema(Schema): + book = fields.Reference(schema=BookSchema, field='isbn') + rating = fields.Integer() + text = fields.String() + + referencing_review_schema = ReferencingReviewSchema() + referencing_review_schema.dump(review) + # {'book': '0-684-80122-1', + # 'rating': 10, + # 'text': 'Has lots of sharks.'} + + +Hyperlinks +========== + +One application of :class:`~lima.fields.Reference` is linking to ressources via +hyperlinks in RESTful Web services. Here is a quick sketch: + +.. code-block:: python + :emphasize-lines: 6,12,18 + + # your framework should provide something like this + def book_url(book): + return 'https://my.service/books/{}'.format(book.isbn) + + class BookSchema(Schema): + url = fields.String(get=book_url) + isbn = fields.String() + author = fields.String() + title = fields.String() + + class ReviewSchema(Schema): + book = fields.Reference(schema=BookSchema, field='url') + rating = fields.Integer() + text = fields.String() + + review_schema = ReviewSchema() + review_schema.dump(review) + # {'book': 'https://my.service/books/0-684-80122-1', + # 'rating': 10, + # 'text': 'Has lots of sharks.'} + +If you want to do `JSON-LD `_ and you want to have fields +with names like ``"@id"`` or ``"@context"``, have a look at the section on +:ref:`field_name_mangling` for an easy way to accomplish this. + + + +Two-way Relationships +===================== + +Up until now, we've only dealt with one-way relationships (*From* a review *to* +its book). If not only a review should link to its book, but a book should +also link to it's most popular review, we can adapt our model like this: + +.. code-block:: python + :emphasize-lines: 7,19-20 + + # books now link to their most popular review + class Book: + def __init__(self, isbn, author, title, pop_review=None): + self.isbn = isbn + self.author = author + self.title = title + self.pop_review = pop_review + + # unchanged: reviews still link to their books + class Review: + def __init__(self, rating, text, book=None): + self.rating = rating + self.text = text + self.book = book + + book = Book('0-684-80122-1', 'Hemingway', 'The Old Man and the Sea') + review = Review(4, "Why doesn't he just kill ALL the sharks?") + + book.pop_review = review + review.book = book + + +If we want to construct schemas for models like this, we will have to adress +two problems: + +1. **Definition order:** If we define our :class:`BookSchema` first, its + :attr:`pop_review` attribute will have to reference a :class:`ReviewSchema` + - but this doesn't exist yet, since we decided to define :class:`BookSchema` + first. If we decide to define :class:`ReviewSchema` first instead, we run + into the same problem with its :attr:`book` attribute. + +2. **Recursion:** A review links to a book that links to a review that links to + a book that links to a review that links to a book that links to a review + that links to a book ``RuntimeError: maximum recursion depth exceeded`` + +lima makes it easy to deal with those problems: + +To overcome the problem of recursion, just exclude the attribute on the other +side that links back. + +To overcome the problem of definition order, lima supports lazy evaluation of +schemas. Just pass the *qualified name* (or the *fully module-qualified name*) +of a schema class to :class:`~lima.fields.Embed` instead of the class itself: + +.. code-block:: python + :emphasize-lines: 5,8 + + class BookSchema(Schema): + isbn = fields.String() + author = fields.String() + title = fields.String() + pop_review = fields.Embed(schema='ReviewSchema', exclude='book') + + class ReviewSchema(Schema): + book = fields.Embed(schema=BookSchema, exclude='pop_review') + rating = fields.Integer() + text = fields.String() + +Now embedding works both ways: + +.. code-block:: python + :emphasize-lines: 5-6,11-13 + + book_schema = BookSchema() + book_schema.dump(book) + # {'author': 'Hemingway', + # 'isbn': '0-684-80122-1', + # 'pop_review': {'rating': 4, + # 'text': "Why doesn't he just kill ALL the sharks?"}, + # 'title': The Old Man and the Sea'} + + review_schema = ReviewSchema() + review_schema.dump(review) + # {'book': {'author': 'Hemingway', + # 'isbn': '0-684-80122-1', + # 'title': 'The Old Man and the Sea'}, + # 'rating': 4, + # 'text': "Why doesn't he just kill ALL the sharks?"} + +.. _on_class_names: + +.. admonition:: On class names + :class: note + + For referring to classes via their name, the lima documentation only ever + talks about two different kinds of class names: the *qualified name* + (*qualname* for short) and the *fully module-qualified name*: + + The qualified name + This is the value of the class's :attr:`__qualname__` attribute. Most + of the time, it's the same as the class's :attr:`__name__` attribute + (except if you define classes within classes or functions ...). If you + define ``class Foo: pass`` at the top level of your module, the class's + qualified name is simply ``Foo``. Qualified names were introduced with + Python 3.3 via `PEP 3155 `_ + + The fully module-qualified name + This is the qualified name of the class prefixed with the full name of + the module the class is defined in. If you define ``class Qux: pass`` + within a class :class:`Baz` (resulting in the qualified name + ``Baz.Qux``) at the top level of your ``foo.bar`` module, the class's + fully module-qualified name is ``foo.bar.Baz.Qux``. + +.. warning:: + + If you define schemas in local namespaces (at function execution time), + their names become meaningless outside of their local context. For + example: + + .. code-block:: python + + def make_schema(): + class FooSchema(Schema): + foo = fields.String() + return FooSchema + + schemas = [make_schema() for i in range(1000)] + + Which of those one thousend schemas would we refer to, would we try to link + to a ``FooSchema`` by name? To avoid ambiguity, lima will refuse to link to + schemas defined in local namespaces. + +By the way, there's nothing stopping us from using the idioms we just learned +for models that link to themselves - everything works as you'd expect: + +.. code-block:: python + :emphasize-lines: 5,10 + + class MarriedPerson: + def __init__(self, first_name, last_name, spouse=None): + self.first_name = first_name + self.last_name = last_name + self.spouse = spouse + + class MarriedPersonSchema(Schema): + first_name = fields.String() + last_name = fields.String() + spouse = fields.Embed(schema='MarriedPersonSchema', exclude='spouse') + + +One-to-many and many-to-many Relationships +========================================== + +Until now, we've only dealt with one-to-one relations. What about one-to-many +and many-to-many relations? Those link to collections of objects. + +We know the necessary building blocks already: Providing additional keyword +arguments to :class:`~lima.fields.Embed` (or :class:`~lima.fields.Reference` +respectively) passes them through to the specified schema's constructor. And +providing ``many=True`` to a schema's construtor will have the schema +marshalling collections - so if our model looks like this: + + +.. code-block:: python + :emphasize-lines: 7,16-20 + + # books now have a list of reviews + class Book: + def __init__(self, isbn, author, title): + self.isbn = isbn + self.author = author + self.title = title + self.reviews = [] + + class Review: + def __init__(self, rating, text, book=None): + self.rating = rating + self.text = text + self.book = book + + book = Book('0-684-80122-1', 'Hemingway', 'The Old Man and the Sea') + book.reviews = [ + Review(10, 'Has lots of sharks.', book), + Review(4, "Why doesn't he just kill ALL the sharks?", book), + Review(8, 'Better than the movie!', book), + ] + +... we wourld define our schemas like this: + +.. code-block:: python + :emphasize-lines: 5-7,10 + + class BookSchema(Schema): + isbn = fields.String() + author = fields.String() + title = fields.String() + reviews = fields.Embed(schema='ReviewSchema', + many=True, + exclude='book') + + class ReviewSchema(Schema): + book = fields.Embed(schema=BookSchema, exclude='reviews') + rating = fields.Integer() + text = fields.String() + +... which enables us to serialize a book object with many reviews: + +.. code-block:: python + :emphasize-lines: 5-8 + + book_schema = BookSchema() + book_schema.dump(book) + # {'author': 'Hemingway', + # 'isbn': '0-684-80122-1', + # 'reviews': [ + # {'rating': 10, 'text': 'Has lots of sharks.'}, + # {'rating': 4, 'text': "Why doesn't he just kill ALL the sharks?"}, + # {'rating': 8, 'text': 'Better than the movie!'}], + # 'title': The Old Man and the Sea' + + +Linked Data Recap +================= + +- You now know how to marshal embedded linked objects (via a field of type + :class:`lima.fields.Embed`) + +- You now know how to marshal references to linked objects (via a field of + type :class:`lima.fields.References`) + +- You know about lazy evaluation of linked schemas and how to specify those via + qualified and fully module-qualified names. + +- You know how to implement two-way relationships between objects (pass + ``exclude`` or ``only`` to the linked schema through + :class:`lima.fields.Embed`) + +- You know how to marshal linked collections of objects (pass ``many=True`` to + the linked schema through :class:`lima.fields.Embed`) diff --git a/docs/nested_data.rst b/docs/nested_data.rst deleted file mode 100644 index c725f33..0000000 --- a/docs/nested_data.rst +++ /dev/null @@ -1,264 +0,0 @@ -=========== -Nested Data -=========== - -Most ORMs represent linked objects nested under an attribute of the linking -object. As an example, lets model the relationship between a book and its -author: - -.. code-block:: python - :emphasize-lines: 10,14 - - class Person: - def __init__(self, first_name, last_name): - self.first_name = first_name - self.last_name = last_name - - # A book links to its author via a nested Person object - class Book: - def __init__(self, title, author=None): - self.title = title - self.author = author - - person = Person('Ernest', 'Hemingway') - book = Book('The Old Man and the Sea') - book.author = person - - -One-way Relationships -===================== - -Currently, this relationship is one way only: *From* a book *to* its author. -The author doesn't know anything about books yet (well, in our model at least). - -To serialize this construct, we have to tell lima that a :class:`Book` object -has a :class:`Person` object nested inside, designated via the :attr:`author` -attribute. - -For this we use a field of type :class:`lima.fields.Nested` and tell lima what -data to expect by providing the ``schema`` parameter: - -.. code-block:: python - :emphasize-lines: 9,13 - - from lima import fields, Schema - - class PersonSchema(Schema): - first_name = fields.String() - last_name = fields.String() - - class BookSchema(Schema): - title = fields.String() - author = fields.Nested(schema=PersonSchema) - - schema = BookSchema() - schema.dump(book) - # {'author': {'first_name': 'Ernest', 'last_name': 'Hemingway'}, - # 'title': The Old Man and the Sea'} - -Along with the mandatory keyword-only argument ``schema``, -:class:`lima.fields.Nested` accepts the optional keyword-only-arguments we -already know (``attr`` or ``get``). All other keyword arguments provided to -:class:`lima.fields.Nested` get passed through to the constructor of the nested -schema. This allows us to do stuff like the following: - -.. code-block:: python - :emphasize-lines: 3, 7 - - class BookSchema(Schema): - title = fields.String() - author = fields.Nested(schema=PersonSchema, only='last_name') - - schema = BookSchema() - schema.dump(book) - # {'author': {'last_name': 'Hemingway'}, - # 'title': The Old Man and the Sea'} - - -Two-way Relationships -===================== - -If not only a book should link to its author, but an author should also link to -his/her bestselling book, we can adapt our model like this: - -.. code-block:: python - :emphasize-lines: 5,11,15-16 - - # authors link to their bestselling book - class Author(Person): - def __init__(self, first_name, last_name, bestseller=None): - super().__init__(first_name, last_name) - self.bestseller = bestseller - - # books link to their authors - class Book: - def __init__(self, title, author=None): - self.title = title - self.author = author - - author = Author('Ernest', 'Hemingway') - book = Book('The Old Man and the Sea') - book.author = author - author.bestseller = book - -If we want to construct schemas for models like this, we will have to adress -two problems: - -1. **Definition order:** If we define our :class:`AuthorSchema` first, its - :attr:`bestseller` attribute will have to reference a :class:`BookSchema` - - but this doesn't exist yet, since we decided to define :class:`AuthorSchema` - first. If we decide to define :class:`BookSchema` first instead, we run into - the same problem with its :attr:`author` attribute. - -2. **Recursion:** An author links to a book that links to an author that links - to a book that links to an author that links to a book that links to an - author that links to a book that links to an author that links to a book - that links to an author ``RuntimeError: maximum recursion depth exceeded`` - -lima makes it easy to deal with those problems: - -To overcome the problem of recursion, just exclude the attribute on the other -side that links back. - -To overcome the problem of definition order, lima supports lazy evaluation of -schemas. Just pass the *qualified name* (or the *fully module-qualified name*) -of a schema class to :class:`lima.fields.Nested` instead of the class itself: - -.. code-block:: python - :emphasize-lines: 2,6,12,16 - - class AuthorSchema(PersonSchema): - bestseller = fields.Nested(schema='BookSchema', exclude='author') - - class BookSchema(Schema): - title = fields.String() - author = fields.Nested(schema=AuthorSchema, exclude='bestseller') - - author_schema = AuthorSchema() - author_schema.dump(author) - # {'first_name': 'Ernest', - # 'last_name': 'Hemingway', - # 'bestseller': {'title': The Old Man and the Sea'} - - book_schema = BookSchema() - book_schema.dump(book) - # {'author': {'first_name': 'Ernest', 'last_name': 'Hemingway'}, - # 'title': The Old Man and the Sea'} - -.. _on_class_names: - -.. admonition:: On class names - :class: note - - For referring to classes via their name, the lima documentation only ever - talks about two different kinds of class names: the *qualified name* - (*qualname* for short) and the *fully module-qualified name*: - - The qualified name - This is the value of the class's :attr:`__qualname__` attribute. Most - of the time, it's the same as the class's :attr:`__name__` attribute - (except if you define classes within classes or functions ...). If you - define ``class Foo: pass`` at the top level of your module, the class's - qualified name is simply *Foo*. Qualified names were introduced with - Python 3.3 via `PEP 3155 `_ - - The fully module-qualified name - This is the qualified name of the class prefixed with the full name of - the module the class is defined in. If you define ``class Qux: pass`` - within a class :class:`Baz` (resulting in the qualified name *Baz.Qux*) - at the top level of your ``foo.bar`` module, the class's fully - module-qualified name is *foo.bar.Baz.Qux*. - -.. warning:: - - If you define schemas in local namespaces (at function execution time), - their names become meaningless outside of their local context. For - example: - - .. code-block:: python - - def make_schema(): - class FooSchema(Schema): - foo = fields.String() - return FooSchema - - schemas = [make_schema() for i in range(1000)] - - Which of those one thousend schemas would we refer to, would we try to link - to a ``FooSchema`` by name? To avoid ambiguity, lima will refuse to link to - schemas defined in local namespaces. - -By the way, there's nothing stopping us from using the idioms we just learned -for models that link to themselves - everything works as you'd expect: - -.. code-block:: python - :emphasize-lines: 4,7 - - class MarriedPerson(Person): - def __init__(self, first_name, last_name, spouse=None): - super().__init__(first_name, last_name) - self.spouse = spouse - - class MarriedPersonSchema(PersonSchema): - spouse = fields.Nested(schema='MarriedPersonSchema', exclude='spouse') - - -One-to-many and many-to-many Relationships -========================================== - -Until now, we've only dealt with one-to-one relations. What about one-to-many -and many-to-many relations? Those link to collections of objects. - -We know the necessary building blocks already: Providing additional keyword -arguments to :class:`lima.fields.Nested` passes them through to the specified -schema's constructor. And providing ``many=True`` to a schema's construtor will -have the schema marshalling collections - so: - - -.. code-block:: python - :emphasize-lines: 5,15,19 - - # authors link to their books now - class Author(Person): - def __init__(self, first_name, last_name, books=None): - super().__init__(first_name, last_name) - self.books = books - - author = Author('Virginia', 'Woolf') - author.books = [ - Book('Mrs Dalloway', author), - Book('To the Lighthouse', author), - Book('Orlando', author) - ] - - class AuthorSchema(PersonSchema): - books = fields.Nested(schema='BookSchema', exclude='author', many=True) - - class BookSchema(Schema): - title = fields.String() - author = fields.Nested(schema=AuthorSchema, exclude='books') - - schema = AuthorSchema() - schema.dump(author) - # {'books': [{'title': 'Mrs Dalloway'}, - # {'title': 'To the Lighthouse'}, - # {'title': 'Orlando'}], - # 'last_name': 'Woolf', - # 'first_name': 'Virginia'} - - -Nested Data Recap -================= - -- You now know how to marshal nested objects (via a field of type - :class:`lima.fields.Nested`) - -- You know about lazy evaluation of nested schemas and how to specify those via - qualified and fully module-qualified names. - -- You know how to implement two-way relationships between objects (pass - ``exclude`` or ``only`` to the nested schema through - :class:`lima.fields.Nested`) - -- You know how to marshal nested collections of objects (pass ``many=True`` to - the nested schema through :class:`lima.fields.Nested`) diff --git a/docs/project_info.rst b/docs/project_info.rst index 8461e11..666bd7d 100644 --- a/docs/project_info.rst +++ b/docs/project_info.rst @@ -35,7 +35,9 @@ The lima sources include a copy of the `Read the Docs Sphinx Theme The author believes to have benefited a lot from looking at the documentation and source code of other awesome projects, among them `django `_, -`morepath `_ and +`morepath `_, +`Pyramid `_ +(:class:`lima.util.reify` was taken from there) and `SQLAlchemy `_ as well as the Python standard library itself. (Seriously, look in there!) diff --git a/docs/schemas.rst b/docs/schemas.rst index ecda39e..7831d07 100644 --- a/docs/schemas.rst +++ b/docs/schemas.rst @@ -75,9 +75,9 @@ exclusive): .. warning:: Having to provide ``"only"`` on Schema definition hints at bad design - why - would you add a lot of fields just to remove them quickly afterwards? Have - a look at :ref:`schema_objects` for the preferred way to selectively - remove fields. + would you add a lot of fields just to remove all but one of them + afterwards? Have a look at :ref:`schema_objects` for the preferred way to + selectively remove fields. And finally, we can't just *exclude* fields, we can *include* them too. So here is a user schema with fields provided via ``__lima_args__``: @@ -269,18 +269,10 @@ Consider this: ] Instead of looping over this collection ourselves, we can ask the schema object -to do this for us - either for a single call (by specifying ``many=True`` to -the :meth:`dump` method), or for every call of :meth:`dump` (by specifying -``many=True`` to the schema's constructor): +to do this for us by specifying ``many=True`` to the schema's constructor): .. code-block:: python - :emphasize-lines: 2,7 - - person_schema = PersonSchema(only='last_name') - person_schema.dump(persons, many=True) - # [{'last_name': 'Hemingway'}, - # {'last_name': 'Woolf'}, - # {'last_name': 'Zweig'}] + :emphasize-lines: 1 many_persons_schema = PersonSchema(only='last_name', many=True) many_persons_schema.dump(persons) diff --git a/lima/__init__.py b/lima/__init__.py index 69e4d2a..c3d00c2 100644 --- a/lima/__init__.py +++ b/lima/__init__.py @@ -6,4 +6,4 @@ from lima import schema from lima.schema import Schema -__version__ = '0.3.1' +__version__ = '0.4' diff --git a/lima/fields.py b/lima/fields.py index fef06b6..422ef8a 100644 --- a/lima/fields.py +++ b/lima/fields.py @@ -4,6 +4,7 @@ from lima import abc from lima import registry +from lima import util class Field(abc.FieldABC): @@ -139,117 +140,238 @@ def pack(val): return val.isoformat() if val is not None else None -class Nested(Field): - '''A Field referencing another object with it's respective schema. +class _LinkedObjectField(Field): + '''A base class for fields that represent linked objects. + + This is to be considered an abstract class. Concrete implementations will + have to define their own :meth:`pack` methods, utilizing the associated + schema of the linked object. Args: - schema: The schema of the referenced object. This can be specified via - a schema *object,* a schema *class* (that will get instantiated - immediately) or the qualified *name* of a schema class (for when - the named schema has not been defined at the time of the - :class:`Nested` object's creation). If two or more schema classes - with the same name exist in different modules, the schema class - name has to be fully module-qualified (see the :ref:`entry on class - names ` for clarification of these concepts). - Schemas defined within a local namespace can not be referenced by - name. + schema: The schema of the linked object. This can be specified via a + schema *object,* a schema *class* or the qualified *name* of a + schema class (for when the named schema has not been defined at the + time of instantiation. If two or more schema classes with the same + name exist in different modules, the schema class name has to be + fully module-qualified (see the :ref:`entry on class names + ` for clarification of these concepts). Schemas + defined within a local namespace can not be referenced by name. - attr: The optional name of the corresponding attribute. + attr: See :class:`Field`. - get: An optional getter function accepting an object as its only - parameter and returning the field value. + get: See :class:`Field`. - val: An optional constant value for the field. + val: See :class:`Field`. kwargs: Optional keyword arguments to pass to the :class:`Schema`'s constructor when the time has come to instance it. Must be empty if ``schema`` is a :class:`lima.schema.Schema` object. - .. versionadded:: 0.3 - The ``val`` parameter. + The schema of the linked object associated with a field of this type will + be lazily evaluated the first time it is needed. This means that incorrect + arguments might produce errors at a time after the field's instantiation. + + ''' + def __init__(self, *, schema, attr=None, get=None, val=None, **kwargs): + super().__init__(attr=attr, get=get, val=val) + + # those will be evaluated later on (in _schema_inst) + self._schema_arg = schema + self._schema_kwargs = kwargs + + @util.reify + def _schema_inst(self): + '''Determine and return the associated Schema instance (reified). + + If no associated Schema instance exists at call time (because only a + Schema class name was supplied to the constructor), find the Schema + class in the global registry and instantiate it. + + Returns: + A schema instance for the linked object. - Raises: - ValueError: If ``kwargs`` are specified even if ``schema`` is a - :class:`lima.schema.Schema` *object.* + Raises: + ValueError: If ``kwargs`` were specified to the field constructor + even if a :class:`lima.schema.Schema` *instance* was provided + as the ``schema`` arg. + + TypeError: If the ``schema`` arg provided to the field constructor + has the wrong type. + + ''' + with util.complain_about('Lazy evaluation of schema instance'): + + # those were supplied to field constructor + schema = self._schema_arg + kwargs = self._schema_kwargs + + # in case schema is a Schema object + if isinstance(schema, abc.SchemaABC): + if kwargs: + msg = ('No additional keyword args must be ' + 'supplied to field constructor if ' + 'schema already is a Schema object.') + raise ValueError(msg) + return schema + + # in case schema is a schema class + elif (isinstance(schema, type) and + issubclass(schema, abc.SchemaABC)): + return schema(**kwargs) + + # in case schema is a string + elif isinstance(schema, str): + cls = registry.global_registry.get(schema) + return cls(**kwargs) + + # otherwise fail + msg = 'schema arg supplied to constructor has illegal type ({})' + raise TypeError(msg.format(type(schema))) + + def pack(self, val): + raise NotImplementedError + + +class Embed(_LinkedObjectField): + '''A Field to embed linked objects. + + Args: + schema: The schema of the linked object. This can be specified via a + schema *object,* a schema *class* or the qualified *name* of a + schema class (for when the named schema has not been defined at the + time of instantiation. If two or more schema classes with the same + name exist in different modules, the schema class name has to be + fully module-qualified (see the :ref:`entry on class names + ` for clarification of these concepts). Schemas + defined within a local namespace can not be referenced by name. + + attr: See :class:`Field`. + + get: See :class:`Field`. + + val: See :class:`Field`. + + kwargs: Optional keyword arguments to pass to the :class:`Schema`'s + constructor when the time has come to instance it. Must be empty if + ``schema`` is a :class:`lima.schema.Schema` object. Examples: :: # refer to PersonSchema class - author = Nested(schema=PersonSchema) + author = Embed(schema=PersonSchema) # refer to PersonSchema class with additional params - artists = Nested(schema=PersonSchema, exclude='email', many=True) + artists = Embed(schema=PersonSchema, exclude='email', many=True) # refer to PersonSchema object - author = Nested(schema=PersonSchema()) + author = Embed(schema=PersonSchema()) # refer to PersonSchema object with additional params - # (note that Nested() gets no kwargs) - artists = Nested(schema=PersonSchema(exclude='email', many=true)) + # (note that Embed() itself gets no kwargs) + artists = Embed(schema=PersonSchema(exclude='email', many=true)) # refer to PersonSchema per name - author = Nested(schema='PersonSchema') + author = Embed(schema='PersonSchema') # refer to PersonSchema per name with additional params - author = Nested(schema='PersonSchema', exclude='email', many=True) + author = Embed(schema='PersonSchema', exclude='email', many=True) # refer to PersonSchema per module-qualified name # (in case of ambiguity) - author = Nested(schema='project.persons.PersonSchema') + author = Embed(schema='project.persons.PersonSchema') # specify attr name as well - user = Nested(attr='login_user', schema=PersonSchema) + user = Embed(attr='login_user', schema=PersonSchema) ''' - def __init__(self, *, schema, attr=None, get=None, val=None, **kwargs): - super().__init__(attr=attr, get=get, val=val) + @util.reify + def _pack_func(self): + '''Return the associated schema's dump fields *function* (reified).''' + return self._schema_inst._dump_fields - # in case schema is a Schema object - if isinstance(schema, abc.SchemaABC): - if kwargs: - msg = ('No keyword args must be supplied' - 'if schema is a Schema object.') - raise ValueError(msg) - self.schema_inst = schema + def pack(self, val): + '''Return the marshalled representation of val. + + Args: + val: The linked object to embed. - # in case schema is a schema class - elif isinstance(schema, type) and issubclass(schema, abc.SchemaABC): - self.schema_inst = schema(**kwargs) + Returns: + The marshalled representation of ``val`` (or ``None`` if ``val`` is + ``None``). - # in case schema is a schema name: save args for later instantiation - elif isinstance(schema, str): - self.schema_inst = None - self.schema_name = schema - self.schema_kwargs = kwargs + Note that the return value is determined using an (internal) dump + fields *function* of the associated schema object. This means that + overriding the associated schema's :meth:`~lima.schema.Schema.dump` + *method* has no effect on the result of this method. - # otherwise fail - else: - msg = 'Illegal type for schema param: {}' - raise TypeError(msg.format(type(schema))) + ''' + return self._pack_func(val) if val is not None else None - def pack(self, val): - '''Return the output of the referenced object's schema's dump method. - If the referenced object's schema was specified by name at the - :class:`Nested` field's creation, this is the time when this schema is - instantiated (this is done only once). +class Reference(_LinkedObjectField): + '''A Field to reference linked objects. + + Args: + + schema: A schema for the linked object (see :class:`Embed` for details + on how to specify this schema). One field of this schema will act + as reference to the linked object. + + field: The name of the field to act as reference to the linked object. + + attr: see :class:`Field`. + + get: see :class:`Field`. + + val: see :class:`Field`. + + kwargs: see :class:`Embed`. + + + ''' + def __init__(self, + *, + schema, + field, + attr=None, + get=None, + val=None, + **kwargs): + super().__init__(schema=schema, attr=attr, get=get, val=val, **kwargs) + self._field = field + + @util.reify + def _pack_func(self): + '''Return the associated schema's dump field *function* (reified).''' + return self._schema_inst._dump_field_func(self._field) + + def pack(self, val): + '''Return value of reference field of marshalled representation of val. Args: - val: The nested object to convert. + val: The nested object to get the reference to. Returns: - The output of the referenced :class:`lima.schema.Schema`'s - :meth:`lima.schema.Schema.dump` method. + The value of the reference-field of the marshalled representation + of val (see ``field`` argument of constructor) or ``None`` if + ``val`` is ``None``. + + Note that the return value is determined using an (internal) dump field + *function* of the associated schema object. This means that overriding + the associated schema's :meth:`~lima.schema.Schema.dump` *method* has + no effect on the result of this method. ''' - # if schema_inst doesn't exist yet (because a schema class name was - # supplied to the constructor), find the schema class in the global - # registry and instantiate it. - if not self.schema_inst: - cls = registry.global_registry.get(self.schema_name) - self.schema_inst = cls(**self.schema_kwargs) + return self._pack_func(val) if val is not None else None + + +Nested = Embed +'''A Field to embed linked object(s) - return self.schema_inst.dump(val) if val is not None else None +:class:`Nested` is the old name of class :class:`Embed`. + +.. deprecated:: 0.4 + Will be removed in 0.5. Use :class:`Embed` instead''' TYPE_MAPPING = { @@ -263,12 +385,4 @@ def pack(self, val): '''A mapping of native Python types to :class:`Field` classes. This can be used to automatically create fields for objects you know the -attribute's types of. - -''' -type_mapping = TYPE_MAPPING -'''An alias for :attr:`TYPE_MAPPING`. - -.. deprecated:: 0.3 - Will be removed in 0.4. Use :attr:`TYPE_MAPPING` instead. -''' +attribute's types of.''' diff --git a/lima/registry.py b/lima/registry.py index 839df26..6f4882d 100644 --- a/lima/registry.py +++ b/lima/registry.py @@ -98,6 +98,6 @@ def get(self, name): '''A global :class:`Registry` instance. Used internally by lima to automatically keep track of created Schemas (this is -needed by :class:`lima.fields.Nested`). +needed by some field classes). ''' diff --git a/lima/schema.py b/lima/schema.py index cd6325d..afb8188 100644 --- a/lima/schema.py +++ b/lima/schema.py @@ -35,7 +35,7 @@ def _fields_from_bases(bases): def _fields_include(fields, include): '''Return a copy of fields with fields in include included.''' util.ensure_mapping(include) - util.ensure_only_instances_of(include, str) + util.ensure_only_instances_of(include.keys(), str) util.ensure_only_instances_of(include.values(), abc.FieldABC) result = fields.copy() result.update(include) @@ -46,22 +46,14 @@ def _fields_exclude(fields, remove): '''Return a copy of fields with fields mentioned in exclude missing.''' util.ensure_only_instances_of(remove, str) util.ensure_subset_of(remove, fields) - result = OrderedDict() - for k, v in fields.items(): - if k not in remove: - result[k] = v - return result + return OrderedDict([(k, v) for k, v in fields.items() if k not in remove]) def _fields_only(fields, only): '''Return a copy of fields containing only fields mentioned in only.''' util.ensure_only_instances_of(only, str) util.ensure_subset_of(only, fields) - result = OrderedDict() - for k, v in fields.items(): - if k in only: - result[k] = v - return result + return OrderedDict([(k, v) for k, v in fields.items() if k in only]) def _mangle_name(name): @@ -81,6 +73,193 @@ def _mangle_name(name): return mapping[before] + after +def _make_function(name, code, globals_=None): + '''Return a function created by executing a code string in a new namespace. + + This is not much more than a wrapper around :func:`exec`. + + Args: + name: The name of the function to create. Must match the function name + in ``code``. + + code: A String containing the function definition code. The name of the + function must match ``name``. + + globals_: A dict of globals to mix into the new function's namespace. + ``__builtins__`` must be provided explicitly if required. + + .. warning: + + All pitfalls of using :func:`exec` apply to this function as well. + + ''' + namespace = dict(__builtins__={}) + if globals_: + namespace.update(globals_) + exec(code, namespace) + return namespace[name] + + +def _field_val_cns(field, field_name, field_num): + '''Return (code, namespace)-tuple for determining a field's value. + + Args: + field: A :class:`lima.fields.Field` instance. + + field_name: The name (key) of the field. + + field_num: A schema-wide unique number for the field + + Returns: + A tuple consisting of: a) a fragment of Python code to determine the + field's value for an object called ``obj`` and b) a namespace dict + containing the objects necessary for this code fragment to work. + + For a field ``myfield`` that has a ``pack`` and a ``get`` callable defined, + the output of this function could look something like this: + + .. code-block:: python + + ( + 'pack3(get3(obj))', # the code + {'get3': myfield.get, 'pack3': myfield.pack} # the namespace + ) + ''' + namespace = {} + if hasattr(field, 'val'): + # add constant-field-value-shortcut to namespace + name = 'val{}'.format(field_num) + namespace[name] = field.val + + # later, get value using this shortcut + val_code = name + + elif hasattr(field, 'get'): + # add getter-shortcut to namespace + name = 'get{}'.format(field_num) + namespace[name] = field.get + + # later, get value by calling this shortcut + val_code = '{}(obj)'.format(name) + + else: + # neither constant val nor getter: try to get value via attr + # (if attr is not specified, use field name as attr) + obj_attr = getattr(field, 'attr', field_name) + + if not str.isidentifier(obj_attr) or keyword.iskeyword(obj_attr): + msg = 'Not a valid attribute name: {!r}' + raise ValueError(msg.format(obj_attr)) + + # later, get value using this attr + val_code = 'obj.{}'.format(obj_attr) + + if hasattr(field, 'pack'): + # add pack-shortcut to namespace + name = 'pack{}'.format(field_num) + namespace[name] = field.pack + + # later, pass field value to this shortcut + val_code = '{}({})'.format(name, val_code) + + return val_code, namespace + + +def _dump_field_func(field, field_name, many): + '''Return a customized function that dumps a single field. + + Args: + field: The field. + + field_name: The name (key) of the field. + + many: If True(ish), the resulting function will expect collections of + objects, otherwise it will expect a single object. + + Returns: + A custom function that expects an object (or a collection of objects + depending on ``many``), and returns a single field's value per object. + + ''' + val_code, namespace = _field_val_cns(field, field_name, 0) + + if many: + func_tpl = 'def dump_field(objs): return [{val_code} for obj in objs]' + else: + func_tpl = 'def dump_field(obj): return {val_code}' + + # assemble function code + code = func_tpl.format(val_code=val_code) + + # finally create and return function + return _make_function('dump_field', code, namespace) + + +def _dump_fields_func(fields, ordered, many): + '''Return a customized function that dumps multiple fields. + + Args: + fields: An ordered mapping of field names to fields. + + ordered: If True(ish), the resulting function will return OrderedDict + objects, otherwise it will return ordinary dicts. + + many: If True(ish), the resulting function will expect collections of + objects, otherwise it will expect a single object. + + Returns: + A custom function that expects an object (or a collectionof objects + depending on ``many``), and returns multiple fields' values per object. + + ''' + # Get correct templates & namespace depending on "ordered" and "many" args + if ordered: + if many: + func_tpl = ( + 'def dump_fields(objs):\n' + ' return [OrderedDict([{joined_entries}]) for obj in objs]' + ) + else: + func_tpl = ( + 'def dump_fields(obj):\n' + ' return OrderedDict([{joined_entries}])' + ) + entry_tpl = '({field_name!r}, {val_code})' + namespace = {'OrderedDict': OrderedDict} + else: + if many: + func_tpl = ( + 'def dump_fields(objs):\n' + ' return [{{{joined_entries}}} for obj in objs]' + ) + else: + func_tpl = ( + 'def dump_fields(obj):\n' + ' return {{{joined_entries}}}' + ) + entry_tpl = '{field_name!r}: {val_code}' + namespace = {} + + # one entry per field + entries = [] + + # iterate over fields to fill up entries + for field_num, (field_name, field) in enumerate(fields.items()): + val_code, val_ns = _field_val_cns(field, field_name, field_num) + namespace.update(val_ns) + + # add entry + entries.append( + entry_tpl.format(field_name=field_name, val_code=val_code) + ) + + # assemble function code + code = func_tpl.format(joined_entries=', '.join(entries)) + + # finally create and return function + return _make_function('dump_fields', code, namespace) + + # Schema Metaclass ############################################################ class SchemaMeta(type): @@ -205,7 +384,12 @@ def __new__(metacls, name, bases, namespace): @classmethod def __prepare__(metacls, name, bases): - '''Return an OrderedDict as the class namespace.''' + '''Return an OrderedDict as the class namespace. + + This allows us to keep track of the order in which fields were defined + for a schema. + + ''' return OrderedDict() @@ -234,7 +418,7 @@ class Schema(abc.SchemaABC, metaclass=SchemaMeta): ordered: An optional boolean indicating if the :meth:`Schema.dump` method should output :class:`collections.OrderedDict` objects - instead of simple :class:`dict` objects. Defaults to ``False``. + instead of simple :class:`dict` objects. Defaults to ``False``. This does not influence how nested fields are serialized. many: An optional boolean indicating if the new Schema will be @@ -303,123 +487,61 @@ def __init__(self, with util.complain_about('only'): fields = _fields_only(fields, util.vector_context(only)) + # add instance vars to self self._fields = fields + self._dump_field_func_cache = {} # dict of funcs dumping single fields self._ordered = ordered - self.many = many - - # get code for the customized dump function - code = self._get_dump_function_code() - - # this defines _dump_function in self's namespace - exec(code, globals(), self.__dict__) - - def _get_dump_function_code(self): - '''Get code for a customized dump function.''' - # note that even _though dump_function might *look* like a method at - # first glance, it is *not*, since it will tied to a specific Schema - # instance instead of to the Schema class like a method would be. This - # means that ten Schema objects will have ten separate dump functions - # associated with them. - - # get correct templates - if self._ordered: - func_tpl = textwrap.dedent( - '''\ - def _dump_function(schema, obj): - return OrderedDict([ - {contents} - ]) - ''' - ) - entry_tpl = '("{key}", {get_val})' - else: - func_tpl = textwrap.dedent( - '''\ - def _dump_function(schema, obj): - return {{ - {contents} - }} - ''' - ) - entry_tpl = '"{key}": {get_val}' - - # one entry per field - entries = [] - - # iterate over fields to fill up entries - for field_num, (field_name, field) in enumerate(self._fields.items()): + self._many = many - if hasattr(field, 'val'): - # add constant-field-value-shortcut to self - val_name = '__val_{}'.format(field_num) - setattr(self, val_name, field.val) + @property + def many(self): + '''Read-only property: does the dump method expect collections?''' + return self._many - # later, get value using this shortcut - get_val = 'schema.{}'.format(val_name) + @property + def ordered(self): + '''Read-only property: does the dump method return ordered dicts?''' + return self._ordered - elif hasattr(field, 'get'): - # add getter-shortcut to self - getter_name = '__get_{}'.format(field_num) - setattr(self, getter_name, field.get) + @util.reify + def _dump_fields(self): + '''Return instance-specific dump function for all fields (reified).''' + with util.complain_about('Lazy creation of dump fields function'): + return _dump_fields_func(self._fields, self._ordered, self._many) - # later, get value by calling getter-shortcut - get_val = 'schema.{}(obj)'.format(getter_name) + def _dump_field_func(self, field_name): + '''Return instance-specific dump function for a single field. - else: - # neither constant val nor getter: try to get value via attr - # (if no attr name is specified, use field name as attr name) - attr = getattr(field, 'attr', field_name) + Functions are created when requested for the first time and get cached + for subsequent calls of this method. - if not str.isidentifier(attr) or keyword.iskeyword(attr): - msg = 'Not a valid attribute name: {!r}' - raise ValueError(msg.format(attr)) - - # later, get value using attr - get_val = 'obj.{}'.format(attr) - - if hasattr(field, 'pack'): - # add pack-shortcut to self - packer_name = '__pack_{}'.format(field_num) - setattr(self, packer_name, field.pack) - - # later, wrap pass result of get_val to pack-shortcut - get_val = 'schema.{}({})'.format(packer_name, get_val) - - # try to guard against code injection via quotes in key - key = str(field_name) - if '"' in key or "'" in key: - msg = 'Quotes are not allowed in field names: {}' - raise ValueError(msg.format(key)) - - # add entry - entries.append(entry_tpl.format(key=key, get_val=get_val)) + ''' + if field_name in self._dump_field_func_cache: + return self._dump_field_func_cache[field_name] - sep = ',\n ' - code = func_tpl.format(contents=sep.join(entries)) - return code + with util.complain_about('Lazy creation of dump field function'): + func = _dump_field_func(self._fields[field_name], + field_name, self._many) + self._dump_field_func_cache[field_name] = func + return func - def dump(self, obj, *, many=None): + def dump(self, obj): '''Return a marshalled representation of obj. Args: - obj: The object (or collection of objects) to marshall. - - many: Wether obj is a single object or a collection of objects. If - ``many`` is ``None``, the value of the instance's - :attr:`many` attribute is used. + obj: The object (or collection of objects, depending on the + schema's :attr:`many` property) to marshall. Returns: A representation of ``obj`` in the form of a JSON-serializable dict - (or :class:`collections.OrderedDict` if the Schema was created with - ``ordered==True``), with each entry corresponding to one of the - :class:`Schema`'s fields. (Or a list of such dicts in case a - collection of objects was marshalled) + (or :class:`collections.OrderedDict`, depending on the schema's + :attr:`ordered` property), with each entry corresponding to one of + the schema's fields. (Or a list of such dicts in case a collection + of objects was marshalled) + + .. versionchanged:: 0.4 + Removed the ``many`` parameter of this method. ''' - dump_function = self._dump_function - if many is None: - many = self.many - if many: - return [dump_function(self, o) for o in obj] - else: - return dump_function(self, obj) + # call the instance-specific dump function + return self._dump_fields(obj) diff --git a/lima/util.py b/lima/util.py index b4557c8..d7f7ad4 100644 --- a/lima/util.py +++ b/lima/util.py @@ -23,13 +23,66 @@ def complain_about(name): raise +# The code for this class is taken from pyramid.decorator (with negligible +# alterations), licensed under the Repoze Public License (see +# http://www.pylonsproject.org/about/license) +class reify: + '''Like property, but saves the underlying method's result for later use. + + Use as a class method decorator. It operates almost exactly like the Python + ``@property`` decorator, but it puts the result of the method it decorates + into the instance dict after the first call, effectively replacing the + function it decorates with an instance variable. It is, in Python parlance, + a non-data descriptor. An example: + + .. code-block:: python + + class Foo(object): + @reify + def jammy(self): + print('jammy called') + return 1 + + And usage of Foo: + + >>> f = Foo() + >>> v = f.jammy + 'jammy called' + >>> print(v) + 1 + >>> f.jammy + 1 + >>> # jammy func not called the second time; it replaced itself with 1 + + Taken from pyramid.decorator (see source for license info). + + ''' + def __init__(self, wrapped): + self.wrapped = wrapped + self.__doc__ = wrapped.__doc__ + + def __get__(self, instance, owner): + if instance is None: + return self + val = self.wrapped(instance) + setattr(instance, self.wrapped.__name__, val) + return val + + +# The code for this class is taken directly from the Python 3.4 standard +# library (to support Python 3.3), licensed under the PSF License (see +# https://docs.python.org/3/license.html) class suppress: '''Context manager to suppress specified exceptions - This context manager is taken directly from the Python 3.4 standard library - to get support for Python 3.3. + After the exception is suppressed, execution proceeds with the next + statement following the with statement. + + with suppress(FileNotFoundError): + os.remove(somefile) + # Execution still resumes here if the file was already removed - See https://docs.python.org/3.4/library/contextlib.html#contextlib.suppress + Backported for Python 3.3 from Python 3.4 (see source for license info). ''' def __init__(self, *exceptions): @@ -39,6 +92,15 @@ def __enter__(self): pass def __exit__(self, exctype, excinst, exctb): + # Unlike isinstance and issubclass, CPython exception handling + # currently only looks at the concrete type hierarchy (ignoring + # the instance and subclass checking hooks). While Guido considers + # that a bug rather than a feature, it's a fairly hard one to fix + # due to various internal implementation details. suppress provides + # the simpler issubclass based semantics, rather than trying to + # exactly reproduce the limitations of the CPython interpreter. + # + # See http://bugs.python.org/issue12029 for more details return exctype is not None and issubclass(exctype, self._exceptions) diff --git a/tests/test_dump.py b/tests/test_dump.py index aac7add..0aedd7e 100644 --- a/tests/test_dump.py +++ b/tests/test_dump.py @@ -1,14 +1,15 @@ -'''tests for schema.Schema.dump module''' - from collections import OrderedDict from datetime import date, datetime import pytest -from lima import fields, schema, registry +from lima import fields, schema + +# model ----------------------------------------------------------------------- -class Person: +class Knight: + '''A knight.''' def __init__(self, title, name, number, born): self.title = title self.name = name @@ -16,211 +17,343 @@ def __init__(self, title, name, number, born): self.born = born -class PersonSchema(schema.Schema): +class King(Knight): + '''A king is a knight with subjects.''' + def __init__(self, title, name, number, born, subjects=None): + super().__init__(title, name, number, born) + self.subjects = subjects if subjects is not None else [] + + +# schemas --------------------------------------------------------------------- + +class KnightSchema(schema.Schema): title = fields.String() name = fields.String() number = fields.Integer() born = fields.Date() -class DifferentAttrSchema(schema.Schema): +class FieldWithAttrArgSchema(schema.Schema): date_of_birth = fields.Date(attr='born') -class GetterSchema(schema.Schema): - some_getter = lambda obj: '{} {}'.format(obj.title, obj.name) +class FieldWithGetterArgSchema(schema.Schema): + full_name = fields.String( + get=lambda obj: '{} {}'.format(obj.title, obj.name) + ) - full_name = fields.String(get=some_getter) +class FieldWithValArgSchema(schema.Schema): + constant_date = fields.Date(val=date(2014, 10, 20)) -class ConstantValueSchema(schema.Schema): - constant = fields.Date(val=date(2014, 10, 20)) +class KingWithEmbeddedSubjectsObjSchema(KnightSchema): + subjects = fields.Embed(schema=KnightSchema(many=True)) -class KnightSchema(schema.Schema): - name = fields.String() +class KingWithEmbeddedSubjectsClassSchema(KnightSchema): + subjects = fields.Embed(schema=KnightSchema, many=True) -class KingSchemaNestedStr(KnightSchema): - title = fields.String() - subjects = fields.Nested(schema=__name__ + '.KnightSchema', many=True) +class KingWithEmbeddedSubjectsStrSchema(KnightSchema): + subjects = fields.Embed(schema=__name__ + '.KnightSchema', many=True) -class KingSchemaNestedClass(KnightSchema): - title = fields.String() - subjects = fields.Nested(schema=KnightSchema, many=True) +class KingWithReferencedSubjectsObjSchema(KnightSchema): + subjects = fields.Reference(schema=KnightSchema(many=True), field='name') -class KingSchemaNestedObject(KnightSchema): - some_schema_object = KnightSchema(many=True) - title = fields.String() - subjects = fields.Nested(schema=some_schema_object) +class KingWithReferencedSubjectsClassSchema(KnightSchema): + subjects = fields.Reference(schema=KnightSchema, field='name', many=True) -class SelfReferentialKingSchema(schema.Schema): - name = fields.String() - boss = fields.Nested(schema=__name__ + '.SelfReferentialKingSchema', - exclude='boss') +class KingWithReferencedSubjectsStrSchema(KnightSchema): + subjects = fields.Reference(schema=__name__ + '.KnightSchema', + field='name', many=True) + + +class KingSchemaEmbedSelf(KnightSchema): + boss = fields.Embed(schema=__name__ + '.KingSchemaEmbedSelf', + exclude='boss') + +class KingSchemaReferenceSelf(KnightSchema): + boss = fields.Reference(schema=__name__ + '.KingSchemaEmbedSelf', + field='name') + + +# fixtures -------------------------------------------------------------------- @pytest.fixture -def king(): - return Person('King', 'Arthur', 1, date(501, 1, 1)) +def bedevere(): + return Knight('Sir', 'Bedevere', 2, date(502, 2, 2)) @pytest.fixture -def knights(): - return [ - Person('Sir', 'Bedevere', 2, date(502, 2, 2)), - Person('Sir', 'Lancelot', 3, date(503, 3, 3)), - Person('Sir', 'Galahad', 4, date(504, 4, 4)), - ] +def lancelot(): + return Knight('Sir', 'Lancelot', 3, date(503, 3, 3)) -def test_simple_dump(king): - person_schema = PersonSchema() - expected = { - 'title': 'King', - 'name': 'Arthur', - 'number': 1, - 'born': '0501-01-01' - } +@pytest.fixture +def galahad(): + return Knight('Sir', 'Galahad', 4, date(504, 4, 4)) + + +@pytest.fixture +def knights(bedevere, lancelot, galahad): + return [bedevere, lancelot, galahad] + + +@pytest.fixture +def arthur(knights): + return King('King', 'Arthur', 1, date(501, 1, 1), knights) - assert person_schema.dump(king) == expected +# tests ----------------------------------------------------------------------- -def test_simple_dump_exclude(king): - person_schema = PersonSchema(exclude=['born']) +def test_dump_single_unordered(lancelot): + knight_schema = KnightSchema(many=False, ordered=False) + result = knight_schema.dump(lancelot) expected = { - 'title': 'King', - 'name': 'Arthur', - 'number': 1, + 'title': 'Sir', + 'name': 'Lancelot', + 'number': 3, + 'born': '0503-03-03' } + assert type(result) == dict + assert result == expected + + +def test_dump_single_ordered(lancelot): + knight_schema = KnightSchema(many=False, ordered=True) + result = knight_schema.dump(lancelot) + expected = OrderedDict([ + ('title', 'Sir'), + ('name', 'Lancelot'), + ('number', 3), + ('born', '0503-03-03'), + ]) + assert type(result) == OrderedDict + assert result == expected + + +def test_dump_many_unordered(knights): + knight_schema = KnightSchema(many=True, ordered=False) + result = knight_schema.dump(knights) + expected = [ + dict(title='Sir', name='Bedevere', number=2, born='0502-02-02'), + dict(title='Sir', name='Lancelot', number=3, born='0503-03-03'), + dict(title='Sir', name='Galahad', number=4, born='0504-04-04'), + ] + assert all(type(x) == dict for x in result) + assert result == expected - assert person_schema.dump(king) == expected + +def test_dump_many_ordered(knights): + knight_schema = KnightSchema(many=True, ordered=True) + result = knight_schema.dump(knights) + expected = [ + OrderedDict([('title', 'Sir'), ('name', 'Bedevere'), + ('number', 2), ('born', '0502-02-02')]), + OrderedDict([('title', 'Sir'), ('name', 'Lancelot'), + ('number', 3), ('born', '0503-03-03')]), + OrderedDict([('title', 'Sir'), ('name', 'Galahad'), + ('number', 4), ('born', '0504-04-04')]), + ] + assert all(type(x) == OrderedDict for x in result) + assert result == expected -def test_simple_dump_only(king): - person_schema = PersonSchema(only=['name']) +def test_field_exclude_dump(lancelot): + knight_schema = KnightSchema(exclude=['born', 'number']) + result = knight_schema.dump(lancelot) expected = { - 'name': 'Arthur', + 'title': 'Sir', + 'name': 'Lancelot', } - - assert person_schema.dump(king) == expected + assert result == expected -def test_attr_field_dump(king): - attr_schema = DifferentAttrSchema() +def test_field_only_dump(lancelot): + knight_schema = KnightSchema(only=['name', 'number']) + result = knight_schema.dump(lancelot) expected = { - 'date_of_birth': '0501-01-01' + 'name': 'Lancelot', + 'number': 3, } - assert attr_schema.dump(king) == expected + assert result == expected -def test_getter_field_dump(king): - getter_schema = GetterSchema() +def test_dump_field_with_attr_arg(lancelot): + attr_schema = FieldWithAttrArgSchema() + result = attr_schema.dump(lancelot) expected = { - 'full_name': 'King Arthur' + 'date_of_birth': '0503-03-03' } - assert getter_schema.dump(king) == expected + assert result == expected -def test_constant_value_field_dump(king): - constant_value_schema = ConstantValueSchema() +def test_dump_field_with_getter_arg(lancelot): + getter_schema = FieldWithGetterArgSchema() + result = getter_schema.dump(lancelot) expected = { - 'constant': '2014-10-20' + 'full_name': 'Sir Lancelot' } - assert constant_value_schema.dump(king) == expected + assert result == expected -def test_many_dump1(knights): - multi_person_schema = PersonSchema(only=['name'], many=True) - expected = [ - {'name': 'Bedevere'}, - {'name': 'Lancelot'}, - {'name': 'Galahad'}, - ] - assert multi_person_schema.dump(knights) == expected +def test_dump_field_with_val_arg(lancelot): + val_schema = FieldWithValArgSchema() + result = val_schema.dump(lancelot) + expected = { + 'constant_date': '2014-10-20' + } + assert result == expected -def test_many_dump2(knights): - multi_person_schema = PersonSchema(only=['name'], many=False) - expected = [ - {'name': 'Bedevere'}, - {'name': 'Lancelot'}, - {'name': 'Galahad'}, - ] - assert multi_person_schema.dump(knights, many=True) == expected +def test_fail_on_unexpected_collection(knights): + knight_schema = KnightSchema(many=False) + with pytest.raises(AttributeError): + knight_schema.dump(knights) -@pytest.mark.parametrize('schema_cls', - [KingSchemaNestedStr, - KingSchemaNestedClass, - KingSchemaNestedObject]) -def test_dump_nested_schema(schema_cls, king, knights): - '''Test with nested Schema specified as a String''' - king_schema = schema_cls() - king.subjects = knights +@pytest.mark.parametrize( + 'king_schema_cls', + [KingWithEmbeddedSubjectsObjSchema, + KingWithEmbeddedSubjectsClassSchema, + KingWithEmbeddedSubjectsStrSchema] +) +def test_dump_embedding_schema(king_schema_cls, arthur): + king_schema = king_schema_cls() expected = { 'title': 'King', 'name': 'Arthur', + 'number': 1, + 'born': '0501-01-01', 'subjects': [ - {'name': 'Bedevere'}, - {'name': 'Lancelot'}, - {'name': 'Galahad'}, + dict(title='Sir', name='Bedevere', number=2, born='0502-02-02'), + dict(title='Sir', name='Lancelot', number=3, born='0503-03-03'), + dict(title='Sir', name='Galahad', number=4, born='0504-04-04'), ] } - assert king_schema.dump(king) == expected - - -def test_dump_nested_schema_instance_double_kwargs_error(king, knights): - '''Test for ValueError when providing unnecssary kwargs.''' - - class KnightSchema(schema.Schema): - name = fields.String() + assert king_schema.dump(arthur) == expected - nested_schema = KnightSchema(many=True) - with pytest.raises(ValueError): - class KingSchema(KnightSchema): - title = fields.String() - subjects = fields.Nested(schema=nested_schema, many=True) +@pytest.mark.parametrize( + 'king_schema_cls', + [KingWithReferencedSubjectsObjSchema, + KingWithReferencedSubjectsClassSchema, + KingWithReferencedSubjectsStrSchema] +) +def test_dump_referencing_schema(king_schema_cls, arthur): + king_schema = king_schema_cls() + expected = { + 'title': 'King', + 'name': 'Arthur', + 'number': 1, + 'born': '0501-01-01', + 'subjects': ['Bedevere', 'Lancelot', 'Galahad'] + } + assert king_schema.dump(arthur) == expected -def test_dump_nested_schema_self(king): - '''Test with nested Schema specified as a String''' - king_schema = SelfReferentialKingSchema() - king.boss = king +def test_embed_self_schema(arthur): + # a king is his own boss + arthur.boss = arthur + king_schema = KingSchemaEmbedSelf() + result = king_schema.dump(arthur) expected = { + 'title': 'King', 'name': 'Arthur', - 'boss': {'name': 'Arthur'}, + 'number': 1, + 'born': '0501-01-01', + 'boss': { + 'title': 'King', + 'name': 'Arthur', + 'number': 1, + 'born': '0501-01-01', + } } - assert king_schema.dump(king) == expected + assert result == expected -def test_ordered(king): - '''Test dumping to OrderedDicts''' - person_schema_unordered = PersonSchema(ordered=False) - expected_unordered = { +def test_reference_self_schema(arthur): + # a king is his own boss + arthur.boss = arthur + king_schema = KingSchemaReferenceSelf() + result = king_schema.dump(arthur) + expected = { 'title': 'King', 'name': 'Arthur', 'number': 1, 'born': '0501-01-01', + 'boss': 'Arthur', } - person_schema_ordered = PersonSchema(ordered=True) - expected_ordered = OrderedDict( - [ - ('title', 'King'), - ('name', 'Arthur'), - ('number', 1), - ('born', '0501-01-01'), - ] - ) - result_unordered = person_schema_unordered.dump(king) - result_ordered = person_schema_ordered.dump(king) + assert result == expected + + +def test_fail_on_unnecessary_keywords(): + + class EmbedSchema(schema.Schema): + some_field = fields.String() + + embed_schema = EmbedSchema(many=True) + + class EmbeddingSchema(schema.Schema): + another_field = fields.String() + # here we provide a schema _instance_. the kwarg "many" is unnecessary + incorrect_embed_field = fields.Embed(schema=embed_schema, many=True) + + # the incorrect field is constructed lazily. we'll have to access it + with pytest.raises(ValueError): + EmbeddingSchema.__fields__['incorrect_embed_field']._schema_inst + + +def test_fail_on_unnecessary_arg(): + + class EmbedSchema(schema.Schema): + some_field = fields.String() + + embed_schema = EmbedSchema(many=True) + + class EmbeddingSchema(schema.Schema): + another_field = fields.String() + # here we provide a schema _instance_. the kwarg "many" is unnecessary + incorrect_embed_field = fields.Embed(schema=embed_schema, many=True) + + # the incorrect field is constructed lazily. we'll have to access it + with pytest.raises(ValueError): + EmbeddingSchema.__fields__['incorrect_embed_field']._schema_inst + + +def test_dump_exotic_field_names(): + exotic_names = [ + '', # empty string + '"', # single quote + "'", # double quote + '\u2665', # unicode heart symbol + 'print(123)', # valid python code + 'print("123\'', # invalid python code + ] - assert result_unordered.__class__ == dict - assert result_ordered.__class__ == OrderedDict - assert result_unordered == expected_unordered - assert result_ordered == expected_ordered + class ExoticFieldNamesSchema(schema.Schema): + __lima_args__ = { + 'include': {name: fields.String(attr='foo') + for name in exotic_names} + } + + class Foo: + def __init__(self): + self.foo = 'foobar' + + obj = Foo() + exotic_field_names_schema = ExoticFieldNamesSchema() + result = exotic_field_names_schema.dump(obj) + expected = {name: 'foobar' for name in exotic_names} + assert result == expected + + for name in exotic_names: + dump_field_func = exotic_field_names_schema._dump_field_func(name) + result = dump_field_func(obj) + expected = 'foobar' + assert result == expected diff --git a/tests/test_fields.py b/tests/test_fields.py index 609a6d9..88c56a0 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -4,7 +4,7 @@ import pytest -from lima import abc, fields, schema +from lima import abc, exc, fields, schema PASSTHROUGH_FIELDS = [ @@ -106,14 +106,172 @@ def test_datetime_pack(): assert fields.DateTime.pack(datetime) == expected -# tests of nested fields assume a lot of the other stuff also works - -def test_nested_by_name(): - field = fields.Nested(schema='NonExistentSchema') - assert field.schema_name == 'NonExistentSchema' - - -def test_nested_error_on_illegal_schema_spec(): - - with pytest.raises(TypeError): - field = fields.Nested(schema=123) +class SomeClass: + '''Arbitrary class (to test linked object fields).''' + def __init__(self, name, number): + self.name = name + self.number = number + + +class SomeSchema(schema.Schema): + '''Schema for SomeClass (to test linked object fields).''' + name = fields.String() + number = fields.Integer() + + +class TestLinkedObjectField: + '''Tests for _LinkedObjectField, the base class for Embed and Reference''' + + @pytest.mark.parametrize( + 'schema_arg', + [SomeSchema(), SomeSchema, __name__ + '.SomeSchema'] + ) + def test_linked_object_field(self, schema_arg): + field = fields._LinkedObjectField(schema=schema_arg) + assert field._schema_arg is schema_arg + assert isinstance(field._schema_inst, SomeSchema) + + @pytest.mark.parametrize( + 'field', + [ + fields._LinkedObjectField( + schema=SomeSchema(many=True, only='number') + ), + fields._LinkedObjectField( + schema=SomeSchema, many=True, only='number' + ), + fields._LinkedObjectField( + schema=__name__ + '.SomeSchema', many=True, only='number' + ) + ] + ) + def test_linked_object_field_with_kwargs(self, field): + assert isinstance(field._schema_inst, SomeSchema) + assert field._schema_inst.many is True + assert list(field._schema_inst._fields.keys()) == ['number'] + + def test_linked_object_fail_on_unnecessary_kwargs(self): + schema_inst = SomeSchema() + # "many" is already defined for a schema instance. providing it again + # when embedding will raise an error on lazy eval + field = fields._LinkedObjectField(schema=schema_inst, many=True) + with pytest.raises(ValueError): + field._schema_inst # this will complain about our earlier error + + def test_linked_object_fail_on_unnecessary_kwargs(self): + schema_inst = SomeSchema() + # "many" is already defined for a schema instance. providing it again + # when embedding will raise an error on lazy eval + field = fields._LinkedObjectField(schema=schema_inst, many=True) + with pytest.raises(ValueError): + field._schema_inst # this will complain about our earlier error + + def test_linked_object_fail_on_nonexistent_class(self): + # nonexistent schema name will raise an error on lazy eval + field = fields._LinkedObjectField(schema='NonExistentSchemaName') + with pytest.raises(exc.ClassNotFoundError): + field._schema_inst # this will complain about our earlier error + + def test_linked_object_fail_on_illegal_schema_arg(self): + # wrong "schema" arg type will raise an error on lazy eval + field = fields._LinkedObjectField(schema=123) + with pytest.raises(TypeError): + field._schema_inst # this will complain about our earlier error + + def test_linked_object_field_pack_not_implemented(self): + field = fields._LinkedObjectField(schema=SomeSchema) + # pack method is not implemented + with pytest.raises(NotImplementedError): + field.pack('foo') + + +class TestEmbed: + '''Tests for Embed, a class for embedding linked objects.''' + + @pytest.mark.parametrize( + 'schema_arg', + [SomeSchema(), SomeSchema, __name__ + '.SomeSchema'] + ) + def test_pack(self, schema_arg): + field = fields.Embed(schema=schema_arg) + result = field.pack(SomeClass('one', 1)) + expected = {'name': 'one', 'number': 1} + assert result == expected + + @pytest.mark.parametrize( + 'field', + [ + fields.Embed( + schema=SomeSchema(many=True, only='number') + ), + fields.Embed( + schema=SomeSchema, many=True, only='number' + ), + fields.Embed( + schema=__name__ + '.SomeSchema', many=True, only='number' + ) + ] + ) + def test_pack_with_kwargs(self, field): + result = field.pack([SomeClass('one', 1), SomeClass('two', 2)]) + expected = [{'number': 1}, {'number': 2}] + assert result == expected + + +class TestReference: + '''Tests for Reference, a class for referencing linked objects.''' + + @pytest.mark.parametrize( + 'schema_arg', + [SomeSchema(), SomeSchema, __name__ + '.SomeSchema'] + ) + def test_pack(self, schema_arg): + field = fields.Reference(schema=schema_arg, field='number') + result = field.pack(SomeClass('one', 1)) + expected = 1 + assert result == expected + + @pytest.mark.parametrize( + 'field', + [ + fields.Reference( + schema=SomeSchema(many=True, only='number'), + field = 'number' + ), + fields.Reference( + schema=SomeSchema, many=True, only='number', + field = 'number' + ), + fields.Reference( + schema=__name__ + '.SomeSchema', many=True, only='number', + field = 'number' + ) + ] + ) + def test_pack_with_kwargs(self, field): + result = field.pack([SomeClass('one', 1), SomeClass('two', 2)]) + expected = [1, 2] + assert result == expected + + @pytest.mark.parametrize( + 'field', + [ + fields.Reference( + schema=SomeSchema(many=True, exclude='number'), + field = 'number' + ), + fields.Reference( + schema=SomeSchema, many=True, exclude='number', + field = 'number' + ), + fields.Reference( + schema=__name__ + '.SomeSchema', many=True, exclude='number', + field = 'number' + ) + ] + ) + def test_fail_on_missing_field_arg(self, field): + # field 'number' is no field of the field's associated schema instance since it + # was excluded + with pytest.raises(KeyError): + result = field.pack([SomeClass('one', 1), SomeClass('two', 2)]) diff --git a/tests/test_schema.py b/tests/test_schema.py index 9a35279..8441c3f 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -35,6 +35,7 @@ class PersonSchema(schema.Schema): class NonLocalSchema(schema.Schema): foo = fields.String() + class TestHelperFunctions: '''Class collecting tests of helper functions.''' @@ -53,6 +54,25 @@ def test_mangle_name(self): assert mangle('hash__foo') == '#foo' assert mangle('plus__foo') == '+foo' + def test_make_function(self): + code = 'def func_in_namespace(): return 1' + my_function = schema._make_function('func_in_namespace', code) + assert(callable(my_function)) + assert(my_function() == 1) + # make sure the new name didn't leak out into globals/locals + with pytest.raises(NameError): + func_in_namespace + + code = 'def func_in_namespace(): return a' + namespace = dict(a=42) + my_function = schema._make_function('func_in_namespace', + code, namespace) + assert(callable(my_function)) + assert(my_function() == 42) + # make sure the new name didn't leak out of namespace + with pytest.raises(NameError): + func_in_namespace + class TestSchemaDefinition: '''Class collecting tests of Schema class definition.''' @@ -583,14 +603,44 @@ def test_fail_on_exclude_and_only(self, person_schema_cls): person_schema = person_schema_cls(exclude=['number'], only=['name']) + def test_schema_many_property(self, person_schema_cls): + '''test if schema.many gets set and is read-only''' + person_schema = person_schema_cls() + assert person_schema.many == False + person_schema = person_schema_cls(many=False) + assert person_schema.many == False + person_schema = person_schema_cls(many=True) + assert person_schema.many == True + with pytest.raises(AttributeError): + person_schema.many = False + + def test_schema_ordered_property(self, person_schema_cls): + '''test if schema.ordered gets set and is read-only''' + person_schema = person_schema_cls() + assert person_schema.ordered == False + person_schema = person_schema_cls(ordered=False) + assert person_schema.ordered == False + person_schema = person_schema_cls(ordered=True) + assert person_schema.ordered == True + with pytest.raises(AttributeError): + person_schema.ordered = False + + +class TestLazyDumpFunctionCreation: + def test_fail_on_non_identifier_attr_name(self): '''Test if providing a non-identifier attr name raises an error''' + class TestSchema(schema.Schema): foo = fields.String() foo.attr = 'this-is@not;an+identifier' + test_schema = TestSchema() + # dump funcs are created at first access. the next lines should fail with pytest.raises(ValueError): - test_schema = TestSchema() + test_schema._dump_fields + with pytest.raises(ValueError): + test_schema._dump_field_func('foo') def test_fail_on_non_identifier_field_name_without_attr(self): '''Test if providing a non-identifier field name raises an error ... @@ -606,8 +656,12 @@ class TestSchema(schema.Schema): } } + test_schema = TestSchema() + # dump funcs are created at first access. the next lines should fail + with pytest.raises(ValueError): + test_schema._dump_fields with pytest.raises(ValueError): - test_schema = TestSchema() + test_schema._dump_field_func('not@an-identifier') def test_fail_on_keyword_attr_name(self): '''Test if providing a non-identifier attr name raises an error''' @@ -615,8 +669,22 @@ class TestSchema(schema.Schema): foo = fields.String() foo.attr = 'class' # 'class' is a keyword + test_schema = TestSchema() + # dump funcs are created at first access. the next lines should fail + with pytest.raises(ValueError): + test_schema._dump_fields with pytest.raises(ValueError): - test_schema = TestSchema() + test_schema._dump_field_func('foo') + + def test_fail_on_nonexistent_field(self): + '''Test if providing a non-identifier attr name raises an error''' + class TestSchema(schema.Schema): + foo = fields.String() + + test_schema = TestSchema() + # dump funcs are created at first access. the next lines should fail + with pytest.raises(KeyError): + test_schema._dump_field_func('this_field_does_not_exist') def test_fail_on_keyword_field_name_without_attr(self): '''Test if providing a non-identifier field name raises an error ... @@ -632,8 +700,12 @@ class TestSchema(schema.Schema): } } + test_schema = TestSchema() + # dump funcs are created at first access. the next lines should fail with pytest.raises(ValueError): - test_schema = TestSchema() + test_schema._dump_fields + with pytest.raises(ValueError): + test_schema._dump_field_func('class') def test_succes_on_non_identifier_field_name_with_attr(self): '''Test if providing a non-identifier field name raises no error ... @@ -648,49 +720,23 @@ class TestSchema(schema.Schema): } } + # these should all succeed test_schema = TestSchema() + test_schema._dump_fields + test_schema._dump_field_func('not;an-identifier') assert 'not;an-identifier' in test_schema._fields - def test_fail_on_field_name_with_quotes(self): - '''Test if providing a field name with quotes raises an error ...''' - class TestSchema(schema.Schema): - __lima_args__ = { - 'include': { - 'field_with_"quotes"': fields.String(attr='foo') - } - } - - with pytest.raises(ValueError): - test_schema = TestSchema() - - def test_get_dump_function_code(self): - '''Test if _get_dump_function_code gets a simple function right.''' - from textwrap import dedent + def test_function_caching(self): + '''Test if lazily created functions are cached''' class TestSchema(schema.Schema): - foo = fields.String(attr='foo_attr') - bar = fields.String() + foo = fields.String() test_schema = TestSchema() - expected = dedent( - '''\ - def _dump_function(schema, obj): - return { - "foo": obj.foo_attr, - "bar": obj.bar - } - ''' - ) - assert test_schema._get_dump_function_code() == expected - - test_schema = TestSchema(ordered=True) - expected = dedent( - '''\ - def _dump_function(schema, obj): - return OrderedDict([ - ("foo", obj.foo_attr), - ("bar", obj.bar) - ]) - ''' - ) - assert test_schema._get_dump_function_code() == expected + fn1 = test_schema._dump_fields + fn2 = test_schema._dump_fields + assert fn1 is fn2 # after first eval, the same obj should be returned + + fn1 = test_schema._dump_field_func('foo') + fn2 = test_schema._dump_field_func('foo') + assert fn1 is fn2 # after first eval, the same obj should be returned diff --git a/tests/test_util.py b/tests/test_util.py index e4a36c4..56ba59a 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -3,6 +3,8 @@ import pytest +from lima import fields +from lima import schema from lima import util @@ -42,6 +44,34 @@ def test_complain_about(): assert str(e).startswith('bar') +# Adapted from Pyramid's test suite, licensed under the RPL +class TestReify: + '''Class collecting tests of helper functions.''' + + class Dummy: + pass + + def test__get__with_instance(self): + def wrapee(inst): + return 42 + decorated = util.reify(wrapee) + inst = TestReify.Dummy() + result = decorated.__get__(instance=inst, owner=...) + assert result == 42 + assert inst.__dict__['wrapee'] == 42 + + def test__get__without_instance(self): + decorated = util.reify(None) + result = decorated.__get__(instance=None, owner=...) + assert result == decorated + + def test__doc__copied(self): + def wrapee(inst): + '''My docstring''' + decorated = util.reify(wrapee) + assert decorated.__doc__ == 'My docstring' + + def test_vector_context(): '''Test if vector context boxes scalars into lists.''' assert util.vector_context([]) == []