markdown-exec 1.10.1__py3-none-any.whl → 1.10.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- markdown_exec/__init__.py +50 -138
- markdown_exec/_internal/__init__.py +0 -0
- markdown_exec/{debug.py → _internal/debug.py} +13 -15
- markdown_exec/_internal/formatters/__init__.py +1 -0
- markdown_exec/{formatters → _internal/formatters}/_exec_python.py +1 -1
- markdown_exec/_internal/formatters/base.py +191 -0
- markdown_exec/_internal/formatters/bash.py +32 -0
- markdown_exec/_internal/formatters/console.py +29 -0
- markdown_exec/_internal/formatters/markdown.py +11 -0
- markdown_exec/_internal/formatters/pycon.py +26 -0
- markdown_exec/_internal/formatters/pyodide.py +73 -0
- markdown_exec/_internal/formatters/python.py +85 -0
- markdown_exec/_internal/formatters/sh.py +32 -0
- markdown_exec/_internal/formatters/tree.py +60 -0
- markdown_exec/_internal/logger.py +89 -0
- markdown_exec/_internal/main.py +123 -0
- markdown_exec/_internal/mkdocs_plugin.py +143 -0
- markdown_exec/_internal/processors.py +136 -0
- markdown_exec/_internal/rendering.py +280 -0
- markdown_exec/{pyodide.js → assets/pyodide.js} +15 -7
- markdown_exec/formatters/__init__.py +17 -1
- markdown_exec/formatters/base.py +12 -183
- markdown_exec/formatters/bash.py +10 -25
- markdown_exec/formatters/console.py +12 -24
- markdown_exec/formatters/markdown.py +11 -5
- markdown_exec/formatters/pycon.py +12 -24
- markdown_exec/formatters/pyodide.py +12 -65
- markdown_exec/formatters/python.py +11 -79
- markdown_exec/formatters/sh.py +10 -25
- markdown_exec/formatters/tree.py +12 -55
- markdown_exec/logger.py +12 -87
- markdown_exec/mkdocs_plugin.py +11 -135
- markdown_exec/processors.py +12 -118
- markdown_exec/rendering.py +11 -270
- {markdown_exec-1.10.1.dist-info → markdown_exec-1.10.3.dist-info}/METADATA +4 -3
- markdown_exec-1.10.3.dist-info/RECORD +42 -0
- markdown_exec-1.10.3.dist-info/entry_points.txt +7 -0
- markdown_exec-1.10.1.dist-info/RECORD +0 -26
- markdown_exec-1.10.1.dist-info/entry_points.txt +0 -7
- /markdown_exec/{ansi.css → assets/ansi.css} +0 -0
- /markdown_exec/{pyodide.css → assets/pyodide.css} +0 -0
- {markdown_exec-1.10.1.dist-info → markdown_exec-1.10.3.dist-info}/WHEEL +0 -0
- {markdown_exec-1.10.1.dist-info → markdown_exec-1.10.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,85 @@
|
|
1
|
+
# Formatter for executing Python code.
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import re
|
6
|
+
import sys
|
7
|
+
import traceback
|
8
|
+
from collections import defaultdict
|
9
|
+
from functools import partial
|
10
|
+
from io import StringIO
|
11
|
+
from types import ModuleType
|
12
|
+
from typing import Any
|
13
|
+
|
14
|
+
from markdown_exec._internal.formatters._exec_python import exec_python
|
15
|
+
from markdown_exec._internal.formatters.base import ExecutionError, base_format
|
16
|
+
from markdown_exec._internal.rendering import code_block
|
17
|
+
|
18
|
+
_sessions_globals: dict[str, dict] = defaultdict(dict)
|
19
|
+
_sessions_counter: dict[str | None, int] = defaultdict(int)
|
20
|
+
_code_blocks: dict[str, list[str]] = {}
|
21
|
+
|
22
|
+
|
23
|
+
def _buffer_print(buffer: StringIO, *texts: str, end: str = "\n", **kwargs: Any) -> None: # noqa: ARG001
|
24
|
+
buffer.write(" ".join(str(text) for text in texts) + end)
|
25
|
+
|
26
|
+
|
27
|
+
def _code_block_id(
|
28
|
+
id: str | None = None, # noqa: A002
|
29
|
+
session: str | None = None,
|
30
|
+
title: str | None = None,
|
31
|
+
) -> str:
|
32
|
+
_sessions_counter[session] += 1
|
33
|
+
if id:
|
34
|
+
code_block_id = f"id {id}"
|
35
|
+
elif session:
|
36
|
+
code_block_id = f"session {session}; n{_sessions_counter[session]}"
|
37
|
+
if title:
|
38
|
+
code_block_id = f"{code_block_id}; title {title}"
|
39
|
+
else:
|
40
|
+
code_block_id = f"n{_sessions_counter[session]}"
|
41
|
+
if title:
|
42
|
+
code_block_id = f"{code_block_id}; title {title}"
|
43
|
+
return f"<code block: {code_block_id}>"
|
44
|
+
|
45
|
+
|
46
|
+
def _run_python(
|
47
|
+
code: str,
|
48
|
+
returncode: int | None = None, # noqa: ARG001
|
49
|
+
session: str | None = None,
|
50
|
+
id: str | None = None, # noqa: A002
|
51
|
+
**extra: str,
|
52
|
+
) -> str:
|
53
|
+
title = extra.get("title")
|
54
|
+
code_block_id = _code_block_id(id, session, title)
|
55
|
+
_code_blocks[code_block_id] = code.split("\n")
|
56
|
+
exec_globals = _sessions_globals[session] if session else {}
|
57
|
+
|
58
|
+
# Other libraries expect functions to have a valid `__module__` attribute.
|
59
|
+
# To achieve this, we need to add a `__name__` attribute to the globals.
|
60
|
+
# We compute the name from the code block ID, replacing invalid characters with `_`.
|
61
|
+
# We also create a module object with the same name and add it to `sys.modules`,
|
62
|
+
# because that's what yet other libraries expect (`dataclasses` for example).
|
63
|
+
module_name = re.sub(r"[^a-zA-Z\d]+", "_", code_block_id)
|
64
|
+
exec_globals["__name__"] = module_name
|
65
|
+
sys.modules[module_name] = ModuleType(module_name)
|
66
|
+
|
67
|
+
buffer = StringIO()
|
68
|
+
exec_globals["print"] = partial(_buffer_print, buffer)
|
69
|
+
|
70
|
+
try:
|
71
|
+
exec_python(code, code_block_id, exec_globals)
|
72
|
+
except Exception as error:
|
73
|
+
trace = traceback.TracebackException.from_exception(error)
|
74
|
+
for frame in trace.stack:
|
75
|
+
if frame.filename.startswith("<code block: "):
|
76
|
+
if sys.version_info >= (3, 13):
|
77
|
+
frame._lines = _code_blocks[frame.filename][frame.lineno - 1] # type: ignore[attr-defined,operator]
|
78
|
+
else:
|
79
|
+
frame._line = _code_blocks[frame.filename][frame.lineno - 1] # type: ignore[attr-defined,operator]
|
80
|
+
raise ExecutionError(code_block("python", "".join(trace.format()), **extra)) from error
|
81
|
+
return buffer.getvalue()
|
82
|
+
|
83
|
+
|
84
|
+
def _format_python(**kwargs: Any) -> str:
|
85
|
+
return base_format(language="python", run=_run_python, **kwargs)
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# Formatter for executing shell code.
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import subprocess
|
6
|
+
from typing import Any
|
7
|
+
|
8
|
+
from markdown_exec._internal.formatters.base import ExecutionError, base_format
|
9
|
+
from markdown_exec._internal.rendering import code_block
|
10
|
+
|
11
|
+
|
12
|
+
def _run_sh(
|
13
|
+
code: str,
|
14
|
+
returncode: int | None = None,
|
15
|
+
session: str | None = None, # noqa: ARG001
|
16
|
+
id: str | None = None, # noqa: A002,ARG001
|
17
|
+
**extra: str,
|
18
|
+
) -> str:
|
19
|
+
process = subprocess.run( # noqa: S603
|
20
|
+
["sh", "-c", code], # noqa: S607
|
21
|
+
stdout=subprocess.PIPE,
|
22
|
+
stderr=subprocess.STDOUT,
|
23
|
+
text=True,
|
24
|
+
check=False,
|
25
|
+
)
|
26
|
+
if process.returncode != returncode:
|
27
|
+
raise ExecutionError(code_block("sh", process.stdout, **extra), process.returncode)
|
28
|
+
return process.stdout
|
29
|
+
|
30
|
+
|
31
|
+
def _format_sh(**kwargs: Any) -> str:
|
32
|
+
return base_format(language="sh", run=_run_sh, **kwargs)
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# Formatter for file-system trees.
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from textwrap import dedent
|
6
|
+
from typing import TYPE_CHECKING, Any
|
7
|
+
|
8
|
+
from markdown_exec._internal.rendering import MarkdownConverter, code_block
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from markdown import Markdown
|
12
|
+
|
13
|
+
|
14
|
+
def _rec_build_tree(lines: list[str], parent: list, offset: int, base_indent: int) -> int:
|
15
|
+
while offset < len(lines):
|
16
|
+
line = lines[offset]
|
17
|
+
lstripped = line.lstrip()
|
18
|
+
indent = len(line) - len(lstripped)
|
19
|
+
if indent == base_indent:
|
20
|
+
parent.append((lstripped, []))
|
21
|
+
offset += 1
|
22
|
+
elif indent > base_indent:
|
23
|
+
offset = _rec_build_tree(lines, parent[-1][1], offset, indent)
|
24
|
+
else:
|
25
|
+
return offset
|
26
|
+
return offset
|
27
|
+
|
28
|
+
|
29
|
+
def _build_tree(code: str) -> list[tuple[str, list]]:
|
30
|
+
lines = dedent(code.strip()).split("\n")
|
31
|
+
root_layer: list[tuple[str, list]] = []
|
32
|
+
_rec_build_tree(lines, root_layer, 0, 0)
|
33
|
+
return root_layer
|
34
|
+
|
35
|
+
|
36
|
+
def _rec_format_tree(tree: list[tuple[str, list]], *, root: bool = True) -> list[str]:
|
37
|
+
lines = []
|
38
|
+
n_items = len(tree)
|
39
|
+
for index, node in enumerate(tree):
|
40
|
+
last = index == n_items - 1
|
41
|
+
prefix = "" if root else f"{'└' if last else '├'}── "
|
42
|
+
if node[1]:
|
43
|
+
lines.append(f"{prefix}📁 {node[0]}")
|
44
|
+
sublines = _rec_format_tree(node[1], root=False)
|
45
|
+
if root:
|
46
|
+
lines.extend(sublines)
|
47
|
+
else:
|
48
|
+
indent_char = " " if last else "│"
|
49
|
+
lines.extend([f"{indent_char} {line}" for line in sublines])
|
50
|
+
else:
|
51
|
+
name = node[0].split()[0]
|
52
|
+
icon = "📁" if name.endswith("/") else "📄"
|
53
|
+
lines.append(f"{prefix}{icon} {node[0]}")
|
54
|
+
return lines
|
55
|
+
|
56
|
+
|
57
|
+
def _format_tree(code: str, md: Markdown, result: str, **options: Any) -> str:
|
58
|
+
markdown = MarkdownConverter(md)
|
59
|
+
output = "\n".join(_rec_format_tree(_build_tree(code)))
|
60
|
+
return markdown.convert(code_block(result or "bash", output, **options.get("extra", {})))
|
@@ -0,0 +1,89 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from typing import Any, Callable, ClassVar
|
5
|
+
|
6
|
+
|
7
|
+
class _Logger:
|
8
|
+
_default_logger: Any = logging.getLogger
|
9
|
+
_instances: ClassVar[dict[str, _Logger]] = {}
|
10
|
+
|
11
|
+
# See same code in Griffe project.
|
12
|
+
def __init__(self, name: str) -> None:
|
13
|
+
# Default logger that can be patched by third-party.
|
14
|
+
self._logger = self.__class__._default_logger(name)
|
15
|
+
|
16
|
+
def __getattr__(self, name: str) -> Any:
|
17
|
+
# Forward everything to the logger.
|
18
|
+
return getattr(self._logger, name)
|
19
|
+
|
20
|
+
@classmethod
|
21
|
+
def get(cls, name: str) -> _Logger:
|
22
|
+
"""Get a logger instance.
|
23
|
+
|
24
|
+
Parameters:
|
25
|
+
name: The logger name.
|
26
|
+
|
27
|
+
Returns:
|
28
|
+
The logger instance.
|
29
|
+
"""
|
30
|
+
if name not in cls._instances:
|
31
|
+
cls._instances[name] = cls(name)
|
32
|
+
return cls._instances[name]
|
33
|
+
|
34
|
+
@classmethod
|
35
|
+
def _patch_loggers(cls, get_logger_func: Callable) -> None:
|
36
|
+
# Patch current instances.
|
37
|
+
for name, instance in cls._instances.items():
|
38
|
+
instance._logger = get_logger_func(name)
|
39
|
+
# Future instances will be patched as well.
|
40
|
+
cls._default_logger = get_logger_func
|
41
|
+
|
42
|
+
|
43
|
+
def get_logger(name: str) -> _Logger:
|
44
|
+
"""Create and return a new logger instance.
|
45
|
+
|
46
|
+
Parameters:
|
47
|
+
name: The logger name.
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
The logger.
|
51
|
+
"""
|
52
|
+
return _Logger.get(name)
|
53
|
+
|
54
|
+
|
55
|
+
def patch_loggers(get_logger_func: Callable[[str], Any]) -> None:
|
56
|
+
"""Patch loggers.
|
57
|
+
|
58
|
+
We provide the `patch_loggers`function so dependant libraries
|
59
|
+
can patch loggers as they see fit.
|
60
|
+
|
61
|
+
For example, to fit in the MkDocs logging configuration
|
62
|
+
and prefix each log message with the module name:
|
63
|
+
|
64
|
+
```python
|
65
|
+
import logging
|
66
|
+
from markdown_exec.logger import patch_loggers
|
67
|
+
|
68
|
+
|
69
|
+
class LoggerAdapter(logging.LoggerAdapter):
|
70
|
+
def __init__(self, prefix, logger):
|
71
|
+
super().__init__(logger, {})
|
72
|
+
self.prefix = prefix
|
73
|
+
|
74
|
+
def process(self, msg, kwargs):
|
75
|
+
return f"{self.prefix}: {msg}", kwargs
|
76
|
+
|
77
|
+
|
78
|
+
def get_logger(name):
|
79
|
+
logger = logging.getLogger(f"mkdocs.plugins.{name}")
|
80
|
+
return LoggerAdapter(name.split(".", 1)[0], logger)
|
81
|
+
|
82
|
+
|
83
|
+
patch_loggers(get_logger)
|
84
|
+
```
|
85
|
+
|
86
|
+
Parameters:
|
87
|
+
get_logger_func: A function accepting a name as parameter and returning a logger.
|
88
|
+
"""
|
89
|
+
_Logger._patch_loggers(get_logger_func)
|
@@ -0,0 +1,123 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import os
|
4
|
+
import re
|
5
|
+
from typing import TYPE_CHECKING, Any
|
6
|
+
|
7
|
+
if TYPE_CHECKING:
|
8
|
+
from markdown import Markdown
|
9
|
+
|
10
|
+
from markdown_exec._internal.formatters.base import default_tabs
|
11
|
+
from markdown_exec._internal.formatters.bash import _format_bash
|
12
|
+
from markdown_exec._internal.formatters.console import _format_console
|
13
|
+
from markdown_exec._internal.formatters.markdown import _format_markdown
|
14
|
+
from markdown_exec._internal.formatters.pycon import _format_pycon
|
15
|
+
from markdown_exec._internal.formatters.pyodide import _format_pyodide
|
16
|
+
from markdown_exec._internal.formatters.python import _format_python
|
17
|
+
from markdown_exec._internal.formatters.sh import _format_sh
|
18
|
+
from markdown_exec._internal.formatters.tree import _format_tree
|
19
|
+
|
20
|
+
MARKDOWN_EXEC_AUTO = [lang.strip() for lang in os.getenv("MARKDOWN_EXEC_AUTO", "").split(",")]
|
21
|
+
"""Languages to automatically execute."""
|
22
|
+
|
23
|
+
formatters = {
|
24
|
+
"bash": _format_bash,
|
25
|
+
"console": _format_console,
|
26
|
+
"md": _format_markdown,
|
27
|
+
"markdown": _format_markdown,
|
28
|
+
"py": _format_python,
|
29
|
+
"python": _format_python,
|
30
|
+
"pycon": _format_pycon,
|
31
|
+
"pyodide": _format_pyodide,
|
32
|
+
"sh": _format_sh,
|
33
|
+
"tree": _format_tree,
|
34
|
+
}
|
35
|
+
"""Formatters for each language."""
|
36
|
+
|
37
|
+
# negative look behind: matches only if | (pipe) if not preceded by \ (backslash)
|
38
|
+
_tabs_re = re.compile(r"(?<!\\)\|")
|
39
|
+
|
40
|
+
|
41
|
+
def validator(
|
42
|
+
language: str,
|
43
|
+
inputs: dict[str, str],
|
44
|
+
options: dict[str, Any],
|
45
|
+
attrs: dict[str, Any], # noqa: ARG001
|
46
|
+
md: Markdown, # noqa: ARG001
|
47
|
+
) -> bool:
|
48
|
+
"""Validate code blocks inputs.
|
49
|
+
|
50
|
+
Parameters:
|
51
|
+
language: The code language, like python or bash.
|
52
|
+
inputs: The code block inputs, to be sorted into options and attrs.
|
53
|
+
options: The container for options.
|
54
|
+
attrs: The container for attrs:
|
55
|
+
md: The Markdown instance.
|
56
|
+
|
57
|
+
Returns:
|
58
|
+
Success or not.
|
59
|
+
"""
|
60
|
+
exec_value = language in MARKDOWN_EXEC_AUTO or _to_bool(inputs.pop("exec", "no"))
|
61
|
+
if language not in {"tree", "pyodide"} and not exec_value:
|
62
|
+
return False
|
63
|
+
id_value = inputs.pop("id", "")
|
64
|
+
id_prefix_value = inputs.pop("idprefix", None)
|
65
|
+
html_value = _to_bool(inputs.pop("html", "no"))
|
66
|
+
source_value = inputs.pop("source", "")
|
67
|
+
result_value = inputs.pop("result", "")
|
68
|
+
returncode_value = int(inputs.pop("returncode", "0"))
|
69
|
+
session_value = inputs.pop("session", "")
|
70
|
+
update_toc_value = _to_bool(inputs.pop("updatetoc", "yes"))
|
71
|
+
tabs_value = inputs.pop("tabs", "|".join(default_tabs))
|
72
|
+
tabs = tuple(_tabs_re.split(tabs_value, maxsplit=1))
|
73
|
+
workdir_value = inputs.pop("workdir", None)
|
74
|
+
width_value = int(inputs.pop("width", "0"))
|
75
|
+
options["id"] = id_value
|
76
|
+
options["id_prefix"] = id_prefix_value
|
77
|
+
options["html"] = html_value
|
78
|
+
options["source"] = source_value
|
79
|
+
options["result"] = result_value
|
80
|
+
options["returncode"] = returncode_value
|
81
|
+
options["session"] = session_value
|
82
|
+
options["update_toc"] = update_toc_value
|
83
|
+
options["tabs"] = tabs
|
84
|
+
options["workdir"] = workdir_value
|
85
|
+
options["width"] = width_value
|
86
|
+
options["extra"] = inputs
|
87
|
+
return True
|
88
|
+
|
89
|
+
|
90
|
+
def formatter(
|
91
|
+
source: str,
|
92
|
+
language: str,
|
93
|
+
css_class: str, # noqa: ARG001
|
94
|
+
options: dict[str, Any],
|
95
|
+
md: Markdown,
|
96
|
+
classes: list[str] | None = None, # noqa: ARG001
|
97
|
+
id_value: str = "", # noqa: ARG001
|
98
|
+
attrs: dict[str, Any] | None = None, # noqa: ARG001
|
99
|
+
**kwargs: Any, # noqa: ARG001
|
100
|
+
) -> str:
|
101
|
+
"""Execute code and return HTML.
|
102
|
+
|
103
|
+
Parameters:
|
104
|
+
source: The code to execute.
|
105
|
+
language: The code language, like python or bash.
|
106
|
+
css_class: The CSS class to add to the HTML element.
|
107
|
+
options: The container for options.
|
108
|
+
attrs: The container for attrs:
|
109
|
+
md: The Markdown instance.
|
110
|
+
classes: Additional CSS classes.
|
111
|
+
id_value: An optional HTML id.
|
112
|
+
attrs: Additional attributes
|
113
|
+
**kwargs: Additional arguments passed to SuperFences default formatters.
|
114
|
+
|
115
|
+
Returns:
|
116
|
+
HTML contents.
|
117
|
+
"""
|
118
|
+
fmt = formatters.get(language, lambda source, **kwargs: source)
|
119
|
+
return fmt(code=source, md=md, **options) # type: ignore[operator]
|
120
|
+
|
121
|
+
|
122
|
+
def _to_bool(value: str) -> bool:
|
123
|
+
return value.lower() not in {"", "no", "off", "false", "0"}
|
@@ -0,0 +1,143 @@
|
|
1
|
+
# This module contains an optional plugin for MkDocs.
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import logging
|
6
|
+
import os
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import TYPE_CHECKING, Any
|
9
|
+
|
10
|
+
from mkdocs.config import config_options
|
11
|
+
from mkdocs.config.base import Config
|
12
|
+
from mkdocs.exceptions import PluginError
|
13
|
+
from mkdocs.plugins import BasePlugin
|
14
|
+
from mkdocs.utils import write_file
|
15
|
+
|
16
|
+
from markdown_exec._internal.logger import patch_loggers
|
17
|
+
from markdown_exec._internal.main import formatter, formatters, validator
|
18
|
+
from markdown_exec._internal.rendering import MarkdownConverter, markdown_config
|
19
|
+
|
20
|
+
if TYPE_CHECKING:
|
21
|
+
from collections.abc import MutableMapping
|
22
|
+
|
23
|
+
from jinja2 import Environment
|
24
|
+
from mkdocs.config.defaults import MkDocsConfig
|
25
|
+
from mkdocs.structure.files import Files
|
26
|
+
|
27
|
+
try:
|
28
|
+
__import__("pygments_ansi_color")
|
29
|
+
except ImportError:
|
30
|
+
_ansi_ok = False
|
31
|
+
else:
|
32
|
+
_ansi_ok = True
|
33
|
+
|
34
|
+
|
35
|
+
class _LoggerAdapter(logging.LoggerAdapter):
|
36
|
+
def __init__(self, prefix: str, logger: logging.Logger) -> None:
|
37
|
+
super().__init__(logger, {})
|
38
|
+
self.prefix = prefix
|
39
|
+
|
40
|
+
def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, MutableMapping[str, Any]]:
|
41
|
+
return f"{self.prefix}: {msg}", kwargs
|
42
|
+
|
43
|
+
|
44
|
+
def _get_logger(name: str) -> _LoggerAdapter:
|
45
|
+
logger = logging.getLogger(f"mkdocs.plugins.{name}")
|
46
|
+
return _LoggerAdapter(name.split(".", 1)[0], logger)
|
47
|
+
|
48
|
+
|
49
|
+
patch_loggers(_get_logger)
|
50
|
+
|
51
|
+
|
52
|
+
class MarkdownExecPluginConfig(Config):
|
53
|
+
"""Configuration of the plugin (for `mkdocs.yml`)."""
|
54
|
+
|
55
|
+
ansi = config_options.Choice(("auto", "off", "required", True, False), default="auto")
|
56
|
+
"""Whether the `ansi` extra is required when installing the package."""
|
57
|
+
languages = config_options.ListOfItems(
|
58
|
+
config_options.Choice(formatters.keys()),
|
59
|
+
default=list(formatters.keys()),
|
60
|
+
)
|
61
|
+
"""Which languages to enabled the extension for."""
|
62
|
+
|
63
|
+
|
64
|
+
class MarkdownExecPlugin(BasePlugin[MarkdownExecPluginConfig]):
|
65
|
+
"""MkDocs plugin to easily enable custom fences for code blocks execution."""
|
66
|
+
|
67
|
+
def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
|
68
|
+
"""Configure the plugin.
|
69
|
+
|
70
|
+
Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config).
|
71
|
+
In this hook, we add custom fences for all the supported languages.
|
72
|
+
|
73
|
+
We also save the Markdown extensions configuration
|
74
|
+
into [`markdown_config`][markdown_exec.markdown_config].
|
75
|
+
|
76
|
+
Arguments:
|
77
|
+
config: The MkDocs config object.
|
78
|
+
|
79
|
+
Returns:
|
80
|
+
The modified config.
|
81
|
+
"""
|
82
|
+
if "pymdownx.superfences" not in config["markdown_extensions"]:
|
83
|
+
message = "The 'markdown-exec' plugin requires the 'pymdownx.superfences' Markdown extension to work."
|
84
|
+
raise PluginError(message)
|
85
|
+
if self.config.ansi in ("required", True) and not _ansi_ok:
|
86
|
+
raise PluginError(
|
87
|
+
"The configuration for the 'markdown-exec' plugin requires "
|
88
|
+
"that it is installed with the 'ansi' extra. "
|
89
|
+
"Install it with 'pip install markdown-exec[ansi]'.",
|
90
|
+
)
|
91
|
+
self.mkdocs_config_dir = os.getenv("MKDOCS_CONFIG_DIR")
|
92
|
+
os.environ["MKDOCS_CONFIG_DIR"] = os.path.dirname(config["config_file_path"])
|
93
|
+
self.languages = self.config.languages
|
94
|
+
mdx_configs = config.setdefault("mdx_configs", {})
|
95
|
+
superfences = mdx_configs.setdefault("pymdownx.superfences", {})
|
96
|
+
custom_fences = superfences.setdefault("custom_fences", [])
|
97
|
+
for language in self.languages:
|
98
|
+
custom_fences.append(
|
99
|
+
{
|
100
|
+
"name": language,
|
101
|
+
"class": language,
|
102
|
+
"validator": validator,
|
103
|
+
"format": formatter,
|
104
|
+
},
|
105
|
+
)
|
106
|
+
markdown_config.save(config.markdown_extensions, config.mdx_configs)
|
107
|
+
return config
|
108
|
+
|
109
|
+
def on_env(
|
110
|
+
self,
|
111
|
+
env: Environment,
|
112
|
+
*,
|
113
|
+
config: MkDocsConfig,
|
114
|
+
files: Files, # noqa: ARG002
|
115
|
+
) -> Environment | None:
|
116
|
+
"""Add assets to the environment."""
|
117
|
+
if self.config.ansi in ("required", True) or (self.config.ansi == "auto" and _ansi_ok):
|
118
|
+
self._add_css(config, "ansi.css")
|
119
|
+
if "pyodide" in self.languages:
|
120
|
+
self._add_css(config, "pyodide.css")
|
121
|
+
self._add_js(config, "pyodide.js")
|
122
|
+
return env
|
123
|
+
|
124
|
+
def on_post_build(self, *, config: MkDocsConfig) -> None: # noqa: ARG002
|
125
|
+
"""Reset the plugin state."""
|
126
|
+
MarkdownConverter.counter = 0
|
127
|
+
markdown_config.reset()
|
128
|
+
if self.mkdocs_config_dir is None:
|
129
|
+
os.environ.pop("MKDOCS_CONFIG_DIR", None)
|
130
|
+
else:
|
131
|
+
os.environ["MKDOCS_CONFIG_DIR"] = self.mkdocs_config_dir
|
132
|
+
|
133
|
+
def _add_asset(self, config: MkDocsConfig, asset_file: str, asset_type: str) -> None:
|
134
|
+
asset_filename = f"assets/_markdown_exec_{asset_file}"
|
135
|
+
asset_content = Path(__file__).parent.parent.joinpath("assets", asset_file).read_text()
|
136
|
+
write_file(asset_content.encode("utf-8"), os.path.join(config.site_dir, asset_filename))
|
137
|
+
config[f"extra_{asset_type}"].insert(0, asset_filename)
|
138
|
+
|
139
|
+
def _add_css(self, config: MkDocsConfig, css_file: str) -> None:
|
140
|
+
self._add_asset(config, css_file, "css")
|
141
|
+
|
142
|
+
def _add_js(self, config: MkDocsConfig, js_file: str) -> None:
|
143
|
+
self._add_asset(config, js_file, "javascript")
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# This module contains a Markdown extension
|
2
|
+
# allowing to integrate generated headings into the ToC.
|
3
|
+
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
import copy
|
7
|
+
import re
|
8
|
+
from typing import TYPE_CHECKING
|
9
|
+
from xml.etree.ElementTree import Element
|
10
|
+
|
11
|
+
from markdown.treeprocessors import Treeprocessor
|
12
|
+
from markdown.util import HTML_PLACEHOLDER_RE
|
13
|
+
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
from markdown import Markdown
|
16
|
+
from markupsafe import Markup
|
17
|
+
|
18
|
+
|
19
|
+
# code taken from mkdocstrings, credits to @oprypin
|
20
|
+
class IdPrependingTreeprocessor(Treeprocessor):
|
21
|
+
"""Prepend the configured prefix to IDs of all HTML elements."""
|
22
|
+
|
23
|
+
name = "markdown_exec_ids"
|
24
|
+
"""The name of the treeprocessor."""
|
25
|
+
|
26
|
+
def __init__(self, md: Markdown, id_prefix: str) -> None:
|
27
|
+
super().__init__(md)
|
28
|
+
self.id_prefix = id_prefix
|
29
|
+
"""The prefix to prepend to IDs."""
|
30
|
+
|
31
|
+
def run(self, root: Element) -> None:
|
32
|
+
"""Run the treeprocessor."""
|
33
|
+
if not self.id_prefix:
|
34
|
+
return
|
35
|
+
for el in root.iter():
|
36
|
+
id_attr = el.get("id")
|
37
|
+
if id_attr:
|
38
|
+
el.set("id", self.id_prefix + id_attr)
|
39
|
+
|
40
|
+
href_attr = el.get("href")
|
41
|
+
if href_attr and href_attr.startswith("#"):
|
42
|
+
el.set("href", "#" + self.id_prefix + href_attr[1:])
|
43
|
+
|
44
|
+
name_attr = el.get("name")
|
45
|
+
if name_attr:
|
46
|
+
el.set("name", self.id_prefix + name_attr)
|
47
|
+
|
48
|
+
if el.tag == "label":
|
49
|
+
for_attr = el.get("for")
|
50
|
+
if for_attr:
|
51
|
+
el.set("for", self.id_prefix + for_attr)
|
52
|
+
|
53
|
+
|
54
|
+
# code taken from mkdocstrings, credits to @oprypin
|
55
|
+
class HeadingReportingTreeprocessor(Treeprocessor):
|
56
|
+
"""Records the heading elements encountered in the document."""
|
57
|
+
|
58
|
+
name = "markdown_exec_record_headings"
|
59
|
+
"""The name of the treeprocessor."""
|
60
|
+
regex = re.compile("[Hh][1-6]")
|
61
|
+
"""The regex to match heading tags."""
|
62
|
+
|
63
|
+
def __init__(self, md: Markdown, headings: list[Element]):
|
64
|
+
super().__init__(md)
|
65
|
+
self.headings = headings
|
66
|
+
"""The list of heading elements."""
|
67
|
+
|
68
|
+
def run(self, root: Element) -> None:
|
69
|
+
"""Run the treeprocessor."""
|
70
|
+
for el in root.iter():
|
71
|
+
if self.regex.fullmatch(el.tag):
|
72
|
+
el = copy.copy(el) # noqa: PLW2901
|
73
|
+
# 'toc' extension's first pass (which we require to build heading stubs/ids) also edits the HTML.
|
74
|
+
# Undo the permalink edit so we can pass this heading to the outer pass of the 'toc' extension.
|
75
|
+
if len(el) > 0 and el[-1].get("class") == self.md.treeprocessors["toc"].permalink_class: # type: ignore[attr-defined]
|
76
|
+
del el[-1]
|
77
|
+
self.headings.append(el)
|
78
|
+
|
79
|
+
|
80
|
+
class InsertHeadings(Treeprocessor):
|
81
|
+
"""Our headings insertor."""
|
82
|
+
|
83
|
+
name = "markdown_exec_insert_headings"
|
84
|
+
"""The name of the treeprocessor."""
|
85
|
+
|
86
|
+
def __init__(self, md: Markdown):
|
87
|
+
"""Initialize the object.
|
88
|
+
|
89
|
+
Arguments:
|
90
|
+
md: A `markdown.Markdown` instance.
|
91
|
+
"""
|
92
|
+
super().__init__(md)
|
93
|
+
self.headings: dict[Markup, list[Element]] = {}
|
94
|
+
"""The dictionary of headings."""
|
95
|
+
|
96
|
+
def run(self, root: Element) -> None:
|
97
|
+
"""Run the treeprocessor."""
|
98
|
+
if not self.headings:
|
99
|
+
return
|
100
|
+
|
101
|
+
for el in root.iter():
|
102
|
+
match = HTML_PLACEHOLDER_RE.match(el.text or "")
|
103
|
+
if match:
|
104
|
+
counter = int(match.group(1))
|
105
|
+
markup: Markup = self.md.htmlStash.rawHtmlBlocks[counter] # type: ignore[assignment]
|
106
|
+
if headings := self.headings.get(markup):
|
107
|
+
div = Element("div", {"class": "markdown-exec"})
|
108
|
+
div.extend(headings)
|
109
|
+
el.append(div)
|
110
|
+
|
111
|
+
|
112
|
+
class RemoveHeadings(Treeprocessor):
|
113
|
+
"""Our headings remover."""
|
114
|
+
|
115
|
+
name = "markdown_exec_remove_headings"
|
116
|
+
"""The name of the treeprocessor."""
|
117
|
+
|
118
|
+
def run(self, root: Element) -> None:
|
119
|
+
"""Run the treeprocessor."""
|
120
|
+
self._remove_duplicated_headings(root)
|
121
|
+
|
122
|
+
def _remove_duplicated_headings(self, parent: Element) -> None:
|
123
|
+
carry_text = ""
|
124
|
+
for el in reversed(parent): # Reversed mainly for the ability to mutate during iteration.
|
125
|
+
if el.tag == "div" and el.get("class") == "markdown-exec":
|
126
|
+
# Delete the duplicated headings along with their container, but keep the text (i.e. the actual HTML).
|
127
|
+
carry_text = (el.text or "") + carry_text
|
128
|
+
parent.remove(el)
|
129
|
+
else:
|
130
|
+
if carry_text:
|
131
|
+
el.tail = (el.tail or "") + carry_text
|
132
|
+
carry_text = ""
|
133
|
+
self._remove_duplicated_headings(el)
|
134
|
+
|
135
|
+
if carry_text:
|
136
|
+
parent.text = (parent.text or "") + carry_text
|