From 9a56728df8275abd7d4dffcd26b46db0b549f672 Mon Sep 17 00:00:00 2001 From: oniboni Date: Fri, 17 May 2024 15:24:37 +0000 Subject: [PATCH 01/12] feat: return direct image tags instead of markdown --- kroki/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kroki/plugin.py b/kroki/plugin.py index eb93877..a625fda 100644 --- a/kroki/plugin.py +++ b/kroki/plugin.py @@ -116,7 +116,7 @@ def _replace_kroki_block( ) log.debug("%s", response) if response.is_ok(): - return f"![Kroki]({response.image_url})" + return f'

\nKroki\n

' if self.fail_fast: raise PluginError(response.err_msg) From 687ac0c03ac6d2e08866e92b5bf381236c2d0b17 Mon Sep 17 00:00:00 2001 From: oniboni Date: Sat, 18 May 2024 10:28:49 +0000 Subject: [PATCH 02/12] test: fix test diagram code --- tests/data/happy_path/docs/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/data/happy_path/docs/index.md b/tests/data/happy_path/docs/index.md index b2789e2..400bcd1 100644 --- a/tests/data/happy_path/docs/index.md +++ b/tests/data/happy_path/docs/index.md @@ -2,6 +2,7 @@ ```c4plantuml !include +!include title System Context diagram for MkDocs Kroki Plugin From 4c552b2b71f8318dd2da44783b5be6793d4e0a78 Mon Sep 17 00:00:00 2001 From: oniboni Date: Sat, 18 May 2024 10:29:52 +0000 Subject: [PATCH 03/12] build: build add test environment --- pyproject.toml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f349e6f..043180b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,13 +58,22 @@ include = [ "/kroki", ] +# this enables you to run arbitrary commands inside of the hatch-managed hatch-test environment +# e.g. +# REPL: +# hatch run test:python +# serve happy path test: +# hatch run test:mkdocs serve -f tests/data/happy_path/mkdocs.yml +[tool.hatch.envs.test] +template = "hatch-test" +[tool.hatch.envs.test.env-vars] +KROKI_SERVER_URL = "http://localhost:8080" + [tool.hatch.envs.hatch-test] extra-dependencies = [ "mkdocs-material", "click", ] -# mkdocs log outputs are flaky :( -retries = 2 [[tool.hatch.envs.hatch-test.matrix]] python = ["3.10", "3.11", "3.12"] [tool.hatch.envs.hatch-test.scripts] From 45e1ae36d6a6e2167ee64b0091ea2934bc2a24a3 Mon Sep 17 00:00:00 2001 From: oniboni Date: Sat, 18 May 2024 11:03:12 +0000 Subject: [PATCH 04/12] feat: unify plugin logger and colorize prefix --- kroki/client.py | 7 ++----- kroki/config.py | 4 +--- kroki/logging.py | 10 ++++++++++ kroki/plugin.py | 6 ++---- kroki/util.py | 5 +++-- 5 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 kroki/logging.py diff --git a/kroki/client.py b/kroki/client.py index 7b87e46..5b4b3bd 100644 --- a/kroki/client.py +++ b/kroki/client.py @@ -6,16 +6,13 @@ import requests from mkdocs.exceptions import PluginError -from mkdocs.plugins import get_plugin_logger from mkdocs.structure.files import Files as MkDocsFiles from mkdocs.structure.pages import Page as MkDocsPage from kroki.config import KrokiDiagramTypes +from kroki.logging import log from kroki.util import DownloadedImage -log = get_plugin_logger(__name__) - - MAX_URI_SIZE: Final[int] = 4096 @@ -44,7 +41,7 @@ def __init__( self.diagram_types = diagram_types self.fail_fast = fail_fast - log.debug("Client initialized", extra={"http_method": self.http_method, "server_url": self.server_url}) + log.debug("Client initialized [http_method: %s, server_url: %s]", self.http_method, self.server_url) def _kroki_url_base(self, kroki_type: str) -> str: file_type = self.diagram_types.get_file_ext(kroki_type) diff --git a/kroki/config.py b/kroki/config.py index c9c08b4..f445fb5 100644 --- a/kroki/config.py +++ b/kroki/config.py @@ -1,8 +1,6 @@ from typing import ClassVar -from mkdocs.plugins import get_plugin_logger - -log = get_plugin_logger(__name__) +from kroki.logging import log class KrokiDiagramTypes: diff --git a/kroki/logging.py b/kroki/logging.py new file mode 100644 index 0000000..34bff41 --- /dev/null +++ b/kroki/logging.py @@ -0,0 +1,10 @@ +from typing import Final + +from mkdocs.plugins import get_plugin_logger + +log = get_plugin_logger(__name__) + +_COLOR_PURPLE: Final[str] = "\x1b[35;1m" +_COLOR_RESET: Final[str] = "\x1b[0m" + +log.prefix = f"{_COLOR_PURPLE}{log.prefix}{_COLOR_RESET}" diff --git a/kroki/plugin.py b/kroki/plugin.py index a625fda..419f489 100644 --- a/kroki/plugin.py +++ b/kroki/plugin.py @@ -8,14 +8,12 @@ from mkdocs.config.defaults import MkDocsConfig from mkdocs.exceptions import PluginError from mkdocs.plugins import BasePlugin as MkDocsBasePlugin -from mkdocs.plugins import get_plugin_logger from mkdocs.structure.files import Files as MkDocsFiles from mkdocs.structure.pages import Page as MkDocsPage from kroki.client import KrokiClient, KrokiResponse from kroki.config import KrokiDiagramTypes - -log = get_plugin_logger(__name__) +from kroki.logging import log class DeprecatedDownloadImagesCompat(config_options.Deprecated): @@ -65,7 +63,7 @@ class KrokiPlugin(MkDocsBasePlugin[KrokiPluginConfig]): ) def on_config(self, config: MkDocsConfig) -> MkDocsConfig: - log.debug("Configuring", extra={"config": self.config}) + log.debug("Configuring config: %s", self.config) self.diagram_types = KrokiDiagramTypes( blockdiag_enabled=self.config.EnableBlockDiag, diff --git a/kroki/util.py b/kroki/util.py index 6546591..396ee9e 100644 --- a/kroki/util.py +++ b/kroki/util.py @@ -1,12 +1,11 @@ from os import makedirs, path from uuid import NAMESPACE_OID, uuid3 -from mkdocs.plugins import get_plugin_logger from mkdocs.structure.files import File as MkDocsFile from mkdocs.structure.files import Files as MkDocsFiles from mkdocs.structure.pages import Page as MkDocsPage -log = get_plugin_logger(__name__) +from kroki.logging import log class DownloadedImage: @@ -24,6 +23,7 @@ def save(self, files: MkDocsFiles, page: MkDocsPage) -> None: file_path = path.join(page_abs_dest_dir, self.file_name) + log.debug("Saving image data: %s", file_path) with open(file_path, "wb") as file: file.write(self.file_content) @@ -41,4 +41,5 @@ def save(self, files: MkDocsFiles, page: MkDocsPage) -> None: # MkDocs will not copy the file in this case dummy_file.abs_src_path = dummy_file.abs_dest_path = file_path + log.debug("Appending dummy mkdocs file: %s", dummy_file) files.append(dummy_file) From a0a06f761d1dbb5ea00cee4496d47a248e34d9ab Mon Sep 17 00:00:00 2001 From: oniboni Date: Sat, 18 May 2024 11:04:01 +0000 Subject: [PATCH 05/12] fix: use plugin version in default user agent --- kroki/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kroki/plugin.py b/kroki/plugin.py index 419f489..273e39b 100644 --- a/kroki/plugin.py +++ b/kroki/plugin.py @@ -11,6 +11,7 @@ from mkdocs.structure.files import Files as MkDocsFiles from mkdocs.structure.pages import Page as MkDocsPage +from kroki import version from kroki.client import KrokiClient, KrokiResponse from kroki.config import KrokiDiagramTypes from kroki.logging import log @@ -37,7 +38,7 @@ class KrokiPluginConfig(MkDocsBaseConfig): EnableMermaid = config_options.Type(bool, default=True) EnableDiagramsnet = config_options.Type(bool, default=False) HttpMethod = config_options.Choice(choices=["GET", "POST"], default="GET") - UserAgent = config_options.Type(str, default=f"{__name__}/0.7.1") + UserAgent = config_options.Type(str, default=f"{__name__}/{version}") FencePrefix = config_options.Type(str, default="kroki-") FileTypes = config_options.Type(list, default=["svg"]) FileTypeOverrides = config_options.Type(dict, default={}) From 0fcf944c24707e035f112b20f275327ce6360993 Mon Sep 17 00:00:00 2001 From: oniboni Date: Sun, 19 May 2024 17:20:53 +0000 Subject: [PATCH 06/12] refactor: rename config.py -> diagram_types.py --- kroki/{config.py => diagram_types.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename kroki/{config.py => diagram_types.py} (100%) diff --git a/kroki/config.py b/kroki/diagram_types.py similarity index 100% rename from kroki/config.py rename to kroki/diagram_types.py From 417f99d3fbef4b2c6ed6a6b644a8a163e224b44b Mon Sep 17 00:00:00 2001 From: oniboni Date: Sun, 19 May 2024 17:21:54 +0000 Subject: [PATCH 07/12] refactor: restructure modules for better testability --- kroki/client.py | 146 ++++++++++++++++----------------- kroki/common.py | 32 ++++++++ kroki/config.py | 37 +++++++++ kroki/diagram_types.py | 75 +++++++++-------- kroki/parsing.py | 66 +++++++++++++++ kroki/plugin.py | 133 ++++-------------------------- kroki/render.py | 43 ++++++++++ kroki/util.py | 45 ---------- pyproject.toml | 1 + tests/conftest.py | 3 +- tests/test_errors.py | 16 ++-- tests/test_errors_fail_fast.py | 10 +-- tests/test_fences.py | 16 ++-- tests/utils.py | 5 ++ 14 files changed, 337 insertions(+), 291 deletions(-) create mode 100644 kroki/common.py create mode 100644 kroki/config.py create mode 100644 kroki/parsing.py create mode 100644 kroki/render.py delete mode 100644 kroki/util.py diff --git a/kroki/client.py b/kroki/client.py index 5b4b3bd..b3832b8 100644 --- a/kroki/client.py +++ b/kroki/client.py @@ -1,45 +1,64 @@ import base64 +import textwrap import zlib -from dataclasses import dataclass -from logging import DEBUG +from os import makedirs, path from typing import Final +from uuid import NAMESPACE_OID, uuid3 import requests -from mkdocs.exceptions import PluginError -from mkdocs.structure.files import Files as MkDocsFiles -from mkdocs.structure.pages import Page as MkDocsPage +from result import Err, Ok, Result -from kroki.config import KrokiDiagramTypes +from kroki.common import ErrorResult, KrokiImageContext, MkDocsEventContext, MkDocsFile +from kroki.diagram_types import KrokiDiagramTypes from kroki.logging import log -from kroki.util import DownloadedImage MAX_URI_SIZE: Final[int] = 4096 +FILE_PREFIX: Final[str] = "kroki-generated-" -@dataclass -class KrokiResponse: - err_msg: None | str = None - image_url: None | str = None +class DownloadedContent: + def __init__(self, file_content: bytes, file_extension: str, additional_metadata: None | dict) -> None: + file_uuid = uuid3(NAMESPACE_OID, f"{additional_metadata}{file_content}") - def is_ok(self) -> bool: - return self.image_url is not None and self.err_msg is None + self.file_name = f"{FILE_PREFIX}{file_uuid}.{file_extension}" + self.file_content = file_content + + def save(self, context: MkDocsEventContext) -> None: + # wherever MkDocs wants to host or build, we plant the image next + # to the generated static page + page_abs_dest_dir = path.dirname(context.page.file.abs_dest_path) + makedirs(page_abs_dest_dir, exist_ok=True) + + file_path = path.join(page_abs_dest_dir, self.file_name) + + log.debug("Saving downloaded data: %s", file_path) + with open(file_path, "wb") as file: + file.write(self.file_content) + + # make MkDocs believe that the file was present from the beginning + file_src_uri = path.join(path.dirname(context.page.file.src_uri), self.file_name) + file_dest_uri = path.join(path.dirname(context.page.file.dest_uri), self.file_name) + + dummy_file = MkDocsFile( + path=file_src_uri, + src_dir="", + dest_dir="", + use_directory_urls=False, + dest_uri=file_dest_uri, + ) + # MkDocs will not copy the file in this case + dummy_file.abs_src_path = dummy_file.abs_dest_path = file_path + + log.debug("Appending dummy mkdocs file: %s", dummy_file) + context.files.append(dummy_file) class KrokiClient: - def __init__( - self, - server_url: str, - http_method: str, - user_agent: str, - diagram_types: KrokiDiagramTypes, - *, - fail_fast: bool, - ) -> None: + def __init__(self, server_url: str, http_method: str, user_agent: str, diagram_types: KrokiDiagramTypes) -> None: self.server_url = server_url self.http_method = http_method self.headers = {"User-Agent": user_agent} self.diagram_types = diagram_types - self.fail_fast = fail_fast log.debug("Client initialized [http_method: %s, server_url: %s]", self.http_method, self.server_url) @@ -47,84 +66,59 @@ def _kroki_url_base(self, kroki_type: str) -> str: file_type = self.diagram_types.get_file_ext(kroki_type) return f"{self.server_url}/{kroki_type}/{file_type}" - def _kroki_url_get( - self, - kroki_type: str, - kroki_diagram_data: str, - kroki_diagram_options: dict[str, str], - ) -> KrokiResponse: - kroki_data_param = base64.urlsafe_b64encode(zlib.compress(str.encode(kroki_diagram_data), 9)).decode() + def _kroki_url_get(self, kroki_context: KrokiImageContext) -> Result[str, ErrorResult]: + kroki_data_param = base64.urlsafe_b64encode(zlib.compress(str.encode(kroki_context.data.unwrap()), 9)).decode() kroki_query_param = ( - "&".join([f"{k}={v}" for k, v in kroki_diagram_options.items()]) if len(kroki_diagram_options) > 0 else "" + "&".join([f"{k}={v}" for k, v in kroki_context.options.items()]) if len(kroki_context.options) > 0 else "" ) - kroki_url = self._kroki_url_base(kroki_type) + kroki_url = self._kroki_url_base(kroki_context.endpoint) image_url = f"{kroki_url}/{kroki_data_param}?{kroki_query_param}" if len(image_url) >= MAX_URI_SIZE: - log.warning("Kroki may not be able to read the data completely!", extra={"data_len": len(image_url)}) + log.warning("Kroki may not be able to read the data completely! [data_len: %i]", len(image_url)) - log.debug("Image url: %s", image_url) - return KrokiResponse(image_url=image_url) + log.debug("Image url: %s", textwrap.shorten(image_url, 50)) + return Ok(image_url) - def _kroki_post( - self, - kroki_type: str, - kroki_diagram_data: str, - kroki_diagram_options: dict[str, str], - files: MkDocsFiles, - page: MkDocsPage, - ) -> KrokiResponse: - url = self._kroki_url_base(kroki_type) - - log.debug("POST %s", url) + def _kroki_post(self, kroki_context: KrokiImageContext, context: MkDocsEventContext) -> Result[str, ErrorResult]: + url = self._kroki_url_base(kroki_context.endpoint) + + log.debug("POST %s", textwrap.shorten(url, 50)) try: response = requests.post( url, headers=self.headers, json={ - "diagram_source": kroki_diagram_data, - "diagram_options": kroki_diagram_options, + "diagram_source": kroki_context.data.unwrap(), + "diagram_options": kroki_context.options, }, timeout=10, + stream=True, ) except requests.RequestException as error: - error_message = f"Request error [url:{url}]: {error}" - log.exception(error_message, stack_info=log.isEnabledFor(DEBUG)) - if self.fail_fast: - raise PluginError(error_message) from error - - return KrokiResponse(err_msg=error_message) + return Err(ErrorResult(err_msg=f"Request error [url:{url}]: {error}", error=error)) if response.status_code == requests.codes.ok: - downloaded_image = DownloadedImage( + downloaded_image = DownloadedContent( response.content, - self.diagram_types.get_file_ext(kroki_type), - kroki_diagram_options, + self.diagram_types.get_file_ext(kroki_context.endpoint), + kroki_context.options, ) - downloaded_image.save(files, page) - return KrokiResponse(image_url=downloaded_image.file_name) + downloaded_image.save(context) + return Ok(downloaded_image.file_name) - error_message = ( - "Diagram error!" - if response.status_code == requests.codes.bad_request - else f"Could not retrieve image data, got: {response}" - ) - log.error(error_message) - if self.fail_fast: - raise PluginError(error_message) + if response.status_code == requests.codes.bad_request: + return Err(ErrorResult(err_msg="Diagram error!", response_text=response.text)) - return KrokiResponse(err_msg=error_message) + return Err(ErrorResult(err_msg=f"Could not retrieve image data, got: {response}")) def get_image_url( self, - kroki_type: str, - kroki_diagram_data: str, - kroki_diagram_options: dict[str, str], - files: MkDocsFiles, - page: MkDocsPage, - ) -> KrokiResponse: + kroki_context: KrokiImageContext, + context: MkDocsEventContext, + ) -> Result[str, ErrorResult]: if self.http_method == "GET": - return self._kroki_url_get(kroki_type, kroki_diagram_data, kroki_diagram_options) + return self._kroki_url_get(kroki_context) - return self._kroki_post(kroki_type, kroki_diagram_data, kroki_diagram_options, files, page) + return self._kroki_post(kroki_context, context) diff --git a/kroki/common.py b/kroki/common.py new file mode 100644 index 0000000..f7f124d --- /dev/null +++ b/kroki/common.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass + +from mkdocs.config.defaults import MkDocsConfig as _MkDocsConfig +from mkdocs.structure.files import File, Files +from mkdocs.structure.pages import Page +from result import Result + +MkDocsPage = Page +MkDocsConfig = _MkDocsConfig +MkDocsFiles = Files +MkDocsFile = File + + +@dataclass +class ErrorResult: + err_msg: str + error: None | Exception = None + response_text: None | str = None + + +@dataclass +class MkDocsEventContext: + page: MkDocsPage + config: MkDocsConfig + files: MkDocsFiles + + +@dataclass +class KrokiImageContext: + endpoint: str + options: dict + data: Result[str, ErrorResult] diff --git a/kroki/config.py b/kroki/config.py new file mode 100644 index 0000000..9ac37aa --- /dev/null +++ b/kroki/config.py @@ -0,0 +1,37 @@ +import os + +from mkdocs.config import config_options +from mkdocs.config.base import Config as MkDocsBaseConfig + +from kroki import version + + +class DeprecatedDownloadImagesCompat(config_options.Deprecated): + def pre_validation(self, config: "KrokiPluginConfig", key_name: str) -> None: + """Set `HttpMethod: 'POST'`, if enabled""" + if config.get(key_name) is None: + return + + self.warnings.append(self.message.format(key_name)) + + download_images: bool = config.pop(key_name) + if download_images: + config.HttpMethod = "POST" + + +class KrokiPluginConfig(MkDocsBaseConfig): + ServerURL = config_options.URL(default=os.getenv("KROKI_SERVER_URL", "https://kroki.io")) + EnableBlockDiag = config_options.Type(bool, default=True) + Enablebpmn = config_options.Type(bool, default=True) + EnableExcalidraw = config_options.Type(bool, default=True) + EnableMermaid = config_options.Type(bool, default=True) + EnableDiagramsnet = config_options.Type(bool, default=False) + HttpMethod = config_options.Choice(choices=["GET", "POST"], default="GET") + UserAgent = config_options.Type(str, default=f"{__name__}/{version}") + FencePrefix = config_options.Type(str, default="kroki-") + FileTypes = config_options.Type(list, default=["svg"]) + FileTypeOverrides = config_options.Type(dict, default={}) + FailFast = config_options.Type(bool, default=False) + + DownloadImages = DeprecatedDownloadImagesCompat(moved_to="HttpMethod: 'POST'") + DownloadDir = config_options.Deprecated(removed=True) diff --git a/kroki/diagram_types.py b/kroki/diagram_types.py index f445fb5..a9b04f2 100644 --- a/kroki/diagram_types.py +++ b/kroki/diagram_types.py @@ -1,10 +1,11 @@ +from collections import ChainMap from typing import ClassVar from kroki.logging import log class KrokiDiagramTypes: - kroki_base: ClassVar[dict[str, list[str]]] = { + _kroki_base: ClassVar[dict[str, list[str]]] = { "bytefield": ["svg"], "ditaa": ["png", "svg"], "erd": ["png", "svg", "jpeg", "pdf"], @@ -26,7 +27,7 @@ class KrokiDiagramTypes: "wireviz": ["png", "svg"], } - kroki_blockdiag: ClassVar[dict[str, list[str]]] = { + _kroki_blockdiag: ClassVar[dict[str, list[str]]] = { "blockdiag": ["png", "svg", "pdf"], "seqdiag": ["png", "svg", "pdf"], "actdiag": ["png", "svg", "pdf"], @@ -35,59 +36,69 @@ class KrokiDiagramTypes: "rackdiag": ["png", "svg", "pdf"], } - kroki_bpmn: ClassVar[dict[str, list[str]]] = { + _kroki_bpmn: ClassVar[dict[str, list[str]]] = { "bpmn": ["svg"], } - kroki_excalidraw: ClassVar[dict[str, list[str]]] = { + _kroki_excalidraw: ClassVar[dict[str, list[str]]] = { "excalidraw": ["svg"], } - kroki_mermaid: ClassVar[dict[str, list[str]]] = { + _kroki_mermaid: ClassVar[dict[str, list[str]]] = { "mermaid": ["png", "svg"], } - kroki_diagramsnet: ClassVar[dict[str, list[str]]] = { + _kroki_diagramsnet: ClassVar[dict[str, list[str]]] = { "diagramsnet": ["svg"], } def __init__( self, + fence_prefix: str, + file_types: list[str], + file_type_overrides: dict[str, str], *, blockdiag_enabled: bool, bpmn_enabled: bool, excalidraw_enabled: bool, mermaid_enabled: bool, diagramsnet_enabled: bool, - file_types: list[str], - file_type_overrides: dict[str, str], ): - diagram_types = self.kroki_base.copy() - - if blockdiag_enabled: - diagram_types.update(self.kroki_blockdiag) - if bpmn_enabled: - diagram_types.update(self.kroki_bpmn) - if excalidraw_enabled: - diagram_types.update(self.kroki_excalidraw) - if mermaid_enabled: - diagram_types.update(self.kroki_mermaid) - if diagramsnet_enabled: - diagram_types.update(self.kroki_diagramsnet) - - self.diagram_types_supporting_file = {} - - for diagram_type, diagram_file_types in diagram_types.items(): - diagram_file_type = next(filter(lambda file: file in diagram_file_types, file_types), None) - if diagram_file_type is not None: - self.diagram_types_supporting_file[diagram_type] = next( - filter(lambda file: file in diagram_file_types, file_types), None + self._fence_prefix = fence_prefix + + kroki_types = ChainMap( + self._kroki_base, + self._kroki_blockdiag if blockdiag_enabled else {}, + self._kroki_bpmn if bpmn_enabled else {}, + self._kroki_excalidraw if excalidraw_enabled else {}, + self._kroki_mermaid if mermaid_enabled else {}, + self._kroki_diagramsnet if diagramsnet_enabled else {}, + ) + + self._kroki_types_supporting_file = {} + + for kroki_type, kroki_file_types in kroki_types.items(): + kroki_file_type = next(filter(lambda file: file in kroki_file_types, file_types), None) + if kroki_file_type is not None: + self._kroki_types_supporting_file[kroki_type] = next( + filter(lambda file: file in kroki_file_types, file_types), None ) - for diagram_type, diagram_file_type in file_type_overrides.items(): - self.diagram_types_supporting_file[diagram_type] = diagram_file_type + for kroki_type, kroki_file_type in file_type_overrides.items(): + self._kroki_types_supporting_file[kroki_type] = kroki_file_type - log.debug("File and Diagram types configured: %s", self.diagram_types_supporting_file) + log.debug("File and Diagram types configured: %s", self._kroki_types_supporting_file) def get_file_ext(self, kroki_type: str) -> str: - return self.diagram_types_supporting_file[kroki_type] + return self._kroki_types_supporting_file[kroki_type] + + def get_kroki_type(self, block_type: None | str) -> None | str: + if block_type is None: + return + if not block_type.startswith(self._fence_prefix): + return + kroki_type = block_type.removeprefix(self._fence_prefix).lower() + if kroki_type not in self._kroki_types_supporting_file: + return + + return kroki_type diff --git a/kroki/parsing.py b/kroki/parsing.py new file mode 100644 index 0000000..41d9967 --- /dev/null +++ b/kroki/parsing.py @@ -0,0 +1,66 @@ +import re +import textwrap +from collections.abc import Callable +from pathlib import Path +from typing import Final + +from result import Err, Ok, Result + +from kroki.common import ErrorResult, KrokiImageContext, MkDocsEventContext +from kroki.diagram_types import KrokiDiagramTypes +from kroki.logging import log + + +class MarkdownParser: + from_file_prefix: Final[str] = "@from_file:" + _FENCE_RE = re.compile( + r"(?P^(?P[ ]*)(?:````*|~~~~*))[ ]*" + r"(\.?(?P[\w#.+-]*)[ ]*)?" + r"(?P(?:[ ]?[a-zA-Z0-9\-_]+=[a-zA-Z0-9\-_]+)*)\n" + r"(?P.*?)(?<=\n)" + r"(?P=fence)[ ]*$", + flags=re.IGNORECASE + re.DOTALL + re.MULTILINE, + ) + + def __init__(self, docs_dir: str, diagram_types: KrokiDiagramTypes) -> None: + self.diagram_types = diagram_types + self.docs_dir = docs_dir + + def _get_block_content(self, block_data: str) -> Result[str, ErrorResult]: + if not block_data.startswith(self.from_file_prefix): + return Ok(block_data) + + file_name = block_data.removeprefix(self.from_file_prefix).strip() + file_path = Path(self.docs_dir) / file_name + log.debug('Reading kroki block from file: "%s"', file_path.absolute()) + try: + with open(file_path) as data_file: + return Ok(data_file.read()) + except OSError as error: + return Err( + ErrorResult( + err_msg=f'Can\'t read file: "{file_path.absolute()}" from code block "{block_data}"', error=error + ) + ) + + def replace_kroki_blocks( + self, + markdown: str, + block_callback: Callable[[KrokiImageContext, MkDocsEventContext], str], + context: MkDocsEventContext, + ) -> str: + def replace_kroki_block(match_obj: re.Match): + kroki_type = self.diagram_types.get_kroki_type(match_obj.group("lang")) + if kroki_type is None: + # Skip not supported code blocks + return match_obj.group() + + kroki_options = match_obj.group("opts") + kroki_context = KrokiImageContext( + endpoint=kroki_type, + options=dict(x.split("=") for x in kroki_options.strip().split(" ")) if kroki_options else {}, + data=self._get_block_content(textwrap.dedent(match_obj.group("code"))), + ) + return textwrap.indent(block_callback(kroki_context, context), match_obj.group("indent")) + + return re.sub(self._FENCE_RE, replace_kroki_block, markdown) diff --git a/kroki/plugin.py b/kroki/plugin.py index 273e39b..b4ede8b 100644 --- a/kroki/plugin.py +++ b/kroki/plugin.py @@ -1,144 +1,41 @@ -import os -import re -import textwrap -from pathlib import Path - -from mkdocs.config import config_options -from mkdocs.config.base import Config as MkDocsBaseConfig -from mkdocs.config.defaults import MkDocsConfig -from mkdocs.exceptions import PluginError from mkdocs.plugins import BasePlugin as MkDocsBasePlugin -from mkdocs.structure.files import Files as MkDocsFiles -from mkdocs.structure.pages import Page as MkDocsPage -from kroki import version -from kroki.client import KrokiClient, KrokiResponse -from kroki.config import KrokiDiagramTypes +from kroki.client import KrokiClient +from kroki.common import MkDocsConfig, MkDocsEventContext, MkDocsFiles, MkDocsPage +from kroki.config import KrokiPluginConfig +from kroki.diagram_types import KrokiDiagramTypes from kroki.logging import log - - -class DeprecatedDownloadImagesCompat(config_options.Deprecated): - def pre_validation(self, config: "KrokiPluginConfig", key_name: str) -> None: - """Set `HttpMethod: 'POST'`, if enabled""" - if config.get(key_name) is None: - return - - self.warnings.append(self.message.format(key_name)) - - download_images: bool = config.pop(key_name) - if download_images: - config.HttpMethod = "POST" - - -class KrokiPluginConfig(MkDocsBaseConfig): - ServerURL = config_options.URL(default=os.getenv("KROKI_SERVER_URL", "https://kroki.io")) - EnableBlockDiag = config_options.Type(bool, default=True) - Enablebpmn = config_options.Type(bool, default=True) - EnableExcalidraw = config_options.Type(bool, default=True) - EnableMermaid = config_options.Type(bool, default=True) - EnableDiagramsnet = config_options.Type(bool, default=False) - HttpMethod = config_options.Choice(choices=["GET", "POST"], default="GET") - UserAgent = config_options.Type(str, default=f"{__name__}/{version}") - FencePrefix = config_options.Type(str, default="kroki-") - FileTypes = config_options.Type(list, default=["svg"]) - FileTypeOverrides = config_options.Type(dict, default={}) - FailFast = config_options.Type(bool, default=False) - - DownloadImages = DeprecatedDownloadImagesCompat(moved_to="HttpMethod: 'POST'") - DownloadDir = config_options.Deprecated(removed=True) +from kroki.parsing import MarkdownParser +from kroki.render import ContentRenderer class KrokiPlugin(MkDocsBasePlugin[KrokiPluginConfig]): - diagram_types: KrokiDiagramTypes - kroki_client: KrokiClient - from_file_prefix = "@from_file:" - global_config: MkDocsConfig - fail_fast: bool - _FENCE_RE = re.compile( - r"(?P^(?P[ ]*)(?:````*|~~~~*))[ ]*" - r"(\.?(?P[\w#.+-]*)[ ]*)?" - r"(?P(?:[ ]?[a-zA-Z0-9\-_]+=[a-zA-Z0-9\-_]+)*)\n" - r"(?P.*?)(?<=\n)" - r"(?P=fence)[ ]*$", - flags=re.IGNORECASE + re.DOTALL + re.MULTILINE, - ) - def on_config(self, config: MkDocsConfig) -> MkDocsConfig: log.debug("Configuring config: %s", self.config) self.diagram_types = KrokiDiagramTypes( + self.config.FencePrefix, + self.config.FileTypes, + self.config.FileTypeOverrides, blockdiag_enabled=self.config.EnableBlockDiag, bpmn_enabled=self.config.Enablebpmn, excalidraw_enabled=self.config.EnableExcalidraw, mermaid_enabled=self.config.EnableMermaid, diagramsnet_enabled=self.config.EnableDiagramsnet, - file_types=self.config.FileTypes, - file_type_overrides=self.config.FileTypeOverrides, ) - self.kroki_client = KrokiClient( server_url=self.config.ServerURL, http_method=self.config.HttpMethod, user_agent=self.config.UserAgent, diagram_types=self.diagram_types, - fail_fast=self.config.FailFast, ) - - self.global_config = config - - self.fail_fast = self.config.FailFast + self.parser = MarkdownParser(config.docs_dir, self.diagram_types) + self.renderer = ContentRenderer(self.kroki_client, fail_fast=self.config.FailFast) return config - def _replace_kroki_block( - self, kroki_type: str, kroki_options: str, kroki_data: str, files: MkDocsFiles, page: MkDocsPage - ) -> str: - if kroki_data.startswith(self.from_file_prefix): - file_name = kroki_data.removeprefix(self.from_file_prefix).strip() - file_path = Path(self.global_config.docs_dir) / file_name - log.debug('Reading kroki block from file: "%s"', file_path.absolute()) - try: - with open(file_path) as data_file: - kroki_data = data_file.read() - except OSError as error: - msg = f'Can\'t read file: "{file_path.absolute()}"' - log.exception(msg) - if self.fail_fast: - raise PluginError(msg) from error - - return f'!!! error "{msg}"' - - kroki_diagram_options = dict(x.split("=") for x in kroki_options.strip().split(" ")) if kroki_options else {} - - response: KrokiResponse = self.kroki_client.get_image_url( - kroki_type, kroki_data, kroki_diagram_options, files, page - ) - log.debug("%s", response) - if response.is_ok(): - return f'

\nKroki\n

' - - if self.fail_fast: - raise PluginError(response.err_msg) - - return f'!!! error "{response.err_msg}"\n\n```\n{kroki_data}\n```' - - def on_page_markdown(self, markdown: str, files: MkDocsFiles, page: MkDocsPage, **_kwargs) -> str: - log.debug("on_page_markdown [page: %s]", page) - - key_types = self.diagram_types.diagram_types_supporting_file.keys() - fence_prefix = self.config.FencePrefix - - def replace_kroki_block(match_obj: re.Match): - kroki_type = match_obj.group("lang").lower() - if kroki_type.startswith(fence_prefix): - kroki_type = kroki_type[len(fence_prefix) :] - - if kroki_type in key_types: - return match_obj.group("indent") + self._replace_kroki_block( - kroki_type, match_obj.group("opts"), textwrap.dedent(match_obj.group("code")), files, page - ) - - # Not supported, skip over whole block - return match_obj.group() + def on_page_markdown(self, markdown: str, page: MkDocsPage, config: MkDocsConfig, files: MkDocsFiles) -> str: + mkdocs_context = MkDocsEventContext(page=page, config=config, files=files) + log.debug("on_page_content [%s]", mkdocs_context) - return re.sub(self._FENCE_RE, replace_kroki_block, markdown) + return self.parser.replace_kroki_blocks(markdown, self.renderer.render_kroki_block, mkdocs_context) diff --git a/kroki/render.py b/kroki/render.py new file mode 100644 index 0000000..03e610b --- /dev/null +++ b/kroki/render.py @@ -0,0 +1,43 @@ +from mkdocs.exceptions import PluginError +from result import Err, Ok + +from kroki.client import KrokiClient +from kroki.common import ErrorResult, KrokiImageContext, MkDocsEventContext +from kroki.logging import log + + +class ContentRenderer: + def __init__(self, kroki_client: KrokiClient, *, fail_fast: bool) -> None: + self.fail_fast = fail_fast + self.kroki_client = kroki_client + + def _image_response(self, src_path: str): + # return f'

\nKroki\n

' + return f"![Kroki]({src_path})" + + def _err_response(self, err_result: ErrorResult, kroki_data: None | str = None): + if ErrorResult.error is None: + log.error("%s", err_result.err_msg) + else: + log.error("%s [raised by %s]", err_result.err_msg, err_result.error) + + if self.fail_fast: + raise PluginError(err_result.err_msg) from err_result.error + + # return '
' \ + # f"{err_result.err_msg}" \ + # f"

{err_result.response_text or ''}

" \ + # f'
{kroki_context.data}
' \ + # "
" + return f'!!! error "{err_result.err_msg}"\n{err_result.response_text or ""}\n```\n{kroki_data or ""}\n```' + + def render_kroki_block(self, kroki_context: KrokiImageContext, context: MkDocsEventContext) -> str: + match kroki_context.data: + case Ok(kroki_data): + match self.kroki_client.get_image_url(kroki_context, context): + case Ok(image_url): + return self._image_response(image_url) + case Err(err_result): + return self._err_response(err_result, kroki_data) + case Err(err_result): + return self._err_response(err_result) diff --git a/kroki/util.py b/kroki/util.py deleted file mode 100644 index 396ee9e..0000000 --- a/kroki/util.py +++ /dev/null @@ -1,45 +0,0 @@ -from os import makedirs, path -from uuid import NAMESPACE_OID, uuid3 - -from mkdocs.structure.files import File as MkDocsFile -from mkdocs.structure.files import Files as MkDocsFiles -from mkdocs.structure.pages import Page as MkDocsPage - -from kroki.logging import log - - -class DownloadedImage: - def __init__(self, file_content: bytes, file_extension: str, additional_metadata: dict) -> None: - file_uuid = uuid3(NAMESPACE_OID, f"{additional_metadata}{file_content}") - - self.file_name = f"kroki-generated-{file_uuid}.{file_extension}" - self.file_content = file_content - - def save(self, files: MkDocsFiles, page: MkDocsPage) -> None: - # wherever MkDocs wants to host or build, we plant the image next - # to the generated static page - page_abs_dest_dir = path.dirname(page.file.abs_dest_path) - makedirs(page_abs_dest_dir, exist_ok=True) - - file_path = path.join(page_abs_dest_dir, self.file_name) - - log.debug("Saving image data: %s", file_path) - with open(file_path, "wb") as file: - file.write(self.file_content) - - # make MkDocs believe that the file was present from the beginning - file_src_uri = path.join(path.dirname(page.file.src_uri), self.file_name) - file_dest_uri = path.join(path.dirname(page.file.dest_uri), self.file_name) - - dummy_file = MkDocsFile( - path=file_src_uri, - src_dir="", - dest_dir="", - use_directory_urls=False, - dest_uri=file_dest_uri, - ) - # MkDocs will not copy the file in this case - dummy_file.abs_src_path = dummy_file.abs_dest_path = file_path - - log.debug("Appending dummy mkdocs file: %s", dummy_file) - files.append(dummy_file) diff --git a/pyproject.toml b/pyproject.toml index 043180b..3bac532 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ dependencies = [ "mkdocs>=1.5.0", "requests>=2.27.0", + "result", ] [project.entry-points."mkdocs.plugins"] diff --git a/tests/conftest.py b/tests/conftest.py index 55329ae..2eed27d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ def no_actual_requests_please(monkeypatch): class MockResponse: status_code: int content: None | bytes = None + text: None | str = None @pytest.fixture @@ -31,7 +32,7 @@ def kroki_bad_request(monkeypatch) -> None: """Let request post calls always return a mocked response with status code 400""" def mock_post(*_args, **_kwargs): - return MockResponse(status_code=400) + return MockResponse(status_code=400, text="Error 400: Syntax Error? (line: 10)") monkeypatch.setattr(requests, "post", mock_post) diff --git a/tests/test_errors.py b/tests/test_errors.py index 2f48af1..1b5534b 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,6 +1,6 @@ import pytest -from tests.utils import MkDocsHelper +from tests.utils import MkDocsHelper, get_expected_log_line @pytest.mark.usefixtures("kroki_timeout") @@ -11,9 +11,10 @@ def test_request_timeout() -> None: result = mkdocs_helper.invoke_build() assert result.exit_code == 0 - assert "kroki: Request error" in result.output + assert get_expected_log_line("Request error") in result.output with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file: assert '

!!! error "Request error' in index_html_file.read() + # assert '

Request error' in index_html_file.read() @pytest.mark.usefixtures("kroki_bad_request") @@ -24,9 +25,10 @@ def test_request_bad_request() -> None: result = mkdocs_helper.invoke_build() assert result.exit_code == 0 - assert "kroki: Diagram error!" in result.output + assert get_expected_log_line("Diagram error!") in result.output with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file: - assert '

!!! error "Diagram error!"

' in index_html_file.read() + assert '

!!! error "Diagram error!"' in index_html_file.read() + # assert '

Diagram error!' in index_html_file.read() @pytest.mark.usefixtures("kroki_is_a_teapot") @@ -37,9 +39,10 @@ def test_request_other_error() -> None: result = mkdocs_helper.invoke_build() assert result.exit_code == 0 - assert "Could not retrieve image data" in result.output + assert get_expected_log_line("Could not retrieve image data") in result.output with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file: assert '

!!! error "Could not retrieve image data' in index_html_file.read() + # assert '

Could not retrieve image data' in index_html_file.read() @pytest.mark.usefixtures("kroki_dummy") @@ -49,6 +52,7 @@ def test_missing_file_from() -> None: result = mkdocs_helper.invoke_build() assert result.exit_code == 0 - assert "kroki: Can't read file:" in result.output + assert get_expected_log_line("Can't read file:") in result.output with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file: assert "

!!! error \"Can't read file: " in index_html_file.read() + # assert "

Can't read file: " in index_html_file.read() diff --git a/tests/test_errors_fail_fast.py b/tests/test_errors_fail_fast.py index fef3eed..493facb 100644 --- a/tests/test_errors_fail_fast.py +++ b/tests/test_errors_fail_fast.py @@ -1,6 +1,6 @@ import pytest -from tests.utils import MkDocsHelper +from tests.utils import MkDocsHelper, get_expected_log_line @pytest.mark.usefixtures("kroki_timeout") @@ -12,7 +12,7 @@ def test_request_timeout() -> None: result = mkdocs_helper.invoke_build() assert result.exit_code == 1 - assert "Request error" in result.output + assert get_expected_log_line("Request error") in result.output assert "Aborted with a BuildError!" in result.output @@ -25,7 +25,7 @@ def test_request_bad_request() -> None: result = mkdocs_helper.invoke_build() assert result.exit_code == 1 - assert "Diagram error!" in result.output + assert get_expected_log_line("Diagram error!") in result.output assert "Aborted with a BuildError!" in result.output @@ -38,7 +38,7 @@ def test_request_other_error() -> None: result = mkdocs_helper.invoke_build() assert result.exit_code == 1 - assert "Could not retrieve image data" in result.output + assert get_expected_log_line("Could not retrieve image data") in result.output assert "Aborted with a BuildError!" in result.output @@ -50,5 +50,5 @@ def test_missing_file_from() -> None: result = mkdocs_helper.invoke_build() assert result.exit_code == 1 - assert "kroki: Can't read file:" in result.output + assert get_expected_log_line("Can't read file:") in result.output assert "Aborted with a BuildError!" in result.output diff --git a/tests/test_fences.py b/tests/test_fences.py index 7b1c46e..f8fe349 100644 --- a/tests/test_fences.py +++ b/tests/test_fences.py @@ -150,9 +150,9 @@ def test_fences(test_code_block) -> None: mkdocs_helper.set_http_method("POST") result = mkdocs_helper.invoke_build() - assert result.exit_code == 0 + assert result.exit_code == 0, f"exit code {result.exit_code}, expected 0" image_files = list((mkdocs_helper.test_dir / "site").glob("*.svg")) - assert len(image_files) == 1 + assert len(image_files) == 1, f"created images {len(image_files)}, expected 1" @pytest.mark.parametrize( @@ -166,21 +166,21 @@ def test_fences_not_supported(test_code_block) -> None: mkdocs_helper.set_http_method("POST") result = mkdocs_helper.invoke_build() - assert result.exit_code == 0 + assert result.exit_code == 0, f"exit code {result.exit_code}, expected 0" image_files = list((mkdocs_helper.test_dir / "site").glob("*.svg")) - assert len(image_files) == 0 + assert len(image_files) == 0, f"created images {len(image_files)}, expected 0" @pytest.mark.usefixtures("kroki_dummy") def test_pandoc_fenced_code_blocks() -> None: - with MkDocsTemplateHelper("""~~~~~~~~~~~~~~~~ mermaid -~~~~~~~~~~ + with MkDocsTemplateHelper("""~~~~~~~~~~~~~~~~ +~~~~~~~~~~ mermaid code including tildes ~~~~~~~~~~ ~~~~~~~~~~~~~~~~""") as mkdocs_helper: mkdocs_helper.set_http_method("POST") result = mkdocs_helper.invoke_build() - assert result.exit_code == 0 + assert result.exit_code == 0, f"exit code {result.exit_code}, expected 0" image_files = list((mkdocs_helper.test_dir / "site").glob("*.svg")) - assert len(image_files) == 1 + assert len(image_files) == 0, f"created images {len(image_files)}, expected 0" diff --git a/tests/utils.py b/tests/utils.py index ff26812..664bf6e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -11,9 +11,14 @@ from click.testing import CliRunner, Result from mkdocs.__main__ import build_command +from kroki.logging import log from tests.compat import chdir +def get_expected_log_line(log_msg) -> str: + return f"{log.prefix}: {log_msg}" + + class NoPluginEntryError(ValueError): def __init__(self) -> None: super().__init__("No kroki plugin entry found") From c1ba3f3514b9c9c06ea350efe5fa230127ce35d6 Mon Sep 17 00:00:00 2001 From: oniboni Date: Sun, 19 May 2024 18:26:20 +0000 Subject: [PATCH 08/12] refactor: rename option Enablebpmn -> EnableBpmn old option is automatically moved to new one --- README.md | 2 +- kroki/config.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e00a473..e56b333 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ plugins: | `ServerURL` | URL of your kroki-Server, default: `!ENV [KROKI_SERVER_URL, 'https://kroki.io']` | | `FencePrefix` | Diagram prefix, default: `kroki-` | | `EnableBlockDiag` | Enable BlockDiag (and the related Diagrams), default: `true` | -| `Enablebpmn` | Enable BPMN, default: `true` | +| `EnableBpmn` | Enable BPMN, default: `true` | | `EnableExcalidraw` | Enable Excalidraw, default: `true` | | `EnableMermaid` | Enable Mermaid, default: `true` | | `EnableDiagramsnet` | Enable diagrams.net (draw.io), default: `false` | diff --git a/kroki/config.py b/kroki/config.py index 9ac37aa..1077eb0 100644 --- a/kroki/config.py +++ b/kroki/config.py @@ -22,7 +22,7 @@ def pre_validation(self, config: "KrokiPluginConfig", key_name: str) -> None: class KrokiPluginConfig(MkDocsBaseConfig): ServerURL = config_options.URL(default=os.getenv("KROKI_SERVER_URL", "https://kroki.io")) EnableBlockDiag = config_options.Type(bool, default=True) - Enablebpmn = config_options.Type(bool, default=True) + EnableBpmn = config_options.Type(bool, default=True) EnableExcalidraw = config_options.Type(bool, default=True) EnableMermaid = config_options.Type(bool, default=True) EnableDiagramsnet = config_options.Type(bool, default=False) @@ -34,4 +34,5 @@ class KrokiPluginConfig(MkDocsBaseConfig): FailFast = config_options.Type(bool, default=False) DownloadImages = DeprecatedDownloadImagesCompat(moved_to="HttpMethod: 'POST'") + Enablebpmn = config_options.Deprecated(moved_to="EnableBpmn") DownloadDir = config_options.Deprecated(removed=True) From dfc2f28659d0691aa055e422721e0e7181855942 Mon Sep 17 00:00:00 2001 From: oniboni Date: Thu, 30 May 2024 21:25:34 +0000 Subject: [PATCH 09/12] refactor: add image file extension to retrieed image data --- kroki/client.py | 35 ++++++++++++++++++++--------------- kroki/common.py | 8 +++++++- kroki/parsing.py | 2 +- kroki/render.py | 18 ++++++------------ 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/kroki/client.py b/kroki/client.py index b3832b8..93fd5f8 100644 --- a/kroki/client.py +++ b/kroki/client.py @@ -8,7 +8,7 @@ import requests from result import Err, Ok, Result -from kroki.common import ErrorResult, KrokiImageContext, MkDocsEventContext, MkDocsFile +from kroki.common import ErrorResult, ImageSrc, KrokiImageContext, MkDocsEventContext, MkDocsFile from kroki.diagram_types import KrokiDiagramTypes from kroki.logging import log @@ -63,26 +63,33 @@ def __init__(self, server_url: str, http_method: str, user_agent: str, diagram_t log.debug("Client initialized [http_method: %s, server_url: %s]", self.http_method, self.server_url) def _kroki_url_base(self, kroki_type: str) -> str: - file_type = self.diagram_types.get_file_ext(kroki_type) - return f"{self.server_url}/{kroki_type}/{file_type}" + return f"{self.server_url}/{kroki_type}" - def _kroki_url_get(self, kroki_context: KrokiImageContext) -> Result[str, ErrorResult]: + def _get_file_ext(self, kroki_type: str) -> str: + return self.diagram_types.get_file_ext(kroki_type) + + def _kroki_url_get(self, kroki_context: KrokiImageContext) -> Result[ImageSrc, ErrorResult]: kroki_data_param = base64.urlsafe_b64encode(zlib.compress(str.encode(kroki_context.data.unwrap()), 9)).decode() kroki_query_param = ( "&".join([f"{k}={v}" for k, v in kroki_context.options.items()]) if len(kroki_context.options) > 0 else "" ) - kroki_url = self._kroki_url_base(kroki_context.endpoint) - image_url = f"{kroki_url}/{kroki_data_param}?{kroki_query_param}" + kroki_endpoint = self._kroki_url_base(kroki_type=kroki_context.kroki_type) + file_ext = self._get_file_ext(kroki_context.kroki_type) + image_url = f"{kroki_endpoint}/{file_ext}/{kroki_data_param}?{kroki_query_param}" if len(image_url) >= MAX_URI_SIZE: log.warning("Kroki may not be able to read the data completely! [data_len: %i]", len(image_url)) log.debug("Image url: %s", textwrap.shorten(image_url, 50)) - return Ok(image_url) + return Ok(ImageSrc(url=image_url, file_ext=file_ext)) - def _kroki_post(self, kroki_context: KrokiImageContext, context: MkDocsEventContext) -> Result[str, ErrorResult]: - url = self._kroki_url_base(kroki_context.endpoint) + def _kroki_post( + self, kroki_context: KrokiImageContext, context: MkDocsEventContext + ) -> Result[ImageSrc, ErrorResult]: + kroki_endpoint = self._kroki_url_base(kroki_context.kroki_type) + file_ext = self._get_file_ext(kroki_context.kroki_type) + url = f"{kroki_endpoint}/{file_ext}" log.debug("POST %s", textwrap.shorten(url, 50)) try: @@ -102,11 +109,11 @@ def _kroki_post(self, kroki_context: KrokiImageContext, context: MkDocsEventCont if response.status_code == requests.codes.ok: downloaded_image = DownloadedContent( response.content, - self.diagram_types.get_file_ext(kroki_context.endpoint), + file_ext, kroki_context.options, ) downloaded_image.save(context) - return Ok(downloaded_image.file_name) + return Ok(ImageSrc(url=downloaded_image.file_name, file_ext=file_ext)) if response.status_code == requests.codes.bad_request: return Err(ErrorResult(err_msg="Diagram error!", response_text=response.text)) @@ -114,10 +121,8 @@ def _kroki_post(self, kroki_context: KrokiImageContext, context: MkDocsEventCont return Err(ErrorResult(err_msg=f"Could not retrieve image data, got: {response}")) def get_image_url( - self, - kroki_context: KrokiImageContext, - context: MkDocsEventContext, - ) -> Result[str, ErrorResult]: + self, kroki_context: KrokiImageContext, context: MkDocsEventContext + ) -> Result[ImageSrc, ErrorResult]: if self.http_method == "GET": return self._kroki_url_get(kroki_context) diff --git a/kroki/common.py b/kroki/common.py index f7f124d..7af79d2 100644 --- a/kroki/common.py +++ b/kroki/common.py @@ -11,6 +11,12 @@ MkDocsFile = File +@dataclass +class ImageSrc: + url: str + file_ext: str + + @dataclass class ErrorResult: err_msg: str @@ -27,6 +33,6 @@ class MkDocsEventContext: @dataclass class KrokiImageContext: - endpoint: str + kroki_type: str options: dict data: Result[str, ErrorResult] diff --git a/kroki/parsing.py b/kroki/parsing.py index 41d9967..3910641 100644 --- a/kroki/parsing.py +++ b/kroki/parsing.py @@ -57,7 +57,7 @@ def replace_kroki_block(match_obj: re.Match): kroki_options = match_obj.group("opts") kroki_context = KrokiImageContext( - endpoint=kroki_type, + kroki_type=kroki_type, options=dict(x.split("=") for x in kroki_options.strip().split(" ")) if kroki_options else {}, data=self._get_block_content(textwrap.dedent(match_obj.group("code"))), ) diff --git a/kroki/render.py b/kroki/render.py index 03e610b..fbbac4a 100644 --- a/kroki/render.py +++ b/kroki/render.py @@ -2,7 +2,7 @@ from result import Err, Ok from kroki.client import KrokiClient -from kroki.common import ErrorResult, KrokiImageContext, MkDocsEventContext +from kroki.common import ErrorResult, ImageSrc, KrokiImageContext, MkDocsEventContext from kroki.logging import log @@ -11,11 +11,10 @@ def __init__(self, kroki_client: KrokiClient, *, fail_fast: bool) -> None: self.fail_fast = fail_fast self.kroki_client = kroki_client - def _image_response(self, src_path: str): - # return f'

\nKroki\n

' - return f"![Kroki]({src_path})" + def _image_response(self, image_src: ImageSrc) -> str: + return f"![Kroki]({image_src.url})" - def _err_response(self, err_result: ErrorResult, kroki_data: None | str = None): + def _err_response(self, err_result: ErrorResult, kroki_data: None | str = None) -> str: if ErrorResult.error is None: log.error("%s", err_result.err_msg) else: @@ -24,19 +23,14 @@ def _err_response(self, err_result: ErrorResult, kroki_data: None | str = None): if self.fail_fast: raise PluginError(err_result.err_msg) from err_result.error - # return '
' \ - # f"{err_result.err_msg}" \ - # f"

{err_result.response_text or ''}

" \ - # f'
{kroki_context.data}
' \ - # "
" return f'!!! error "{err_result.err_msg}"\n{err_result.response_text or ""}\n```\n{kroki_data or ""}\n```' def render_kroki_block(self, kroki_context: KrokiImageContext, context: MkDocsEventContext) -> str: match kroki_context.data: case Ok(kroki_data): match self.kroki_client.get_image_url(kroki_context, context): - case Ok(image_url): - return self._image_response(image_url) + case Ok(image_src): + return self._image_response(image_src) case Err(err_result): return self._err_response(err_result, kroki_data) case Err(err_result): From ba1f29815cbb3a5c1d9dbb0d8a351a458af13d72 Mon Sep 17 00:00:00 2001 From: oniboni Date: Thu, 30 May 2024 21:29:25 +0000 Subject: [PATCH 10/12] test: test fences unit-style --- pyproject.toml | 5 +- tests/conftest.py | 16 +++ tests/test_fences.py | 294 +++++++++++++++++++++++++++++++------------ 3 files changed, 233 insertions(+), 82 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3bac532..b2f9cd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,8 +72,9 @@ KROKI_SERVER_URL = "http://localhost:8080" [tool.hatch.envs.hatch-test] extra-dependencies = [ - "mkdocs-material", - "click", + "pytest-mock", + "mkdocs-material", + "click", ] [[tool.hatch.envs.hatch-test.matrix]] python = ["3.10", "3.11", "3.12"] diff --git a/tests/conftest.py b/tests/conftest.py index 2eed27d..e8a3c85 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,8 @@ import pytest import requests +from kroki.diagram_types import KrokiDiagramTypes + @pytest.fixture(autouse=True) def no_actual_requests_please(monkeypatch): @@ -10,6 +12,20 @@ def no_actual_requests_please(monkeypatch): monkeypatch.delattr("requests.sessions.Session.request") +@pytest.fixture +def mock_kroki_diagram_types() -> KrokiDiagramTypes: + return KrokiDiagramTypes( + "", + ["svg"], + {}, + blockdiag_enabled=True, + bpmn_enabled=True, + excalidraw_enabled=True, + mermaid_enabled=True, + diagramsnet_enabled=True, + ) + + @dataclass class MockResponse: status_code: int diff --git a/tests/test_fences.py b/tests/test_fences.py index f8fe349..dceade2 100644 --- a/tests/test_fences.py +++ b/tests/test_fences.py @@ -1,120 +1,264 @@ +from dataclasses import dataclass + import pytest +from pytest_mock import MockerFixture +from result import Ok + +from kroki.common import KrokiImageContext +from kroki.parsing import KrokiDiagramTypes, MarkdownParser + + +@dataclass +class StubInput: + page_data: str + expected_code_block_data: str | None = None + epxected_options: dict | None = None + expected_kroki_type: str | None = None -from tests.utils import MkDocsTemplateHelper TEST_CASES = { - "#35": """ + "#35": StubInput( + page_data=""" ```` plantuml stuff containing ``` + ```` """, - "https://spec.commonmark.org/0.31.2/#example-119": """ + expected_code_block_data="stuff containing ```\n\n", + expected_kroki_type="plantuml", + epxected_options={}, + ), + "https://pandoc.org/MANUAL.html#fenced-code-blocks": StubInput( + page_data="""~~~~~~~~~~~~~~~~ mermaid +~~~~~~~~~~ +code including tildes +~~~~~~~~~~ +~~~~~~~~~~~~~~~~""", + expected_code_block_data="~~~~~~~~~~\ncode including tildes\n~~~~~~~~~~\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-119": StubInput( + page_data=""" ``` mermaid < > ``` """, - "https://spec.commonmark.org/0.31.2/#example-120": """ + expected_code_block_data="<\n >\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-120": StubInput( + page_data=""" ~~~ mermaid < > ~~~ """, - "https://spec.commonmark.org/0.31.2/#example-122": """ + expected_code_block_data="<\n >\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-122": StubInput( + page_data=""" ``` mermaid aaa ~~~ ``` """, - "https://spec.commonmark.org/0.31.2/#example-123": """ + expected_code_block_data="aaa\n~~~\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-123": StubInput( + page_data=""" ~~~ mermaid aaa ``` ~~~ """, - "https://spec.commonmark.org/0.31.2/#example-125": """ + expected_code_block_data="aaa\n```\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-125": StubInput( + page_data=""" ~~~~ mermaid aaa ~~~ ~~~~ """, - "https://spec.commonmark.org/0.31.2/#example-129": """ + expected_code_block_data="aaa\n~~~\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-129": StubInput( + page_data=""" ``` mermaid ``` """, - "https://spec.commonmark.org/0.31.2/#example-130": """ + expected_code_block_data="\n\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-130": StubInput( + page_data=""" +``` mermaid +``` +""", + expected_code_block_data="", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-147": StubInput( + page_data=""" ``` mermaid +``` aaa ``` """, + expected_code_block_data="``` aaa\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), +} + +TEST_CASES_NOT_COMPLYING = { + "https://spec.commonmark.org/0.31.2/#example-132": StubInput( + page_data=""" + ``` mermaid +aaa + aaa +aaa + ``` +""", + expected_code_block_data="aaa\n aaa\naaa\n", # "aaa\naaa\naaa\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-133": StubInput( + page_data=""" + ``` mermaid + aaa + aaa + aaa + ``` +""", + expected_code_block_data=" aaa\n aaa\naaa\n", # "aaa\n aaa\naaa\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), } TEST_CASES_NOT_SUPPORTED = { - "https://spec.commonmark.org/0.31.2/#example-121": """ + "https://spec.commonmark.org/0.31.2/#example-121": StubInput( + page_data=""" `` mermaid foo `` """, - "https://spec.commonmark.org/0.31.2/#example-124": """ + expected_code_block_data="foo\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-124": StubInput( + page_data=""" ```` mermaid aaa ``` `````` """, - "https://spec.commonmark.org/0.31.2/#example-126": """ + expected_code_block_data="aaa\n```\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-126": StubInput( + page_data=""" ``` mermaid """, - "https://spec.commonmark.org/0.31.2/#example-127": """ + expected_code_block_data="\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-127": StubInput( + page_data=""" ````` mermaid ``` aaa """, - "https://spec.commonmark.org/0.31.2/#example-128": """ + expected_code_block_data="\n```\naaa\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-128": StubInput( + page_data=""" > ``` mermaid > aaa bbb """, - "https://spec.commonmark.org/0.31.2/#example-131": """ + expected_code_block_data="aaa\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-131": StubInput( + page_data=""" ``` mermaid aaa aaa ``` """, - "https://spec.commonmark.org/0.31.2/#example-132": """ - ``` -aaa - aaa -aaa - ``` -""", - "https://spec.commonmark.org/0.31.2/#example-133": """ - ``` - aaa + expected_code_block_data="aaa\naaa\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-134": StubInput( + page_data=""" + ``` mermaid aaa - aaa - ``` -""", - "https://spec.commonmark.org/0.31.2/#example-135": """ + ```, + expected_code_block_data="aaa\n", # should not be replaced.. + expected_kroki_type="mermaid", + epxected_options={}, +""" + ), + "https://spec.commonmark.org/0.31.2/#example-135": StubInput( + page_data=""" ``` aaa ``` """, - "https://spec.commonmark.org/0.31.2/#example-136": """ + expected_code_block_data="aaa\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-136": StubInput( + page_data=""" ``` aaa ``` """, - "https://spec.commonmark.org/0.31.2/#example-140": """ + expected_code_block_data="aaa\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-140": StubInput( + page_data=""" foo ``` bar ``` baz """, - "https://spec.commonmark.org/0.31.2/#example-141": """ + expected_code_block_data="bar\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-141": StubInput( + page_data=""" foo --- ~~~ @@ -122,65 +266,55 @@ ~~~ # baz """, - "https://spec.commonmark.org/0.31.2/#example-146": """ -~~~ aa ``` ~~~ + expected_code_block_data="bar\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), + "https://spec.commonmark.org/0.31.2/#example-146": StubInput( + page_data=""" +~~~ mermaid ``` ~~~ foo ~~~ """, - "https://spec.commonmark.org/0.31.2/#example-147": """ -``` -``` aaa -``` -""", + expected_code_block_data="foo\n", + expected_kroki_type="mermaid", + epxected_options={}, + ), } -TEST_CASES_ESCAPED = { - "https://spec.commonmark.org/0.31.2/#example-134": """ - ``` - aaa - ``` -""", -} +@pytest.mark.parametrize( + "test_data", + [pytest.param(v, id=k) for k, v in TEST_CASES.items()] + + [pytest.param(v, id=k) for k, v in TEST_CASES_NOT_COMPLYING.items()], +) +def test_fences(test_data: StubInput, mock_kroki_diagram_types: KrokiDiagramTypes, mocker: MockerFixture) -> None: + parser = MarkdownParser("", mock_kroki_diagram_types) -@pytest.mark.parametrize("test_code_block", [pytest.param(v, id=k) for k, v in TEST_CASES.items()]) -@pytest.mark.usefixtures("kroki_dummy") -def test_fences(test_code_block) -> None: - with MkDocsTemplateHelper(test_code_block) as mkdocs_helper: - mkdocs_helper.set_http_method("POST") - result = mkdocs_helper.invoke_build() + callback_stub = mocker.stub() + context_stub = mocker.stub() - assert result.exit_code == 0, f"exit code {result.exit_code}, expected 0" - image_files = list((mkdocs_helper.test_dir / "site").glob("*.svg")) - assert len(image_files) == 1, f"created images {len(image_files)}, expected 1" + parser.replace_kroki_blocks(test_data.page_data, callback_stub, context_stub) + callback_stub.assert_called_once_with( + KrokiImageContext( + kroki_type=test_data.expected_kroki_type, + data=Ok(test_data.expected_code_block_data), + options=test_data.epxected_options, + ), + context_stub, + ) -@pytest.mark.parametrize( - "test_code_block", - [pytest.param(v, id=k) for k, v in TEST_CASES_NOT_SUPPORTED.items()] - + [pytest.param(v, id=k) for k, v in TEST_CASES_ESCAPED.items()], -) -@pytest.mark.usefixtures("kroki_dummy") -def test_fences_not_supported(test_code_block) -> None: - with MkDocsTemplateHelper(test_code_block) as mkdocs_helper: - mkdocs_helper.set_http_method("POST") - result = mkdocs_helper.invoke_build() - assert result.exit_code == 0, f"exit code {result.exit_code}, expected 0" - image_files = list((mkdocs_helper.test_dir / "site").glob("*.svg")) - assert len(image_files) == 0, f"created images {len(image_files)}, expected 0" +@pytest.mark.parametrize("test_data", [pytest.param(v, id=k) for k, v in TEST_CASES_NOT_SUPPORTED.items()]) +def test_fences_not_supported( + test_data: StubInput, mock_kroki_diagram_types: KrokiDiagramTypes, mocker: MockerFixture +) -> None: + parser = MarkdownParser("", mock_kroki_diagram_types) + callback_stub = mocker.stub() + context_stub = mocker.stub() -@pytest.mark.usefixtures("kroki_dummy") -def test_pandoc_fenced_code_blocks() -> None: - with MkDocsTemplateHelper("""~~~~~~~~~~~~~~~~ -~~~~~~~~~~ mermaid -code including tildes -~~~~~~~~~~ -~~~~~~~~~~~~~~~~""") as mkdocs_helper: - mkdocs_helper.set_http_method("POST") - result = mkdocs_helper.invoke_build() + parser.replace_kroki_blocks(test_data.page_data, callback_stub, context_stub) - assert result.exit_code == 0, f"exit code {result.exit_code}, expected 0" - image_files = list((mkdocs_helper.test_dir / "site").glob("*.svg")) - assert len(image_files) == 0, f"created images {len(image_files)}, expected 0" + callback_stub.assert_not_called() From 569b1e3a73b654a020ec57d8a95a4b5186896730 Mon Sep 17 00:00:00 2001 From: oniboni Date: Thu, 30 May 2024 21:34:48 +0000 Subject: [PATCH 11/12] test: add test for md code block inside html --- pyproject.toml | 1 + tests/test_nested.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/test_nested.py diff --git a/pyproject.toml b/pyproject.toml index b2f9cd1..73fb328 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ KROKI_SERVER_URL = "http://localhost:8080" [tool.hatch.envs.hatch-test] extra-dependencies = [ "pytest-mock", + "beautifulsoup4", "mkdocs-material", "click", ] diff --git a/tests/test_nested.py b/tests/test_nested.py new file mode 100644 index 0000000..e8d0042 --- /dev/null +++ b/tests/test_nested.py @@ -0,0 +1,31 @@ +import bs4 +import pytest + +from tests.utils import MkDocsTemplateHelper + + +@pytest.mark.usefixtures("kroki_dummy") +def test_block_inside_html() -> None: + with MkDocsTemplateHelper(""" +
+ Show Sequence diagram... +```mermaid +graph TD + a --> b +``` +
+ +```mermaid +graph TD + a --> b +``` +""") as mkdocs_helper: + mkdocs_helper.set_http_method("POST") + result = mkdocs_helper.invoke_build() + + assert result.exit_code == 0, f"exit code {result.exit_code}, expected 0" + with open(mkdocs_helper.test_dir / "site/index.html") as index_html: + index_soup = bs4.BeautifulSoup(index_html.read()) + for string in index_soup.strings: + assert "![Kroki]" not in string, f"markdown image was not rendered to HTML: {string}" + assert len(index_soup.find_all("img", alt="Kroki")) == 2, "no image was included" From 582f91e576ed264da7b049988a7154bafa0e6eb1 Mon Sep 17 00:00:00 2001 From: oniboni Date: Sat, 1 Jun 2024 10:39:38 +0000 Subject: [PATCH 12/12] fix: use html insead of markdown to include images --- kroki/client.py | 13 ++++++++++++- kroki/render.py | 24 ++++++++++++++++++++++-- tests/conftest.py | 4 ++++ tests/test_errors.py | 17 +++++++++-------- tests/test_nested.py | 4 +--- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/kroki/client.py b/kroki/client.py index 93fd5f8..d0cc823 100644 --- a/kroki/client.py +++ b/kroki/client.py @@ -17,11 +17,20 @@ class DownloadedContent: + def _ugly_temp_excalidraw_fix(self) -> None: + """TODO: remove me, when excalidraw container works again.. + ref: https://github.com/excalidraw/excalidraw/issues/7366""" + self.file_content = self.file_content.replace( + b"https://unpkg.com/@excalidraw/excalidraw@undefined/dist", + b"https://unpkg.com/@excalidraw/excalidraw@0.17.1/dist", + ) + def __init__(self, file_content: bytes, file_extension: str, additional_metadata: None | dict) -> None: file_uuid = uuid3(NAMESPACE_OID, f"{additional_metadata}{file_content}") self.file_name = f"{FILE_PREFIX}{file_uuid}.{file_extension}" self.file_content = file_content + self._ugly_temp_excalidraw_fix() def save(self, context: MkDocsEventContext) -> None: # wherever MkDocs wants to host or build, we plant the image next @@ -118,7 +127,9 @@ def _kroki_post( if response.status_code == requests.codes.bad_request: return Err(ErrorResult(err_msg="Diagram error!", response_text=response.text)) - return Err(ErrorResult(err_msg=f"Could not retrieve image data, got: {response}")) + return Err( + ErrorResult(err_msg=f"Could not retrieve image data, got: {response.reason} [{response.status_code}]") + ) def get_image_url( self, kroki_context: KrokiImageContext, context: MkDocsEventContext diff --git a/kroki/render.py b/kroki/render.py index fbbac4a..05b97f8 100644 --- a/kroki/render.py +++ b/kroki/render.py @@ -11,8 +11,22 @@ def __init__(self, kroki_client: KrokiClient, *, fail_fast: bool) -> None: self.fail_fast = fail_fast self.kroki_client = kroki_client + def _get_media_type(self, file_ext: str) -> str: + match file_ext: + case "png": + return "image/png" + case "svg": + return "image/svg+xml" + case "jpeg": + return "image/jpg" + case "pdf": + return "application/pdf" + case _: + raise NotImplementedError(file_ext) + def _image_response(self, image_src: ImageSrc) -> str: - return f"![Kroki]({image_src.url})" + media_type = self._get_media_type(image_src.file_ext) + return f'' def _err_response(self, err_result: ErrorResult, kroki_data: None | str = None) -> str: if ErrorResult.error is None: @@ -23,7 +37,13 @@ def _err_response(self, err_result: ErrorResult, kroki_data: None | str = None) if self.fail_fast: raise PluginError(err_result.err_msg) from err_result.error - return f'!!! error "{err_result.err_msg}"\n{err_result.response_text or ""}\n```\n{kroki_data or ""}\n```' + return ( + '
' + f"{err_result.err_msg}" + f'

{err_result.response_text or ""}

' + f'
{kroki_data or ""}
' + "
" + ) def render_kroki_block(self, kroki_context: KrokiImageContext, context: MkDocsEventContext) -> str: match kroki_context.data: diff --git a/tests/conftest.py b/tests/conftest.py index e8a3c85..d40cc85 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,10 @@ class MockResponse: content: None | bytes = None text: None | str = None + @property + def reason(self) -> str: + return requests.codes.get(self.status_code) + @pytest.fixture def kroki_timeout(monkeypatch) -> None: diff --git a/tests/test_errors.py b/tests/test_errors.py index 1b5534b..52266a5 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,3 +1,4 @@ +import bs4 import pytest from tests.utils import MkDocsHelper, get_expected_log_line @@ -13,8 +14,8 @@ def test_request_timeout() -> None: assert result.exit_code == 0 assert get_expected_log_line("Request error") in result.output with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file: - assert '

!!! error "Request error' in index_html_file.read() - # assert '

Request error' in index_html_file.read() + index_soup = bs4.BeautifulSoup(index_html_file.read()) + assert "Request error" in index_soup.find("details").summary.text @pytest.mark.usefixtures("kroki_bad_request") @@ -27,8 +28,8 @@ def test_request_bad_request() -> None: assert result.exit_code == 0 assert get_expected_log_line("Diagram error!") in result.output with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file: - assert '

!!! error "Diagram error!"' in index_html_file.read() - # assert '

Diagram error!' in index_html_file.read() + index_soup = bs4.BeautifulSoup(index_html_file.read()) + assert "Diagram error!" in index_soup.find("details").summary.text @pytest.mark.usefixtures("kroki_is_a_teapot") @@ -41,8 +42,8 @@ def test_request_other_error() -> None: assert result.exit_code == 0 assert get_expected_log_line("Could not retrieve image data") in result.output with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file: - assert '

!!! error "Could not retrieve image data' in index_html_file.read() - # assert '

Could not retrieve image data' in index_html_file.read() + index_soup = bs4.BeautifulSoup(index_html_file.read()) + assert "Could not retrieve image data" in index_soup.find("details").summary.text @pytest.mark.usefixtures("kroki_dummy") @@ -54,5 +55,5 @@ def test_missing_file_from() -> None: assert result.exit_code == 0 assert get_expected_log_line("Can't read file:") in result.output with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file: - assert "

!!! error \"Can't read file: " in index_html_file.read() - # assert "

Can't read file: " in index_html_file.read() + index_soup = bs4.BeautifulSoup(index_html_file.read()) + assert "Can't read file: " in index_soup.find("details").summary.text diff --git a/tests/test_nested.py b/tests/test_nested.py index e8d0042..9012d99 100644 --- a/tests/test_nested.py +++ b/tests/test_nested.py @@ -26,6 +26,4 @@ def test_block_inside_html() -> None: assert result.exit_code == 0, f"exit code {result.exit_code}, expected 0" with open(mkdocs_helper.test_dir / "site/index.html") as index_html: index_soup = bs4.BeautifulSoup(index_html.read()) - for string in index_soup.strings: - assert "![Kroki]" not in string, f"markdown image was not rendered to HTML: {string}" - assert len(index_soup.find_all("img", alt="Kroki")) == 2, "no image was included" + assert len(index_soup.find_all("object", attrs={"name": "Kroki"})) == 2, "not all image were included"