├── .codecov.yml
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .project
├── .pydevproject
├── .readthedocs.yaml
├── CHANGELOG.rst
├── LICENSE
├── README.md
├── docs
├── Makefile
├── conf.py
├── index.rst
├── make.bat
└── source
│ ├── api_module.rst
│ ├── api_reference.rst
│ ├── decorators.rst
│ ├── query_builder.rst
│ └── resources.rst
├── environment.devenv.yml
├── news
└── TEMPLATE
├── pyproject.toml
├── requirements.txt
├── setup.cfg
├── setup.py
├── src
└── flask_restalchemy
│ ├── __init__.py
│ ├── api.py
│ ├── conftest.py
│ ├── decorators
│ ├── __init__.py
│ └── request_hooks.py
│ ├── resources
│ ├── __init__.py
│ ├── querybuilder.py
│ └── resources.py
│ ├── serialization.py
│ └── tests
│ ├── __init__.py
│ ├── employer_serializer.py
│ ├── sample_model.py
│ ├── test_api.py
│ ├── test_api
│ ├── test_get.yml
│ └── test_get_collection.yml
│ ├── test_api_processors.py
│ ├── test_api_relations.py
│ ├── test_api_relations
│ ├── test_get_collection.yml
│ ├── test_get_item.yml
│ └── test_property.yml
│ ├── test_blueprint.py
│ ├── test_child_resource.py
│ ├── test_decorators.py
│ ├── test_query_callback.py
│ ├── test_query_callback
│ ├── test_get_collection_model.yml
│ ├── test_get_collection_property.yml
│ ├── test_get_collection_relation_protoss.yml
│ └── test_get_collection_relation_terrans.yml
│ ├── test_querybuilder.py
│ ├── test_simple_api.py
│ └── test_swagger_spec_gen.py
└── tox.ini
/.codecov.yml:
--------------------------------------------------------------------------------
1 | codecov:
2 | notify:
3 | require_ci_to_pass: yes
4 |
5 | coverage:
6 | precision: 2
7 | round: down
8 | range: "80...100"
9 |
10 | status:
11 | project: yes
12 | patch: yes
13 | changes: no
14 |
15 | parsers:
16 | gcov:
17 | branch_detection:
18 | conditional: yes
19 | loop: yes
20 | method: no
21 | macro: no
22 |
23 | comment:
24 | layout: "header, diff"
25 | behavior: default
26 | require_changes: no
27 |
28 | ignore:
29 | - "setup.py"
30 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | - rb-*
8 | tags:
9 | - v*
10 |
11 | pull_request:
12 |
13 | jobs:
14 | build:
15 |
16 | runs-on: ${{ matrix.os }}
17 |
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | python: ["3.6", "3.7", "3.8", "3.9", "3.10"]
22 | os: [ubuntu-20.04, windows-latest]
23 |
24 | steps:
25 | - uses: actions/checkout@v3
26 | - name: Set up Python
27 | uses: actions/setup-python@v4
28 | with:
29 | python-version: ${{ matrix.python }}
30 | - name: Install tox
31 | run: |
32 | python -m pip install --upgrade pip
33 | python -m pip install tox tox-gh-actions
34 | - name: Test with tox
35 | run: tox
36 | - name: Upload codecov
37 | uses: codecov/codecov-action@v3
38 | with:
39 | token: ${{ secrets.CODECOV_TOKEN }}
40 | fail_ci_if_error: true
41 |
42 | deploy:
43 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'ESSS/flask-restalchemy'
44 |
45 | runs-on: ubuntu-20.04
46 |
47 | needs: build
48 |
49 | steps:
50 | - uses: actions/checkout@v2
51 |
52 | - name: Set up Python
53 | uses: actions/setup-python@v2
54 | with:
55 | python-version: "3.6"
56 |
57 | - name: Build Package
58 | run: |
59 | python -m pip install --upgrade pip wheel setuptools
60 | python setup.py sdist bdist_wheel
61 | - name: Publish package to PyPI
62 | uses: pypa/gh-action-pypi-publish@release/v1
63 | with:
64 | user: __token__
65 | password: ${{ secrets.pypi_token }}
66 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | *.py[cod]
3 | __pycache__/
4 | .*cache
5 | *.egg-info
6 | *.eggs
7 | docs/_build/
8 | .~*
9 | *$py.class
10 | build/
11 | dist/
12 | rever/
13 |
14 | # Project settings
15 | .idea/
16 |
17 | # env generated files
18 | environment.yml
19 | venv
20 | .tox/
21 | .coverage
22 | coverage.xml
23 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/ambv/black
3 | rev: 19.3b0
4 | hooks:
5 | - id: black
6 | args: [--safe, --quiet]
7 | language_version: python3
8 | additional_dependencies: [click==8.0.2]
9 | - repo: https://github.com/asottile/blacken-docs
10 | rev: v1.0.0
11 | hooks:
12 | - id: blacken-docs
13 | additional_dependencies: [black==19.3b0]
14 | - repo: https://github.com/pre-commit/pre-commit-hooks
15 | rev: v1.3.0
16 | hooks:
17 | - id: trailing-whitespace
18 | - id: end-of-file-fixer
19 | - id: debug-statements
20 | - repo: local
21 | hooks:
22 | - id: rst
23 | name: rst
24 | entry: rst-lint --encoding utf-8
25 | files: ^(CHANGELOG.rst|HOWTORELEASE.rst|README.rst)$
26 | language: python
27 | additional_dependencies: [pygments, restructuredtext_lint]
28 |
--------------------------------------------------------------------------------
/.project:
--------------------------------------------------------------------------------
1 |
2 |
3 | flask-restalchemy
4 |
5 |
6 |
7 |
8 |
9 | org.python.pydev.PyDevBuilder
10 |
11 |
12 |
13 |
14 |
15 | org.python.pydev.pythonNature
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.pydevproject:
--------------------------------------------------------------------------------
1 |
2 |
3 | Default
4 | python 3.6
5 |
6 | /${PROJECT_DIR_NAME}
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # Read the Docs configuration file for Sphinx projects
2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3 |
4 | # Required
5 | version: 2
6 |
7 | # Set the OS, Python version and other tools you might need
8 | build:
9 | os: ubuntu-22.04
10 | tools:
11 | python: "3.12"
12 |
13 |
14 | # Build documentation in the "docs/" directory with Sphinx
15 | sphinx:
16 | configuration: docs/conf.py
17 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | ============================
2 | Flask-RESTAlchemy Change Log
3 | ============================
4 |
5 | .. current developments
6 |
7 | v0.14.1
8 | ====================
9 |
10 | **Fixed:**
11 |
12 | * Fix license placement on setup.py
13 |
14 | v0.14.0
15 | ====================
16 |
17 | **Added:**
18 |
19 | * Added api decorator 'route' to register a url rule
20 |
21 |
22 | v0.13.0
23 | ====================
24 |
25 | **Changed:**
26 |
27 | * Removed flask-restful dependency
28 |
29 | **Added:**
30 |
31 | * Added support for request decorators
32 | * Added new field NestedModelListField
33 | * Added support for free functions rules
34 | * Added a way to specify the http methods available on an endpoint
35 |
36 | **Fixed:**
37 |
38 | * Fix bug that was removing target objects when undoing a relationship
39 | * Fix bug that was causing the session not be passed to the serializers
40 |
41 | v0.12.1
42 | ====================
43 |
44 |
45 |
46 | v0.12.0
47 | ====================
48 |
49 | **Changed:**
50 |
51 | Change the way Column Serializers are registered: by using Api class method
52 | `register_column_serializer` (instead of ModelSerializer.DEFAULT_SERIALIZER class attribute).
53 |
54 |
55 |
56 | v0.11.1
57 | ====================
58 |
59 | **Fixed:**
60 |
61 | When adding the same property twice with different url an error occurred due to the endpoint provided to the RESTFul be
62 | the same. Now add_property have an optional parameter 'endpoint_name' to enable specify a different endpoint and
63 | defaults to the provided 'url_rule'
64 |
65 | v0.11.0
66 | ====================
67 |
68 | **Changed:**
69 |
70 | * NestedAttributesField parameters must now be typed
71 |
72 | **Fixed:**
73 |
74 | * Swagger spec generation
75 |
76 | v0.10.4
77 | ====================
78 |
79 | **Added:**
80 |
81 | * Support for append an existent item to a relation end point
82 |
83 |
84 | v0.10.3
85 | ====================
86 |
87 | v0.10.2
88 | ====================
89 |
90 | v0.10.1
91 | ====================
92 |
93 | **Added:**
94 |
95 | * Support for pagination and query on property end points
96 |
97 | v0.10.0
98 | ====================
99 |
100 | **Added:**
101 |
102 | * add pre/post commit hooks to put
103 |
104 | v0.9.2
105 | ====================
106 |
107 | **Added:**
108 |
109 | * Support for serialize Enum columns
110 | * Support for pagination on relationships
111 |
112 |
113 | **Changed:**
114 |
115 | * Now the auto generated end point name of a relationship uses the relationship name instead of the related model name
116 |
117 | **Fixed:**
118 |
119 | * Fix changelog auto generation
120 |
121 |
122 | v0.9.0
123 | ====================
124 |
125 | **Added:**
126 |
127 | * Add pagination and filter support for relationships
128 | * Add documentation with sphinx
129 |
130 | **Changed:**
131 |
132 |
133 | v0.8.5
134 | ====================
135 |
136 | **Changed:**
137 |
138 | * Change the way relation is checked on query related object
139 |
140 | v0.8.3
141 | ====================
142 |
143 | **Changed:**
144 |
145 | * Make `query_from_request` reusable
146 |
147 | v0.8.2
148 | ====================
149 |
150 | **Added:**
151 |
152 | * Enable deletion of an item from a relation that uses secondary table
153 |
154 |
155 | v0.8.1
156 | ====================
157 |
158 | **Added:**
159 |
160 | * Support Flask Blueprints
161 |
162 | v0.8.0
163 | ====================
164 |
165 | **Added:**
166 |
167 | * Added support to a unit definition for the Measure column
168 |
169 | v0.7.1
170 | ====================
171 |
172 | **Added:**
173 |
174 | * Support custom hooks before and after commit data to the DB
175 |
176 | v0.6.0
177 | ====================
178 |
179 | **Changed:**
180 |
181 | * Do not add Zulu TZ on naive datetimes
182 | * Rename package from flask-rest-orm to flask-restalchemy
183 |
184 | v0.5.0
185 | ====================
186 |
187 | **Added:**
188 |
189 | * Support filters and pagination
190 |
191 | v0.4.1
192 | ====================
193 |
194 | **Added:**
195 |
196 | * Support custom implementation of DateTime columns
197 |
198 | v0.4.2
199 | ====================
200 |
201 | **Fixed:**
202 |
203 | * Support Zulu time zone
204 |
205 | v0.4.1
206 | ====================
207 |
208 | **Added:**
209 |
210 | * Added PrimaryKeyField to serialized only the Foreign key of a model
211 |
212 | **Fixed:**
213 |
214 | * Update classifiers by removing Python 2 support
215 |
216 | v0.4.0
217 | ====================
218 |
219 | **Changed:**
220 |
221 | * Replace marshmallow serializers with our own serializer implementation
222 | * More robust serialization of dates and times
223 |
224 | v0.3.0
225 | ====================
226 |
227 | **Added:**
228 |
229 | * Added collection name parameter on add_model method
230 | * Compatibility with python 3.5
231 | * Enable custom endpoint
232 |
233 | v0.2.0
234 | ====================
235 |
236 | **Added:**
237 |
238 | * Added query filters and limits
239 |
240 | v0.1.0
241 | ====================
242 |
243 | **Added:**
244 |
245 | * First release version
246 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018, ESSS
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Flask-RESTAlchemy #
2 |
3 | [](https://github.com/ESSS/serialchemy/actions)
4 | [](https://codecov.io/gh/ESSS/flask-restalchemy)
5 | [](https://github.com/psf/black)
6 | [](https://flask-restalchemy.readthedocs.io/en/latest)
7 |
8 | A Flask extension to build REST APIs. It dismiss the need of building *Schema* classes,
9 | since usually all the information needed to serialize an SQLAlchemy instance is in the model
10 | itself.
11 |
12 | By adding a model to the API, all its properties will be exposed:
13 |
14 | ```python
15 | class User(Base):
16 |
17 | __tablename__ = "User"
18 |
19 | id = Column(Integer, primary_key=True)
20 | firstname = Column(String)
21 | lastname = Column(String)
22 | email = Column(String)
23 | password = Column(String)
24 |
25 |
26 | api = Api(flask_app)
27 | api.add_model(User, "/user")
28 | ```
29 |
30 | To change the way properties are serialized, declare only the one that needs a non-default
31 | behaviour:
32 |
33 | ```python
34 | from serialchemy import ModelSerializer, Field
35 |
36 |
37 | class UserSerializer(ModelSerializer):
38 |
39 | password = Field(load_only=True)
40 |
41 |
42 | api = Api(flask_app)
43 | api.add_model(User, "/user", serializer_class=UserSerializer)
44 | ```
45 |
46 | ### Release
47 | A reminder for the maintainers on how to make a new release.
48 |
49 | Note that the VERSION should folow the semantic versioning as X.Y.Z
50 | Ex.: v1.0.5
51 |
52 | 1. Create a ``release-VERSION`` branch from ``upstream/master``.
53 | 2. Update ``CHANGELOG.rst``.
54 | 3. Push a branch with the changes.
55 | 4. Once all builds pass, push a ``VERSION`` tag to ``upstream``.
56 | 5. Merge the PR.
57 |
58 | [Changelog]: https://regro.github.io/rever-docs/devguide.html#changelog
59 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SPHINXPROJ = Flask-RESTAlchemy
8 | SOURCEDIR = .
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Configuration file for the Sphinx documentation builder.
4 | #
5 | # This file does only contain a selection of the most common options. For a
6 | # full list see the documentation:
7 | # http://www.sphinx-doc.org/en/stable/config
8 |
9 | # -- Path setup --------------------------------------------------------------
10 |
11 | # If extensions (or modules to document with autodoc) are in another directory,
12 | # add these directories to sys.path here. If the directory is relative to the
13 | # documentation root, use os.path.abspath to make it absolute, like shown here.
14 | #
15 | import os
16 | import sys
17 |
18 | sys.path.insert(0, os.path.abspath("../src/"))
19 |
20 |
21 | # -- Project information -----------------------------------------------------
22 |
23 | project = "Flask-RESTAlchemy"
24 | copyright = '2018, ESSS'
25 | author = "ESSS"
26 |
27 | # The short X.Y version
28 | version = ""
29 | # The full version, including alpha/beta/rc tags
30 | release = "0.5.0"
31 |
32 |
33 | # -- General configuration ---------------------------------------------------
34 |
35 | # If your documentation needs a minimal Sphinx version, state it here.
36 | #
37 | # needs_sphinx = '1.0'
38 |
39 | # Add any Sphinx extension module names here, as strings. They can be
40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
41 | # ones.
42 | extensions = [
43 | "sphinx.ext.autodoc",
44 | "sphinx.ext.autosummary",
45 | "sphinx.ext.intersphinx",
46 | "sphinx.ext.todo",
47 | "sphinx.ext.viewcode",
48 | ]
49 |
50 | # Add any paths that contain templates here, relative to this directory.
51 | templates_path = ["_templates"]
52 |
53 | # The suffix(es) of source filenames.
54 | # You can specify multiple suffix as a list of string:
55 | #
56 | # source_suffix = ['.rst', '.md']
57 | source_suffix = ".rst"
58 |
59 | # The master toctree document.
60 | master_doc = "index"
61 |
62 | # The language for content autogenerated by Sphinx. Refer to documentation
63 | # for a list of supported languages.
64 | #
65 | # This is also used if you do content translation via gettext catalogs.
66 | # Usually you set "language" from the command line for these cases.
67 | language = None
68 |
69 | # List of patterns, relative to source directory, that match files and
70 | # directories to ignore when looking for source files.
71 | # This pattern also affects html_static_path and html_extra_path .
72 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "test/*", "tests/*"]
73 |
74 | # The name of the Pygments (syntax highlighting) style to use.
75 | pygments_style = "sphinx"
76 |
77 |
78 | # -- Options for HTML output -------------------------------------------------
79 |
80 | # The theme to use for HTML and HTML Help pages. See the documentation for
81 | # a list of builtin themes.
82 | #
83 | html_theme = "alabaster"
84 |
85 | # Theme options are theme-specific and customize the look and feel of a theme
86 | # further. For a list of options available for each theme, see the
87 | # documentation.
88 | #
89 | # html_theme_options = {}
90 |
91 | # Add any paths that contain custom static files (such as style sheets) here,
92 | # relative to this directory. They are copied after the builtin static files,
93 | # so a file named "default.css" will overwrite the builtin "default.css".
94 | html_static_path = [""]
95 |
96 | # Custom sidebar templates, must be a dictionary that maps document names
97 | # to template names.
98 | #
99 | # The default sidebars (for documents that don't match any pattern) are
100 | # defined by theme itself. Builtin themes are using these templates by
101 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
102 | # 'searchbox.html']``.
103 | #
104 | # html_sidebars = {}
105 |
106 |
107 | # -- Options for HTMLHelp output ---------------------------------------------
108 |
109 | # Output file base name for HTML help builder.
110 | htmlhelp_basename = "Flask-RESTAlchemydoc"
111 |
112 |
113 | # -- Options for LaTeX output ------------------------------------------------
114 |
115 | latex_elements = {
116 | # The paper size ('letterpaper' or 'a4paper').
117 | #
118 | # 'papersize': 'letterpaper',
119 | # The font size ('10pt', '11pt' or '12pt').
120 | #
121 | # 'pointsize': '10pt',
122 | # Additional stuff for the LaTeX preamble.
123 | #
124 | # 'preamble': '',
125 | # Latex figure (float) alignment
126 | #
127 | # 'figure_align': 'htbp',
128 | }
129 |
130 | # Grouping the document tree into LaTeX files. List of tuples
131 | # (source start file, target name, title,
132 | # author, documentclass [howto, manual, or own class]).
133 | latex_documents = [
134 | (
135 | master_doc,
136 | "Flask-RESTAlchemy.tex",
137 | "Flask-RESTAlchemy Documentation",
138 | "ESSS",
139 | "manual",
140 | )
141 | ]
142 |
143 |
144 | # -- Options for manual page output ------------------------------------------
145 |
146 | # One entry per manual page. List of tuples
147 | # (source start file, name, description, authors, manual section).
148 | man_pages = [
149 | (master_doc, "flask-restalchemy", "Flask-RESTAlchemy Documentation", [author], 1)
150 | ]
151 |
152 |
153 | # -- Options for Texinfo output ----------------------------------------------
154 |
155 | # Grouping the document tree into Texinfo files. List of tuples
156 | # (source start file, target name, title, author,
157 | # dir menu entry, description, category)
158 | texinfo_documents = [
159 | (
160 | master_doc,
161 | "Flask-RESTAlchemy",
162 | "Flask-RESTAlchemy Documentation",
163 | author,
164 | "Flask-RESTAlchemy",
165 | "One line description of project.",
166 | "Miscellaneous",
167 | )
168 | ]
169 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. Flask-RESTAlchemy documentation master file, created by
2 | sphinx-quickstart on Fri Mar 23 18:56:26 2018.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | Flask-RESTAlchemy's!
7 | ====================
8 |
9 | A Flask extension to build REST APIs based on `SQLAlchemy`_ models. Exported models expose all their properties by
10 | default, making the definition of *Schema* classes optional.
11 |
12 | Installation
13 | ------------
14 |
15 | Install Flask-RESTAlchemy with `pip`:
16 |
17 | .. code-block:: shell
18 |
19 | pip install flask-restalchemy
20 |
21 | or `conda` (package available on `conda-forge`_):
22 |
23 | .. code-block:: shell
24 |
25 | conda install flask-restalchemy -c conda-forge
26 |
27 |
28 | Minimal App
29 | -----------
30 |
31 | Let's define a very simple *SQLAlchemy* model using `flask-sqlalchemy`_:
32 |
33 | .. code-block:: python
34 |
35 | from flask_sqlalchemy import SQLAlchemy
36 | from sqlalchemy import Column, String, Integer
37 |
38 | db = SQLAlchemy()
39 |
40 |
41 | class Hero(db.Model):
42 | id = Column(Integer, primary_key=True)
43 | name = Column(String)
44 | secret_name = Column(String)
45 |
46 | Expose SQLAlchemy models in a very simple way is one of the aims of Flask-RESTAlchemy. Just instantiate an `Api` object
47 | and use `Api.add_model` to expose a model through an endpoint:
48 |
49 | .. code-block:: python
50 |
51 | from flask import Flask
52 | from flask_restalchemy import Api
53 |
54 | app = Flask("tour-of-heroes")
55 |
56 |
57 | @app.route("/create_db", methods=["POST"])
58 | def create_db():
59 | db.create_all()
60 | return "DB created"
61 |
62 |
63 | # Set an SQLite in-memory database
64 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
65 | db.init_app(app) # Must be called before Api object creation
66 |
67 | api = Api(app)
68 | api.add_model(Hero, "/heroes")
69 |
70 | if __name__ == "__main__":
71 | app.run()
72 |
73 | `Api.add_model` creates methods GET and POST for the `Heroes` collection at ``/heroes`` and methods GET, PUT and DELETE
74 | for ``/heroes/:id`` Let's see it in action:
75 |
76 | .. code-block:: shell
77 |
78 | $ python app.py
79 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
80 |
81 | $ curl -X POST http://localhost:5000/create_db
82 | DB Created
83 |
84 | $ curl -H "Content-Type:application/json" -d "{\"name\":\"Mr. Nice\"}" http://localhost:5000/heroes
85 | $ curl -X GET http://localhost:5000/heroes/1
86 | {
87 | "id": 1,
88 | "name": "Mr. Nice",
89 | secret_name: "",
90 | }
91 |
92 | Serializers could be used to override the default serialization of models:
93 |
94 | .. code-block:: python
95 |
96 | class HeroSerializer(ModelSerializer):
97 |
98 | secret_name = Field(load_only=True)
99 |
100 |
101 | api = Api(app)
102 | api.add_model(Hero, "/heroes", serializer_class=HeroSerializer)
103 |
104 | In the above example, `secret_name` property will not be exposed on a GET, but can be updated in a PUT or POST.
105 |
106 | .. _conda-forge: https://conda-forge.org
107 | .. _flask-sqlalchemy: http://lask-sqlalchemy.pocoo.org
108 | .. _SQLAlchemy: http://www.sqlalchemy.org
109 |
110 | Documentation
111 | =============
112 |
113 | .. toctree::
114 | :maxdepth: 2
115 |
116 | source/api_reference
117 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=build
12 | set SPHINXPROJ=Flask-RESTAlchemy
13 |
14 | if "%1" == "" goto help
15 |
16 | %SPHINXBUILD% >NUL 2>NUL
17 | if errorlevel 9009 (
18 | echo.
19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
20 | echo.installed, then set the SPHINXBUILD environment variable to point
21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
22 | echo.may add the Sphinx directory to PATH.
23 | echo.
24 | echo.If you don't have Sphinx installed, grab it from
25 | echo.http://sphinx-doc.org/
26 | exit /b 1
27 | )
28 |
29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
30 | goto end
31 |
32 | :help
33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
34 |
35 | :end
36 | popd
37 |
--------------------------------------------------------------------------------
/docs/source/api_module.rst:
--------------------------------------------------------------------------------
1 | Api
2 | ===
3 |
4 |
5 | The ``Api`` class
6 | -----------------
7 |
8 | .. autoclass:: flask_restalchemy.Api
9 | :members:
10 | :undoc-members:
11 | :show-inheritance:
12 |
--------------------------------------------------------------------------------
/docs/source/api_reference.rst:
--------------------------------------------------------------------------------
1 | API Reference
2 | ==========================
3 |
4 |
5 | .. toctree::
6 | :maxdepth: 4
7 |
8 | api_module
9 | resources
10 | query_builder
11 | decorators
12 |
--------------------------------------------------------------------------------
/docs/source/decorators.rst:
--------------------------------------------------------------------------------
1 | Decorators
2 | ==========
3 |
4 |
5 | .. automodule:: flask_restalchemy.decorators.request_hooks
6 | :members:
7 | :undoc-members:
8 | :show-inheritance:
9 |
--------------------------------------------------------------------------------
/docs/source/query_builder.rst:
--------------------------------------------------------------------------------
1 | Query builder
2 | =============
3 |
4 |
5 | .. automodule:: flask_restalchemy.resources.querybuilder
6 | :members:
7 | :undoc-members:
8 | :show-inheritance:
9 |
--------------------------------------------------------------------------------
/docs/source/resources.rst:
--------------------------------------------------------------------------------
1 | Resources
2 | =========
3 |
4 |
5 | The ``BaseResource`` class
6 | --------------------------
7 |
8 | .. autoclass:: flask_restalchemy.resources.BaseResource
9 | :members:
10 | :undoc-members:
11 | :show-inheritance:
12 |
13 | The ``BaseModelResource`` class
14 | -------------------------------
15 |
16 | .. autoclass:: flask_restalchemy.resources.BaseModelResource
17 | :members:
18 | :undoc-members:
19 | :show-inheritance:
20 |
21 | The ``ModelResource`` class
22 | ---------------------------
23 |
24 | .. autoclass:: flask_restalchemy.resources.ModelResource
25 | :members:
26 | :undoc-members:
27 | :show-inheritance:
28 |
29 | The ``ToManyRelationResource`` class
30 | ------------------------------------
31 |
32 | .. autoclass:: flask_restalchemy.resources.ToManyRelationResource
33 | :members:
34 | :undoc-members:
35 | :show-inheritance:
36 |
37 |
38 | The ``CollectionPropertyResource`` class
39 | ----------------------------------------
40 |
41 | .. autoclass:: flask_restalchemy.resources.CollectionPropertyResource
42 | :members:
43 | :undoc-members:
44 | :show-inheritance:
45 |
--------------------------------------------------------------------------------
/environment.devenv.yml:
--------------------------------------------------------------------------------
1 | {% set PY = os.environ['CONDA_PY'] | int %}
2 | name: flask-restalchemy-py{{ PY }}
3 |
4 | dependencies:
5 | {% if PY==36 %}
6 | - python ==3.6.11
7 | {% elif PY==310 %}
8 | - python ==3.10.4 # [win]
9 | - python ==3.10.2 # [linux]
10 | {% endif %}
11 | - sqlalchemy ==1.3.15 # [PY==36]
12 | - sqlalchemy ==1.4.44 # [PY==310]
13 | - flask>=1.0
14 | - sqlalchemy-utils>=0.30
15 | - flask-sqlalchemy>=2.3.0
16 | - pytest
17 | - pytest-regressions
18 | - pytest-mock>=1.10.0
19 | - tox
20 |
21 | environment:
22 | PYTHONPATH:
23 | - {{ root }}/src
24 | - {{ root }}/../serialchemy/src
25 |
--------------------------------------------------------------------------------
/news/TEMPLATE:
--------------------------------------------------------------------------------
1 | **Added:** None
2 |
3 | **Changed:** None
4 |
5 | **Deprecated:** None
6 |
7 | **Removed:** None
8 |
9 | **Fixed:**
10 |
11 | Type checking registration on `register_column_serializer` method
12 |
13 | **Security:** None
14 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = [
3 | "setuptools>=45",
4 | "setuptools_scm[toml]>=6.2",
5 | "wheel",
6 | ]
7 | build-backend = "setuptools.build_meta"
8 |
9 | [tool.setuptools_scm]
10 | git_describe_command = "git describe --dirty --tags --long --exclude RFDAP*"
11 |
12 | [tool.black]
13 | line-length = 100
14 | skip-string-normalization = true
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | flask==1.0.2
2 | sqlalchemy==1.3.0
3 | sqlalchemy_utils>=0.30
4 | flask-sqlalchemy>=2.3.0
5 | serialchemy>=0.3.0
6 | pytest==4.0.2
7 | pytest-datadir
8 | pytest-regressions==1.0.5
9 | pytest-mock>=1.10.0
10 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | long_description = file: README.md
3 | long_description_content_type= text/markdown
4 |
5 | [bdist_wheel]
6 | python_tag = py3
7 |
8 | [flake8]
9 | exclude = docs
10 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 |
3 | install_requires = [
4 | "flask-sqlalchemy >= 2.3.0",
5 | "sqlalchemy_utils>=0.30",
6 | "flask>=1.0.0",
7 | "sqlalchemy>=1.3,<2",
8 | "serialchemy>=0.3.0",
9 | ]
10 |
11 | extras_require = {
12 | "docs": ["sphinx >= 1.4", "sphinx_rtd_theme"],
13 | "testing": ["codecov", "pytest", "pytest-cov", "pytest-mock", "pytest-regressions", "tox"],
14 | }
15 |
16 | setup(
17 | name="flask-restalchemy",
18 | use_scm_version={"git_describe_command": "git describe --dirty --tags --long --match v*"},
19 | setup_requires=["setuptools_scm"],
20 | packages=find_packages("src"),
21 | package_dir={"": "src"},
22 | package_data={"": ["**/*.yml"]},
23 | url="https://github.com/ESSS/flask-restalchemy",
24 | license="MIT",
25 | author="ESSS",
26 | author_email="foss@esss.co",
27 | description="Flask extension to build REST APIs based on SQLAlchemy models ",
28 | keywords="flask sqlalchemy orm",
29 | license_files=('LICENSE',),
30 | install_requires=install_requires,
31 | extras_require=extras_require,
32 | python_requires=">=3.6",
33 | classifiers=[
34 | "Environment :: Web Environment",
35 | "Development Status :: 3 - Alpha",
36 | "Intended Audience :: Developers",
37 | "License :: OSI Approved :: MIT License",
38 | "Operating System :: OS Independent",
39 | "Programming Language :: Python :: 3.6",
40 | "Programming Language :: Python :: 3.7",
41 | "Topic :: Database :: Front-Ends",
42 | "Topic :: Internet :: WWW/HTTP",
43 | ],
44 | )
45 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/__init__.py:
--------------------------------------------------------------------------------
1 | from .api import Api
2 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/api.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Mapping
2 |
3 | from flask import current_app
4 |
5 | from .serialization import ColumnSerializer, ModelSerializer
6 | from .resources.resources import (
7 | ToManyRelationResource,
8 | ModelResource,
9 | CollectionPropertyResource,
10 | BaseResource,
11 | ViewFunctionResource,
12 | )
13 |
14 |
15 | class Api:
16 | """
17 | :param (Flask|Blueprint) blueprint: Flask application or Blueprint
18 |
19 | :param callable request_decorators: request decorators for this API object (see
20 | Flask-Restful decorators docs for more information)
21 | """
22 |
23 | def __init__(self, blueprint=None, request_decorators=None):
24 | """Constructor"""
25 | # noinspection PyPackageRequirements
26 | self.default_mediatype = "application/json"
27 | self._blueprint = blueprint
28 | self._db = None
29 | self._api_request_decorators = ResourceDecorators(request_decorators)
30 |
31 | def init_app(self, blueprint):
32 | self._blueprint = blueprint
33 |
34 | def add_model(
35 | self,
36 | model,
37 | url=None,
38 | serializer_class=None,
39 | view_name=None,
40 | request_decorators=None,
41 | methods=None,
42 | query_modifier=None,
43 | ):
44 | """
45 | Create API endpoints for the given SQLAlchemy declarative class.
46 |
47 | :param class model: the SQLAlchemy declarative class
48 |
49 | :param string url: one or more url routes to match for the resource, standard flask routing
50 | rules apply. Defaults to model name in lower case.
51 |
52 | :param string view_name: custom name for the collection endpoint url definition, if
53 | not set the model table name will be used
54 |
55 | :param Type[ModelSerializer] serializer_class: If `None`, a default serializer will be
56 | created.
57 |
58 | :param list|dict request_decorators: decorators to be applied to HTTP methods. Could be a
59 | list of decorators or a dict mapping HTTP method types to a list of decorators (dict
60 | keys should be 'get', 'post' or 'put').
61 |
62 | :param list methods: A list with verbs to be used, if None, default will use all
63 |
64 | :param callable query_modifier: function that returns a query and expects a `model` as parameter that
65 | should be used to create the query and expects a `parent_query` to be incremented with the callback query
66 | function. The method signature should look like this: query_callback(resource_model, parent_query)
67 | """
68 | view_name = view_name or model.__tablename__
69 | if not serializer_class:
70 | serializer = self.create_default_serializer(model)
71 | else:
72 | serializer = serializer_class(model)
73 | url = url if url is not None else "/" + view_name.lower()
74 |
75 | view_init_args = (model, serializer, self.get_db_session, query_modifier)
76 | decorators = self._create_decorators(request_decorators)
77 | self.add_resource(
78 | ModelResource,
79 | url,
80 | view_name,
81 | view_init_args,
82 | decorators=decorators,
83 | methods=methods,
84 | )
85 |
86 | def add_relation(
87 | self,
88 | relation_property,
89 | url_rule=None,
90 | serializer_class=None,
91 | request_decorators=None,
92 | endpoint_name=None,
93 | methods=None,
94 | query_modifier=None,
95 | ):
96 | """
97 | Create API endpoints for the given SQLAlchemy relationship.
98 |
99 | :param relation_property: model relationship representing the collection to receive the
100 | CRUD operations.
101 |
102 | :param string url_rule: one or more url routes to match for the resource, standard
103 | flask routing rules apply. Defaults to model name in lower case.
104 |
105 | :param Type[ModelSerializer] serializer_class: If `None`, a default serializer will be created.
106 |
107 | :param list|dict request_decorators: decorators to be applied to HTTP methods. Could be a list of decorators
108 | or a dict mapping HTTP verb types to a list of decorators (dict keys should be 'get', 'post' or 'put').
109 | See https://flask-restful.readthedocs.io/en/latest/extending.html#resource-method-decorators for more
110 | details.
111 |
112 | :param string endpoint_name: endpoint name (defaults to :meth:`{model_collection_name}-{related_collection_name}-relation`
113 | Can be used to reference this route in :class:`fields.Url` fields
114 |
115 | :param list methods: A list with verbs to be used, if None, default will use all
116 |
117 | :param callable query_modifier: function that returns a query and expects a `model` as parameter that
118 | should be used to create the query and expects a `parent_query` to be incremented with the callback query
119 | function. The method signature should look like this: query_callback(resource_model, parent_query)
120 | """
121 | model = relation_property.prop.mapper.class_
122 | related_model = relation_property.class_
123 | view_name = f"{model.__name__}_{related_model.__name__}".lower()
124 |
125 | if not serializer_class:
126 | serializer = self.create_default_serializer(model)
127 | else:
128 | serializer = serializer_class(model)
129 | if url_rule:
130 | assert "" in url_rule
131 | else:
132 | parent_endpoint = related_model.__tablename__.lower()
133 | url_rule = "/{}//{}".format(
134 | parent_endpoint, relation_property.key
135 | )
136 | endpoint_name = endpoint_name or url_rule
137 |
138 | view_init_args = (
139 | relation_property,
140 | serializer,
141 | self.get_db_session,
142 | query_modifier,
143 | )
144 | self.add_resource(
145 | ToManyRelationResource,
146 | url_rule,
147 | view_name,
148 | view_init_args,
149 | decorators=self._create_decorators(request_decorators),
150 | methods=methods,
151 | )
152 |
153 | def add_property(
154 | self,
155 | property_type,
156 | model,
157 | property_name,
158 | url_rule=None,
159 | serializer_class=None,
160 | request_decorators=[],
161 | endpoint_name=None,
162 | methods=None,
163 | query_modifier=None,
164 | ):
165 | if not serializer_class:
166 | serializer = self.create_default_serializer(property_type)
167 | else:
168 | serializer = serializer_class(property_type)
169 | view_name = f"{model.__name__}_{property_name}".lower()
170 | if url_rule:
171 | assert "" in url_rule
172 | else:
173 | parent_endpoint = model.__tablename__.lower()
174 | url_rule = "/{}//{}".format(
175 | parent_endpoint, property_name.lower()
176 | )
177 |
178 | endpoint = endpoint_name or url_rule
179 |
180 | view_init_args = (
181 | property_type,
182 | model,
183 | property_name,
184 | serializer,
185 | self.get_db_session,
186 | query_modifier,
187 | )
188 | self.add_resource(
189 | CollectionPropertyResource,
190 | url_rule,
191 | view_name,
192 | view_init_args,
193 | decorators=self._create_decorators(request_decorators),
194 | methods=methods,
195 | )
196 |
197 | def add_resource(
198 | self,
199 | resource_class,
200 | url_rule,
201 | view_name,
202 | resource_init_args=(),
203 | resource_init_kwargs=None,
204 | decorators=None,
205 | methods=None,
206 | ):
207 | if not issubclass(resource_class, BaseResource):
208 | raise TypeError("Resource must inherit BaseResource")
209 | if resource_init_kwargs is None:
210 | resource_init_kwargs = {}
211 | else:
212 | assert (
213 | "request_decorators" not in resource_init_kwargs
214 | ), "Use add_resource 'decorators' parameter"
215 | resource_init_kwargs["request_decorators"] = self._create_decorators(decorators)
216 | view_func = resource_class.as_view(
217 | view_name, *resource_init_args, **resource_init_kwargs
218 | )
219 | self.register_view(view_func, url_rule, methods=methods)
220 |
221 | def register_view(self, view_func, url, pk="id", pk_type="int", methods=None):
222 | """
223 | Configure URL rule as specified by HTTP verbs passed as parameter.
224 |
225 | :param view_func: the function to call when serving a request to the
226 | provided endpoint
227 |
228 | :param url: one or more url routes to match for the resource, standard flask routing rules
229 | apply. Defaults to model name in lower case.
230 |
231 | :param pk: primary key
232 |
233 | :param pk_type: primary key type
234 |
235 | :param list[str] methods: verbs to be accepted by view
236 | """
237 | app = self._blueprint
238 | if methods is None:
239 | app.add_url_rule(
240 | url, defaults={pk: None}, view_func=view_func, methods=["GET"]
241 | )
242 | app.add_url_rule(url, view_func=view_func, methods=["POST"])
243 | app.add_url_rule(
244 | f"{url}/<{pk_type}:{pk}>",
245 | view_func=view_func,
246 | methods=["GET", "PUT", "DELETE"],
247 | )
248 | else:
249 | if "GET_COLLECTION" in methods:
250 | methods.remove("GET_COLLECTION")
251 | app.add_url_rule(
252 | url, defaults={pk: None}, view_func=view_func, methods=["GET"]
253 | )
254 | if "POST" in methods:
255 | methods.remove("POST")
256 | app.add_url_rule(url, view_func=view_func, methods=["POST"])
257 | if methods:
258 | app.add_url_rule(
259 | f"{url}/<{pk_type}:{pk}>", view_func=view_func, methods=methods
260 | )
261 |
262 | def route(self, rule, endpoint=None, **kwargs):
263 | """
264 | A decorator that is used to register a view function for a
265 | given URL rule. This does the same thing as :meth:`add_url_rule`
266 | but is intended for decorator usage::
267 |
268 | @api.route('/')
269 | def index():
270 | return 'Hello World'
271 |
272 | :param rule: the URL rule as string
273 | :param endpoint: the endpoint for the registered URL rule.
274 | Uses the name of the view function by default
275 | :param kwargs: the options to be forwarded to the underlying
276 | :meth:`add_url_rule`.
277 | """
278 |
279 | def decorator(f):
280 | rule_endpoint = endpoint or f.__name__
281 | self.add_url_rule(rule, rule_endpoint, f, **kwargs)
282 | return f
283 |
284 | return decorator
285 |
286 | def add_url_rule(
287 | self, rule, endpoint, view_func, methods=None, request_decorators=()
288 | ):
289 | """
290 | This is almost the same as `Flask.add_url_rule` method, but with added support for
291 | decorators.
292 |
293 | :param rule: the URL rule as string
294 |
295 | :param endpoint: the endpoint for the registered URL rule. Flask
296 | itself assumes the name of the view function as
297 | endpoint
298 |
299 | :param view_func: the function to call when serving a request to the
300 | provided endpoint
301 |
302 | :param list[str] methods: verbs to be accepted by view
303 |
304 | :param list|dict request_decorators: decorators to be applied to HTTP methods. Could be a list of decorators
305 | or a dict mapping HTTP verb types to a list of decorators (dict keys should be 'get', 'post' or 'put').
306 | See https://flask-restful.readthedocs.io/en/latest/extending.html#resource-method-decorators for more
307 | details.
308 | """
309 | app = self._blueprint
310 | resource_as_view = ViewFunctionResource.as_view(
311 | endpoint,
312 | view_func,
313 | request_decorators=self._create_decorators(request_decorators),
314 | )
315 | app.add_url_rule(rule, view_func=resource_as_view, methods=methods)
316 |
317 | def _create_decorators(self, request_decorators):
318 | merged_request_decorators = ResourceDecorators(request_decorators)
319 | merged_request_decorators.merge(self._api_request_decorators)
320 | return merged_request_decorators
321 |
322 | @staticmethod
323 | def create_default_serializer(model_class):
324 | """
325 | Create a default serializer for the given SQLAlchemy declarative class. Recipe based on
326 | https://marshmallow-sqlalchemy.readthedocs.io/en/latest/recipes.html#automatically-generating-schemas-for-sqlalchemy-models
327 |
328 | :param model_class: the SQLAlchemy mapped class
329 |
330 | :rtype: class
331 | """
332 | return ModelSerializer(model_class)
333 |
334 | def get_db_session(self):
335 | """ef register_view(self, view_func, url, pk='id', pk_type='int', methods=None):
336 | Returns an SQLAlchemy object session. Used by flask-restful Resources to access
337 | the database.
338 | """
339 | if not self._db:
340 | # Get the Flask application
341 | flask_app = current_app
342 | assert flask_app and flask_app.extensions, "Flask App not initialized yet"
343 | self._db = flask_app.extensions["sqlalchemy"].db
344 | return self._db.session
345 |
346 | @classmethod
347 | def register_column_serializer(cls, serializer_class, predicate):
348 | """
349 | Register a serializer for a given column to be used globally by ModelSerializers
350 |
351 | :param Type[ColumnSerializer] serializer_class: the Serializer class
352 |
353 | :param callable predicate: a function that receives a column type and returns True if the
354 | given serializer is valid for that column
355 | """
356 | if not issubclass(serializer_class, ColumnSerializer):
357 | raise TypeError("Invalid serializer class")
358 | ModelSerializer.EXTRA_SERIALIZERS.append((serializer_class, predicate))
359 |
360 |
361 | class ResourceDecorators(Mapping):
362 | """
363 | API decorators can be set at the API instance level or per resource added. This class helps
364 | to manage and merge decorators added with both strategies.
365 | """
366 |
367 | def __init__(self, request_decorators=None):
368 | self._verb_decorators = {}
369 | for verb in ["ALL", "GET", "POST", "PUT", "DELETE"]:
370 | self._verb_decorators[verb] = []
371 | if request_decorators:
372 | self.merge(request_decorators)
373 |
374 | def merge(self, request_decorators):
375 | if callable(request_decorators):
376 | self._verb_decorators["ALL"].append(request_decorators)
377 | elif isinstance(request_decorators, list):
378 | self._verb_decorators["ALL"].extend(request_decorators)
379 | elif isinstance(request_decorators, (dict, ResourceDecorators)):
380 | for verb, decorator_value in request_decorators.items():
381 | if callable(decorator_value):
382 | self._verb_decorators[verb].append(decorator_value)
383 | elif isinstance(decorator_value, list):
384 | self._verb_decorators[verb].extend(decorator_value)
385 | else:
386 | raise TypeError()
387 | else:
388 | TypeError()
389 |
390 | def __getitem__(self, verb):
391 | return self._verb_decorators[verb]
392 |
393 | def __iter__(self):
394 | return iter(self._verb_decorators)
395 |
396 | def __len__(self):
397 | return len(self._verb_decorators)
398 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/conftest.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import pytest
4 | from flask import Flask
5 |
6 | from flask_restalchemy.tests.sample_model import db
7 |
8 |
9 | @pytest.fixture
10 | def client(flask_app, db_session):
11 | return flask_app.test_client()
12 |
13 |
14 | @pytest.fixture()
15 | def flask_app():
16 | app = Flask("flask_sqlapi_sample")
17 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
18 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
19 | app.config["PROPAGATE_EXCEPTIONS"] = True
20 | db.init_app(app)
21 | with app.app_context():
22 | yield app
23 |
24 |
25 | @pytest.fixture()
26 | def db_session(flask_app):
27 | db.create_all()
28 | yield db.session
29 | db.session.remove()
30 | db.drop_all()
31 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/decorators/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ESSS/flask-restalchemy/2e0e8e6c3768590620f06330d0bc02dbe697d4cd/src/flask_restalchemy/decorators/__init__.py
--------------------------------------------------------------------------------
/src/flask_restalchemy/decorators/request_hooks.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 |
4 | def before_request(_callable):
5 | def create_decorator(func):
6 | @wraps(func)
7 | def request_decorator(*args, **kw):
8 | _callable(*args, **kw)
9 | return func(*args, **kw)
10 |
11 | return request_decorator
12 |
13 | return create_decorator
14 |
15 |
16 | def after_request(_callable):
17 | def create_decorator(func):
18 | @wraps(func)
19 | def request_decorator(*args, **kw):
20 | response = func(*args, **kw)
21 | decorated_response = _callable(response, **kw)
22 | return decorated_response or response
23 |
24 | return request_decorator
25 |
26 | return create_decorator
27 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/resources/__init__.py:
--------------------------------------------------------------------------------
1 | from .resources import (
2 | ModelResource,
3 | BaseResource,
4 | BaseModelResource,
5 | CollectionPropertyResource,
6 | ToManyRelationResource,
7 | load_request_json,
8 | create_response_from_query,
9 | )
10 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/resources/querybuilder.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import desc, or_, and_, func
2 | import json
3 | import operator
4 | from sqlalchemy.ext.associationproxy import AssociationProxyInstance
5 |
6 | CASE_INSENSITIVE_ORDER_BY_ENABLED = True
7 |
8 |
9 | def create_collection_query(parent_query, model_class, model_serializer, args):
10 | """
11 | Build a query using query parameters in the http URL, disposed on the request args.
12 | The default logical operator is AND, but you can set the OR as in the following examples:
13 |
14 | a) OR -> ?filter={"$or":{"name": {"startswith": "Terrans 1"},"location": "Location 1"}}
15 | b) AND -> ?filter={"$and":{"name": {"ilike": "%Terrans 1%"},"location": "Location 1"}}
16 | or ?filter={"name": {"ilike": "%Terrans 1%"},"location": {"eq": "Location 1"}}
17 |
18 | Ordered search is available using 'order_by='. The minus sign ("-") could be
19 | used to set descending order.
20 |
21 | :param parent_query:
22 | SQLAlchemy query instance
23 |
24 | :param class model_class:
25 | SQLAlchemy model class representing a database resource
26 |
27 | :param model_serializer:
28 | instance of model serializer
29 |
30 | :param args:
31 | arguments of the Flask http request
32 |
33 | :rtype: query
34 | :return: SQLAlchemy query instance
35 | """
36 |
37 | def build_filter_operator(column_name, request_filter, serializer):
38 | if column_name == "$or":
39 | return or_(
40 | build_filter_operator(attr, value, serializer)
41 | for attr, value in request_filter.items()
42 | )
43 | elif column_name == "$and":
44 | return and_(
45 | build_filter_operator(attr, value, serializer)
46 | for attr, value in request_filter.items()
47 | )
48 | if isinstance(request_filter, dict):
49 | op_name = next(iter(request_filter))
50 | return get_operator(
51 | getattr(model_class, column_name),
52 | op_name,
53 | request_filter.get(op_name),
54 | get_field_serializer_or_none(serializer, column_name),
55 | )
56 | return get_operator(
57 | getattr(model_class, column_name),
58 | None,
59 | request_filter,
60 | get_field_serializer_or_none(serializer, column_name),
61 | )
62 |
63 | res_query = parent_query
64 | if "filter" in args:
65 | filters = json.loads(args["filter"])
66 | for attr, value in filters.items():
67 | res_query = res_query.filter(
68 | build_filter_operator(attr, value, model_serializer)
69 | )
70 | if "order_by" in args:
71 | fields = args["order_by"].split(",")
72 | for field in fields:
73 | field_name = field.lstrip("-")
74 | column = getattr(model_class, field_name)
75 | # Join with the associated table and define column as the associated property to support sorting
76 | if isinstance(column, AssociationProxyInstance):
77 | res_query = res_query.outerjoin(column.target_class)
78 | column = column.remote_attr
79 | if CASE_INSENSITIVE_ORDER_BY_ENABLED and str(column.type) == "VARCHAR":
80 | column = func.lower(column)
81 | if field[0] == "-":
82 | column = desc(column)
83 | res_query = res_query.order_by(column)
84 | # limit and pagination have to be done after order_by
85 | if "limit" in args:
86 | limit = args["limit"]
87 | res_query = res_query.limit(limit)
88 |
89 | return res_query
90 |
91 |
92 | # Filter operators defined on SQLAlchemy ColumnElement
93 | SQLA_OPERATORS = {
94 | "like": "like",
95 | "notlike": "notlike",
96 | "ilike": "ilike",
97 | "notilike": "notilike",
98 | "is": "is_",
99 | "isnot": "isnot",
100 | "match": "match",
101 | "startswith": "startswith",
102 | "endswith": "endswith",
103 | "contains": "contains",
104 | "in": "in_",
105 | "notin": "notin_",
106 | "between": "between",
107 | "eq": "__eq__",
108 | "ne": "__ne__",
109 | "gt": "__gt__",
110 | "ge": "__ge__",
111 | "lt": "__lt__",
112 | "le": "__le__",
113 | }
114 |
115 |
116 | def parse_value(value, serializer):
117 | if not serializer:
118 | return value
119 | if isinstance(value, list):
120 | return [serializer.load(item) for item in value]
121 | return serializer.load(value)
122 |
123 |
124 | def get_operator(column, op_name, value, serializer):
125 | """
126 | :param column:
127 | SQLAlchemy ColumnElement
128 |
129 | :param op_name:
130 | Key of OPERATORS or COMPARE_OPERATORS
131 |
132 | :param value:
133 | value to be applied to the operator
134 |
135 | :rtype: ColumnOperators
136 | :return:
137 | returns a boolean, comparison, and other operators for ColumnElement expressions.
138 | ref: http://docs.sqlalchemy.org/en/latest/core/sqlelement.html#sqlalchemy.sql.operators.ColumnOperators
139 | """
140 | if not op_name:
141 | return column.operate(operator.eq, parse_value(value, serializer))
142 | elif op_name in SQLA_OPERATORS:
143 | op = SQLA_OPERATORS.get(op_name)
144 | if op == "between":
145 | return column.between(
146 | parse_value(value[0], serializer), parse_value(value[1], serializer)
147 | )
148 | return getattr(column, op)(parse_value(value, serializer))
149 | else:
150 | raise ValueError(f"Unknown operator {op_name}")
151 |
152 |
153 | def get_field_serializer_or_none(serializer, field_name):
154 | field = serializer.fields.get(field_name)
155 | if not field:
156 | return None
157 | return field.serializer
158 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/resources/resources.py:
--------------------------------------------------------------------------------
1 | import warnings
2 |
3 | from flask import request, json, jsonify, Response
4 | from flask.views import MethodView
5 | from sqlalchemy.orm import load_only
6 | from sqlalchemy.orm.collections import InstrumentedList
7 |
8 | from flask_restalchemy.serialization import ModelSerializer
9 | from .querybuilder import create_collection_query
10 |
11 |
12 | class BaseResource(MethodView):
13 | """The Base class for resources
14 |
15 | :param dict|list request_decorators: a list of decorators
16 | """
17 |
18 | def __init__(self, request_decorators=None):
19 | if not request_decorators:
20 | return
21 | for verb, decorator_list in request_decorators.items():
22 | for decorator in decorator_list:
23 | if verb == "ALL":
24 | self.dispatch_request = decorator(self.dispatch_request)
25 | else:
26 | verb_method_name = verb.lower()
27 | decorated_method = decorator(getattr(self, verb_method_name))
28 | setattr(self, verb_method_name, decorated_method)
29 |
30 | def dispatch_request(self, *args, **kwargs):
31 | view_response = super().dispatch_request(*args, **kwargs)
32 | data, code, header = unpack(view_response)
33 | if isinstance(data, Response):
34 | return data
35 | elif isinstance(data, str):
36 | return data, code, header
37 | else:
38 | return jsonify(data), code, header
39 |
40 |
41 | class ViewFunctionResource(BaseResource):
42 |
43 | """
44 | Class created to provide url rules for free functions.
45 |
46 | :param callable func: function to be called
47 |
48 | :param dict request_decorators: dictionary of decorators for the function
49 | """
50 |
51 | def __init__(self, func, request_decorators=None):
52 | super().__init__(request_decorators)
53 | self.func = func
54 |
55 | def get(self, *args, **kwargs):
56 | return self.func(*args, **kwargs)
57 |
58 | def post(self, *args, **kwargs):
59 | return self.func(*args, **kwargs)
60 |
61 | def put(self, *args, **kwargs):
62 | return self.func(*args, **kwargs)
63 |
64 | def delete(self, *args, **kwargs):
65 | return self.func(*args, **kwargs)
66 |
67 |
68 | class BaseModelResource(BaseResource):
69 | """The Base class for ORM resources
70 |
71 | :param class declarative_model: the SQLAlchemy declarative class.
72 |
73 | :param ModelSerializer serializer: schema for serialization. If `None`, a default serializer will be created.
74 |
75 | :param callable session_getter: a callable that returns the DB session. A callable is used since a reference to
76 | DB session may not be available on the resource initialization.
77 |
78 | :param callable query_modifier: function that returns a query and expects a `model` as parameter that
79 | should be used to create the query and expects a `parent_query` to be incremented with the callback query
80 | function. The method signature should look like this: query_callback(parent_query, resource_model)
81 |
82 | :param dict|list request_decorators: a list of decorators
83 | """
84 |
85 | def __init__(
86 | self,
87 | declarative_model,
88 | serializer,
89 | session_getter,
90 | query_modifier=None,
91 | request_decorators=None,
92 | ):
93 | """Constructor
94 | """
95 |
96 | super().__init__(request_decorators)
97 | self._resource_model = declarative_model
98 | self._serializer = serializer
99 | self._serializer.strict = True
100 | assert isinstance(
101 | self._serializer, ModelSerializer
102 | ), f"Invalid serializer instance: {serializer}"
103 | self._session_getter = session_getter
104 | self._query_modifier = query_modifier
105 |
106 | def _save_model(self, model):
107 | session = self._session_getter()
108 | session.add(model)
109 | session.commit()
110 |
111 | def _save_serialized(self, serialized_data, existing_model=None):
112 | model = self._serializer.load(
113 | serialized_data, existing_model, self._session_getter()
114 | )
115 | self._save_model(model)
116 | return self._serializer.dump(model)
117 |
118 | @property
119 | def _db_session(self):
120 | return self._session_getter()
121 |
122 |
123 | class ModelResource(BaseModelResource):
124 | def get(self, id=None):
125 | if id is not None:
126 | model = self._resource_model.query.get(id)
127 | if model is None:
128 | return NOT_FOUND_ERROR, 404
129 | return self._serializer.dump(model)
130 | else:
131 | query = self._resource_model.query
132 | if self._query_modifier:
133 | query = self._query_modifier(query, self._resource_model)
134 | query = create_collection_query(
135 | query, self._resource_model, self._serializer, request.args
136 | )
137 |
138 | return create_response_from_query(query, self._serializer)
139 |
140 | def post(self):
141 | serialized = load_request_json()
142 | saved = self._save_serialized(serialized)
143 | return saved, 201
144 |
145 | def put(self, id):
146 | model = self._resource_model.query.get(id)
147 | if model is None:
148 | return NOT_FOUND_ERROR, 404
149 |
150 | serialized = self._serializer.dump(model)
151 | request_data = load_request_json()
152 | serialized.update(request_data)
153 | result = self._save_serialized(serialized, existing_model=model)
154 | return result
155 |
156 | def delete(self, id):
157 | model = self._resource_model.query.get(id)
158 | if model is None:
159 | return NOT_FOUND_ERROR, 404
160 | session = self._db_session
161 | session.delete(model)
162 | session.flush()
163 | session.commit()
164 | return "", 204
165 |
166 |
167 | class ToManyRelationResource(BaseModelResource):
168 | """Resource class that receives an SQLAlchemy relationship define the API to provide
169 | LIST and CREATE over data of the child model associated with a specific
170 | element of the parent model.
171 |
172 | :param relationship relation_property: the SQLAlchemy relationship.
173 |
174 | :param ModelSerializer serializer: schema for serialization. If `None`, a default serializer will be created.
175 |
176 | :param callable session_getter: a callable that returns the DB session. A callable is used since a reference to
177 | DB session may not be available on the resource initialization.
178 |
179 | :param callable query_modifier: function that returns a query and expects a `model` as parameter that
180 | should be used to create the query and expects a `parent_query` to be incremented with the callback query
181 | function. The method signature should look like this: query_callback(parent_query, resource_model)
182 |
183 | :param dict|list request_decorators: a list of decorators
184 | """
185 |
186 | def __init__(
187 | self,
188 | relation_property,
189 | serializer,
190 | session_getter,
191 | query_modifier=None,
192 | request_decorators=None,
193 | ):
194 | """Constructor
195 | """
196 | resource_model = relation_property.prop.mapper.class_
197 | super().__init__(
198 | resource_model,
199 | serializer,
200 | session_getter,
201 | query_modifier=query_modifier,
202 | request_decorators=request_decorators,
203 | )
204 | self._relation_property = relation_property
205 | self._related_model = relation_property.class_
206 |
207 | def get(self, relation_id, id=None):
208 | if id:
209 | requested_obj = self._query_related_obj(relation_id, id)
210 | if not requested_obj:
211 | return NOT_FOUND_ERROR, 404
212 | return self._serializer.dump(requested_obj), 200
213 | else:
214 | session = self._db_session
215 | # using options(load_only('id')) avoid unintended subquerying, as all we want is
216 | # check if the element exists
217 | related_obj = (
218 | session.query(self._related_model)
219 | .options(load_only("id"))
220 | .get(relation_id)
221 | )
222 | if related_obj is None:
223 | return NOT_FOUND_ERROR, 404
224 |
225 | # TODO: Is there a more efficient way than using getattr?
226 | relation_list_or_query = getattr(related_obj, self._relation_property.key)
227 | if isinstance(relation_list_or_query, InstrumentedList) or not hasattr(
228 | relation_list_or_query, "paginate"
229 | ):
230 | warnings.warn(
231 | "Warnning: relationship does not support pagination nor filter."
232 | 'Use flask-sqlalchemy relationship with lazy="dynamic".'
233 | )
234 | collection = [
235 | self._serializer.dump(item) for item in relation_list_or_query
236 | ]
237 | else:
238 | query = relation_list_or_query
239 | if self._query_modifier:
240 | query = self._query_modifier(query, self._resource_model)
241 | query = create_collection_query(
242 | query, self._resource_model, self._serializer, request.args
243 | )
244 | collection = create_response_from_query(query, self._serializer)
245 | return collection
246 |
247 | def post(self, relation_id):
248 | session = self._db_session
249 | related_obj = session.query(self._related_model).get(relation_id)
250 | if not related_obj:
251 | return NOT_FOUND_ERROR, 404
252 | collection = getattr(related_obj, self._relation_property.key)
253 | data_dict = load_request_json()
254 | resource_id = data_dict.get("id", None)
255 |
256 | if resource_id is not None:
257 | model = session.query(self._resource_model).get(resource_id)
258 | if model is None:
259 | return NOT_FOUND_ERROR, 404
260 | status_code = 200
261 | else:
262 | model = self._serializer.load(data_dict, session=session)
263 | status_code = 201
264 | session.add(model)
265 | collection.append(model)
266 | self._save_model(model)
267 | saved = self._serializer.dump(model)
268 | return saved, status_code
269 |
270 | def put(self, relation_id, id):
271 | request_data = load_request_json()
272 | requested_obj = self._query_related_obj(relation_id, id)
273 | if not requested_obj:
274 | return NOT_FOUND_ERROR, 404
275 | serialized = self._serializer.dump(requested_obj)
276 | serialized.update(request_data)
277 | saved = self._save_serialized(serialized, requested_obj)
278 | return saved
279 |
280 | def delete(self, relation_id, id):
281 | session = self._db_session
282 | requested_obj = self._query_related_obj(relation_id, id)
283 | if not requested_obj:
284 | return NOT_FOUND_ERROR, 404
285 | related_obj = session.query(self._related_model).get(relation_id)
286 | collection = getattr(related_obj, self._relation_property.key)
287 | collection.remove(requested_obj)
288 | session.delete(requested_obj)
289 | session.commit()
290 | return "", 204
291 |
292 | def _query_related_obj(self, relation_id, id):
293 | """
294 | Query resource model by ID but also add the relationship as a query constrain.
295 |
296 | :param relation_id: id of the related model
297 | :param id: id of the model being required
298 | :return: model with 'id' that has a related model with 'related_id'
299 | """
300 |
301 | # This checks if there is a parent with the related child on its relation property
302 | related = (
303 | self._db_session.query(self._related_model)
304 | .options(load_only("id"))
305 | .filter(
306 | self._related_model.id == relation_id,
307 | self._relation_property.any(id=id),
308 | )
309 | .one_or_none()
310 | )
311 |
312 | if related is None:
313 | return None
314 |
315 | return self._db_session.query(self._resource_model).get(id)
316 |
317 |
318 | class CollectionPropertyResource(ToManyRelationResource):
319 | def __init__(
320 | self,
321 | declarative_model,
322 | related_model,
323 | property_name,
324 | serializer,
325 | session_getter,
326 | query_modifier=None,
327 | request_decorators=None,
328 | ):
329 | super(ToManyRelationResource, self).__init__(
330 | declarative_model,
331 | serializer,
332 | session_getter,
333 | query_modifier=query_modifier,
334 | request_decorators=request_decorators,
335 | )
336 | self._related_model = related_model
337 | self._property_name = property_name
338 |
339 | def get(self, relation_id, id=None):
340 | session = self._db_session
341 | related_obj = session.query(self._related_model).get(relation_id)
342 | if related_obj is None:
343 | return NOT_FOUND_ERROR, 404
344 | relation_list_or_query = getattr(related_obj, self._property_name)
345 | if isinstance(relation_list_or_query, InstrumentedList) or not hasattr(
346 | relation_list_or_query, "paginate"
347 | ):
348 | warnings.warn(
349 | "Warnning: property "
350 | + self._property_name
351 | + " does not support pagination nor filter."
352 | " Use flask-sqlalchemy and make your property return a query object"
353 | )
354 | collection = [
355 | self._serializer.dump(item) for item in relation_list_or_query
356 | ]
357 | else:
358 | query = relation_list_or_query
359 | if self._query_modifier:
360 | query = self._query_modifier(query, self._related_model)
361 | query = create_collection_query(
362 | query, self._resource_model, self._serializer, request.args
363 | )
364 | collection = create_response_from_query(query, self._serializer)
365 | return collection
366 |
367 | def post(self, relation_id):
368 | return "POST not allowed for property resources", 405
369 |
370 |
371 | def load_request_json():
372 | """
373 | Returns request data as dict.
374 |
375 | :rtype: dict
376 | """
377 | if request.data:
378 | return json.loads(request.data.decode("utf-8"))
379 | else:
380 | return request.form.to_dict()
381 |
382 |
383 | def unpack(value):
384 | """
385 | Return a three tuple of data, code, and headers
386 |
387 | :param value:
388 | :return:
389 | """
390 | if not isinstance(value, tuple):
391 | return value, 200, {}
392 |
393 | try:
394 | data, code, headers = value
395 | return data, code, headers
396 | except ValueError:
397 | pass
398 |
399 | try:
400 | data, code = value
401 | return data, code, {}
402 | except ValueError:
403 | pass
404 |
405 | return value, 200, {}
406 |
407 |
408 | def create_response_from_query(query, serializer):
409 | if "page" in request.args:
410 | data = query.paginate()
411 | return {
412 | "page": data.page,
413 | "per_page": data.per_page,
414 | "count": data.total,
415 | "results": [serializer.dump(item) for item in data.items],
416 | }
417 | else:
418 | data = query.all()
419 | return [serializer.dump(item) for item in data]
420 |
421 |
422 | NOT_FOUND_ERROR = "Resource not found in the database!"
423 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/serialization.py:
--------------------------------------------------------------------------------
1 | from serialchemy import (
2 | Field,
3 | PrimaryKeyField,
4 | NestedAttributesField,
5 | NestedModelField,
6 | NestedModelListField,
7 | )
8 | from serialchemy import ColumnSerializer, ModelSerializer
9 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ESSS/flask-restalchemy/2e0e8e6c3768590620f06330d0bc02dbe697d4cd/src/flask_restalchemy/tests/__init__.py
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/employer_serializer.py:
--------------------------------------------------------------------------------
1 | from flask_restalchemy.serialization import ModelSerializer, Field, NestedModelField
2 | from flask_restalchemy.tests.sample_model import Address
3 |
4 |
5 | class EmployeeSerializer(ModelSerializer):
6 |
7 | password = Field(load_only=True)
8 | created_at = Field(dump_only=True)
9 | company_name = Field(dump_only=True)
10 | address = NestedModelField(Address)
11 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/sample_model.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from flask_sqlalchemy import SQLAlchemy
4 | from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, select, Table
5 | from sqlalchemy.orm import column_property, object_session
6 | from sqlalchemy.ext.associationproxy import association_proxy
7 |
8 | db = SQLAlchemy()
9 | Base = db.Model
10 |
11 | relationship = db.relationship
12 |
13 |
14 | class Company(Base):
15 |
16 | __tablename__ = "Company"
17 |
18 | id = Column(Integer, primary_key=True)
19 | name = Column(String)
20 | location = Column(String)
21 | employees = relationship("Employee", lazy="dynamic")
22 |
23 |
24 | class Department(Base):
25 |
26 | __tablename__ = "Department"
27 |
28 | id = Column(Integer, primary_key=True)
29 | name = Column(String)
30 |
31 |
32 | class Address(Base):
33 |
34 | __tablename__ = "Address"
35 |
36 | id = Column(Integer, primary_key=True)
37 | street = Column(String)
38 | number = Column(String)
39 | zip = Column(String)
40 | city = Column(String)
41 | state = Column(String)
42 |
43 |
44 | class ContactType(Base):
45 |
46 | __tablename__ = "ContactType"
47 |
48 | id = Column(Integer, primary_key=True)
49 | label = Column(String(15))
50 |
51 |
52 | class Contact(Base):
53 |
54 | __tablename__ = "Contact"
55 |
56 | id = Column(Integer, primary_key=True)
57 | type = relationship(ContactType)
58 | type_id = Column(ForeignKey("ContactType.id"))
59 | value = Column(String)
60 | employee_id = Column(ForeignKey("Employee.id"))
61 |
62 |
63 | class Employee(Base):
64 |
65 | __tablename__ = "Employee"
66 |
67 | id = Column(Integer, primary_key=True)
68 | firstname = Column(String)
69 | lastname = Column(String)
70 | email = Column(String)
71 | admission = Column(DateTime, default=datetime(2000, 1, 1))
72 | company_id = Column(ForeignKey("Company.id"))
73 | company = relationship(Company, back_populates="employees")
74 | company_name = column_property(
75 | select([Company.name]).where(Company.id == company_id)
76 | )
77 | address_id = Column(ForeignKey("Address.id"))
78 | address = relationship(Address)
79 | city = association_proxy("address", "city")
80 | departments = relationship(
81 | "Department", secondary="employee_department", lazy="dynamic"
82 | )
83 | contacts = relationship(Contact, cascade="all, delete-orphan")
84 |
85 | password = Column(String)
86 | created_at = Column(DateTime, default=datetime(2000, 1, 2))
87 |
88 | @property
89 | def colleagues(self):
90 | return (
91 | object_session(self)
92 | .query(Employee)
93 | .filter(Employee.company_id == self.company_id)
94 | )
95 |
96 |
97 | employee_department = Table(
98 | "employee_department",
99 | Base.metadata,
100 | Column("employee_id", Integer, ForeignKey("Employee.id")),
101 | Column("department_id", Integer, ForeignKey("Department.id")),
102 | )
103 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_api.py:
--------------------------------------------------------------------------------
1 | import json
2 | from datetime import datetime
3 |
4 | import pytest
5 | from flask_restalchemy.serialization import (
6 | ModelSerializer,
7 | Field,
8 | NestedModelField,
9 | NestedModelListField,
10 | )
11 |
12 | from flask_restalchemy import Api
13 | from flask_restalchemy.tests.sample_model import (
14 | Employee,
15 | Company,
16 | Address,
17 | Contact,
18 | ContactType,
19 | )
20 | from flask_restalchemy.resources.resources import ViewFunctionResource
21 |
22 |
23 | class EmployeeSerializer(ModelSerializer):
24 |
25 | password = Field(load_only=True)
26 | created_at = Field(dump_only=True)
27 | company_name = Field(dump_only=True)
28 | address = NestedModelField(Address)
29 | contacts = NestedModelListField(Contact)
30 |
31 |
32 | @pytest.fixture(autouse=True)
33 | def sample_api(flask_app):
34 | api = Api(flask_app)
35 | api.add_model(Company)
36 | api.add_model(Company, view_name="alt_company")
37 | api.add_model(Employee, serializer_class=EmployeeSerializer)
38 |
39 |
40 | @pytest.fixture(autouse=True)
41 | def create_test_sample(db_session):
42 | contact_type1 = ContactType(label="Phone")
43 | contact_type2 = ContactType(label="Email")
44 |
45 | company = Company(id=5, name="Terrans")
46 | emp1 = Employee(id=1, firstname="Jim", lastname="Raynor", company=company)
47 | emp2 = Employee(id=2, firstname="Sarah", lastname="Kerrigan", company=company)
48 |
49 | addr1 = Address(street="5 Av", number="943", city="Tarsonis")
50 | emp1.address = addr1
51 |
52 | db_session.add(contact_type1)
53 | db_session.add(contact_type2)
54 | db_session.add(company)
55 | db_session.add(emp1)
56 | db_session.add(emp2)
57 | db_session.commit()
58 |
59 |
60 | def test_get(client, data_regression):
61 | resp = client.get("/employee/1")
62 | assert resp.status_code == 200
63 | serialized = resp.get_json()
64 | data_regression.check(serialized)
65 | assert "password" not in serialized
66 | resp = client.get("/employee/10239")
67 | assert resp.status_code == 404
68 |
69 |
70 | def test_get_collection(client, data_regression):
71 | resp = client.get("/employee")
72 | assert resp.status_code == 200
73 | serialized = resp.get_json()
74 | data_regression.check(serialized)
75 |
76 |
77 | def test_post(client):
78 | contacts = [
79 | {"type_id": 1, "value": "0000-0000"},
80 | {"type_id": 2, "value": "test@mail.co"},
81 | ]
82 | post_data = {
83 | "id": 3,
84 | "firstname": "Tychus",
85 | "lastname": "Findlay",
86 | "admission": "2002-02-02T00:00:00+0300",
87 | "contacts": contacts,
88 | }
89 | resp = client.post("/employee", data=json.dumps(post_data))
90 | assert resp.status_code == 201
91 | emp3 = Employee.query.get(3)
92 | contact1 = emp3.contacts[0]
93 | assert emp3.id == 3
94 | assert emp3.firstname == "Tychus"
95 | assert emp3.lastname == "Findlay"
96 | assert emp3.admission == datetime(2002, 2, 2)
97 | assert contact1
98 | assert contact1.value == "0000-0000"
99 |
100 |
101 | def test_post_default_serializer(client):
102 | resp = client.post("/company", data={"name": "Mangsk Corp"})
103 | assert resp.status_code == 201
104 |
105 |
106 | def test_put(client):
107 | resp = client.put("/employee/1", data={"firstname": "Jimmy"})
108 | assert resp.status_code == 200
109 | emp3 = Employee.query.get(1)
110 | assert emp3.firstname == "Jimmy"
111 |
112 |
113 | def test_alternative_url(client):
114 | resp = client.get("/alt_company/5")
115 | assert resp.status_code == 200
116 | data = resp.get_json()
117 | assert data["name"] == "Terrans"
118 |
119 |
120 | def test_url_rule(flask_app, client):
121 | def hello_world(name):
122 | return f"hello {name}"
123 |
124 | api = Api(flask_app)
125 | api.add_url_rule("/", "index", hello_world)
126 | resp = client.get("/raynor")
127 | assert resp.status_code == 200
128 | assert resp.data == b"hello raynor"
129 |
130 | resp = client.post("/kerrigan")
131 | assert resp.status_code == 200
132 | assert resp.data == b"hello kerrigan"
133 |
134 | resp = client.put("/artanis")
135 | assert resp.status_code == 200
136 | assert resp.data == b"hello artanis"
137 |
138 | resp = client.delete("/zeratul")
139 | assert resp.status_code == 200
140 | assert resp.data == b"hello zeratul"
141 |
142 |
143 | def test_route(flask_app, client):
144 |
145 | api = Api(flask_app)
146 |
147 | @api.route("/hello/")
148 | def hello_world(name):
149 | return f"hello {name}"
150 |
151 | resp = client.get("/hello/raynor")
152 | assert resp.status_code == 200
153 | assert resp.data == b"hello raynor"
154 |
155 | resp = client.post("/hello/kerrigan")
156 | assert resp.status_code == 200
157 | assert resp.data == b"hello kerrigan"
158 |
159 |
160 | @pytest.mark.parametrize(
161 | "methods",
162 | [
163 | ["GET_COLLECTION", "GET", "POST", "PUT", "DELETE"],
164 | ["GET_COLLECTION"],
165 | ["POST"],
166 | ["GET_COLLECTION", "POST"],
167 | ["PUT", "DELETE"],
168 | ],
169 | )
170 | def test_http_verbs(flask_app, client, methods):
171 | def ping(*args, **kwargs):
172 | return "ping"
173 |
174 | api = Api(flask_app)
175 | api.add_resource(
176 | ViewFunctionResource,
177 | "/ping",
178 | "ping",
179 | resource_init_args=(ping,),
180 | methods=methods,
181 | )
182 | resp = client.get("/ping")
183 | assert resp.status_code == 200 if "GET_COLLECTION" in methods else 405
184 | resp = client.get("/ping/1")
185 | assert resp.status_code == 200 if "GET" in methods else 405
186 | resp = client.post("/ping")
187 | assert resp.status_code == 200 if "POST" in methods else 405
188 | resp = client.delete("/ping/1")
189 | assert resp.status_code == 200 if "PUT" in methods else 405
190 | resp = client.put("/ping/1")
191 | assert resp.status_code == 200 if "DELETE" in methods else 405
192 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_api/test_get.yml:
--------------------------------------------------------------------------------
1 | address:
2 | city: Tarsonis
3 | id: 1
4 | number: '943'
5 | state: null
6 | street: 5 Av
7 | zip: null
8 | address_id: 1
9 | admission: '2000-01-01T00:00:00'
10 | company_id: 5
11 | company_name: Terrans
12 | contacts: []
13 | created_at: '2000-01-02T00:00:00'
14 | email: null
15 | firstname: Jim
16 | id: 1
17 | lastname: Raynor
18 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_api/test_get_collection.yml:
--------------------------------------------------------------------------------
1 | - address:
2 | city: Tarsonis
3 | id: 1
4 | number: '943'
5 | state: null
6 | street: 5 Av
7 | zip: null
8 | address_id: 1
9 | admission: '2000-01-01T00:00:00'
10 | company_id: 5
11 | company_name: Terrans
12 | contacts: []
13 | created_at: '2000-01-02T00:00:00'
14 | email: null
15 | firstname: Jim
16 | id: 1
17 | lastname: Raynor
18 | - address: null
19 | address_id: null
20 | admission: '2000-01-01T00:00:00'
21 | company_id: 5
22 | company_name: Terrans
23 | contacts: []
24 | created_at: '2000-01-02T00:00:00'
25 | email: null
26 | firstname: Sarah
27 | id: 2
28 | lastname: Kerrigan
29 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_api_processors.py:
--------------------------------------------------------------------------------
1 | import json
2 | from unittest.mock import call
3 |
4 | import pytest
5 |
6 | from flask_restalchemy import Api
7 | from flask_restalchemy.decorators.request_hooks import before_request, after_request
8 | from flask_restalchemy.tests.sample_model import Employee, Company, Address
9 |
10 |
11 | @pytest.fixture
12 | def sample_api(flask_app):
13 | return Api(flask_app)
14 |
15 |
16 | @pytest.fixture(autouse=True)
17 | def create_test_sample(db_session):
18 | company = Company(id=5, name="Terrans")
19 | emp1 = Employee(id=1, firstname="Jim", lastname="Raynor", company=company)
20 |
21 | addr1 = Address(street="5 Av", number="943", city="Tarsonis")
22 | emp1.address = addr1
23 |
24 | db_session.add(company)
25 | db_session.add(emp1)
26 | db_session.commit()
27 |
28 |
29 | @pytest.mark.parametrize("decorator_verb", ["ALL", "GET"])
30 | def test_get_item_preprocessor(sample_api, client, mocker, decorator_verb):
31 | pre_processor_mock = mocker.Mock(return_value=None)
32 | sample_api.add_model(
33 | Employee,
34 | request_decorators={decorator_verb: before_request(pre_processor_mock)},
35 | )
36 |
37 | resp = client.get("/employee/1")
38 | assert resp.status_code == 200
39 | pre_processor_mock.assert_called_once_with(id=1)
40 | resp = client.post("/employee", data=json.dumps({"firstname": "Jeff"}))
41 | assert resp.status_code == 201
42 | # 2 calls if all verbs were decorated, otherwise test only for GET call
43 | assert pre_processor_mock.call_count == 2 if decorator_verb == "all" else 1
44 |
45 |
46 | def test_get_collection_preprocessor(sample_api, client, mocker):
47 | pre_processor_mock = mocker.Mock(return_value=None)
48 | sample_api.add_model(
49 | Employee, request_decorators=before_request(pre_processor_mock)
50 | )
51 |
52 | resp = client.get("/employee")
53 | assert resp.status_code == 200
54 | assert pre_processor_mock.call_args == call(id=None)
55 |
56 | resp = client.post("/employee", data=json.dumps({"firstname": "Jeff"}))
57 | assert resp.status_code == 201
58 | assert pre_processor_mock.call_args == call()
59 |
60 | resp = client.put("/employee/1", data=json.dumps({"lastname": "R."}))
61 | assert resp.status_code == 200
62 | assert pre_processor_mock.call_args == call(id=1)
63 |
64 | assert pre_processor_mock.call_count == 3
65 |
66 |
67 | def test_post_processors(sample_api, client, mocker):
68 | pre_mock = mocker.Mock(return_value=None)
69 | post_mock = mocker.Mock(return_value=None)
70 | sample_api.add_model(
71 | Employee,
72 | request_decorators={
73 | "ALL": after_request(post_mock),
74 | "POST": before_request(pre_mock),
75 | },
76 | )
77 |
78 | data = {"firstname": "Ana", "lastname": "Queen"}
79 | resp = client.post("/employee", data=json.dumps(data))
80 | assert resp.status_code == 201
81 | assert pre_mock.call_count == 1
82 |
83 | employee_id = resp.get_json()["id"]
84 | assert employee_id
85 | assert post_mock.call_count == 1
86 | post_mock_args = post_mock.call_args[0]
87 | assert post_mock_args[0][1] == 201
88 | assert post_mock_args[0][0].data == resp.data
89 |
90 |
91 | def test_put_preprocessors(sample_api, client, mocker):
92 | pre_mock = mocker.Mock(return_value=None)
93 | post_mock = mocker.Mock(return_value=None)
94 | sample_api.add_model(
95 | Employee,
96 | request_decorators={
97 | "PUT": [before_request(pre_mock), after_request(post_mock)]
98 | },
99 | )
100 |
101 | data = {"firstname": "Ana", "lastname": "Queen"}
102 | resp = client.put("/employee/1", data=json.dumps(data))
103 | assert resp.status_code == 200
104 | assert pre_mock.call_count == 1
105 | assert pre_mock.call_args == call(id=1)
106 |
107 | assert post_mock.call_count == 1
108 |
109 |
110 | def test_delete_preprocessors(sample_api, client, mocker):
111 | pre_mock = mocker.Mock(return_value=None)
112 | post_mock = mocker.Mock(return_value=None)
113 | sample_api.add_model(
114 | Employee,
115 | request_decorators={
116 | "DELETE": [before_request(pre_mock), after_request(post_mock)]
117 | },
118 | )
119 |
120 | resp = client.delete("/employee/1")
121 | assert resp.status_code == 204
122 | assert pre_mock.call_args == call(id=1)
123 | assert post_mock.call_args == call(("", 204), id=1)
124 |
125 |
126 | def test_property_get_collection_processor(sample_api, client, mocker):
127 | pre_mock = mocker.Mock(return_value=None)
128 | sample_api.add_property(
129 | Employee,
130 | Employee,
131 | "colleagues",
132 | request_decorators={"GET": before_request(pre_mock)},
133 | )
134 |
135 | resp = client.get("/employee/1/colleagues")
136 | assert resp.status_code == 200
137 | pre_mock.assert_called_once_with(id=None, relation_id=1)
138 |
139 |
140 | def test_relation_get_item_preprocessor(sample_api, client, mocker):
141 | pre_mock = mocker.Mock(return_value=None)
142 | sample_api.add_relation(
143 | Company.employees, request_decorators={"GET": before_request(pre_mock)}
144 | )
145 |
146 | resp = client.get("/company/5/employees/1")
147 | assert resp.status_code == 200
148 | pre_mock.assert_called_once_with(relation_id=5, id=1)
149 |
150 |
151 | def test_relation_get_collection_preprocessor(sample_api, client, mocker):
152 | pre_mock = mocker.Mock(return_value=None)
153 | sample_api.add_relation(
154 | Company.employees, request_decorators={"GET": before_request(pre_mock)}
155 | )
156 |
157 | resp = client.get("/company/5/employees")
158 | assert resp.status_code == 200
159 | pre_mock.assert_called_once_with(relation_id=5, id=None)
160 |
161 |
162 | def test_relation_post_processors(sample_api, client, mocker):
163 | pre_mock = mocker.Mock(return_value=None)
164 | post_mock = mocker.Mock(return_value=None)
165 | sample_api.add_relation(
166 | Company.employees,
167 | request_decorators={
168 | "POST": [before_request(pre_mock), after_request(post_mock)]
169 | },
170 | )
171 |
172 | data = {"firstname": "Ana", "lastname": "Queen"}
173 | resp = client.post("/company/5/employees", data=json.dumps(data))
174 | assert resp.status_code == 201
175 | pre_mock.assert_called_once_with(relation_id=5)
176 | assert post_mock.call_count == 1
177 | assert post_mock.call_args[1] == {"relation_id": 5}
178 |
179 |
180 | def test_relation_put_preprocessors(sample_api, client, mocker):
181 | pre_mock = mocker.Mock(return_value=None)
182 | post_mock = mocker.Mock(return_value=None)
183 | sample_api.add_relation(
184 | Company.employees,
185 | request_decorators={
186 | "PUT": [before_request(pre_mock), after_request(post_mock)]
187 | },
188 | )
189 |
190 | data = {"firstname": "Ana", "lastname": "Queen"}
191 | resp = client.put("/company/5/employees/1", data=json.dumps(data))
192 | assert resp.status_code == 200
193 | assert pre_mock.call_args == call(relation_id=5, id=1)
194 | assert post_mock.call_count == 1
195 | assert post_mock.call_args[1] == {"relation_id": 5, "id": 1}
196 |
197 |
198 | def test_relation_delete_preprocessors(sample_api, client, mocker):
199 | pre_mock = mocker.Mock(return_value=None)
200 | post_mock = mocker.Mock(return_value=None)
201 | sample_api.add_relation(
202 | Company.employees,
203 | request_decorators={
204 | "DELETE": [before_request(pre_mock), after_request(post_mock)]
205 | },
206 | )
207 |
208 | resp = client.delete("/company/5/employees/1")
209 | assert resp.status_code == 204
210 | assert pre_mock.call_count == 1
211 | assert post_mock.call_count == 1
212 | assert post_mock.call_args[1] == {"relation_id": 5, "id": 1}
213 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_api_relations.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from flask import json
3 | from flask_restalchemy.serialization import ModelSerializer, Field, NestedModelField
4 |
5 | from flask_restalchemy import Api
6 | from flask_restalchemy.tests.sample_model import Employee, Company, Department, Address
7 |
8 |
9 | class EmployeeSerializer(ModelSerializer):
10 |
11 | password = Field(load_only=True)
12 | created_at = Field(dump_only=True)
13 | company_name = Field(dump_only=True)
14 | address = NestedModelField(Address)
15 |
16 |
17 | @pytest.fixture(autouse=True)
18 | def create_test_sample(db_session):
19 | protoss = Company(id=1, name="Protoss")
20 | terrans = Company(id=3, name="Terrans")
21 | raynor = Employee(id=9, firstname="Jim", lastname="Raynor", company=terrans)
22 | kerrigan = Employee(id=3, firstname="Sarah", lastname="Kerrigan", company=terrans)
23 | dept1 = Department(name="Marines")
24 | dept2 = Department(name="Heroes")
25 | raynor.departments.append(dept1)
26 | raynor.departments.append(dept2)
27 |
28 | db_session.add(protoss)
29 | db_session.add(terrans)
30 | db_session.add(dept1)
31 | db_session.add(dept2)
32 | db_session.add(raynor)
33 | db_session.add(kerrigan)
34 | db_session.commit()
35 |
36 |
37 | @pytest.fixture(autouse=True)
38 | def sample_api(flask_app):
39 | api = Api(flask_app)
40 | api.add_model(Company)
41 | api.add_model(Employee)
42 | api.add_relation(Company.employees, serializer_class=EmployeeSerializer)
43 | api.add_property(
44 | Employee, Employee, "colleagues", serializer_class=EmployeeSerializer
45 | )
46 | api.add_relation(Employee.departments)
47 | return api
48 |
49 |
50 | def test_get_collection(client, data_regression):
51 | resp = client.get("/company/3/employees")
52 | assert resp.status_code == 200
53 | assert resp.is_json
54 | data = resp.get_json()
55 | data_regression.check(data)
56 |
57 |
58 | def test_get_item(client, data_regression):
59 | resp = client.get("/company/3/employees/3")
60 | assert resp.status_code == 200
61 | assert resp.is_json
62 | data = resp.get_json()
63 | data_regression.check(data)
64 |
65 | assert client.get("/company/5/employees/999").status_code == 404
66 |
67 |
68 | def test_post_item(client):
69 | post_data = {
70 | "firstname": "Tychus",
71 | "lastname": "Findlay",
72 | "admission": "2002-02-02T00:00:00+0300",
73 | }
74 | resp = client.post("/company/3/employees", data=post_data)
75 | assert resp.status_code == 201
76 | assert resp.is_json
77 | saved_id = resp.get_json()["id"]
78 |
79 | new_employee = Employee.query.get(saved_id)
80 | assert new_employee.firstname == "Tychus"
81 | assert new_employee.company.id == 3
82 |
83 |
84 | def test_put_item(client):
85 | resp = client.put("/company/3/employees/3", data={"lastname": "K."})
86 | assert resp.status_code == 200
87 |
88 | sarah = Employee.query.filter(Employee.firstname == "Sarah").one()
89 | assert sarah.lastname == "K."
90 |
91 |
92 | def test_delete_item(client):
93 | company = Company.query.get(3)
94 | assert [emp.firstname for emp in company.employees] == ["Sarah", "Jim"]
95 |
96 | resp = client.delete("/company/3/employees/9")
97 | assert resp.status_code == 204
98 | assert [emp.firstname for emp in company.employees] == ["Sarah"]
99 |
100 | assert Employee.query.filter_by(firstname="Jim").first() is None
101 |
102 | assert client.delete("/company/5/employees/999").status_code == 404
103 |
104 |
105 | def test_post_append_existent(client):
106 | resp = client.post("/employee", data={"firstname": "Tychus", "lastname": "Findlay"})
107 | assert resp.status_code == 201
108 | data = resp.get_json()
109 | empl_id = data["id"]
110 | thychus = Employee.query.get(empl_id)
111 | assert thychus.company_name is None
112 |
113 | resp = client.post("/company/3/employees", data={"id": empl_id})
114 | assert resp.status_code == 200
115 |
116 | thychus = Employee.query.get(empl_id)
117 | assert thychus.company_name == "Terrans"
118 |
119 | resp = client.post("/company/3/employees", data={"id": 1000})
120 | assert resp.status_code == 404
121 |
122 |
123 | def test_property(client, data_regression):
124 | resp = client.get("/employee/9/colleagues")
125 | assert resp.status_code == 200
126 | response_data = resp.get_json()
127 | data_regression.check(response_data)
128 |
129 | resp = client.post("/employee/9/colleagues")
130 | assert resp.status_code == 405
131 |
132 |
133 | def test_property_pagination(client):
134 |
135 | for i in range(20):
136 | client.post("/company/3/employees", data={"firstname": f"Jimmy {i}"})
137 |
138 | response = client.get("/employee/9/colleagues?order_by=id&limit=5")
139 | assert response.status_code == 200
140 | response_data = response.get_json()
141 | assert len(response_data) == 5
142 | assert response_data[0]["firstname"] == "Sarah"
143 |
144 | response = client.get(
145 | "/employee/9/colleagues?filter={}".format(
146 | json.dumps({"firstname": {"eq": "Sarah"}})
147 | )
148 | )
149 | assert response.status_code == 200
150 | response_data = response.get_json()
151 | assert len(response_data) == 1
152 | assert "firstname" in response_data[0]
153 | assert response_data[0]["firstname"] == "Sarah"
154 |
155 | response = client.get("/employee/9/colleagues?page=1&per_page=10")
156 | assert response.status_code == 200
157 | response_data = response.get_json()
158 | assert len(response_data.get("results")) == 10
159 |
160 |
161 | def test_delete_on_relation_with_secondary(client):
162 | jim = Employee.query.get(9)
163 | assert jim is not None
164 | dep = jim.departments[0]
165 |
166 | sarah = Employee.query.get(3)
167 | assert jim is not None
168 | assert dep not in sarah.departments
169 |
170 | resp = client.get("/employee/3/departments")
171 | assert resp.status_code == 200
172 |
173 | resp = client.delete("/employee/3/departments/" + str(dep.id))
174 | assert resp.status_code == 404
175 |
176 | resp = client.delete("/employee/9/departments/" + str(dep.id))
177 | assert resp.status_code == 204
178 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_api_relations/test_get_collection.yml:
--------------------------------------------------------------------------------
1 | - address: null
2 | address_id: null
3 | admission: '2000-01-01T00:00:00'
4 | company_id: 3
5 | company_name: Terrans
6 | created_at: '2000-01-02T00:00:00'
7 | email: null
8 | firstname: Sarah
9 | id: 3
10 | lastname: Kerrigan
11 | - address: null
12 | address_id: null
13 | admission: '2000-01-01T00:00:00'
14 | company_id: 3
15 | company_name: Terrans
16 | created_at: '2000-01-02T00:00:00'
17 | email: null
18 | firstname: Jim
19 | id: 9
20 | lastname: Raynor
21 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_api_relations/test_get_item.yml:
--------------------------------------------------------------------------------
1 | address: null
2 | address_id: null
3 | admission: '2000-01-01T00:00:00'
4 | company_id: 3
5 | company_name: Terrans
6 | created_at: '2000-01-02T00:00:00'
7 | email: null
8 | firstname: Sarah
9 | id: 3
10 | lastname: Kerrigan
11 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_api_relations/test_property.yml:
--------------------------------------------------------------------------------
1 | - address: null
2 | address_id: null
3 | admission: '2000-01-01T00:00:00'
4 | company_id: 3
5 | company_name: Terrans
6 | created_at: '2000-01-02T00:00:00'
7 | email: null
8 | firstname: Sarah
9 | id: 3
10 | lastname: Kerrigan
11 | - address: null
12 | address_id: null
13 | admission: '2000-01-01T00:00:00'
14 | company_id: 3
15 | company_name: Terrans
16 | created_at: '2000-01-02T00:00:00'
17 | email: null
18 | firstname: Jim
19 | id: 9
20 | lastname: Raynor
21 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_blueprint.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from flask import Blueprint
3 | from flask_restalchemy import Api
4 | from flask_restalchemy.tests.sample_model import Company, Employee
5 |
6 |
7 | @pytest.fixture(autouse=True)
8 | def blueprint(flask_app):
9 | test_blueprint = Blueprint("test", __name__, url_prefix="/bp")
10 | api = Api(test_blueprint)
11 | api.add_model(Company)
12 | api.add_model(Employee)
13 | flask_app.register_blueprint(test_blueprint)
14 |
15 |
16 | def test_blueprint_api(client):
17 | resp = client.post("/bp/company", data={"name": "Mangsk Corp"})
18 | assert resp.status_code == 201
19 |
20 | resp = client.post("/bp/company", data={"name": "Mangsk Corp"})
21 | assert resp.status_code == 201
22 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_child_resource.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ESSS/flask-restalchemy/2e0e8e6c3768590620f06330d0bc02dbe697d4cd/src/flask_restalchemy/tests/test_child_resource.py
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_decorators.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from flask import request
4 | from werkzeug.exceptions import abort
5 |
6 | from flask_restalchemy import Api
7 | from flask_restalchemy.tests.sample_model import Address, Company
8 | from flask_restalchemy.resources.resources import ViewFunctionResource
9 |
10 |
11 | def auth_required(func):
12 | @wraps(func)
13 | def authenticate(*args, **kw):
14 | if not request.headers.get("auth"):
15 | abort(403)
16 | return func(*args, **kw)
17 |
18 | return authenticate
19 |
20 |
21 | def post_hook(func):
22 | @wraps(func)
23 | def authenticate(*args, **kw):
24 | response = func(*args, **kw)
25 | if isinstance(response, str):
26 | response = response + "post_hook"
27 | return response
28 |
29 | return authenticate
30 |
31 |
32 | def test_resource_decorators(client, flask_app):
33 | api = Api(flask_app)
34 | api.add_model(Company, request_decorators=[auth_required])
35 | api.add_model(Address)
36 |
37 | assert client.get("/company").status_code == 403
38 | assert client.post("/company", data={"name": "Terran"}).status_code == 403
39 | assert client.get("/address").status_code == 200
40 | assert client.post("/address", data={"street": "5 Av"}).status_code == 201
41 |
42 | resp = client.post(
43 | "/company", data={"id": 2, "name": "Protoss"}, headers={"auth": True}
44 | )
45 | assert resp.status_code == 201
46 | assert client.get("/company/2").status_code == 403
47 | assert client.get("/company/2", headers={"auth": True}).status_code == 200
48 |
49 |
50 | def test_api_decorators(client, flask_app):
51 | api = Api(flask_app, request_decorators=[auth_required])
52 | api.add_model(Company)
53 | api.add_model(Address, request_decorators=[post_hook])
54 | assert client.get("/company").status_code == 403
55 |
56 | assert (
57 | client.post(
58 | "/company", data={"name": "Terran"}, headers={"auth": True}
59 | ).status_code
60 | == 201
61 | )
62 | assert client.get("/company", headers={"auth": True}).status_code == 200
63 |
64 | response = client.post("/address", headers={"auth": True})
65 |
66 |
67 | def test_add_rule_decorators(client, flask_app):
68 | def hello_world():
69 | return "hello world"
70 |
71 | api = Api(flask_app, request_decorators=[auth_required])
72 | api.add_url_rule(
73 | "/", "index", hello_world, request_decorators={"POST": [post_hook]}
74 | )
75 | resp = client.get("/")
76 | assert resp.status_code == 403
77 | resp = client.post("/", headers={"auth": True})
78 | assert resp.status_code == 200
79 | assert resp.data == b"hello worldpost_hook"
80 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_query_callback.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from flask import json, request
3 | from flask_restalchemy.serialization import ModelSerializer, Field, NestedModelField
4 |
5 | from flask_restalchemy import Api
6 | from flask_restalchemy.tests.sample_model import (
7 | Employee,
8 | Company,
9 | Department,
10 | Address,
11 | employee_department,
12 | db,
13 | )
14 |
15 |
16 | class EmployeeSerializer(ModelSerializer):
17 |
18 | password = Field(load_only=True)
19 | created_at = Field(dump_only=True)
20 | company_name = Field(dump_only=True)
21 | address = NestedModelField(Address)
22 |
23 |
24 | @pytest.fixture(autouse=True)
25 | def create_test_sample(db_session):
26 | protoss = Company(id=1, name="Protoss")
27 | zerg = Company(id=2, name="Zerg")
28 | terrans = Company(id=3, name="Terrans")
29 |
30 | basic = Department(id=100, name="Basic")
31 | heroes = Department(id=200, name="Heroes")
32 |
33 | raynor = Employee(
34 | id=11,
35 | firstname="Jim",
36 | lastname="Raynor",
37 | company=terrans,
38 | departments=[basic, heroes],
39 | )
40 | marine = Employee(
41 | id=12, firstname="John", lastname="Doe", company=terrans, departments=[basic]
42 | )
43 | kerrigan = Employee(
44 | id=13,
45 | firstname="Sarah",
46 | lastname="Kerrigan",
47 | company=terrans,
48 | departments=[heroes],
49 | )
50 |
51 | tassadar = Employee(
52 | id=21,
53 | firstname="Tassadar",
54 | lastname="The Warrior",
55 | company=protoss,
56 | departments=[heroes],
57 | )
58 | zealot = Employee(
59 | id=22, firstname="Aiur", lastname="Zealot", company=protoss, departments=[basic]
60 | )
61 |
62 | zergling = Employee(
63 | id=31, firstname="Rush", lastname="Lings", company=zerg, departments=[basic]
64 | )
65 |
66 | db_session.add_all([protoss, zerg, terrans])
67 | db_session.add_all([basic, heroes])
68 | db_session.add_all([raynor, kerrigan, marine, tassadar, zealot, zergling])
69 | db_session.commit()
70 |
71 |
72 | @pytest.fixture()
73 | def sample_api(flask_app):
74 | api = Api(flask_app)
75 | api.add_model(Employee)
76 | return api
77 |
78 |
79 | def sample_relation_query(parent_query, model):
80 | """
81 | Query callback to get the collection of all Employee that has Department 'Basic'
82 | :param model:
83 | :param parent_query:
84 | :return:
85 | """
86 | subquery = (
87 | db.session.query(employee_department.c.employee_id)
88 | .filter(employee_department.c.department_id == 100)
89 | .subquery()
90 | )
91 | if parent_query:
92 | query = parent_query.filter(model.id.in_(subquery))
93 | else:
94 | query = model.query.filter(model.id.in_(subquery))
95 | return query
96 |
97 |
98 | def sample_model_query(parent_query, model):
99 | """
100 | Query callback to get the collection of all Companies only has 'Basic'
101 | :param model:
102 | :param parent_query:
103 | :return:
104 | """
105 | all_heroes_subquery = (
106 | db.session.query(employee_department.c.employee_id)
107 | .filter(employee_department.c.department_id == 200)
108 | .subquery()
109 | )
110 | all_companies_heroes_only_subquery = (
111 | db.session.query(Employee.company_id)
112 | .filter(Employee.id.in_(all_heroes_subquery))
113 | .subquery()
114 | )
115 | if parent_query:
116 | query = parent_query.filter(
117 | ~model.id.in_(all_companies_heroes_only_subquery)
118 | ) # ~ indicates NOT IN
119 | else:
120 | query = model.query.filter(~model.id.in_(all_companies_heroes_only_subquery))
121 | return query
122 |
123 |
124 | def sample_property_query(parent_query, model):
125 | """
126 | Query callback to get the collection of colleagues but not it self
127 | :param model:
128 | :param parent_query:
129 | :return:
130 | """
131 | self_id = request.view_args["relation_id"]
132 | if parent_query:
133 | query = parent_query.filter(model.id != self_id)
134 | else:
135 | query = model.query.filter_by(model.id != self_id)
136 | return query
137 |
138 |
139 | def test_get_collection_relation_terrans(client, sample_api, data_regression):
140 | sample_api.add_model(Company)
141 | sample_api.add_relation(
142 | Company.employees,
143 | serializer_class=EmployeeSerializer,
144 | query_modifier=sample_relation_query,
145 | )
146 | resp = client.get("/company/3/employees")
147 | assert resp.status_code == 200
148 | assert len(resp.json) == 2
149 | data = resp.get_json()
150 | data_regression.check(data)
151 |
152 |
153 | def test_get_collection_relation_protoss(client, sample_api, data_regression):
154 | sample_api.add_model(Company)
155 | sample_api.add_relation(
156 | Company.employees,
157 | serializer_class=EmployeeSerializer,
158 | query_modifier=sample_relation_query,
159 | )
160 | resp = client.get("/company/1/employees")
161 | assert resp.status_code == 200
162 | assert len(resp.json) == 1
163 | data = resp.get_json()
164 | data_regression.check(data)
165 |
166 |
167 | def test_get_collection_model(client, sample_api, data_regression):
168 | sample_api.add_model(Company, query_modifier=sample_model_query)
169 | resp = client.get("/company")
170 | assert resp.status_code == 200
171 | assert len(resp.json) == 1
172 | data = resp.get_json()
173 | data_regression.check(data)
174 |
175 |
176 | def test_get_collection_property(client, sample_api, data_regression):
177 | sample_api.add_property(
178 | Employee,
179 | Employee,
180 | "colleagues",
181 | serializer_class=EmployeeSerializer,
182 | query_modifier=sample_property_query,
183 | )
184 | resp = client.get("/employee/11/colleagues")
185 | assert resp.status_code == 200
186 | assert len(resp.json) == 2
187 | data = resp.get_json()
188 | data_regression.check(data)
189 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_query_callback/test_get_collection_model.yml:
--------------------------------------------------------------------------------
1 | - id: 2
2 | location: null
3 | name: Zerg
4 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_query_callback/test_get_collection_property.yml:
--------------------------------------------------------------------------------
1 | - address: null
2 | address_id: null
3 | admission: '2000-01-01T00:00:00'
4 | company_id: 3
5 | company_name: Terrans
6 | created_at: '2000-01-02T00:00:00'
7 | email: null
8 | firstname: John
9 | id: 12
10 | lastname: Doe
11 | - address: null
12 | address_id: null
13 | admission: '2000-01-01T00:00:00'
14 | company_id: 3
15 | company_name: Terrans
16 | created_at: '2000-01-02T00:00:00'
17 | email: null
18 | firstname: Sarah
19 | id: 13
20 | lastname: Kerrigan
21 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_query_callback/test_get_collection_relation_protoss.yml:
--------------------------------------------------------------------------------
1 | - address: null
2 | address_id: null
3 | admission: '2000-01-01T00:00:00'
4 | company_id: 1
5 | company_name: Protoss
6 | created_at: '2000-01-02T00:00:00'
7 | email: null
8 | firstname: Aiur
9 | id: 22
10 | lastname: Zealot
11 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_query_callback/test_get_collection_relation_terrans.yml:
--------------------------------------------------------------------------------
1 | - address: null
2 | address_id: null
3 | admission: '2000-01-01T00:00:00'
4 | company_id: 3
5 | company_name: Terrans
6 | created_at: '2000-01-02T00:00:00'
7 | email: null
8 | firstname: Jim
9 | id: 11
10 | lastname: Raynor
11 | - address: null
12 | address_id: null
13 | admission: '2000-01-01T00:00:00'
14 | company_id: 3
15 | company_name: Terrans
16 | created_at: '2000-01-02T00:00:00'
17 | email: null
18 | firstname: John
19 | id: 12
20 | lastname: Doe
21 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_querybuilder.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import pytest
4 |
5 | from flask_restalchemy import Api
6 | from flask_restalchemy.tests.employer_serializer import EmployeeSerializer
7 | from flask_restalchemy.tests.sample_model import Company, Employee, Address
8 |
9 |
10 | @pytest.fixture(autouse=True)
11 | def init_test_data(flask_app, db_session):
12 | for name, location in CLIENTS:
13 | company = Company(name=name, location=location)
14 | db_session.add(company)
15 |
16 | address = Address(street="5th Av.")
17 | emp1 = Employee(firstname="John", lastname="Doe", address=address)
18 | db_session.add(address)
19 | db_session.add(emp1)
20 | db_session.commit()
21 |
22 | api = Api(flask_app)
23 | api.add_model(Company)
24 | api.add_model(Employee, serializer_class=EmployeeSerializer)
25 | api.add_relation(Company.employees, serializer_class=EmployeeSerializer)
26 | return api
27 |
28 |
29 | def test_order(client):
30 | response = client.get("/company?order_by=name")
31 | data_list = response.get_json()
32 | assert data_list[0]["name"] == "abe"
33 | assert data_list[-1]["name"] == "Von"
34 |
35 | response = client.get("/company?order_by=-name")
36 | data_list = response.get_json()
37 | assert data_list[0]["name"] == "Von"
38 | assert data_list[1]["name"] == "vanessa"
39 |
40 |
41 | def test_order_by_relation(client, db_session):
42 | company = Company(name="Headquarters", location="Metropolis")
43 | db_session.add(company)
44 | db_session.flush()
45 |
46 | address_1 = Address(city="Gotham")
47 | address_2 = Address(city="Wakanda")
48 | address_3 = Address(city="Asgard")
49 | db_session.add(address_1)
50 | db_session.add(address_2)
51 | db_session.add(address_3)
52 | db_session.flush()
53 |
54 | employee_1 = Employee(firstname="Batman", company_id=company.id, address=address_1)
55 | employee_2 = Employee(firstname="Tchala", company_id=company.id, address=address_2)
56 | employee_3 = Employee(firstname="Thor", company_id=company.id, address=address_3)
57 |
58 | db_session.add(employee_1)
59 | db_session.add(employee_2)
60 | db_session.add(employee_3)
61 | db_session.commit()
62 |
63 | response = client.get(f"/company/{company.id}/employees")
64 | data_list = response.json
65 | assert len(data_list) == 3
66 | assert data_list[0]["address"]["city"] == "Gotham"
67 | assert data_list[1]["address"]["city"] == "Wakanda"
68 | assert data_list[2]["address"]["city"] == "Asgard"
69 |
70 | response = client.get(f"/company/{company.id}/employees?order_by=city")
71 | data_list = response.json
72 | assert data_list[0]["address"]["city"] == "Asgard"
73 | assert data_list[1]["address"]["city"] == "Gotham"
74 | assert data_list[2]["address"]["city"] == "Wakanda"
75 |
76 | response = client.get(f"/company/{company.id}/employees?order_by=-city")
77 | data_list = response.json
78 | assert data_list[0]["address"]["city"] == "Wakanda"
79 | assert data_list[1]["address"]["city"] == "Gotham"
80 | assert data_list[2]["address"]["city"] == "Asgard"
81 |
82 |
83 | def test_filter(client):
84 | response = client.get("/company")
85 | data_list = response.get_json()
86 | assert len(data_list) == 22
87 |
88 | response = client.get("/company?limit=5")
89 | data_list = response.get_json()
90 | assert len(data_list) == 5
91 |
92 | response = client.get('/company?filter={"name": "Alvin"}')
93 | data_list = response.get_json()
94 | assert len(data_list) == 1
95 |
96 | response = client.get('/company?filter={"name": {"eq": "Alvin"}}')
97 | data_list = response.get_json()
98 | assert len(data_list) == 1
99 |
100 | response = client.get(
101 | '/company?filter={"$or": {"name": "Alvin", "location": "pace"} }'
102 | )
103 | data_list = response.get_json()
104 | assert len(data_list) == 2
105 |
106 | # test if AND is the default Logical Operator
107 | response = client.get('/company?filter={"name": "Keren", "location": "vilify"}')
108 | data_list = response.get_json()
109 | assert len(data_list) == 1
110 |
111 | response = client.get('/company?filter={"name": {"in": ["Alvin", "Keren"]} }')
112 | data_list = response.get_json()
113 | assert len(data_list) == 2
114 |
115 | response = client.get('/company?filter={"name": {"endswith": "a"} }')
116 | data_list = response.get_json()
117 | assert len(data_list) == 7
118 |
119 | response = client.get('/company?limit=2&filter={"name": {"endswith": "a"} }')
120 | data_list = response.get_json()
121 | assert len(data_list) == 2
122 |
123 | response = client.get('/company?limit=2&filter={"name": {"startswith": "Co"} }')
124 | data_list = response.get_json()
125 | assert len(data_list) == 2
126 |
127 | with pytest.raises(ValueError, match="Unknown operator unknown_operator"):
128 | client.get(
129 | "/company?filter={}".format(
130 | json.dumps({"name": {"unknown_operator": "Terr"}})
131 | )
132 | )
133 |
134 |
135 | def test_pagination(client):
136 | response = client.get("/company?page=1&per_page=50")
137 | data_list = response.get_json()
138 | assert len(data_list.get("results")) == 22
139 |
140 | response = client.get("/company?page=1&per_page=5")
141 | data_list = response.get_json()
142 | assert len(data_list.get("results")) == 5
143 |
144 | response = client.get("/company?page=4&per_page=6")
145 | data_list = response.get_json()
146 | assert len(data_list.get("results")) == 4
147 |
148 |
149 | def test_relations_pagination(client):
150 | response = client.post("/company", data={"name": "Terrans 1"})
151 | assert response.status_code == 201
152 | company_id = response.get_json()["id"]
153 |
154 | for i in range(20):
155 | client.post(
156 | f"/company/{company_id}/employees", data={"firstname": f"Jimmy {i}"}
157 | )
158 |
159 | response = client.get(
160 | "/company/{}/employees?filter={}".format(
161 | company_id, json.dumps({"firstname": {"eq": "Jimmy 1"}})
162 | )
163 | )
164 | assert response.status_code == 200
165 | data_list = response.get_json()
166 | assert len(data_list) == 1
167 | assert "firstname" in data_list[0]
168 | assert data_list[0]["firstname"] == "Jimmy 1"
169 |
170 | response = client.get(f"/company/{company_id}/employees?page=1&per_page=5")
171 | assert response.status_code == 200
172 | data_list = response.get_json()
173 | assert len(data_list.get("results")) == 5
174 |
175 |
176 | CLIENTS = [
177 | ("Tyson", "syncretise"),
178 | ("Shandi", "pace"),
179 | ("Lurlene", "meteor"),
180 | ("Cornelius", "revengefulness"),
181 | ("Steven", "monosodium"),
182 | ("Von", "outswirl"),
183 | ("Lucille", "alcatraz"),
184 | ("Shawanda", "genotypic"),
185 | ("Erna", "preornamental"),
186 | ("Gonzalo", "unromanticized"),
187 | ("Glory", "cowtail"),
188 | ("Keren", "vilify"),
189 | ("Alvin", "genesis"),
190 | ("Melita", "numberless"),
191 | ("Lakia", "irretentive"),
192 | ("Joie", "peptonizer"),
193 | ("Jacqulyn", "underbidding"),
194 | ("Julius", "alcmene"),
195 | ("Coretta", "furcated"),
196 | ("Laverna", "mastaba"),
197 | ("abe", "abesee"),
198 | ("vanessa", "vans"),
199 | ]
200 |
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_simple_api.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ESSS/flask-restalchemy/2e0e8e6c3768590620f06330d0bc02dbe697d4cd/src/flask_restalchemy/tests/test_simple_api.py
--------------------------------------------------------------------------------
/src/flask_restalchemy/tests/test_swagger_spec_gen.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from flask_restalchemy.tests.sample_model import Employee
4 |
5 |
6 | @pytest.mark.parametrize(
7 | "method, expected_response_codes, expected_tag",
8 | [
9 | pytest.param("get", {"200", "400", "404"}, "Employee", marks=pytest.mark.xfail),
10 | pytest.param("put", {"204", "400", "404"}, "Employee", marks=pytest.mark.xfail),
11 | pytest.param(
12 | "delete", {"204", "400", "404"}, "Employee", marks=pytest.mark.xfail
13 | ),
14 | ],
15 | )
16 | def test_item_resource_spec_gen(method, expected_response_codes, expected_tag):
17 | serializer = EmployeeSerializer(Employee)
18 | resource = item_resource_factory(ItemResource, serializer)
19 | specs = getattr(resource, method).specs_dict
20 | assert specs["tags"] == [expected_tag]
21 | assert specs["responses"].keys() == expected_response_codes
22 |
23 |
24 | @pytest.mark.parametrize(
25 | "method, expected_response_codes, expected_tag",
26 | [
27 | pytest.param("get", {"200"}, "Employee", marks=pytest.mark.xfail),
28 | pytest.param("post", {"201", "405"}, "Employee", marks=pytest.mark.xfail),
29 | ],
30 | )
31 | def test_collection_resource_spec_gen(method, expected_response_codes, expected_tag):
32 | serializer = EmployeeSerializer(Employee)
33 | resource = collection_resource_factory(CollectionResource, serializer)
34 | specs = getattr(resource, method).specs_dict
35 | assert specs["tags"] == [expected_tag]
36 | assert specs["responses"].keys() == expected_response_codes
37 |
38 |
39 | @pytest.mark.xfail
40 | def test_schema():
41 | resource = item_resource_factory(ItemResource, EmployeeSerializer(Employee))
42 | specs_get_method = resource.get.specs_dict
43 | schema_props = specs_get_method["definitions"]["Employee"]["properties"]
44 | assert set(schema_props.keys()).issuperset({"created_at", "company_name"})
45 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py{36,37,38,39,310}-sqla{13,14}, linting, docs
3 | isolated_build = true
4 |
5 | [gh-actions]
6 | python =
7 | 3.6: py36
8 | 3.7: py37
9 | 3.8: py38
10 | 3.9: py39
11 | 3.10: py310
12 |
13 | [testenv]
14 | extras = testing
15 | commands =
16 | pytest --cov={envsitepackagesdir}/flask_restalchemy --cov-report=xml --pyargs flask_restalchemy
17 | deps =
18 | sqla13: sqlalchemy>=1.3,<1.4
19 | sqla14: sqlalchemy>=1.4,<2
20 |
21 | [testenv:docs]
22 | skipsdist = True
23 | usedevelop = True
24 | changedir = docs
25 | extras = docs
26 | commands =
27 | sphinx-build -W -b html . _build
28 |
29 |
30 | [testenv:linting]
31 | skip_install = True
32 | basepython = python3.7
33 | deps = pre-commit>=1.11.0
34 | commands = pre-commit run --all-files --show-diff-on-failure
35 |
--------------------------------------------------------------------------------