From 112e530ef79649577586b9371b2470d36255874f Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Fri, 10 May 2019 08:03:42 -0700 Subject: [PATCH 1/4] Allow customization of docstring delimiter string --- docs/openapi.rst | 57 ++++++++++++++++++++++++++++++++ flask_rest_api/blueprint.py | 13 +++++--- flask_rest_api/utils.py | 21 +++++++----- tests/test_blueprint.py | 66 +++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 50 ++++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 13 deletions(-) diff --git a/docs/openapi.rst b/docs/openapi.rst index 2b1ecc70..09122a21 100644 --- a/docs/openapi.rst +++ b/docs/openapi.rst @@ -67,6 +67,63 @@ The example above produces the following documentation attributes: } } +The separator ``'---'`` can be customized by setting ``docstring_sep`` +when initializing the blueprint, or by subclassing ``Blueprint``: + +.. code-block:: python + + blp = Blueprint('foo', __name__, docstring_sep='~~~') + + def get(...): + """Find pets by ID + + Return pets based on ID. + ~~~ + Internal comment not meant to be exposed. + """ + + # This does the same thing: + class MyBlueprint('foo', __name__): + DOCSTRING_SEP = "~~~" + +Produces: + +.. code-block:: python + + { + 'get': { + 'summary': 'Find pets by ID', + 'description': 'Return pets based on ID', + } + } + +Setting ``docstring_sep`` to ``None`` will result in the entire docstring +being included in the `description`: + +.. code-block:: python + + blp = Blueprint('foo', __name__, docstring_sep=None) + + def get(...): + """Find pets by ID + + Return pets based on ID. + --- + Even this is included. + """ + +Produces: + +.. code-block:: python + + { + 'get': { + 'summary': 'Find pets by ID', + 'description': ('Return pets based on ID\n---\n' + 'Even this is included.'), + } + } + Document Operations Parameters and Responses ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/flask_rest_api/blueprint.py b/flask_rest_api/blueprint.py index 367a27b5..9cccb762 100644 --- a/flask_rest_api/blueprint.py +++ b/flask_rest_api/blueprint.py @@ -66,9 +66,12 @@ class Blueprint( "files": "multipart/form-data", } + DOCSTRING_SEP = "---" + def __init__(self, *args, **kwargs): self.description = kwargs.pop('description', '') + self.docstring_sep = kwargs.pop('docstring_sep', self.DOCSTRING_SEP) super().__init__(*args, **kwargs) @@ -133,13 +136,15 @@ def _store_endpoint_docs(self, endpoint, obj, parameters, **options): endpoint_manual_doc = self._manual_docs.setdefault( endpoint, OrderedDict()) - def store_method_docs(method, function): + def store_method_docs(method, function, sep='---'): """Add auto and manual doc to table for later registration""" # Get summary/description from docstring # and auto documentation from decorators # Get manual documentation from @doc decorator docstring = function.__doc__ - auto_doc = load_info_from_docstring(docstring) if docstring else {} + auto_doc = {} + if docstring: + auto_doc = load_info_from_docstring(docstring, sep=sep) auto_doc.update(getattr(function, '_apidoc', {})) manual_doc = getattr(function, '_api_manual_doc', {}) # Store function auto and manual docs for later registration @@ -152,12 +157,12 @@ def store_method_docs(method, function): for method in self.HTTP_METHODS: if method in obj.methods: func = getattr(obj, method.lower()) - store_method_docs(method, func) + store_method_docs(method, func, sep=self.docstring_sep) # Function else: methods = options.pop('methods', None) or ['GET'] for method in methods: - store_method_docs(method, obj) + store_method_docs(method, obj, sep=self.docstring_sep) endpoint_auto_doc['parameters'] = parameters diff --git a/flask_rest_api/utils.py b/flask_rest_api/utils.py index eca1f842..b27ff7b5 100644 --- a/flask_rest_api/utils.py +++ b/flask_rest_api/utils.py @@ -32,19 +32,22 @@ def get_appcontext(): return ctx.flask_rest_api -def load_info_from_docstring(docstring): +def load_info_from_docstring(docstring, sep="---"): """Load summary and description from docstring""" split_lines = trim_docstring(docstring).split('\n') - # Info is separated from rest of docstring by a '---' line - for index, line in enumerate(split_lines): - if line.lstrip().startswith('---'): - cut_at = index - break - else: - cut_at = index + 1 + split_info_lines = split_lines + + if sep is not None: + # Info is separated from rest of docstring by a `sep` line + for index, line in enumerate(split_lines): + if line.lstrip().startswith(sep): + cut_at = index + break + else: + cut_at = index + 1 - split_info_lines = split_lines[:cut_at] + split_info_lines = split_lines[:cut_at] # Description is separated from summary by an empty line for index, line in enumerate(split_info_lines): diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py index d344d054..5660da51 100644 --- a/tests/test_blueprint.py +++ b/tests/test_blueprint.py @@ -576,6 +576,72 @@ def view_func(): assert response.status_code == 200 assert response.json == {'Value': 'OK'} + def test_blueprint_custom_docstring_separator(self, app): + api = Api(app) + blp = Blueprint('test', __name__, url_prefix='/test', + docstring_sep="~~~") + + @blp.route('/', methods=['PUT']) + def view_func(): + """Summary + + Long description + ~~~ + Ignore + """ + return jsonify({'Value': 'OK'}) + + api.register_blueprint(blp) + spec = api.spec.to_dict() + path = spec['paths']['/test/'] + assert path['put']['description'] == 'Long description' + + def test_blueprint_custom_docstring_separator_none(self, app): + api = Api(app) + blp = Blueprint('test', __name__, url_prefix='/test', + docstring_sep=None) + + @blp.route('/', methods=['PUT']) + def view_func(): + """Summary + + Long description + ~~~ + Not Ignored + """ + return jsonify({'Value': 'OK'}) + + api.register_blueprint(blp) + spec = api.spec.to_dict() + path = spec['paths']['/test/'] + descr = path['put']['description'] + assert descr == 'Long description\n~~~\nNot Ignored' + + + def test_bluprint_custom_docstring_separator_subclassed(self, app): + + class MyBlueprint(Blueprint): + DOCSTRING_SEP = "~~~" + + api = Api(app) + blp = MyBlueprint('test', __name__, url_prefix='/test') + + @blp.route('/', methods=['PUT']) + def view_func(): + """Summary + + Long description + ~~~ + Ignore + """ + return jsonify({'Value': 'OK'}) + + api.register_blueprint(blp) + spec = api.spec.to_dict() + path = spec['paths']['/test/'] + assert path['put']['description'] == 'Long description' + + def test_blueprint_doc_method_view(self, app): api = Api(app) blp = Blueprint('test', __name__, url_prefix='/test') diff --git a/tests/test_utils.py b/tests/test_utils.py index 063395c4..c5dd2f7c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -55,3 +55,53 @@ def test_load_info_from_docstring(self): 'summary': 'Summary\nTwo-line summary is possible.', 'description': 'Long description\nReally long description' } + + # No separator + docstring = """Summary + + Long description + + Section + ------- + Also included + """ + assert load_info_from_docstring(docstring, sep=None) == { + 'summary': 'Summary', + 'description': ('Long description\n\n' + 'Section\n-------\nAlso included'), + } + + # Custom separator + docstring = """Summary + + Some description. + + Section + ------- + foo + + ~~~ + + Ignored. + """ + assert load_info_from_docstring(docstring, sep="~~~") == { + 'summary': 'Summary', + 'description': ('Some description.\n\n' + 'Section\n-------\n' + 'foo' + ), + } + + # Explicit Default separator + docstring = """Summary + + Some description. + + Section + ------- + Ignored + """ + assert load_info_from_docstring(docstring, sep="---") == { + 'summary': 'Summary', + 'description': 'Some description.\n\nSection', + } From 678aaaa5d64e2f84e0f5ff831d47f6990916bd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Fri, 13 Sep 2019 21:18:51 +0200 Subject: [PATCH 2/4] Rework custom docstring delimiter feature --- docs/openapi.rst | 60 ++----------------- flask_rest_api/blueprint.py | 25 ++++---- flask_rest_api/utils.py | 28 ++++----- tests/test_blueprint.py | 111 +++++++++--------------------------- tests/test_utils.py | 75 +++++++----------------- 5 files changed, 79 insertions(+), 220 deletions(-) diff --git a/docs/openapi.rst b/docs/openapi.rst index 09122a21..09e99cee 100644 --- a/docs/openapi.rst +++ b/docs/openapi.rst @@ -67,62 +67,10 @@ The example above produces the following documentation attributes: } } -The separator ``'---'`` can be customized by setting ``docstring_sep`` -when initializing the blueprint, or by subclassing ``Blueprint``: - -.. code-block:: python - - blp = Blueprint('foo', __name__, docstring_sep='~~~') - - def get(...): - """Find pets by ID - - Return pets based on ID. - ~~~ - Internal comment not meant to be exposed. - """ - - # This does the same thing: - class MyBlueprint('foo', __name__): - DOCSTRING_SEP = "~~~" - -Produces: - -.. code-block:: python - - { - 'get': { - 'summary': 'Find pets by ID', - 'description': 'Return pets based on ID', - } - } - -Setting ``docstring_sep`` to ``None`` will result in the entire docstring -being included in the `description`: - -.. code-block:: python - - blp = Blueprint('foo', __name__, docstring_sep=None) - - def get(...): - """Find pets by ID - - Return pets based on ID. - --- - Even this is included. - """ - -Produces: - -.. code-block:: python - - { - 'get': { - 'summary': 'Find pets by ID', - 'description': ('Return pets based on ID\n---\n' - 'Even this is included.'), - } - } +The delimiter line is the line starting with the delimiter string defined in +``Blueprint.DOCSTRING_INFO_DELIMITER``. This string defaults to ``"---"`` and +can be customized in a subclass. ``None`` means "no delimiter": the whole +docstring is included in the docs. Document Operations Parameters and Responses ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/flask_rest_api/blueprint.py b/flask_rest_api/blueprint.py index 9cccb762..a2a79fa8 100644 --- a/flask_rest_api/blueprint.py +++ b/flask_rest_api/blueprint.py @@ -66,12 +66,11 @@ class Blueprint( "files": "multipart/form-data", } - DOCSTRING_SEP = "---" + DOCSTRING_INFO_DELIMITER = "---" def __init__(self, *args, **kwargs): self.description = kwargs.pop('description', '') - self.docstring_sep = kwargs.pop('docstring_sep', self.DOCSTRING_SEP) super().__init__(*args, **kwargs) @@ -136,16 +135,18 @@ def _store_endpoint_docs(self, endpoint, obj, parameters, **options): endpoint_manual_doc = self._manual_docs.setdefault( endpoint, OrderedDict()) - def store_method_docs(method, function, sep='---'): + def store_method_docs(method, function): """Add auto and manual doc to table for later registration""" - # Get summary/description from docstring - # and auto documentation from decorators + # Get auto documentation from decorators + # and summary/description from docstring # Get manual documentation from @doc decorator - docstring = function.__doc__ - auto_doc = {} - if docstring: - auto_doc = load_info_from_docstring(docstring, sep=sep) - auto_doc.update(getattr(function, '_apidoc', {})) + auto_doc = getattr(function, '_apidoc', {}) + auto_doc.update( + load_info_from_docstring( + function.__doc__, + delimiter=self.DOCSTRING_INFO_DELIMITER + ) + ) manual_doc = getattr(function, '_api_manual_doc', {}) # Store function auto and manual docs for later registration method_l = method.lower() @@ -157,12 +158,12 @@ def store_method_docs(method, function, sep='---'): for method in self.HTTP_METHODS: if method in obj.methods: func = getattr(obj, method.lower()) - store_method_docs(method, func, sep=self.docstring_sep) + store_method_docs(method, func) # Function else: methods = options.pop('methods', None) or ['GET'] for method in methods: - store_method_docs(method, obj, sep=self.docstring_sep) + store_method_docs(method, obj) endpoint_auto_doc['parameters'] = parameters diff --git a/flask_rest_api/utils.py b/flask_rest_api/utils.py index b27ff7b5..1c0d4942 100644 --- a/flask_rest_api/utils.py +++ b/flask_rest_api/utils.py @@ -32,31 +32,33 @@ def get_appcontext(): return ctx.flask_rest_api -def load_info_from_docstring(docstring, sep="---"): - """Load summary and description from docstring""" - split_lines = trim_docstring(docstring).split('\n') +def load_info_from_docstring(docstring, *, delimiter="---"): + """Load summary and description from docstring - split_info_lines = split_lines + :param str delimiter: Summary and description information delimiter. + If a line starts with this string, this line and the lines after are + ignored. Defaults to "---". + """ + split_lines = trim_docstring(docstring).split('\n') - if sep is not None: - # Info is separated from rest of docstring by a `sep` line + if delimiter is not None: + # Info is separated from rest of docstring by a `delimiter` line for index, line in enumerate(split_lines): - if line.lstrip().startswith(sep): + if line.lstrip().startswith(delimiter): cut_at = index break else: cut_at = index + 1 - - split_info_lines = split_lines[:cut_at] + split_lines = split_lines[:cut_at] # Description is separated from summary by an empty line - for index, line in enumerate(split_info_lines): + for index, line in enumerate(split_lines): if line.strip() == '': - summary_lines = split_info_lines[:index] - description_lines = split_info_lines[index + 1:] + summary_lines = split_lines[:index] + description_lines = split_lines[index + 1:] break else: - summary_lines = split_info_lines + summary_lines = split_lines description_lines = [] info = {} diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py index 5660da51..42402849 100644 --- a/tests/test_blueprint.py +++ b/tests/test_blueprint.py @@ -576,72 +576,6 @@ def view_func(): assert response.status_code == 200 assert response.json == {'Value': 'OK'} - def test_blueprint_custom_docstring_separator(self, app): - api = Api(app) - blp = Blueprint('test', __name__, url_prefix='/test', - docstring_sep="~~~") - - @blp.route('/', methods=['PUT']) - def view_func(): - """Summary - - Long description - ~~~ - Ignore - """ - return jsonify({'Value': 'OK'}) - - api.register_blueprint(blp) - spec = api.spec.to_dict() - path = spec['paths']['/test/'] - assert path['put']['description'] == 'Long description' - - def test_blueprint_custom_docstring_separator_none(self, app): - api = Api(app) - blp = Blueprint('test', __name__, url_prefix='/test', - docstring_sep=None) - - @blp.route('/', methods=['PUT']) - def view_func(): - """Summary - - Long description - ~~~ - Not Ignored - """ - return jsonify({'Value': 'OK'}) - - api.register_blueprint(blp) - spec = api.spec.to_dict() - path = spec['paths']['/test/'] - descr = path['put']['description'] - assert descr == 'Long description\n~~~\nNot Ignored' - - - def test_bluprint_custom_docstring_separator_subclassed(self, app): - - class MyBlueprint(Blueprint): - DOCSTRING_SEP = "~~~" - - api = Api(app) - blp = MyBlueprint('test', __name__, url_prefix='/test') - - @blp.route('/', methods=['PUT']) - def view_func(): - """Summary - - Long description - ~~~ - Ignore - """ - return jsonify({'Value': 'OK'}) - - api.register_blueprint(blp) - spec = api.spec.to_dict() - path = spec['paths']['/test/'] - assert path['put']['description'] == 'Long description' - - def test_blueprint_doc_method_view(self, app): api = Api(app) blp = Blueprint('test', __name__, url_prefix='/test') @@ -742,43 +676,50 @@ def get(self): assert resp['description'] == 'Description' assert 'schema' in resp['content']['application/json'] - def test_blueprint_doc_info_from_docstring(self, app): + @pytest.mark.parametrize('delimiter', (False, None, "---")) + def test_blueprint_doc_info_from_docstring(self, app, delimiter): api = Api(app) - blp = Blueprint('test', __name__, url_prefix='/test') + + class MyBlueprint(Blueprint): + # Check delimiter default value + if delimiter is not False: + DOCSTRING_INFO_DELIMITER = delimiter + + blp = MyBlueprint('test', __name__, url_prefix='/test') @blp.route('/') class Resource(MethodView): def get(self): - """Docstring get summary""" + """Get summary""" def put(self): - """Docstring put summary + """Put summary - Docstring put description + Put description + --- + Private docstring """ - @blp.doc( - summary='Decorator patch summary', - description='Decorator patch description' - ) def patch(self): - """Docstring patch summary - - Docstring patch description - """ + pass api.register_blueprint(blp) spec = api.spec.to_dict() path = spec['paths']['/test/'] - assert path['get']['summary'] == 'Docstring get summary' + assert path['get']['summary'] == 'Get summary' assert 'description' not in path['get'] - assert path['put']['summary'] == 'Docstring put summary' - assert path['put']['description'] == 'Docstring put description' - # @doc decorator overrides docstring - assert path['patch']['summary'] == 'Decorator patch summary' - assert path['patch']['description'] == 'Decorator patch description' + assert path['put']['summary'] == 'Put summary' + if delimiter is None: + assert ( + path['put']['description'] == + 'Put description\n---\nPrivate docstring' + ) + else: + assert path['put']['description'] == 'Put description' + assert 'summary' not in path['patch'] + assert 'description' not in path['patch'] @pytest.mark.parametrize('http_methods', ( ['OPTIONS', 'HEAD', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'], diff --git a/tests/test_utils.py b/tests/test_utils.py index c5dd2f7c..6072f9ff 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -32,6 +32,8 @@ def test_deepupdate(self): } def test_load_info_from_docstring(self): + assert (load_info_from_docstring(None)) == {} + assert (load_info_from_docstring(None, delimiter="---")) == {} assert (load_info_from_docstring('')) == {} docstring = """ @@ -51,57 +53,22 @@ def test_load_info_from_docstring(self): --- Ignore this. """ - assert load_info_from_docstring(docstring) == { - 'summary': 'Summary\nTwo-line summary is possible.', - 'description': 'Long description\nReally long description' - } - - # No separator - docstring = """Summary - - Long description - - Section - ------- - Also included - """ - assert load_info_from_docstring(docstring, sep=None) == { - 'summary': 'Summary', - 'description': ('Long description\n\n' - 'Section\n-------\nAlso included'), - } - - # Custom separator - docstring = """Summary - - Some description. - - Section - ------- - foo - - ~~~ - - Ignored. - """ - assert load_info_from_docstring(docstring, sep="~~~") == { - 'summary': 'Summary', - 'description': ('Some description.\n\n' - 'Section\n-------\n' - 'foo' - ), - } - - # Explicit Default separator - docstring = """Summary - - Some description. - - Section - ------- - Ignored - """ - assert load_info_from_docstring(docstring, sep="---") == { - 'summary': 'Summary', - 'description': 'Some description.\n\nSection', - } + assert ( + load_info_from_docstring(docstring) == + load_info_from_docstring(docstring, delimiter="---") == + { + 'summary': 'Summary\nTwo-line summary is possible.', + 'description': 'Long description\nReally long description', + } + ) + assert ( + load_info_from_docstring(docstring, delimiter=None) == + load_info_from_docstring(docstring, delimiter="~~~") == + { + 'summary': 'Summary\nTwo-line summary is possible.', + 'description': ( + 'Long description\nReally long description\n---\n' + 'Ignore this.' + ) + } + ) From cbcad4c48c64982d21dcd8d6eb1363700245a6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Sat, 14 Sep 2019 00:17:36 +0200 Subject: [PATCH 3/4] Add @dougthor42 to AUTHORS.rst --- AUTHORS.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index f1843579..2c5c4073 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -12,3 +12,4 @@ Contributors (chronological) ============================ - Ryan Yin `@ryan4yin `_ +- Douglas Thor `@dougthor42 `_ From a9655058222f7bd87b1160f4ea3af94542612ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Sat, 14 Sep 2019 00:19:37 +0200 Subject: [PATCH 4/4] Update CHANGELOG --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bd682b53..f550cbc4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -19,6 +19,7 @@ Features: reasonable default content type for ``form`` and ``files`` (:pr:`83`). - Add ``description`` parameter to ``Blueprint.arguments`` to pass description for ``requestBody`` (:pr:`93`). +- Allow customization of docstring delimiter string (:issue:`49`). Bug fixes: