diff --git a/.github/workflows/minimal-test.yml b/.github/workflows/minimal-test.yml
index 021f871..b7cce05 100644
--- a/.github/workflows/minimal-test.yml
+++ b/.github/workflows/minimal-test.yml
@@ -7,22 +7,40 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: [3.7, 3.8, 3.9]
- os: [ubuntu-18.04]
+ python-version: ['3.8', '3.9', '3.10']
flask-version: [latest]
+ os: [ubuntu-20.04]
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-python@v2
+ - uses: actions/checkout@v3
+
+ - uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
+
- name: Install poetry
- run: pip install poetry
- - name: Force the chosen flask version
- run: poetry add flask==${{ matrix.flask-version }}
+ run: pip3 install poetry
+
+ - name: Force Python version (linux/macOS)
+ if: matrix.os != 'windows-latest'
+ # sed command for macOS: https://stackoverflow.com/a/44864004
+ run: sed -i.bak 's/python = "^3.6"/python = "~${{ matrix.python-version }}"/' pyproject.toml
+
+ - name: Force Python version (windows)
+ if: matrix.os == 'windows-latest'
+ run: (Get-Content pyproject.toml).replace('python = "^3.6"', 'python = "~${{ matrix.python-version }}"') | Set-Content pyproject.toml
+
+ - name: Force Flask version
+ run: poetry add Flask==${{ matrix.flask-version }}
+
- name: Install requirements
run: poetry install
+
+ - name: List installed package versions for manual inspection
+ run: poetry --version && poetry show
+
- name: Test package
run: poetry run test
+
- name: Test package
run: poetry run doctest
diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml
index 8be8680..8638450 100644
--- a/.github/workflows/publish-prerelease.yml
+++ b/.github/workflows/publish-prerelease.yml
@@ -9,22 +9,29 @@ jobs:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'nodeploy')"
steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-python@v2
+ - uses: actions/checkout@v3
+
+ - uses: actions/setup-python@v4
with:
python-version: 3.7
+
- name: Install poetry
run: pip install poetry
+
- name: Install requirements
run: poetry install
+
- name: Build package
run: poetry build
+
- name: Test package
run: poetry run pytest
+
- name: Check if already uploaded
id: check_pypi
run: poetry run check_pypi_prerelease
continue-on-error: true
+
- name: publish to pypi
run: poetry publish -u __token__ -p ${{ secrets.PYPI_TOKEN }}
if: steps.check_pypi.outcome == 'success'
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2da1d62..5c7ad68 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,7 +1,7 @@
name: Deploy release version to pypi
on:
push:
- branches:
+ branches:
- main
jobs:
@@ -11,19 +11,26 @@ jobs:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'nodeploy')"
steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-python@v2
+ - uses: actions/checkout@v3
+
+ - uses: actions/setup-python@v4
with:
python-version: 3.7
+
- name: Install poetry
run: pip install poetry
+
- name: Install requirements
run: poetry install
+
- name: Build package
run: poetry build
+
- name: Test package
run: poetry run pytest
+
- name: Check if already uploaded
run: poetry run check_pypi
+
- name: publish to pypi
run: poetry publish -u __token__ -p ${{ secrets.PYPI_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fda0012..e2a56d2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -3,26 +3,174 @@ on:
- pull_request
jobs:
+
testing:
strategy:
fail-fast: false
matrix:
- python-version: [3.6, 3.7, 3.8, 3.9]
- os: [ubuntu-18.04, macos-latest, windows-latest]
- flask-version: ["1.0", 1.1, "2.0", latest]
+ python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.x']
+ flask-version: ['1.0', '1.1', '2.0', '2.1', 'latest']
+ os: [ubuntu-20.04, macos-latest, windows-latest]
+ exclude:
+ # starting from Flask 2.1.0, python 3.6 is no longer supported:
+ - flask-version: '2.1'
+ python-version: '3.6'
+
+ - flask-version: 'latest'
+ python-version: '3.6'
+
+ # old versions of Flask no longer working with python >= 3.10:
+ - flask-version: '1.0'
+ python-version: '3.10'
+
+ - flask-version: '1.0'
+ python-version: '3.x'
+
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-python@v2
+ - uses: actions/checkout@v3
+
+ - uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- - name: Install poetry
- run: pip install poetry
- - name: Force the chosen flask version
- run: poetry add flask==${{ matrix.flask-version }}
- - name: Install requirements
- run: poetry install
+
+ - name: Install dev dependencies (python 3.6)
+ if: matrix.python-version == '3.6'
+ run: |
+ pip3 install poetry
+ poetry export --dev --without-hashes --format requirements.txt --output requirements-dev.txt
+ pip3 install -r requirements-dev.txt
+
+ - name: Install Flask ${{ matrix.flask-version }}
+ if: matrix.flask-version != 'latest'
+ run: pip3 install Flask==${{ matrix.flask-version }}
+
+ - name: Install latest Flask
+ if: matrix.flask-version == 'latest'
+ run: pip3 install Flask
+
+ - name: Install dev dependencies (python >= 3.7)
+ if: matrix.python-version != '3.6'
+ run: |
+ pip3 install poetry
+ poetry export --only dev --format requirements.txt --output requirements-dev.txt
+ pip3 install -r requirements-dev.txt
+
+ - name: Overwrite Flask dependencies for legacy install
+ if: matrix.flask-version < '2.0' && matrix.flask-version != 'latest'
+ run: |
+ pip3 install "Jinja2<3.0"
+ pip3 install "MarkupSafe<=2.0.1"
+ pip3 install "itsdangerous<=2.0.1"
+
+ - name: Overwrite Flask dependencies for legacy install (2)
+ if: matrix.flask-version < '2.1' && matrix.flask-version != 'latest'
+ run: pip3 install "werkzeug<=2.0.3"
+
+ - name: List installed package versions for manual inspection
+ run: python3 --version && pip3 list
+
- name: Run unit tests
- run: poetry run test
+ run: python3 -c "from run_tests import test; test()"
+
- name: Run doctests
- run: poetry run doctest
+ run: python3 -c "from run_tests import run_doctest; run_doctest()"
+
+ check_pip_install:
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.x']
+ flask-version: ['1.0', '1.1', '2.0', '2.1', 'latest']
+ os: [ubuntu-20.04, macos-latest, windows-latest]
+ exclude:
+ # starting from Flask 2.1.0, python 3.6 is no longer supported:
+ - flask-version: '2.1'
+ python-version: '3.6'
+
+ - flask-version: 'latest'
+ python-version: '3.6'
+
+ # old versions of Flask no longer working with python >= 3.10:
+ - flask-version: '1.0'
+ python-version: '3.10'
+
+ - flask-version: '1.0'
+ python-version: '3.x'
+
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v3
+
+ - uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dev dependencies (python 3.6)
+ # actually installs all dependencies (no --only flag in this version)
+ # so we run this without installing the target Flask version
+ if: matrix.python-version == '3.6'
+ run: |
+ pip3 install poetry
+ poetry export --dev --without-hashes --format requirements.txt --output requirements-dev.txt
+ pip3 install -r requirements-dev.txt
+
+ - name: Install Flask ${{ matrix.flask-version }}
+ if: matrix.flask-version != 'latest'
+ run: pip3 install Flask==${{ matrix.flask-version }}
+
+ - name: Install latest Flask
+ if: matrix.flask-version == 'latest'
+ run: pip3 install Flask
+
+ - name: Install current flask-selfdoc from GitHub (linux/macOS)
+ if: matrix.os != 'windows-latest'
+ run: |
+ git_url="${{ github.event.pull_request.head.repo.git_url }}@${{ github.event.pull_request.head.ref }}"
+ git_url="git+https${git_url:3}"
+ pip3 install ${git_url}
+
+ - name: Install current flask-selfdoc from GitHub (windows)
+ if: matrix.os == 'windows-latest'
+ run: |
+ $git_url = "${{ github.event.pull_request.head.repo.git_url }}@${{ github.event.pull_request.head.ref }}"
+ $git_url = $git_url.subString(3)
+ $git_url = "git+https$git_url"
+ pip3 install $git_url
+
+ - name: Install dev dependencies (python >= 3.7)
+ if: matrix.python-version != '3.6'
+ run: |
+ pip3 install poetry
+ poetry export --only dev --format requirements.txt --output requirements-dev.txt
+ pip3 install -r requirements-dev.txt
+
+ - name: Overwrite Flask dependencies for legacy install
+ if: matrix.flask-version < '2.0' && matrix.flask-version != 'latest'
+ run: |
+ pip3 install "Jinja2<3.0"
+ pip3 install "MarkupSafe<=2.0.1"
+ pip3 install "itsdangerous<=2.0.1"
+
+ - name: Overwrite Flask dependencies for legacy install (2)
+ if: matrix.flask-version < '2.1' && matrix.flask-version != 'latest'
+ run: pip3 install "werkzeug<=2.0.3"
+
+ - name: List installed package versions for manual inspection
+ run: python3 --version && pip3 list
+
+ - name: Check that Flask version did not change
+ # not implemented for windows yet
+ if: matrix.flask-version != 'latest' && matrix.os != 'windows-latest'
+ run: |
+ flask_version=$(pip3 show Flask | grep Version:)
+ echo "found Flask ${flask_version}"
+ if [[ $(pip3 show Flask | grep Version) == *"Version: ${{ matrix.flask-version }}"* ]]
+ then
+ echo "No reinstall of Flask was done 👍"
+ else
+ exit 1
+ fi
+
+ - name: Try importing flask_selfdoc
+ run: python3 -c "import flask_selfdoc"
\ No newline at end of file
diff --git a/examples/custom/blog.py b/examples/custom/blog.py
index e8d0fd1..c3934be 100644
--- a/examples/custom/blog.py
+++ b/examples/custom/blog.py
@@ -1,7 +1,7 @@
-from os import path
from json import dumps
-from flask import Flask, redirect, request, jsonify
+from flask import Flask, redirect, request
+from flask_selfdoc.autodoc import custom_jsonify
from flask_selfdoc import Autodoc
@@ -58,7 +58,7 @@ def get_post(id):
@app.route('/post', methods=["POST"])
@auto.doc(groups=['posts', 'private'],
- form_data=['title', 'content', 'authorid'])
+ form_data=['title', 'content', 'authorid'])
def post_post():
"""Create a new post."""
authorid = request.form.get('authorid', None)
@@ -84,7 +84,7 @@ def get_user(id):
@app.route('/users', methods=['POST'])
@auto.doc(groups=['users', 'private'],
- form_data=['username'])
+ form_data=['username'])
def post_user(id):
"""Creates a new user."""
User(request.form['username'])
@@ -111,7 +111,7 @@ def private_doc():
@app.route('/doc/json')
def public_doc_json():
- return jsonify(auto.generate())
+ return custom_jsonify(auto.generate(), indent=4, separators=(',', ': '))
if __name__ == '__main__':
diff --git a/examples/simple/blog.py b/examples/simple/blog.py
index 4c44ead..9ff14c8 100644
--- a/examples/simple/blog.py
+++ b/examples/simple/blog.py
@@ -1,6 +1,7 @@
from json import dumps
-from flask import Flask, redirect, request, jsonify
+from flask import Flask, redirect, request
+from flask_selfdoc.autodoc import custom_jsonify
from flask_selfdoc import Autodoc
@@ -128,12 +129,12 @@ def private_doc():
@app.route('/doc/json')
def public_doc_json():
- return jsonify(auto.generate())
+ return custom_jsonify(auto.generate(), indent=4, separators=(',', ': '))
@app.route('/doc/builtin_json')
def public_doc_builtin_json():
- return auto.json()
+ return auto.json(indent=2, separators=(',', ': '))
if __name__ == '__main__':
diff --git a/flask_selfdoc/autodoc.py b/flask_selfdoc/autodoc.py
index d0fb46f..95853ee 100644
--- a/flask_selfdoc/autodoc.py
+++ b/flask_selfdoc/autodoc.py
@@ -1,19 +1,39 @@
+import json
from operator import attrgetter, itemgetter
import os
import re
from collections import defaultdict
import sys
import inspect
+from typing import Optional, Tuple
from flask import current_app, render_template, render_template_string, jsonify
-from jinja2 import evalcontextfilter, Markup
from jinja2.exceptions import TemplateAssertionError
+try:
+ # Jinja2 < 3.1 (Flask <= 2.0 and python 3.6)
+ # https://jinja.palletsprojects.com/en/3.0.x/api/#jinja2.evalcontextfilter
+ from jinja2 import evalcontextfilter as pass_eval_context
+except ImportError:
+ # Jinja2 < 3.1 (Flask >= 2.0 and python <= 3.7)
+ from jinja2 import pass_eval_context
+
+try:
+ # Jinja2 < 3.1 (Flask <= 2.0 and python 3.6)
+ from jinja2 import Markup
+except ImportError:
+ # Jinja2 < 3.1 (Flask >= 2.0 and python <= 3.7)
+ from jinja2.utils import markupsafe
+ Markup = markupsafe.Markup
try:
- from flask import _app_ctx_stack as stack
+ from flask.globals import _cv_app
except ImportError:
- from flask import _request_ctx_stack as stack
+ _cv_app = None
+ try:
+ from flask import _app_ctx_stack as stack
+ except ImportError:
+ from flask import _request_ctx_stack as stack
if sys.version < '3':
@@ -22,6 +42,87 @@
get_function_code = attrgetter('__code__')
+def custom_jsonify(*args,
+ indent: Optional[int] = None,
+ separators: Optional[Tuple] = (',', ':'),
+ **kwargs):
+ response = jsonify(*args, **kwargs)
+ json_data = json.loads(response.data.decode('utf-8'))
+ json_string = json.dumps(json_data,
+ indent=indent,
+ separators=separators)
+ response.data = json_string.encode('utf-8')
+ return response
+
+
+def get_decorator_frame_info(frame) -> dict:
+ """
+ The way that the line number of a decorator is detected changed across
+ python versions:
+ - python <= 3.8:
+ stack()[1].lineno points to the line above the decorated function
+ => points to the closest decorator, not necessarily the one that did the
+ call to stack()
+ - python 3.9 and 3.10:
+ stack()[1].lineno points to the line of the decorated function
+ - python 3.11:
+ stack()[1].lineno points to the exact line of the decorator that did the
+ call to stack()
+
+ Example:
+
+ 1 |def call_stack_and_get_lineno():
+ 2 |
+ 3 | def decorator(func):
+ 4 | calling_frame = stack()[1]
+ 5 | print(calling_frame.lineno)
+ 6 | return func
+ 7 |
+ 8 | return decorator
+ 9 |
+ 10 |
+ 11 |@decorator1
+ 12 |@call_stack_and_get_lineno
+ 13 |@decorator2
+ 14 |def func():
+ 15 | pass
+
+ - python <= 3.8: will print line 13
+ - python 3.9 and 3.10: will print line 14 (desired behaviour)
+ - python 3.11: will print line 12
+
+ We adjust the found line number with some offset (by reading the python
+ source file) if required.
+ """
+ line_number = frame.lineno
+ try:
+ with open(frame.filename, 'r') as python_file:
+ python_lines = python_file.readlines()
+ # current line + next ones
+ context_lines = python_lines[line_number - 1:]
+ except (OSError, FileNotFoundError):
+ print("You're probably using flask_selfdoc with compiled python code "
+ "- prefer uncompiled source files to extract correct filenames "
+ "and line numbers.")
+ # not 100% correct solution, won't work for multiline decorator
+ # or if there are decorators between @autodoc.doc() and the endpoint
+ # function
+ context_lines = frame.code_context
+
+ # if the detected line number doesn't point to a function definition,
+ # we iterate until we find one.
+ for line in context_lines:
+ if not line.strip().startswith('def '):
+ line_number += 1
+ else:
+ break
+
+ return {
+ 'filename': frame.filename,
+ 'line': line_number,
+ }
+
+
class Autodoc(object):
def __init__(self, app=None):
@@ -44,7 +145,10 @@ def init_app(self, app):
self.add_custom_template_filters(app)
def teardown(self, exception):
- ctx = stack.top # noqa: F841
+ if _cv_app is not None:
+ ctx = _cv_app.get(None) # noqa: F841
+ else:
+ ctx = stack.top # noqa: F841
def add_custom_template_filters(self, app):
"""Add custom filters to jinja2 templating engine"""
@@ -57,7 +161,7 @@ def add_custom_nl2br_filters(self, app):
_paragraph_re = re.compile(r'(?:\r\n|\r|\n){3,}')
@app.template_filter()
- @evalcontextfilter
+ @pass_eval_context
def nl2br(eval_ctx, value):
result = '\n\n'.join('%s' % p.replace('\n', Markup(' Create a new post.
\n'))
for p in _paragraph_re.split(value))
@@ -104,10 +208,7 @@ def decorator(f):
# Set location
if set_location:
caller_frame = inspect.stack()[1]
- self.func_locations[f] = {
- 'filename': caller_frame[1],
- 'line': caller_frame[2],
- }
+ self.func_locations[f] = get_decorator_frame_info(caller_frame)
return f
return decorator
@@ -153,7 +254,7 @@ def generate(self, groups='all', sort=None):
methods=sorted(list(rule.methods)),
rule="%s" % rule,
endpoint=rule.endpoint,
- docstring=func.__doc__,
+ docstring=func.__doc__.strip(' ') if func.__doc__ else None,
args=arguments,
defaults=rule.defaults or dict(),
location=location,
@@ -201,7 +302,10 @@ def html(self, groups='all', template=None, **context):
raise RuntimeError(
"Autodoc was not initialized with the Flask app.")
- def json(self, groups='all'):
+ def json(self,
+ groups='all',
+ indent: Optional[int] = None,
+ separators: Optional[Tuple] = (',', ':')):
"""Return a json object with documentation for all the routes specified
by the doc() method.
@@ -224,7 +328,7 @@ def endpoint_info(doc):
'endpoints':
[endpoint_info(doc) for doc in autodoc]
}
- return jsonify(data)
+ return custom_jsonify(data, indent=indent, separators=separators)
def sort_lexically(links):
diff --git a/pyproject.toml b/pyproject.toml
index ccc4250..9a849fe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,11 @@ license = "MIT"
[tool.poetry.dependencies]
python = "^3.6"
-Flask = "^1.0"
+Flask = [
+ {python = "~3.6", version = ">=1.0, <2.1"},
+ {python = ">=3.7, <3.10", version = ">=1.0"},
+ {python = "~3.10", version = ">=1.1"}
+]
[tool.poetry.dev-dependencies]
pytest = "^6.2.5"
diff --git a/run_tests.py b/run_tests.py
index f8f1d2f..6e22b27 100644
--- a/run_tests.py
+++ b/run_tests.py
@@ -1,6 +1,5 @@
import doctest
import logging
-import os
import subprocess
import unittest
diff --git a/tests/config.py b/tests/config.py
index 94f4ffa..0524c84 100644
--- a/tests/config.py
+++ b/tests/config.py
@@ -1,10 +1,3 @@
import os
-import sys
-
-# The way that the line number of a function is detected changed
-# The old version chooses the location of the first decorator,
-# the new version chooses the location of the 'def' keyword.
-# We detect the version and support both.
-NEW_FN_OFFSETS = sys.version_info >= (3, 8)
IS_WINDOWS = os.name == 'nt'
diff --git a/tests/files/builtin.json b/tests/files/builtin.json
index eb8ecf5..f1e6a3b 100644
--- a/tests/files/builtin.json
+++ b/tests/files/builtin.json
@@ -1 +1,126 @@
-{"endpoints":[{"args":[],"docstring":"Return all posts.","methods":["GET","HEAD","OPTIONS"],"rule":"/"},{"args":[],"docstring":"Admin interface.","methods":["GET","HEAD","OPTIONS"],"rule":"/admin"},{"args":[["greeting","Hello"],["id",null]],"docstring":"Return the user for the given id.","methods":["GET","HEAD","OPTIONS"],"rule":"/greet/
Form Data: title, content, authorid.
-
Creates a new user.
Form Data: username.
-