diff --git a/CHANGELOG.rst b/CHANGELOG.rst index be3b7b0..9078349 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,7 @@ Changelog Features: +- Add Flask-SQLAlchemy/marshmallow-sqlalchemy support via the ``ModelSchema`` class. - ``Schema.jsonify`` now takes the same arguments as ``marshmallow.Schema.dump``. Additional keyword arguments are passed to ``flask.jsonify``. diff --git a/README.rst b/README.rst index 9d30b16..4686c35 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,13 @@ Flask-Marshmallow Flask + marshmallow for beautiful APIs ====================================== -Flask-Marshmallow is a thin integration layer for `Flask`_ (a Python web framework) and `marshmallow`_ (an object serialization/deserialization library) that adds additional features to marshmallow, including URL and Hyperlinks fields for HATEOAS-ready APIs. +Flask-Marshmallow is a thin integration layer for `Flask`_ (a Python web framework) and `marshmallow`_ (an object serialization/deserialization library) that adds additional features to marshmallow, including URL and Hyperlinks fields for HATEOAS-ready APIs. It also (optionally) integrates with `Flask-SQLAlchemy `_. + +Get it now +---------- +:: + + pip install flask-marshmallow Create your app. @@ -53,9 +59,6 @@ Define your output format with marshmallow. 'collection': ma.URLFor('authors') }) - user_schema = UserSchema() - users_schema = UserSchema(many=True) - Output the data in your views. @@ -66,12 +69,13 @@ Output the data in your views. all_users = User.all() result = users_schema.dump(all_users) return jsonify(result.data) + # OR + # return user_schema.jsonify(all_users) @app.route('/api/users/') def user_detail(id): user = User.get(id) - result = user_schema.dump(user) - return jsonify(result.data) + return user_schema.jsonify(user) # { # "email": "fred@queen.com", # "date_created": "Fri, 25 Apr 2014 06:02:56 -0000", @@ -82,22 +86,15 @@ Output the data in your views. # } +http://flask-marshmallow.readthedocs.org/ +========================================= + Learn More ========== To learn more about marshmallow, check out its `docs `_. -Get it now -========== - -:: - - pip install flask-marshmallow - - -http://flask-marshmallow.readthedocs.org/ -========================================= Project Links ============= diff --git a/dev-requirements.txt b/dev-requirements.txt index 10a7b66..68083ea 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -6,3 +6,7 @@ mock # Syntax checking flake8==2.4.0 + +# Soft requirements +flask-sqlalchemy +marshmallow-sqlalchemy diff --git a/docs/conf.py b/docs/conf.py index 63f3944..5deb4f6 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -5,7 +5,19 @@ sys.path.insert(0, os.path.abspath('..')) import flask_marshmallow sys.path.append(os.path.abspath("_themes")) -extensions = ['sphinx.ext.autodoc', 'sphinx_issues'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx_issues' +] + +intersphinx_mapping = { + 'python': ('http://python.readthedocs.org/en/latest/', None), + 'flask': ('http://flask.pocoo.org/docs/latest/', None), + 'flask-sqlalchemy': ('http://flask-sqlalchemy.pocoo.org/latest/', None), + 'marshmallow': ('http://marshmallow.readthedocs.org/en/latest/', None), + 'marshmallow-sqlalchemy': ('http://marshmallow-sqlalchemy.readthedocs.org/en/latest/', None), +} primary_domain = 'py' default_role = 'py:obj' diff --git a/docs/index.rst b/docs/index.rst index 6d0f4e5..99a0445 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,7 +11,13 @@ Flask-Marshmallow: Flask + marshmallow for beautiful APIs Flask + marshmallow for beautiful APIs ====================================== -Flask-Marshmallow is a thin integration layer for `Flask`_ (a Python web framework) and `marshmallow`_ (an object serialization/deserialization library) that adds additional features to marshmallow, including URL and Hyperlinks fields for HATEOAS-ready APIs. +Flask-Marshmallow is a thin integration layer for `Flask`_ (a Python web framework) and `marshmallow`_ (an object serialization/deserialization library) that adds additional features to marshmallow, including URL and Hyperlinks fields for HATEOAS-ready APIs. It also (optionally) integrates with `Flask-SQLAlchemy `_. + +Get it now +---------- +:: + + pip install flask-marshmallow Create your app. @@ -78,30 +84,99 @@ Output the data in your views. # } -Learn More -========== -To learn more about marshmallow, check out its `docs `_. +Optional Flask-SQLAlchemy Integration +------------------------------------- +Flask-Marshmallow includes useful extras for integrating with `Flask-SQLAlchemy `_ and `marshmallow-sqlalchemy `_. -Get it now -========== +To enable SQLAlchemy integration, make sure that both Flask-SQLAlchemy and marshmallow-sqlalchemy are installed. :: -:: + pip install -U flask-sqlalchemy marshmallow-sqlalchemy - pip install flask-marshmallow +Next, initialize the `SQLAlchemy ` and `Marshmallow ` extensions, in that order. + +.. code-block:: python + + from flask import Flask + from flask_sqlalchemy import SQLAlchemy + from flask_marshmallow import Marshmallow + + app = Flask(__name__) + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db' + + db = SQLAlchemy(app) + ma = Marshmallow(app) + +.. warning:: + + Flask-SQLAlchemy **must** be initialized before Flask-Marshmallow. + + +Declare your models like normal. + + +.. code-block:: python + + class Author(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255)) + + class Book(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(255)) + author_id = db.Column(db.Integer, db.ForeignKey('author.id')) + author = db.relationship('Author', backref='books') + + +Generate marshmallow `Schemas ` from your models using `ModelSchema `. + +.. code-block:: python + + class AuthorSchema(ma.ModelSchema): + class Meta: + model = Author + + class BookSchema(ma.ModelSchema): + class Meta: + model = Book + +You can now use your schema to dump and load your ORM objects. + + +.. code-block:: python + + >>> db.create_all() + >>> author_schema = AuthorSchema() + >>> book_schema = BookSchema() + >>> author = Author(name='Chuck Paluhniuk') + >>> book = Book(title='Fight Club', author=author) + >>> db.session.add(author) + >>> db.session.add(book) + >>> db.session.commit() + >>> author_schema.dump(author).data + {'id': 1, 'name': 'Chuck Paluhniuk', 'books': [1]} + + +`ModelSchema ` is nearly identical in API to `marshmallow_sqlalchemy.ModelSchema` with the following exceptions: + +- `ModelSchema ` uses the scoped session created by Flask-SQLAlchemy. +- `ModelSchema ` subclasses `flask_marshmallow.Schema`, so it includes the `jsonify ` method. API === .. automodule:: flask_marshmallow - :inherited-members: + :members: .. automodule:: flask_marshmallow.fields :members: +.. automodule:: flask_marshmallow.sqla + :members: + Useful Links ============ diff --git a/flask_marshmallow/__init__.py b/flask_marshmallow/__init__.py index 3b191af..981c519 100755 --- a/flask_marshmallow/__init__.py +++ b/flask_marshmallow/__init__.py @@ -10,14 +10,21 @@ :license: MIT, see LICENSE for more details. """ -from flask import jsonify from marshmallow import ( - Schema as BaseSchema, fields as base_fields, exceptions, pprint ) from . import fields +from .schema import Schema + +try: + import flask_sqlalchemy # flake8: noqa + from . import sqla +except ImportError: + has_sqla = False +else: + has_sqla = True __version__ = '0.6.0.dev' __author__ = 'Steven Loria' @@ -45,32 +52,13 @@ def _attach_fields(obj): setattr(obj, attr, getattr(fields, attr)) -class Schema(BaseSchema): - """Base serializer with which to define custom serializers. - - http://marshmallow.readthedocs.org/en/latest/api_reference.html#serializer - """ - - def jsonify(self, obj, many=False, *args, **kwargs): - """Return a JSON response containing the serialized data. - - - :param obj: Object to serialize. - :param bool many: Set to `True` if `obj` should be serialized as a collection. - :param kwargs: Additional keyword arguments passed to `flask.jsonify`. - - .. versionchanged:: 0.6.0 - Takes the same arguments as `marshmallow.Schema.dump`. Additional - keyword arguments are passed to `flask.jsonify`. - """ - data = self.dump(obj, many=many).data - return jsonify(data, *args, **kwargs) - class Marshmallow(object): """Wrapper class that integrates Marshmallow with a Flask application. To use it, instantiate with an application:: + from flask import Flask + app = Flask(__name__) ma = Marshmallow(app) @@ -91,15 +79,30 @@ class Meta: 'collection': ma.URLFor('book_list') }) + + In order to integrate with Flask-SQLAlchemy, this extension must by initialized *after* + `flask_sqlalchemy.SQLAlchemy`. :: + + db = SQLAlchemy(app) + ma = Marshmallow(app) + + This gives you access to `ma.ModelSchema`, which generates a marshmallow + `Schema ` based on the passed in model. :: + + class AuthorSchema(ma.ModelSchema): + class Meta: + model = Author + :param Flask app: The Flask application object. """ def __init__(self, app=None): - if app is not None: - self.init_app(app) - self.Schema = Schema + if has_sqla: + self.ModelSchema = sqla.ModelSchema _attach_fields(self) + if app is not None: + self.init_app(app) def init_app(self, app): """Initializes the application with the extension. @@ -107,4 +110,9 @@ def init_app(self, app): :param Flask app: The Flask application object. """ app.extensions = getattr(app, 'extensions', {}) + + # If using Flask-SQLAlchemy, attach db.session to ModelSchema + if has_sqla and 'sqlalchemy' in app.extensions: + db = app.extensions['sqlalchemy'].db + self.ModelSchema.OPTIONS_CLASS.session = db.session app.extensions[EXTENSION_NAME] = self diff --git a/flask_marshmallow/fields.py b/flask_marshmallow/fields.py index 577d364..999a6f3 100755 --- a/flask_marshmallow/fields.py +++ b/flask_marshmallow/fields.py @@ -7,7 +7,7 @@ Custom, Flask-specific fields. See the following link for a list of all available fields from the marshmallow library. - See http://marshmallow.readthedocs.org/en/latest/api_reference.html#module-marshmallow.fields + See the `marshmallow.fields` module for information about the available fields. """ import re import sys diff --git a/flask_marshmallow/schema.py b/flask_marshmallow/schema.py new file mode 100644 index 0000000..7ad8adf --- /dev/null +++ b/flask_marshmallow/schema.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +import flask +import marshmallow as ma + +class Schema(ma.Schema): + """Base serializer with which to define custom serializers. + + See `marshmallow.Schema` for more details about the `Schema` API. + """ + + def jsonify(self, obj, many=False, *args, **kwargs): + """Return a JSON response containing the serialized data. + + + :param obj: Object to serialize. + :param bool many: Set to `True` if `obj` should be serialized as a collection. + :param kwargs: Additional keyword arguments passed to `flask.jsonify`. + + .. versionchanged:: 0.6.0 + Takes the same arguments as `marshmallow.Schema.dump`. Additional + keyword arguments are passed to `flask.jsonify`. + """ + data = self.dump(obj, many=many).data + return flask.jsonify(data, *args, **kwargs) diff --git a/flask_marshmallow/sqla.py b/flask_marshmallow/sqla.py new file mode 100644 index 0000000..152a2f5 --- /dev/null +++ b/flask_marshmallow/sqla.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Integration with Flask-SQLAlchemy and marshmallow-sqlalchemy.""" + +import marshmallow_sqlalchemy as msqla +from .schema import Schema + +class DummySession(object): + """Placeholder session object.""" + pass + +class SchemaOpts(msqla.SchemaOpts): + """Schema options for `ModelSchema `. + Same as `marshmallow_sqlalchemy.SchemaOpts`, except that we add a + placeholder `DummySession` if ``sqla_session`` is not defined on + class Meta. The actual session from `flask_sqlalchemy` gets bound + in `init_app`. + """ + session = DummySession() + + def __init__(self, meta): + if not hasattr(meta, 'sqla_session'): + meta.sqla_session = self.session + super(SchemaOpts, self).__init__(meta) + +class ModelSchema(msqla.ModelSchema, Schema): + """ModelSchema that generates fields based on the + `model` class Meta option, which should be a + ``db.Model`` class from `flask_sqlalchemy`. Uses the + scoped session from Flask-SQLAlchemy by default. + + See `marshmallow_sqlalchemy.ModelSchema` for more details + on the `ModelSchema` API. + """ + OPTIONS_CLASS = SchemaOpts diff --git a/tasks.py b/tasks.py index 2c68a7e..68bc2f1 100644 --- a/tasks.py +++ b/tasks.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import os import sys +import webbrowser from invoke import task, run @@ -25,19 +26,39 @@ def clean_docs(): @task def browse_docs(): - run("open %s" % os.path.join(build_dir, 'index.html')) + path = os.path.join(build_dir, 'index.html') + webbrowser.open_new_tab(path) @task -def docs(clean=False, browse=False): +def docs(clean=False, browse=False, watch=False): + """Build the docs.""" if clean: clean_docs() run("sphinx-build %s %s" % (docs_dir, build_dir), pty=True) if browse: browse_docs() + if watch: + watch_docs() + +@task +def watch_docs(): + """Run build the docs when a file changes.""" + try: + import sphinx_autobuild # noqa + except ImportError: + print('ERROR: watch task requires the sphinx_autobuild package.') + print('Install it with:') + print(' pip install sphinx-autobuild') + sys.exit(1) + docs() + run('sphinx-autobuild {} {}'.format(docs_dir, build_dir), pty=True) + @task def readme(browse=False): run('rst2html.py README.rst > README.html') + if browse: + webbrowser.open_new_tab('README.html') @task def publish(test=False): diff --git a/test_flask_marshmallow.py b/test_flask_marshmallow.py index 9e7ab71..0aa3934 100755 --- a/test_flask_marshmallow.py +++ b/test_flask_marshmallow.py @@ -7,6 +7,8 @@ from flask_marshmallow import Marshmallow from flask_marshmallow.fields import _tpl +from flask_sqlalchemy import SQLAlchemy + import marshmallow IS_MARSHMALLOW_2 = int(marshmallow.__version__.split('.')[0]) >= 2 @@ -217,3 +219,68 @@ def test_links_within_nested_object(app, mockbook): author = result.data['author'] assert author['links']['self'] == url_for('author', id=mockbook.author.id) assert author['links']['collection'] == url_for('authors') + +class TestSQLAlchemy: + + @pytest.yield_fixture() + def extapp(self): + app_ = Flask('extapp') + app_.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + SQLAlchemy(app_) + Marshmallow(app_) + ctx = _app.test_request_context() + ctx.push() + + yield app_ + + ctx.pop() + + @pytest.fixture() + def db(self, extapp): + return extapp.extensions['sqlalchemy'].db + + @pytest.fixture() + def extma(self, extapp): + return extapp.extensions['flask-marshmallow'] + + @pytest.yield_fixture() + def models(self, db): + class AuthorModel(db.Model): + __tablename__ = 'author' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255)) + + class BookModel(db.Model): + __tablename__ = 'book' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(255)) + author_id = db.Column(db.Integer, db.ForeignKey('author.id')) + author = db.relationship('AuthorModel', backref='books') + + db.create_all() + + class _models: + def __init__(self): + self.Author = AuthorModel + self.Book = BookModel + yield _models() + db.drop_all() + + def test_can_declare_model(self, extma, models, db): + class AuthorSchema(extma.ModelSchema): + class Meta: + model = models.Author + + author_schema = AuthorSchema() + + author = models.Author(name='Chuck Paluhniuk') + + db.session.add(author) + db.session.commit() + result = author_schema.dump(author) + assert 'id' in result.data + assert 'name' in result.data + assert result.data['name'] == 'Chuck Paluhniuk' + + resp = author_schema.jsonify(author) + assert isinstance(resp, BaseResponse)