From c56ad2521f4df8de187bf099c28dd3c75961d14f Mon Sep 17 00:00:00 2001 From: ValentinFrancois <24223132+ValentinFrancois@users.noreply.github.com> Date: Fri, 5 May 2023 22:59:41 +0200 Subject: [PATCH 1/5] fix Jinja imports --- flask_selfdoc/autodoc.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/flask_selfdoc/autodoc.py b/flask_selfdoc/autodoc.py index d0fb46f..4568e5f 100644 --- a/flask_selfdoc/autodoc.py +++ b/flask_selfdoc/autodoc.py @@ -6,9 +6,23 @@ import inspect 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 @@ -57,7 +71,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('
\n')) for p in _paragraph_re.split(value)) From 688d3a08f9dc78250564c0150041c7bc565a3c7f Mon Sep 17 00:00:00 2001 From: ValentinFrancois <24223132+ValentinFrancois@users.noreply.github.com> Date: Fri, 5 May 2023 23:08:33 +0200 Subject: [PATCH 2/5] fix Flask compatibility --- flask_selfdoc/autodoc.py | 13 ++++++++++--- pyproject.toml | 6 +++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/flask_selfdoc/autodoc.py b/flask_selfdoc/autodoc.py index 4568e5f..e7c8939 100644 --- a/flask_selfdoc/autodoc.py +++ b/flask_selfdoc/autodoc.py @@ -25,9 +25,13 @@ 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': @@ -58,7 +62,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""" 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" From baa41e9f8c61dbdeb1ddc5bfb52c67764814961d Mon Sep 17 00:00:00 2001 From: ValentinFrancois <24223132+ValentinFrancois@users.noreply.github.com> Date: Fri, 5 May 2023 23:10:46 +0200 Subject: [PATCH 3/5] fix decorator line number --- flask_selfdoc/autodoc.py | 73 +++++++++++++++++++++++++++++++++++++--- tests/config.py | 7 ---- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/flask_selfdoc/autodoc.py b/flask_selfdoc/autodoc.py index e7c8939..fbb55e1 100644 --- a/flask_selfdoc/autodoc.py +++ b/flask_selfdoc/autodoc.py @@ -40,6 +40,74 @@ get_function_code = attrgetter('__code__') +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): @@ -125,10 +193,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 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' From e381d32e08e02b6e4b86cd94e856ab017ad7aacb Mon Sep 17 00:00:00 2001 From: ValentinFrancois <24223132+ValentinFrancois@users.noreply.github.com> Date: Fri, 5 May 2023 23:13:06 +0200 Subject: [PATCH 4/5] Add optional JSON doc formatting options and adapt unit tests --- examples/custom/blog.py | 10 +- examples/simple/blog.py | 7 +- flask_selfdoc/autodoc.py | 24 ++- run_tests.py | 1 - tests/files/builtin.json | 127 +++++++++++++- tests/files/custom.json | 302 ++++++++++++++++---------------- tests/files/simple.json | 186 +++++++++++++++++++- tests/files/simple_private.html | 4 +- tests/test_autodoc.py | 10 +- tests/test_check_example.py | 33 ++-- 10 files changed, 517 insertions(+), 187 deletions(-) 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 fbb55e1..95853ee 100644 --- a/flask_selfdoc/autodoc.py +++ b/flask_selfdoc/autodoc.py @@ -1,9 +1,11 @@ +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.exceptions import TemplateAssertionError @@ -40,6 +42,19 @@ 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 @@ -239,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, @@ -287,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. @@ -310,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/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/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//user/"},{"args":[["id",-1]],"docstring":"Return the user for the given id.","methods":["GET","HEAD","OPTIONS"],"rule":"/hello/user/"},{"args":[],"docstring":"Create a new post.\n Form Data: title, content, authorid.\n ","methods":["OPTIONS","POST"],"rule":"/post"},{"args":[["id",null]],"docstring":"Return the post for the given id.","methods":["GET","HEAD","OPTIONS"],"rule":"/post/"},{"args":[],"docstring":"Return all posts.","methods":["GET","HEAD","OPTIONS"],"rule":"/posts"},{"args":[["id",null]],"docstring":"Return the user for the given id.","methods":["GET","HEAD","OPTIONS"],"rule":"/user/"},{"args":[],"docstring":"Return all users.","methods":["GET","HEAD","OPTIONS"],"rule":"/users"},{"args":[],"docstring":"Creates a new user.\n Form Data: username.\n ","methods":["OPTIONS","POST"],"rule":"/users"}]} +{ + "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//user/" + }, + { + "args": [ + [ + "id", + -1 + ] + ], + "docstring": "Return the user for the given id.", + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/hello/user/" + }, + { + "args": [], + "docstring": "Create a new post.\n Form Data: title, content, authorid.\n", + "methods": [ + "OPTIONS", + "POST" + ], + "rule": "/post" + }, + { + "args": [ + [ + "id", + null + ] + ], + "docstring": "Return the post for the given id.", + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/post/" + }, + { + "args": [], + "docstring": "Return all posts.", + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/posts" + }, + { + "args": [ + [ + "id", + null + ] + ], + "docstring": "Return the user for the given id.", + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/user/" + }, + { + "args": [], + "docstring": "Return all users.", + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/users" + }, + { + "args": [], + "docstring": "Creates a new user.\n Form Data: username.\n", + "methods": [ + "OPTIONS", + "POST" + ], + "rule": "/users" + } + ] +} \ No newline at end of file diff --git a/tests/files/custom.json b/tests/files/custom.json index fbfd39f..28aae84 100644 --- a/tests/files/custom.json +++ b/tests/files/custom.json @@ -1,152 +1,152 @@ [ - { - "args": [ - "None" - ], - "defaults": {}, - "docstring": "Return all posts.", - "endpoint": "get_posts", - "location": { - "filename": "%PATH%/examples/custom/blog.py", - "line": 47 - }, - "methods": [ - "GET", - "HEAD", - "OPTIONS" - ], - "rule": "/" - }, - { - "args": [ - "None" - ], - "defaults": {}, - "docstring": "Admin interface.", - "endpoint": "admin", - "location": { - "filename": "%PATH%/examples/custom/blog.py", - "line": 96 - }, - "methods": [ - "GET", - "HEAD", - "OPTIONS" - ], - "rule": "/admin" - }, - { - "args": [ - "None" - ], - "defaults": {}, - "docstring": "Create a new post.", - "endpoint": "post_post", - "form_data": [ - "title", - "content", - "authorid" - ], - "location": { - "filename": "%PATH%/examples/custom/blog.py", - "line": 62 - }, - "methods": [ - "OPTIONS", - "POST" - ], - "rule": "/post" - }, - { - "args": [ - "id" - ], - "defaults": {}, - "docstring": "Return the post for the given id.", - "endpoint": "get_post", - "location": { - "filename": "%PATH%/examples/custom/blog.py", - "line": 54 - }, - "methods": [ - "GET", - "HEAD", - "OPTIONS" - ], - "rule": "/post/" - }, - { - "args": [ - "None" - ], - "defaults": {}, - "docstring": "Return all posts.", - "endpoint": "get_posts", - "location": { - "filename": "%PATH%/examples/custom/blog.py", - "line": 47 - }, - "methods": [ - "GET", - "HEAD", - "OPTIONS" - ], - "rule": "/posts" - }, - { - "args": [ - "id" - ], - "defaults": {}, - "docstring": "Return the user for the given id.", - "endpoint": "get_user", - "location": { - "filename": "%PATH%/examples/custom/blog.py", - "line": 80 - }, - "methods": [ - "GET", - "HEAD", - "OPTIONS" - ], - "rule": "/user/" - }, - { - "args": [ - "None" - ], - "defaults": {}, - "docstring": "Return all users.", - "endpoint": "get_users", - "location": { - "filename": "%PATH%/examples/custom/blog.py", - "line": 73 - }, - "methods": [ - "GET", - "HEAD", - "OPTIONS" - ], - "rule": "/users" - }, - { - "args": [ - "None" - ], - "defaults": {}, - "docstring": "Creates a new user.", - "endpoint": "post_user", - "form_data": [ - "username" - ], - "location": { - "filename": "%PATH%/examples/custom/blog.py", - "line": 88 - }, - "methods": [ - "OPTIONS", - "POST" - ], - "rule": "/users" - } -] + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Return all posts.", + "endpoint": "get_posts", + "location": { + "filename": "%PATH%/examples/custom/blog.py", + "line": 47 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Admin interface.", + "endpoint": "admin", + "location": { + "filename": "%PATH%/examples/custom/blog.py", + "line": 96 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/admin" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Create a new post.", + "endpoint": "post_post", + "form_data": [ + "title", + "content", + "authorid" + ], + "location": { + "filename": "%PATH%/examples/custom/blog.py", + "line": 62 + }, + "methods": [ + "OPTIONS", + "POST" + ], + "rule": "/post" + }, + { + "args": [ + "id" + ], + "defaults": {}, + "docstring": "Return the post for the given id.", + "endpoint": "get_post", + "location": { + "filename": "%PATH%/examples/custom/blog.py", + "line": 54 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/post/" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Return all posts.", + "endpoint": "get_posts", + "location": { + "filename": "%PATH%/examples/custom/blog.py", + "line": 47 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/posts" + }, + { + "args": [ + "id" + ], + "defaults": {}, + "docstring": "Return the user for the given id.", + "endpoint": "get_user", + "location": { + "filename": "%PATH%/examples/custom/blog.py", + "line": 80 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/user/" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Return all users.", + "endpoint": "get_users", + "location": { + "filename": "%PATH%/examples/custom/blog.py", + "line": 73 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/users" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Creates a new user.", + "endpoint": "post_user", + "form_data": [ + "username" + ], + "location": { + "filename": "%PATH%/examples/custom/blog.py", + "line": 88 + }, + "methods": [ + "OPTIONS", + "POST" + ], + "rule": "/users" + } +] \ No newline at end of file diff --git a/tests/files/simple.json b/tests/files/simple.json index b8bc216..70ad4cb 100644 --- a/tests/files/simple.json +++ b/tests/files/simple.json @@ -1 +1,185 @@ -[{"args":["None"],"defaults":{},"docstring":"Return all posts.","endpoint":"get_posts","location":{"filename":"%PATH%/examples/simple/blog.py","line":45},"methods":["GET","HEAD","OPTIONS"],"rule":"/"},{"args":["None"],"defaults":{},"docstring":"Admin interface.","endpoint":"admin","location":{"filename":"%PATH%/examples/simple/blog.py","line":113},"methods":["GET","HEAD","OPTIONS"],"rule":"/admin"},{"args":["greeting","id"],"defaults":{"greeting":"Hello"},"docstring":"Return the user for the given id.","endpoint":"greet_user","location":{"filename":"%PATH%/examples/simple/blog.py","line":96},"methods":["GET","HEAD","OPTIONS"],"rule":"/greet//user/"},{"args":["id"],"defaults":{"id":-1},"docstring":"Return the user for the given id.","endpoint":"hello_to_user","location":{"filename":"%PATH%/examples/simple/blog.py","line":86},"methods":["GET","HEAD","OPTIONS"],"rule":"/hello/user/"},{"args":["None"],"defaults":{},"docstring":"Create a new post.\n Form Data: title, content, authorid.\n ","endpoint":"post_post","location":{"filename":"%PATH%/examples/simple/blog.py","line":59},"methods":["OPTIONS","POST"],"rule":"/post"},{"args":["id"],"defaults":{},"docstring":"Return the post for the given id.","endpoint":"get_post","location":{"filename":"%PATH%/examples/simple/blog.py","line":52},"methods":["GET","HEAD","OPTIONS"],"rule":"/post/"},{"args":["None"],"defaults":{},"docstring":"Return all posts.","endpoint":"get_posts","location":{"filename":"%PATH%/examples/simple/blog.py","line":45},"methods":["GET","HEAD","OPTIONS"],"rule":"/posts"},{"args":["id"],"defaults":{},"docstring":"Return the user for the given id.","endpoint":"get_user","location":{"filename":"%PATH%/examples/simple/blog.py","line":79},"methods":["GET","HEAD","OPTIONS"],"rule":"/user/"},{"args":["None"],"defaults":{},"docstring":"Return all users.","endpoint":"get_users","location":{"filename":"%PATH%/examples/simple/blog.py","line":72},"methods":["GET","HEAD","OPTIONS"],"rule":"/users"},{"args":["None"],"defaults":{},"docstring":"Creates a new user.\n Form Data: username.\n ","endpoint":"post_user","location":{"filename":"%PATH%/examples/simple/blog.py","line":103},"methods":["OPTIONS","POST"],"rule":"/users"}] +[ + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Return all posts.", + "endpoint": "get_posts", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 46 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Admin interface.", + "endpoint": "admin", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 114 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/admin" + }, + { + "args": [ + "greeting", + "id" + ], + "defaults": { + "greeting": "Hello" + }, + "docstring": "Return the user for the given id.", + "endpoint": "greet_user", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 97 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/greet//user/" + }, + { + "args": [ + "id" + ], + "defaults": { + "id": -1 + }, + "docstring": "Return the user for the given id.", + "endpoint": "hello_to_user", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 87 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/hello/user/" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Create a new post.\n Form Data: title, content, authorid.\n", + "endpoint": "post_post", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 60 + }, + "methods": [ + "OPTIONS", + "POST" + ], + "rule": "/post" + }, + { + "args": [ + "id" + ], + "defaults": {}, + "docstring": "Return the post for the given id.", + "endpoint": "get_post", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 53 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/post/" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Return all posts.", + "endpoint": "get_posts", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 46 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/posts" + }, + { + "args": [ + "id" + ], + "defaults": {}, + "docstring": "Return the user for the given id.", + "endpoint": "get_user", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 80 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/user/" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Return all users.", + "endpoint": "get_users", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 73 + }, + "methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "rule": "/users" + }, + { + "args": [ + "None" + ], + "defaults": {}, + "docstring": "Creates a new user.\n Form Data: username.\n", + "endpoint": "post_user", + "location": { + "filename": "%PATH%/examples/simple/blog.py", + "line": 104 + }, + "methods": [ + "OPTIONS", + "POST" + ], + "rule": "/users" + } +] \ No newline at end of file diff --git a/tests/files/simple_private.html b/tests/files/simple_private.html index 9629dd9..18d5bc4 100644 --- a/tests/files/simple_private.html +++ b/tests/files/simple_private.html @@ -176,7 +176,7 @@

Create a new post.
Form Data: title, content, authorid.
-

+

@@ -304,7 +304,7 @@

Creates a new user.
Form Data: username.
-

+

diff --git a/tests/test_autodoc.py b/tests/test_autodoc.py index 8ade1c1..18c62a0 100644 --- a/tests/test_autodoc.py +++ b/tests/test_autodoc.py @@ -7,8 +7,6 @@ from flask import Flask from flask_selfdoc import Autodoc, Selfdoc -from tests.config import NEW_FN_OFFSETS - class TestAutodoc(unittest.TestCase): @@ -261,8 +259,8 @@ def ab(param1, param2): self.assertIn('Returns arguments', doc) def testLocation(self): - offset = 4 if NEW_FN_OFFSETS else 3 - line_no = inspect.stack()[0][2] + offset # the doc() line + offset_to_def = 4 + line_no = inspect.stack()[0].lineno + offset_to_def @self.app.route('/location') @self.autodoc.doc() @@ -277,8 +275,8 @@ def location(): self.assertIn(self.thisFile(), d['location']['filename']) def testLocationWithExtraDecorators(self): - offset = 13 if NEW_FN_OFFSETS else 12 - line_no = inspect.stack()[0][2] + offset # the doc() line + offset_to_def = 13 + line_no = inspect.stack()[0].lineno + offset_to_def def pointless_decorator(): def fn(f): diff --git a/tests/test_check_example.py b/tests/test_check_example.py index 757318e..ff53221 100644 --- a/tests/test_check_example.py +++ b/tests/test_check_example.py @@ -6,17 +6,20 @@ from examples.simple.blog import app as simple_app from tests.config import IS_WINDOWS -from tests.config import NEW_FN_OFFSETS # To regenerate the baseline data files, change this to True. REGENERATE_FILES = False -class TestApp(object): +class TestApp(unittest.TestCase): maxDiff = None + path = None + filename = None + app = None def setUp(self): - self.client = self.app.test_client() + if self.__class__ != TestApp: + self.client = self.app.test_client() def get_request(self): r = self.client.get(self.path) @@ -26,21 +29,23 @@ def get_request(self): def sub_pwd(self, data): file_path = os.getcwd() - return data.replace(file_path, "%PATH%") + return data.replace(file_path, "%PATH%") @unittest.skipIf(REGENERATE_FILES, "Regenerating the baseline files") - @unittest.skipIf(not NEW_FN_OFFSETS, "This test will not work with the old way of detecting fn line numbers") @unittest.skipIf(IS_WINDOWS, "This test will not work with Windows style filepaths") def test_output(self): + if self.__class__ == TestApp: + self.skipTest('base test class') data = self.get_request() + data = self.sub_pwd(data) with open(self.filename) as f: expected = f.read() - - data = self.sub_pwd(data) self.assertEqual(data, expected) @unittest.skipIf(not REGENERATE_FILES, "This is only run to regenerate the baseline.") def test_regenerate(self): + if self.__class__ == TestApp: + self.skipTest('base test class') data = self.get_request() data = self.sub_pwd(data) with open(self.filename, "w") as f: @@ -48,31 +53,31 @@ def test_regenerate(self): self.assertTrue(False, "This test always fails, change REGENERATE_FILES back to False to proceed.") -class TestSimpleApp(TestApp, unittest.TestCase): +class TestSimpleApp(TestApp): app = simple_app filename = "tests/files/simple.html" path = "/doc" -class TestSimpleAppJSONOutput(TestApp, unittest.TestCase): +class TestSimpleAppJSONOutput(TestApp): app = simple_app filename = "tests/files/simple.json" path = "/doc/json" -class TestSimpleAppBuiltinJSONOutput(TestApp, unittest.TestCase): +class TestSimpleAppBuiltinJSONOutput(TestApp): app = simple_app filename = "tests/files/builtin.json" path = "/doc/builtin_json" -class TestSimpleAppPrivateGroup(TestApp, unittest.TestCase): +class TestSimpleAppPrivateGroup(TestApp): app = simple_app filename = "tests/files/simple_private.html" path = "/doc/private" -class TestFactoryApp(TestApp, unittest.TestCase): +class TestFactoryApp(TestApp): filename = "tests/files/factory.html" path = "/doc/" @@ -83,13 +88,13 @@ def setUpClass(cls): cls.app = factory_create_app() -class TestCustomApp(TestApp, unittest.TestCase): +class TestCustomApp(TestApp): app = custom_app filename = "tests/files/custom.html" path = "/doc/" -class TestCustomAppJSONOutput(TestApp, unittest.TestCase): +class TestCustomAppJSONOutput(TestApp): app = custom_app filename = "tests/files/custom.json" path = "/doc/json" From d7f1317bb0851c46550b746edb33e690413c3833 Mon Sep 17 00:00:00 2001 From: ValentinFrancois <24223132+ValentinFrancois@users.noreply.github.com> Date: Fri, 5 May 2023 23:16:58 +0200 Subject: [PATCH 5/5] adapt CI actions - increase range of python and Flask versions tested - test correct install via pip --- .github/workflows/minimal-test.yml | 32 ++++- .github/workflows/publish-prerelease.yml | 11 +- .github/workflows/publish.yml | 13 +- .github/workflows/test.yml | 174 +++++++++++++++++++++-- 4 files changed, 205 insertions(+), 25 deletions(-) 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