Repository: Neoteroi/essentials-openapi (language: python)

Issue:
Markdown missing a newline after simple response types
When a response either has no content or is a reference to a response schema, the newline after it is getting stripped. Here's a small example:

```yaml
openapi: 3.0.0
info:
  title: Test
  description: Demonstrate newline bug
  version: 0.0.0
paths:
  /cats:
    get:
      summary: Get a list of cats
      responses:
        "200":
          $ref: "#/components/responses/GetCatsResponse"
    post:
      summary: Create a new cat
      responses:
        "200":
          description: Success
    delete:
      summary: Delete a cat
      responses:
        "200":
          description: Success

components:
  responses:
    GetCatsResponse:
      description: List of cats
```

Renders markdown like this
```
❯ oad gen-docs -s openapi.yaml -d output.md --style MARKDOWN
```

```md
...
### GET /cats
Get a list of cats

### Response 200 OK
Refer to the common response description: [GetCatsResponse](#getcatsresponse)### POST /cats
Create a new cat

### Response 200 OK### DELETE /cats
Delete a cat

### Response 200 OK
...
```

i.e., `### POST` and `### DELETE` are not on their own line.

Thanks for taking a look!


Discussion / hints:
Hi @kylewm 
Thank You for reporting this issue. I take a look - the good news is that it will be easy to fix. 😄 
The Jinja templates in this project are quite complex, also because of the number of combinations that are possible with OpenAPI specification.
That's great to hear, and thank you for the speedy response! I started taking a look at it myself, but quickly got overwhelmed by jinja's whitespace stripping behavior 😄 so wanted to at least capture the issue. 

Fix patch:
diff --git a/.flake8 b/.flake8
index 292d685..508fa68 100644
--- a/.flake8
+++ b/.flake8
@@ -1,7 +1,9 @@
 [flake8]
+exclude = __pycache__,built,build,venv
 ignore = E203, E266, W503
 max-line-length = 88
 max-complexity = 18
 per-file-ignores =
     tests/test_v2.py: E501
     tests/test_v3.py: E501
+select = B,C,E,F,W,T4,B9
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 9346c1f..d877899 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -83,19 +83,19 @@ jobs:
           bash <(curl -s https://codecov.io/bash)
 
       - name: Install distribution dependencies
-        run: pip install --upgrade twine setuptools wheel
-        if: matrix.python-version == 3.10
+        run: pip install build
+        if: matrix.python-version == 3.11
 
       - name: Create distribution package
-        run: python setup.py sdist bdist_wheel
-        if: matrix.python-version == 3.10
+        run: python -m build
+        if: matrix.python-version == 3.11
 
       - name: Upload distribution package
         uses: actions/upload-artifact@master
         with:
-          name: dist-package-${{ matrix.python-version }}
+          name: dist
           path: dist
-        if: matrix.python-version == 3.10
+        if: matrix.python-version == 3.11
 
   publish:
     runs-on: ubuntu-latest
@@ -105,17 +105,28 @@ jobs:
       - name: Download a distribution artifact
         uses: actions/download-artifact@v2
         with:
-          name: dist-package-3.10
+          name: dist
           path: dist
-      - name: Publish distribution 📦 to Test PyPI
-        uses: pypa/gh-action-pypi-publish@master
+
+      - name: Use Python 3.11
+        uses: actions/setup-python@v1
         with:
-          skip_existing: true
-          user: __token__
-          password: ${{ secrets.test_pypi_password }}
-          repository_url: https://test.pypi.org/legacy/
+          python-version: '3.11'
+
+      - name: Install dependencies
+        run: |
+          pip install twine
+
+      - name: Publish distribution 📦 to Test PyPI
+        run: |
+          twine upload -r testpypi dist/*
+        env:
+          TWINE_USERNAME: __token__
+          TWINE_PASSWORD: ${{ secrets.test_pypi_password }}
+
       - name: Publish distribution 📦 to PyPI
-        uses: pypa/gh-action-pypi-publish@master
-        with:
-          user: __token__
-          password: ${{ secrets.pypi_password }}
+        run: |
+          twine upload -r pypi dist/*
+        env:
+          TWINE_USERNAME: __token__
+          TWINE_PASSWORD: ${{ secrets.pypi_password }}
diff --git a/.gitignore b/.gitignore
index de72d6a..a470096 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@ htmlcov
 __pycache__
 *.egg-info
 *.tar.gz
+_test_files/
+dist/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 926e152..17416a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [1.0.7] - 2023-05-01 :toolbox:
+- Fixes [Markdown missing a newline after simple response types](https://github.com/Neoteroi/essentials-openapi/issues/27).
+- Fixes [Empty header when operations don't have any tag.](https://github.com/Neoteroi/essentials-openapi/issues/28).
+- When operations don't have any tag, the `h2` element
+- Improves tests.
+- Adopts `pyproject.toml`.
+- Workflow maintenance.
+
 ## [1.0.6] - 2023-03-19 :snail:
 - Fixes a bug happening when trying to serialize examples in JSON, when they
   contain datetimes and are provided in YAML;
diff --git a/Makefile b/Makefile
index 7839676..874eeee 100644
--- a/Makefile
+++ b/Makefile
@@ -6,19 +6,23 @@ test-debug:
 
 
 artifacts: test
-	python setup.py sdist
+	python -m build
 
 
 prepforbuild:
-	pip install --upgrade twine setuptools wheel
+	pip install build
 
 
-testrelease:
-	twine upload --repository-url https://test.pypi.org/legacy/ dist/*
+build:
+	python -m build
 
 
-release: artifacts
-	twine upload --repository-url https://upload.pypi.org/legacy/ dist/*
+test-release:
+	twine upload --repository testpypi dist/*
+
+
+release:
+	twine upload --repository pypi dist/*
 
 
 test:
@@ -41,8 +45,25 @@ test-cov:
 	pytest --cov-report html --cov=openapidocs
 
 
+lint: check-flake8 check-isort check-black
+
+
+check-flake8:
+	@echo "$(BOLD)Checking flake8$(RESET)"
+	@flake8 . 2>&1
+
+
+check-isort:
+	@echo "$(BOLD)Checking isort$(RESET)"
+	@isort --check-only . 2>&1
+
+
+check-black:
+	@echo "$(BOLD)Checking black$(RESET)"
+	@black --check . 2>&1
+
+
 format:
-	isort openapidocs
-	isort tests
-	black openapidocs
-	black tests
+	@echo "$(BOLD)Formatting code 🧹 🧼$(RESET)"
+	@black . 2>&1
+	@isort . 2>&1
diff --git a/openapidocs/__init__.py b/openapidocs/__init__.py
index 89bec30..d6780ef 100644
--- a/openapidocs/__init__.py
+++ b/openapidocs/__init__.py
@@ -1,1 +1,2 @@
-VERSION = "1.0.6"
+__version__ = "1.0.7"
+VERSION = __version__
diff --git a/openapidocs/mk/v3/views_markdown/partial/path-items.html b/openapidocs/mk/v3/views_markdown/partial/path-items.html
index e4c7bfe..f309b34 100644
--- a/openapidocs/mk/v3/views_markdown/partial/path-items.html
+++ b/openapidocs/mk/v3/views_markdown/partial/path-items.html
@@ -1,9 +1,9 @@
 {% for tag, operations in handler.get_operations().items() %}
 
-## {{tag}}
+## {{tag or "Endpoints"}}
 
 {% for path, definition in operations %}
-{%- for http_method, operation in definition.items() -%}
+{%- for http_method, operation in definition.items() %}
 
 ### {{http_method.upper()}} {{path | safe}}
 {% if "summary" in operation -%}
diff --git a/openapidocs/mk/v3/views_mkdocs/partial/path-items.html b/openapidocs/mk/v3/views_mkdocs/partial/path-items.html
index cbf777c..3f47cca 100644
--- a/openapidocs/mk/v3/views_mkdocs/partial/path-items.html
+++ b/openapidocs/mk/v3/views_mkdocs/partial/path-items.html
@@ -1,8 +1,8 @@
 {% for tag, operations in handler.get_operations().items() %}
-## <span class="api-tag">{{tag}}</span>
+## <span class="api-tag">{{tag or "Endpoints"}}</span>
 
 {% for path, definition in operations %}
-{%- for http_method, operation in definition.items() -%}
+{%- for http_method, operation in definition.items() %}
 <hr class="operation-separator" />
 
 ### <span class="http-{{http_method.lower()}}">{{http_method.upper()}}</span> {{path | route | safe}}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e825ebe
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,47 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "essentials-openapi"
+dynamic = ["version"]
+authors = [{ name = "Roberto Prevato", email = "roberto.prevato@gmail.com" }]
+description = "Classes to generate OpenAPI Documentation v3 and v2, in JSON and YAML."
+readme = "README.md"
+requires-python = ">=3.7"
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "License :: OSI Approved :: MIT License",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Operating System :: OS Independent",
+]
+keywords = ["openapi", "docs", "swagger", "api", "documentation", "v3", "v2", "json", "yaml", "Markdown"]
+
+dependencies = ["PyYAML>=6", "essentials>=1.1.5"]
+
+[tool.hatch.version]
+path = "openapidocs/__init__.py"
+
+[project.optional-dependencies]
+full = ["click~=8.1.3", "Jinja2~=3.1.2", "MarkupSafe==2.1.2", "rich~=12.6.0", "httpx<1"]
+
+[project.scripts]
+openapidocs = "openapidocs.main:main"
+oad = "openapidocs.main:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["openapidocs"]
+
+[tool.hatch.build.targets.sdist]
+exclude = ["tests"]
+
+[tool.hatch.build]
+only-packages = true
+
+[project.urls]
+"Homepage" = "https://github.com/Neoteroi/essentials-openapi"
+"Bug Tracker" = "https://github.com/Neoteroi/essentials-openapi/issues"
diff --git a/requirements.txt b/requirements.txt
index bd9c0a1..1a28bc8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,7 @@
 anyio==3.6.2
 attrs==22.1.0
 black==22.10.0
+build==0.10.0
 certifi==2022.9.24
 charset-normalizer==3.0.0
 click==8.1.3
@@ -11,14 +12,16 @@ flake8==5.0.4
 Flask==2.2.2
 h11==0.12.0
 httpcore==0.15.0
-httpx==0.23.0
+httpx==0.24.0
 idna==3.4
 iniconfig==1.1.1
 isort==5.10.1
 itsdangerous==2.1.2
 Jinja2==3.1.2
-MarkupSafe==2.1.1
+markdown-it-py==2.2.0
+MarkupSafe==2.1.2
 mccabe==0.7.0
+mdurl==0.1.2
 mypy-extensions==0.4.3
 packaging==21.3
 pathspec==0.10.1
@@ -30,12 +33,13 @@ pydantic==1.10.2
 pyflakes==2.5.0
 Pygments==2.13.0
 pyparsing==3.0.9
+pyproject_hooks==1.0.0
 pytest==7.2.0
 pytest-cov==4.0.0
 PyYAML==6.0
 regex==2022.10.31
 rfc3986==1.5.0
-rich==12.6.0
+rich==13.3.5
 sniffio==1.3.0
 toml==0.10.2
 tomli==2.0.1
diff --git a/setup.py b/setup.py
deleted file mode 100644
index fd63037..0000000
--- a/setup.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from setuptools import setup
-
-from openapidocs import VERSION
-
-
-def readme():
-    with open("README.md") as f:
-        return f.read()
-
-
-setup(
-    name="essentials-openapi",
-    version=VERSION,
-    description="Classes to generate OpenAPI Documentation v3 and v2, in JSON and YAML",
-    long_description=readme(),
-    long_description_content_type="text/markdown",
-    classifiers=[
-        "Development Status :: 5 - Production/Stable",
-        "License :: OSI Approved :: MIT License",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: 3.10",
-        "Programming Language :: Python :: 3.11",
-        "Operating System :: OS Independent",
-    ],
-    url="https://github.com/Neoteroi/essentials-openapi",
-    author="RobertoPrevato",
-    author_email="roberto.prevato@gmail.com",
-    keywords="openapi docs swagger api documentation v3 v2 json yaml Markdown",
-    license="MIT",
-    packages=[
-        "openapidocs",
-        "openapidocs.commands",
-        "openapidocs.mk",
-        "openapidocs.mk.v3",
-        "openapidocs.utils",
-    ],
-    install_requires=[
-        "PyYAML>=6",
-        "essentials>=1.1.5",
-        "dataclasses==0.7;python_version<'3.7'",
-    ],
-    extras_require={
-        "full": ["click~=8.1.3", "Jinja2~=3.1.2", "rich~=12.6.0", "httpx<1"]
-    },
-    entry_points={
-        "console_scripts": [
-            "openapidocs=openapidocs.main:main",
-            "oad=openapidocs.main:main",
-        ]
-    },
-    include_package_data=True,
-    zip_safe=False,
-)


Test patch:
diff --git a/tests/common.py b/tests/common.py
index e8c1416..f96e5ca 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -1,5 +1,6 @@
 import json
 import os
+import re
 from typing import Any
 
 import pkg_resources
@@ -46,3 +47,21 @@ def get_file_json(file_name) -> Any:
 
 def get_file_yaml(file_name) -> Any:
     return yaml.safe_load(get_resource_file_content(file_name))
+
+
+def normalize_str(value: str) -> str:
+    return re.sub("\r?\n{2,}", "\n", value.strip())
+
+
+def compatible_str(value_one: str, value_two: str) -> bool:
+    """
+    Compares two strings ignoring multiple carriage returns and trailing carriage
+    returns. In HTML or Markdown it does not matter if you have multiple carriage
+    returns.
+    """
+    # first check if the two strings are equals: there is no point in stripping carriage
+    # returns otherwise
+    if value_one == value_two:
+        return True
+
+    return normalize_str(value_one) == normalize_str(value_two)
diff --git a/tests/conftest.py b/tests/conftest.py
index a2dff0a..df312e0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,8 @@
+import shutil
 from uuid import UUID
 
+from essentials.folders import ensure_folder
+
 from openapidocs.mk.v3.examples import IntegerExampleHandler, StringExampleHandler
 
 # override the UUID example generator to return the same value, so that tests can have
@@ -12,3 +15,10 @@
     "int32": lambda: 26,
     "int64": lambda: 26,
 }
+
+try:
+    shutil.rmtree("_test_files")
+except OSError:
+    pass
+
+ensure_folder("_test_files")
diff --git a/tests/res/example1-output-plain.md b/tests/res/example1-output-plain.md
index 3a85bba..035351c 100644
--- a/tests/res/example1-output-plain.md
+++ b/tests/res/example1-output-plain.md
@@ -27,6 +27,8 @@ Optional multiline or single-line description in
 
 ## Blobs
 
+
+
 ### POST /api/blobs/initialize-upload
 Initializes a file upload operation.
 
@@ -144,6 +146,8 @@ Refer to the common response description: [Unauthorized](#unauthorized)
 
 ## Categories
 
+
+
 ### GET /api/categories
 Gets the list of categories supported by the system.
 
@@ -253,6 +257,8 @@ _Other possible types: text/json, text/plain_
 
 ## Countries
 
+
+
 ### GET /api/countries
 Gets a list of countries of the World with English dislay names and ISO country
 codes.
@@ -292,6 +298,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### GET /api/system-countries
 Gets a list of countries supported by the system, with English dislay names and
 ISO country codes.
@@ -335,6 +343,8 @@ _Other possible types: text/json, text/plain_
 
 ## Downloads
 
+
+
 ### GET /api/downloads
 Gets a paginated set of downloads records.
 
@@ -473,6 +483,8 @@ _Other possible types: text/json, text/plain_
 
 ## Health
 
+
+
 ### GET /api/health
 API health check
 
@@ -524,6 +536,8 @@ _Other possible types: text/json, text/plain_
 
 ## Professionals
 
+
+
 ### GET /api/pro/own-context
 
 
@@ -596,6 +610,8 @@ _Other possible types: text/json, text/plain_
 
 ## Info
 
+
+
 ### GET /api/info
 Returns information about the API itself.
 
@@ -648,6 +664,8 @@ _Other possible types: text/json, text/plain_
 
 ## Releases
 
+
+
 ### GET /api/releases/{releaseId}
 Returns details about a release by id.
 
@@ -840,6 +858,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### DELETE /api/releases/{releaseId}
 Deletes a release by id.
 
@@ -850,7 +870,9 @@ Deletes a release by id.
 | AAD       | header | string | N/A     | No       | Access token issued by Azure Active Directory. |
 | releaseId | path   | string |         | No       |                                                |
 
-### Response 200 OK### PATCH /api/releases/{releaseId}
+### Response 200 OK
+
+### PATCH /api/releases/{releaseId}
 
 
 **Input parameters**
@@ -1105,6 +1127,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### GET /api/releases
 
 
@@ -1229,6 +1253,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### POST /api/releases
 
 
@@ -1320,6 +1346,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### GET /api/current-releases
 
 
@@ -1361,6 +1389,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### GET /api/orgs/current-releases
 
 
@@ -1402,6 +1432,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### GET /api/releases/{releaseId}/history
 
 
@@ -1504,6 +1536,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### GET /api/releases/{releaseId}/file/{nodeId}
 
 
@@ -1544,6 +1578,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### GET /api/releases/{releaseId}/file/{nodeId}/downloads
 
 
@@ -1584,6 +1620,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### PUT /api/releases/{releaseId}/files
 
 
@@ -1808,6 +1846,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### DELETE /api/releases/{releaseId}/files
 
 
@@ -2029,6 +2069,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### PUT /api/releases/{releaseId}/orgs
 
 
@@ -2259,6 +2301,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### DELETE /api/releases/{releaseId}/orgs
 
 
@@ -2479,6 +2523,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### POST /api/releases/{releaseId}/clone
 
 
@@ -2671,6 +2717,8 @@ _Other possible types: text/json, text/plain_
 }
 ```
 
+
+
 ### POST /api/releases/{releaseId}/publish
 
 
diff --git a/tests/res/example6-output.md b/tests/res/example6-output.md
index bf946d0..53c6a54 100644
--- a/tests/res/example6-output.md
+++ b/tests/res/example6-output.md
@@ -8,7 +8,7 @@ Most likely, it is not desirable to edit this file by hand!
 # Test <span class="api-version">v1</span>
 
 
-## <span class="api-tag"></span>
+## <span class="api-tag">Endpoints</span>
 
 <hr class="operation-separator" />
 
diff --git a/tests/res/example7-output.md b/tests/res/example7-output.md
index a81d35f..ac2bb4a 100644
--- a/tests/res/example7-output.md
+++ b/tests/res/example7-output.md
@@ -39,7 +39,7 @@ Most likely, it is not desirable to edit this file by hand!
     </tbody>
 </table>
 
-## <span class="api-tag"></span>
+## <span class="api-tag">Endpoints</span>
 
 <hr class="operation-separator" />
 
diff --git a/tests/test_cli.py b/tests/test_cli.py
index f917f42..0e955fb 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -11,7 +11,7 @@
 from openapidocs.mk.jinja import OutputStyle
 from openapidocs.utils.source import SourceError, read_from_source, read_from_url
 from openapidocs.utils.web import FailedRequestError, ensure_success, http_get
-from tests.common import get_file_json
+from tests.common import compatible_str, get_file_json
 
 from .serverfixtures import *  # noqa
 from .serverfixtures import BASE_URL
@@ -35,7 +35,7 @@ def remove_file(file_path: Path):
 
 
 def contents_equals(file_path_1, file_path_2):
-    assert read_file(file_path_1) == read_file(file_path_2)
+    assert compatible_str(read_file(file_path_1), read_file(file_path_2))
 
 
 def test_fetch_json(example_1_data):
@@ -107,7 +107,7 @@ def test_generate_docs_command_invalid_source():
     ],
 )
 def test_main_command_gen_mkdocs_docs(valid_source):
-    test_output = Path(f"{uuid4()}.md")
+    test_output = Path(f"_test_files/{uuid4()}.md")
     assert test_output.exists() is False
 
     runner = CliRunner()
@@ -184,7 +184,7 @@ def test_main_command_gen_plantuml_api_docs(valid_source):
 
 def test_main_command_gen_plain_markdown_docs():
     valid_source = "tests/res/example1-openapi.json"
-    test_output = Path(f"{uuid4()}.md")
+    test_output = Path(f"_test_files/{uuid4()}.md")
     assert test_output.exists() is False
 
     runner = CliRunner()
diff --git a/tests/test_mk_v3.py b/tests/test_mk_v3.py
index 95442a0..7404d5e 100644
--- a/tests/test_mk_v3.py
+++ b/tests/test_mk_v3.py
@@ -7,6 +7,7 @@
 )
 from openapidocs.mk.v3.examples import ObjectExampleHandler
 from tests.common import (
+    compatible_str,
     get_file_json,
     get_file_yaml,
     get_resource_file_content,
@@ -22,7 +23,7 @@ def test_v3_markdown_gen(example_file):
     handler = OpenAPIV3DocumentationHandler(data)
 
     html = handler.write()
-    assert html == expected_result
+    assert compatible_str(html, expected_result)
 
 
 def test_v3_markdown_gen_split_file():
@@ -37,7 +38,7 @@ def test_v3_markdown_gen_split_file():
 
     html = handler.write()
 
-    assert html == expected_result
+    assert compatible_str(html, expected_result)
 
 
 def test_file_ref_raises_for_missing_file():
@@ -308,4 +309,4 @@ def test_v3_markdown_yaml(example_file):
 
     html = handler.write()
 
-    assert html == expected_result
+    compatible_str(html, expected_result)