From 907682d64363002122dd63c0b306eda44a56a355 Mon Sep 17 00:00:00 2001 From: Sindre Hansen Date: Tue, 18 Nov 2025 21:24:12 +0100 Subject: [PATCH 1/3] Add custom template parameter --- openapidocs/commands/docs.py | 17 ++- openapidocs/mk/generate.py | 13 +- openapidocs/mk/jinja.py | 72 ++++++--- openapidocs/mk/v3/__init__.py | 6 +- tests/test_template_override.py | 262 ++++++++++++++++++++++++++++++++ 5 files changed, 345 insertions(+), 25 deletions(-) create mode 100644 tests/test_template_override.py diff --git a/openapidocs/commands/docs.py b/openapidocs/commands/docs.py index d3e69e5..0fa7ce0 100644 --- a/openapidocs/commands/docs.py +++ b/openapidocs/commands/docs.py @@ -31,7 +31,20 @@ default="MKDOCS", show_default=True, ) -def generate_documents_command(source: str, destination: str, style: Union[int, str]): +@click.option( + "-T", + "--templates", + help=( + "Path to a custom templates directory. " + "Templates in this directory will override default templates with matching names. " + "Unspecified templates will use defaults." + ), + required=False, + default=None, +) +def generate_documents_command( + source: str, destination: str, style: Union[int, str], templates: Union[str, None] +): """ Generates other kinds of documents from source OpenAPI Documentation files. @@ -48,7 +61,7 @@ def generate_documents_command(source: str, destination: str, style: Union[int, https://github.com/Neoteroi/essentials-openapi """ try: - generate_document(source, destination, style) + generate_document(source, destination, style, templates) except KeyboardInterrupt: # pragma: nocover logger.info("User interrupted") exit(1) diff --git a/openapidocs/mk/generate.py b/openapidocs/mk/generate.py index c907202..a89cf08 100644 --- a/openapidocs/mk/generate.py +++ b/openapidocs/mk/generate.py @@ -1,13 +1,22 @@ +from typing import Optional + from openapidocs.mk.v3 import OpenAPIV3DocumentationHandler from openapidocs.utils.source import read_from_source -def generate_document(source: str, destination: str, style: int | str): +def generate_document( + source: str, + destination: str, + style: int | str, + templates_path: Optional[str] = None, +): # Note: if support for more kinds of OAD versions will be added, handle a version # parameter in this function data = read_from_source(source) - handler = OpenAPIV3DocumentationHandler(data, style=style, source=source) + handler = OpenAPIV3DocumentationHandler( + data, style=style, source=source, templates_path=templates_path + ) html = handler.write() diff --git a/openapidocs/mk/jinja.py b/openapidocs/mk/jinja.py index 8aa3883..f31bfd8 100644 --- a/openapidocs/mk/jinja.py +++ b/openapidocs/mk/jinja.py @@ -1,10 +1,20 @@ """ This module provides a Jinja2 environment. """ + import os from enum import Enum - -from jinja2 import Environment, PackageLoader, Template, select_autoescape +from pathlib import Path +from typing import Optional + +from jinja2 import ( + ChoiceLoader, + Environment, + FileSystemLoader, + PackageLoader, + Template, + select_autoescape, +) from . import get_http_status_phrase, highlight_params, read_dict, sort_dict from .common import DocumentsWriter, is_reference @@ -62,29 +72,50 @@ def __init__( def get_environment( - package_name: str, views_style: OutputStyle = OutputStyle.MKDOCS + package_name: str, + views_style: OutputStyle = OutputStyle.MKDOCS, + custom_templates_path: Optional[str] = None, ) -> Environment: templates_folder = f"views_{views_style.name}".lower() + loaders = [] + + # If custom templates path is provided, validate and add FileSystemLoader first + if custom_templates_path: + custom_path = Path(custom_templates_path) + if not custom_path.exists(): + raise ValueError( + f"Custom templates path does not exist: {custom_templates_path}" + ) + if not custom_path.is_dir(): + raise ValueError( + f"Custom templates path is not a directory: {custom_templates_path}" + ) + loaders.append(FileSystemLoader(str(custom_path))) + + # Always add the package loader as fallback try: - loader = PackageLoader(package_name, templates_folder) + loaders.append(PackageLoader(package_name, templates_folder)) except ValueError as package_loading_error: # pragma: no cover - raise PackageLoadingError( - views_style, templates_folder - ) from package_loading_error - else: - env = Environment( - loader=loader, - autoescape=select_autoescape(["html", "xml"]) - if os.environ.get("SELECT_AUTOESCAPE") in {"YES", "Y", "1"} - else False, - auto_reload=True, - enable_async=False, - ) - configure_filters(env) - configure_functions(env) + if not custom_templates_path: + raise PackageLoadingError( + views_style, templates_folder + ) from package_loading_error + + loader = ChoiceLoader(loaders) + + env = Environment( + loader=loader, + autoescape=select_autoescape(["html", "xml"]) + if os.environ.get("SELECT_AUTOESCAPE") in {"YES", "Y", "1"} + else False, + auto_reload=True, + enable_async=False, + ) + configure_filters(env) + configure_functions(env) - return env + return env class Jinja2DocumentsWriter(DocumentsWriter): @@ -97,8 +128,9 @@ def __init__( self, package_name: str, views_style: OutputStyle = OutputStyle.MKDOCS, + custom_templates_path: Optional[str] = None, ) -> None: - self._env = get_environment(package_name, views_style) + self._env = get_environment(package_name, views_style, custom_templates_path) @property def env(self) -> Environment: diff --git a/openapidocs/mk/v3/__init__.py b/openapidocs/mk/v3/__init__.py index 72a9145..a5250d0 100644 --- a/openapidocs/mk/v3/__init__.py +++ b/openapidocs/mk/v3/__init__.py @@ -1,6 +1,7 @@ """ This module provides functions to generate Markdown for OpenAPI Version 3. """ + import copy import os import warnings @@ -95,11 +96,14 @@ def __init__( writer: Optional[DocumentsWriter] = None, style: Union[int, str] = 1, source: str = "", + templates_path: Optional[str] = None, ) -> None: self._source = source self.texts = texts or EnglishTexts() self._writer = writer or Jinja2DocumentsWriter( - __name__, views_style=style_from_value(style) + __name__, + views_style=style_from_value(style), + custom_templates_path=templates_path, ) self.doc = self.normalize_data(copy.deepcopy(doc)) diff --git a/tests/test_template_override.py b/tests/test_template_override.py new file mode 100644 index 0000000..8fca445 --- /dev/null +++ b/tests/test_template_override.py @@ -0,0 +1,262 @@ +""" +Tests for template override functionality. +""" + +import tempfile +from pathlib import Path + +import pytest + +from openapidocs.mk.generate import generate_document +from openapidocs.mk.jinja import Jinja2DocumentsWriter, OutputStyle, get_environment + + +def test_get_environment_with_invalid_custom_path(): + """Test that invalid custom template paths raise appropriate errors.""" + with pytest.raises(ValueError, match="does not exist"): + get_environment( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path="/nonexistent/path", + ) + + +def test_get_environment_with_file_instead_of_directory(): + """Test that providing a file path instead of directory raises error.""" + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + tmp_path = tmp_file.name + + try: + with pytest.raises(ValueError, match="is not a directory"): + get_environment( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path=tmp_path, + ) + finally: + Path(tmp_path).unlink() + + +def test_get_environment_with_valid_custom_path(): + """Test that valid custom template path creates environment successfully.""" + with tempfile.TemporaryDirectory() as tmpdir: + env = get_environment( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path=tmpdir, + ) + assert env is not None + assert env.loader is not None + + +def test_custom_template_overrides_default(): + """Test that custom template overrides the default layout.html.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a custom layout.html with distinctive content + custom_template = Path(tmpdir) / "layout.html" + custom_template.write_text("CUSTOM TEMPLATE CONTENT {{ info.title }}") + + env = get_environment( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path=tmpdir, + ) + + # Load the template - should get our custom one + template = env.get_template("layout.html") + result = template.render(info={"title": "Test API"}) + + assert "CUSTOM TEMPLATE CONTENT" in result + assert "Test API" in result + + +def test_partial_template_override(): + """Test that individual partial templates can be overridden.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create partial subdirectory + partial_dir = Path(tmpdir) / "partial" + partial_dir.mkdir() + + # Override just one partial template + custom_partial = partial_dir / "info.html" + custom_partial.write_text("CUSTOM INFO PARTIAL") + + env = get_environment( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path=tmpdir, + ) + + # Should load our custom partial + template = env.get_template("partial/info.html") + result = template.render() + assert "CUSTOM INFO PARTIAL" in result + + +def test_fallback_to_default_templates(): + """Test that non-overridden templates fall back to defaults.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create custom layout but don't override partials + custom_template = Path(tmpdir) / "layout.html" + custom_template.write_text("CUSTOM {{ info.title }}") + + env = get_environment( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path=tmpdir, + ) + + # Custom template should load + layout = env.get_template("layout.html") + assert "CUSTOM" in layout.render(info={"title": "Test"}) + + # Default partial should still be accessible + # (this will succeed if the default partial exists) + partial = env.get_template("partial/info.html") + assert partial is not None + + +def test_jinja2_writer_with_custom_templates(): + """Test that Jinja2DocumentsWriter correctly uses custom templates.""" + with tempfile.TemporaryDirectory() as tmpdir: + custom_template = Path(tmpdir) / "layout.html" + custom_template.write_text("CUSTOM WRITER TEST") + + writer = Jinja2DocumentsWriter( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path=tmpdir, + ) + + result = writer.write({}) + assert "CUSTOM WRITER TEST" in result + + +def test_custom_templates_with_different_output_styles(): + """Test custom templates work with different output styles.""" + with tempfile.TemporaryDirectory() as tmpdir: + custom_template = Path(tmpdir) / "layout.html" + custom_template.write_text("STYLE TEST") + + # Test with MARKDOWN style + env = get_environment( + "openapidocs.mk.v3", + OutputStyle.MARKDOWN, + custom_templates_path=tmpdir, + ) + template = env.get_template("layout.html") + assert "STYLE TEST" in template.render() + + # Test with MKDOCS style + env = get_environment( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path=tmpdir, + ) + template = env.get_template("layout.html") + assert "STYLE TEST" in template.render() + + +def test_generate_document_with_custom_templates(tmp_path): + """Test end-to-end document generation with custom templates.""" + # Create a simple OpenAPI spec + spec_file = tmp_path / "openapi.json" + spec_file.write_text( + """ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": {} +} + """ + ) + + # Create custom template directory + templates_dir = tmp_path / "templates" + templates_dir.mkdir() + custom_template = templates_dir / "layout.html" + custom_template.write_text("CUSTOM OUTPUT: {{ info.title }}") + + # Generate document with custom templates + output_file = tmp_path / "output.md" + generate_document( + str(spec_file), + str(output_file), + style="MKDOCS", + templates_path=str(templates_dir), + ) + + # Verify output contains custom template content + result = output_file.read_text() + assert "CUSTOM OUTPUT: Test API" in result + + +def test_generate_document_without_custom_templates(tmp_path): + """Test that document generation works normally without custom templates.""" + spec_file = tmp_path / "openapi.json" + spec_file.write_text( + """ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": {} +} + """ + ) + + output_file = tmp_path / "output.md" + generate_document( + str(spec_file), + str(output_file), + style="MKDOCS", + templates_path=None, + ) + + # Should generate successfully with default templates + assert output_file.exists() + result = output_file.read_text() + assert "Test API" in result + + +def test_custom_templates_preserve_jinja_features(): + """Test that custom templates can use all Jinja2 filters and functions.""" + with tempfile.TemporaryDirectory() as tmpdir: + custom_template = Path(tmpdir) / "layout.html" + # Use custom filters and functions from the environment + custom_template.write_text( + "{{ '/api/users/{id}' | route }} {{ read_dict(data, 'info', 'title') }}" + ) + + env = get_environment( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path=tmpdir, + ) + + template = env.get_template("layout.html") + result = template.render(data={"info": {"title": "My API"}}) + + # Should have access to custom filters + assert "route-param" in result or "{id}" in result + assert "My API" in result + + +def test_empty_custom_templates_directory(): + """Test that an empty custom templates directory falls back to all defaults.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Empty directory - should fall back to all defaults + env = get_environment( + "openapidocs.mk.v3", + OutputStyle.MKDOCS, + custom_templates_path=tmpdir, + ) + + # Should still be able to load default templates + template = env.get_template("layout.html") + assert template is not None From 5fafa7a8e023c60242a07dfd47fa43ea4f77cd8c Mon Sep 17 00:00:00 2001 From: Sindre Hansen Date: Wed, 19 Nov 2025 10:29:30 +0100 Subject: [PATCH 2/3] Update version to v1.3.0 --- CHANGELOG.md | 3 +++ openapidocs/__init__.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c975edd..5bd5672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ 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.3.0] - 2025-11-19 +- Add support for passing custom Jinja2 templates as an argument, by @sindrehan. + ## [1.2.1] - 2025-07-30 - Added support for using the current working directory (CWD) as an option when diff --git a/openapidocs/__init__.py b/openapidocs/__init__.py index 3568f76..aebce15 100644 --- a/openapidocs/__init__.py +++ b/openapidocs/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.2.1" +__version__ = "1.3.0" VERSION = __version__ From fe677d4e805e5898de93f74da3c4822e5b7a859e Mon Sep 17 00:00:00 2001 From: Sindre Hansen Date: Wed, 19 Nov 2025 11:09:37 +0100 Subject: [PATCH 3/3] Add custom templates instructions --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 069cbbd..b3b17db 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,34 @@ oad gen-docs -s source-openapi.json -d schemas.wsd --style "PLANTUML_API" _Example of PlantUML diagram generated from path items._ +#### Using custom templates + +You can override the default templates by providing a custom templates directory: + +```bash +oad gen-docs -s source-openapi.json -d output.md -T ./my-templates/ +``` + +The custom templates directory should contain template files with the same names as the built-in templates. Any template file found in the custom directory will override the corresponding default template, while non-overridden templates will use the defaults. This follows the same pattern as [MkDocs template customization](https://www.mkdocs.org/user-guide/customizing-your-theme/#overriding-template-blocks). + +**Important:** The custom templates directory must match the output style being rendered. Each style (MKDOCS, MARKDOWN, PLANTUML_SCHEMAS, PLANTUML_API) has its own template structure. You need to provide templates appropriate for the `--style` parameter you're using. + +**Template structure:** +- `layout.html` - Main layout template +- `partial/` - Directory containing reusable template components + +**Example custom template directory structure:** +``` +my-templates/ +├── layout.html # Overrides main layout +└── partial/ + ├── info.html # Overrides info section + └── path-items.html # Overrides path items section +``` + +All templates use [Jinja2](https://jinja.palletsprojects.com/) syntax and have access to the same filters, functions, and context variables as the built-in templates. + + ### Goals * Provide an API to generate OpenAPI Documentation files.