`
31 | sponsors.forEach(function (sponsor) {
32 | html += `
33 |
34 |
35 |
36 | `
37 | });
38 | html += '
{code}
"))
32 |
--------------------------------------------------------------------------------
/docs/snippets/gallery/runpy.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import warnings
3 | from contextlib import suppress
4 | from io import StringIO
5 | from runpy import run_module
6 |
7 | old_argv = list(sys.argv)
8 | sys.argv = ["mkdocs"]
9 | old_stdout = sys.stdout
10 | sys.stdout = StringIO()
11 | warnings.filterwarnings("ignore", category=RuntimeWarning)
12 | with suppress(SystemExit):
13 | run_module("mkdocs", run_name="__main__")
14 | output = sys.stdout.getvalue()
15 | sys.stdout = old_stdout
16 | sys.argv = old_argv
17 |
18 | print(f"```\n{output}\n```")
19 |
--------------------------------------------------------------------------------
/docs/snippets/gallery/textual.py:
--------------------------------------------------------------------------------
1 | from textual.app import App, ComposeResult
2 | from textual.widgets import Static
3 | from textual._doc import take_svg_screenshot
4 |
5 |
6 | class TextApp(App):
7 | CSS = """
8 | Screen {
9 | background: darkblue;
10 | color: white;
11 | layout: vertical;
12 | }
13 | Static {
14 | height: auto;
15 | padding: 2;
16 | border: heavy white;
17 | background: #ffffff 30%;
18 | content-align: center middle;
19 | }
20 | """
21 |
22 | def compose(self) -> ComposeResult:
23 | yield Static("Hello")
24 | yield Static("[b]World![/b]")
25 |
26 |
27 | print(take_svg_screenshot(app=TextApp(), terminal_size=(80, 24)))
28 |
--------------------------------------------------------------------------------
/docs/snippets/usage/boolean_matrix.py:
--------------------------------------------------------------------------------
1 | print()
2 | print("a | b | a \\|\\| b")
3 | print("--- | --- | ---")
4 | for a in (True, False):
5 | for b in (True, False):
6 | print(f"{a} | {b} | {a or b}")
7 | print()
8 |
--------------------------------------------------------------------------------
/docs/snippets/usage/hide.py:
--------------------------------------------------------------------------------
1 | print("Hello World!")
2 | print("{platform.machine()}
{platform.version()}
{platform.platform()}
{platform.system()}
%(initial_code)s
36 | "
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("= (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)
86 |
--------------------------------------------------------------------------------
/src/markdown_exec/_internal/formatters/sh.py:
--------------------------------------------------------------------------------
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)
33 |
--------------------------------------------------------------------------------
/src/markdown_exec/_internal/formatters/tree.py:
--------------------------------------------------------------------------------
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", {})))
61 |
--------------------------------------------------------------------------------
/src/markdown_exec/_internal/logger.py:
--------------------------------------------------------------------------------
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)
90 |
--------------------------------------------------------------------------------
/src/markdown_exec/_internal/main.py:
--------------------------------------------------------------------------------
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"(? 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"}
124 |
--------------------------------------------------------------------------------
/src/markdown_exec/_internal/mkdocs_plugin.py:
--------------------------------------------------------------------------------
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")
144 |
--------------------------------------------------------------------------------
/src/markdown_exec/_internal/processors.py:
--------------------------------------------------------------------------------
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
137 |
--------------------------------------------------------------------------------
/src/markdown_exec/assets/pyodide.css:
--------------------------------------------------------------------------------
1 | html[data-theme="light"] {
2 | @import "https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow.css"
3 | }
4 |
5 | html[data-theme="dark"] {
6 | @import "https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow-night-blue.min.css"
7 | }
8 |
9 |
10 | .ace_gutter {
11 | z-index: 1;
12 | }
13 |
14 | .pyodide-editor {
15 | width: 100%;
16 | min-height: 200px;
17 | max-height: 400px;
18 | font-size: .85em;
19 | }
20 |
21 | .pyodide-editor-bar {
22 | color: var(--md-primary-bg-color);
23 | background-color: var(--md-primary-fg-color);
24 | width: 100%;
25 | font: monospace;
26 | font-size: 0.75em;
27 | padding: 2px 0 2px;
28 | }
29 |
30 | .pyodide-bar-item {
31 | padding: 0 18px 0;
32 | display: inline-block;
33 | width: 50%;
34 | }
35 |
36 | .pyodide pre {
37 | margin: 0;
38 | }
39 |
40 | .pyodide-output {
41 | width: 100%;
42 | margin-bottom: -15px;
43 | min-height: 46px;
44 | max-height: 400px
45 | }
46 |
47 | .pyodide-clickable {
48 | cursor: pointer;
49 | text-align: right;
50 | }
51 |
52 | /* For themes other than Material. */
53 | .pyodide .twemoji svg {
54 | width: 1rem;
55 | }
56 |
--------------------------------------------------------------------------------
/src/markdown_exec/assets/pyodide.js:
--------------------------------------------------------------------------------
1 | var _sessions = {};
2 |
3 | function getSession(name, pyodide) {
4 | if (!(name in _sessions)) {
5 | _sessions[name] = pyodide.globals.get("dict")();
6 | }
7 | return _sessions[name];
8 | }
9 |
10 | function writeOutput(element, string) {
11 | element.innerHTML += string + '\n';
12 | }
13 |
14 | function clearOutput(element) {
15 | element.innerHTML = '';
16 | }
17 |
18 | async function evaluatePython(pyodide, editor, output, session) {
19 | pyodide.setStdout({ batched: (string) => { writeOutput(output, new Option(string).innerHTML); } });
20 | let result, code = editor.getValue();
21 | clearOutput(output);
22 | try {
23 | result = await pyodide.runPythonAsync(code, { globals: getSession(session, pyodide) });
24 | } catch (error) {
25 | writeOutput(output, new Option(error.toString()).innerHTML);
26 | }
27 | if (result) writeOutput(output, new Option(result).innerHTML);
28 | hljs.highlightElement(output);
29 | }
30 |
31 | async function initPyodide() {
32 | try {
33 | let pyodide = await loadPyodide();
34 | await pyodide.loadPackage("micropip");
35 | return pyodide;
36 | } catch(error) {
37 | return null;
38 | }
39 | }
40 |
41 | function getTheme() {
42 | return document.body.getAttribute('data-md-color-scheme');
43 | }
44 |
45 | function setTheme(editor, currentTheme, light, dark) {
46 | // https://gist.github.com/RyanNutt/cb8d60997d97905f0b2aea6c3b5c8ee0
47 | if (currentTheme === "default") {
48 | editor.setTheme("ace/theme/" + light);
49 | document.querySelector(`link[title="light"]`).removeAttribute("disabled");
50 | document.querySelector(`link[title="dark"]`).setAttribute("disabled", "disabled");
51 | } else if (currentTheme === "slate") {
52 | editor.setTheme("ace/theme/" + dark);
53 | document.querySelector(`link[title="dark"]`).removeAttribute("disabled");
54 | document.querySelector(`link[title="light"]`).setAttribute("disabled", "disabled");
55 | }
56 | }
57 |
58 | function updateTheme(editor, light, dark) {
59 | // Create a new MutationObserver instance
60 | const observer = new MutationObserver((mutations) => {
61 | // Loop through the mutations that occurred
62 | mutations.forEach((mutation) => {
63 | // Check if the mutation was a change to the data-md-color-scheme attribute
64 | if (mutation.attributeName === 'data-md-color-scheme') {
65 | // Get the new value of the attribute
66 | const newColorScheme = mutation.target.getAttribute('data-md-color-scheme');
67 | // Update the editor theme
68 | setTheme(editor, newColorScheme, light, dark);
69 | }
70 | });
71 | });
72 |
73 | // Configure the observer to watch for changes to the data-md-color-scheme attribute
74 | observer.observe(document.body, {
75 | attributes: true,
76 | attributeFilter: ['data-md-color-scheme'],
77 | });
78 | }
79 |
80 | async function setupPyodide(idPrefix, install = null, themeLight = 'tomorrow', themeDark = 'tomorrow_night', session = null) {
81 | const editor = ace.edit(idPrefix + "editor");
82 | const run = document.getElementById(idPrefix + "run");
83 | const clear = document.getElementById(idPrefix + "clear");
84 | const output = document.getElementById(idPrefix + "output");
85 |
86 | updateTheme(editor, themeLight, themeDark);
87 |
88 | editor.session.setMode("ace/mode/python");
89 | setTheme(editor, getTheme(), themeLight, themeDark);
90 |
91 | writeOutput(output, "Initializing...");
92 | let pyodide = await pyodidePromise;
93 | if (install && install.length) {
94 | try {
95 | micropip = pyodide.pyimport("micropip");
96 | for (const package of install)
97 | await micropip.install(package);
98 | clearOutput(output);
99 | } catch (error) {
100 | clearOutput(output);
101 | writeOutput(output, `Could not install one or more packages: ${install.join(", ")}\n`);
102 | writeOutput(output, new Option(error.toString()).innerHTML);
103 | }
104 | } else {
105 | clearOutput(output);
106 | }
107 | run.onclick = () => evaluatePython(pyodide, editor, output, session);
108 | clear.onclick = () => clearOutput(output);
109 | output.parentElement.parentElement.addEventListener("keydown", (event) => {
110 | if (event.ctrlKey && event.key.toLowerCase() === 'enter') {
111 | event.preventDefault();
112 | run.click();
113 | }
114 | });
115 | }
116 |
117 | var pyodidePromise = initPyodide();
118 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/__init__.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal import formatters
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(formatters, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/base.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal.formatters import base
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters.base` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(base, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/bash.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal.formatters import bash
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters.bash` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(bash, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/console.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal.formatters import console
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters.console` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(console, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/markdown.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal.formatters import markdown
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters.markdown` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(markdown, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/pycon.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal.formatters import pycon
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters.pycon` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(pycon, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/pyodide.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal.formatters import pyodide
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters.pyodide` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(pyodide, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/python.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal.formatters import python
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters.python` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(python, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/sh.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal.formatters import sh
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters.sh` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(sh, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/formatters/tree.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal.formatters import tree
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.formatters.tree` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(tree, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/logger.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal import logger
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.logger` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(logger, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/mkdocs_plugin.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal import mkdocs_plugin
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.mkdocs_plugin` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(mkdocs_plugin, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/processors.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal import processors
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.processors` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(processors, name)
18 |
--------------------------------------------------------------------------------
/src/markdown_exec/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pawamoy/markdown-exec/9df9d5e38540478574a91929773f7b5dc4471a9a/src/markdown_exec/py.typed
--------------------------------------------------------------------------------
/src/markdown_exec/rendering.py:
--------------------------------------------------------------------------------
1 | """Deprecated. Import from `markdown_exec` directly."""
2 |
3 | # YORE: Bump 2: Remove file.
4 |
5 | import warnings
6 | from typing import Any
7 |
8 | from markdown_exec._internal import rendering
9 |
10 |
11 | def __getattr__(name: str) -> Any:
12 | warnings.warn(
13 | "Importing from `markdown_exec.rendering` is deprecated. Import from `markdown_exec` directly.",
14 | DeprecationWarning,
15 | stacklevel=2,
16 | )
17 | return getattr(rendering, name)
18 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests suite for `markdown_exec`."""
2 |
3 | from pathlib import Path
4 |
5 | TESTS_DIR = Path(__file__).parent
6 | TMP_DIR = TESTS_DIR / "tmp"
7 | FIXTURES_DIR = TESTS_DIR / "fixtures"
8 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | """Configuration for the pytest test suite."""
2 |
3 | import pytest
4 | from markdown import Markdown
5 |
6 | from markdown_exec import formatter, formatters, validator
7 |
8 |
9 | @pytest.fixture
10 | def md() -> Markdown:
11 | """Return a Markdown instance.
12 |
13 | Returns:
14 | Markdown instance.
15 | """
16 | fences = [
17 | {
18 | "name": language,
19 | "class": language,
20 | "validator": validator,
21 | "format": formatter,
22 | }
23 | for language in formatters
24 | ]
25 | return Markdown(
26 | extensions=["pymdownx.superfences", "pymdownx.tabbed"],
27 | extension_configs={"pymdownx.superfences": {"custom_fences": fences}},
28 | )
29 |
--------------------------------------------------------------------------------
/tests/test_api.py:
--------------------------------------------------------------------------------
1 | """Tests for our own API exposition."""
2 |
3 | from __future__ import annotations
4 |
5 | from collections import defaultdict
6 | from pathlib import Path
7 | from typing import TYPE_CHECKING
8 |
9 | import griffe
10 | import pytest
11 | from mkdocstrings import Inventory
12 |
13 | import markdown_exec
14 |
15 | if TYPE_CHECKING:
16 | from collections.abc import Iterator
17 |
18 |
19 | @pytest.fixture(name="loader", scope="module")
20 | def _fixture_loader() -> griffe.GriffeLoader:
21 | loader = griffe.GriffeLoader()
22 | loader.load("markdown_exec")
23 | loader.resolve_aliases()
24 | return loader
25 |
26 |
27 | @pytest.fixture(name="internal_api", scope="module")
28 | def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module:
29 | return loader.modules_collection["markdown_exec._internal"]
30 |
31 |
32 | @pytest.fixture(name="public_api", scope="module")
33 | def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module:
34 | return loader.modules_collection["markdown_exec"]
35 |
36 |
37 | def _yield_public_objects(
38 | obj: griffe.Module | griffe.Class,
39 | *,
40 | modules: bool = False,
41 | modulelevel: bool = True,
42 | inherited: bool = False,
43 | special: bool = False,
44 | ) -> Iterator[griffe.Object | griffe.Alias]:
45 | for member in obj.all_members.values() if inherited else obj.members.values():
46 | try:
47 | if member.is_module:
48 | if member.is_alias or not member.is_public:
49 | continue
50 | if modules:
51 | yield member
52 | yield from _yield_public_objects(
53 | member, # type: ignore[arg-type]
54 | modules=modules,
55 | modulelevel=modulelevel,
56 | inherited=inherited,
57 | special=special,
58 | )
59 | elif member.is_public and (special or not member.is_special):
60 | yield member
61 | else:
62 | continue
63 | if member.is_class and not modulelevel:
64 | yield from _yield_public_objects(
65 | member, # type: ignore[arg-type]
66 | modules=modules,
67 | modulelevel=False,
68 | inherited=inherited,
69 | special=special,
70 | )
71 | except (griffe.AliasResolutionError, griffe.CyclicAliasError):
72 | continue
73 |
74 |
75 | @pytest.fixture(name="modulelevel_internal_objects", scope="module")
76 | def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
77 | return list(_yield_public_objects(internal_api, modulelevel=True))
78 |
79 |
80 | @pytest.fixture(name="internal_objects", scope="module")
81 | def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
82 | return list(_yield_public_objects(internal_api, modulelevel=False, special=True))
83 |
84 |
85 | @pytest.fixture(name="public_objects", scope="module")
86 | def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
87 | return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True))
88 |
89 |
90 | @pytest.fixture(name="inventory", scope="module")
91 | def _fixture_inventory() -> Inventory:
92 | inventory_file = Path(__file__).parent.parent / "site" / "objects.inv"
93 | if not inventory_file.exists():
94 | raise pytest.skip("The objects inventory is not available.")
95 | with inventory_file.open("rb") as file:
96 | return Inventory.parse_sphinx(file)
97 |
98 |
99 | def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None:
100 | """All public objects in the internal API are exposed under `markdown_exec`."""
101 | not_exposed = [
102 | obj.path
103 | for obj in modulelevel_internal_objects
104 | if obj.name not in markdown_exec.__all__ or not hasattr(markdown_exec, obj.name)
105 | ]
106 | assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed))
107 |
108 |
109 | def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None:
110 | """All internal objects have unique names."""
111 | names_to_paths = defaultdict(list)
112 | for obj in modulelevel_internal_objects:
113 | names_to_paths[obj.name].append(obj.path)
114 | non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1]
115 | assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique)
116 |
117 |
118 | def test_single_locations(public_api: griffe.Module) -> None:
119 | """All objects have a single public location."""
120 |
121 | def _public_path(obj: griffe.Object | griffe.Alias) -> bool:
122 | return obj.is_public and (obj.parent is None or _public_path(obj.parent))
123 |
124 | multiple_locations = {}
125 | for obj_name in markdown_exec.__all__:
126 | obj = public_api[obj_name]
127 | if obj.aliases and (
128 | public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)]
129 | ):
130 | multiple_locations[obj.path] = public_aliases
131 | assert not multiple_locations, "Multiple public locations:\n" + "\n".join(
132 | f"{path}: {aliases}" for path, aliases in multiple_locations.items()
133 | )
134 |
135 |
136 | def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None:
137 | """All public objects are added to the inventory."""
138 | ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"}
139 | not_in_inventory = [
140 | obj.path for obj in public_objects if obj.name not in ignore_names and obj.path not in inventory
141 | ]
142 | msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}"
143 | assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory)))
144 |
145 |
146 | def test_inventory_matches_api(
147 | inventory: Inventory,
148 | public_objects: list[griffe.Object | griffe.Alias],
149 | loader: griffe.GriffeLoader,
150 | ) -> None:
151 | """The inventory doesn't contain any additional Python object."""
152 | not_in_api = []
153 | public_api_paths = {obj.path for obj in public_objects}
154 | public_api_paths.add("markdown_exec")
155 | # YORE: Bump 2: Remove block.
156 | ignore_modules = {
157 | "markdown_exec.formatters",
158 | "markdown_exec.formatters.base",
159 | "markdown_exec.formatters.bash",
160 | "markdown_exec.formatters.console",
161 | "markdown_exec.formatters.markdown",
162 | "markdown_exec.formatters.pycon",
163 | "markdown_exec.formatters.pyodide",
164 | "markdown_exec.formatters.python",
165 | "markdown_exec.formatters.sh",
166 | "markdown_exec.formatters.tree",
167 | "markdown_exec.logger",
168 | "markdown_exec.mkdocs_plugin",
169 | "markdown_exec.processors",
170 | "markdown_exec.rendering",
171 | }
172 |
173 | for item in inventory.values():
174 | # YORE: Bump 2: Remove block.
175 | if item.name in ignore_modules:
176 | continue
177 |
178 | if (
179 | item.domain == "py"
180 | and "(" not in item.name
181 | and (item.name == "markdown_exec" or item.name.startswith("markdown_exec."))
182 | ):
183 | obj = loader.modules_collection[item.name]
184 | if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases):
185 | not_in_api.append(item.name)
186 | msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}"
187 | assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api)))
188 |
189 |
190 | def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None:
191 | """No module docstrings should be written in our internal API.
192 |
193 | The reasoning is that docstrings are addressed to users of the public API,
194 | but internal modules are not exposed to users, so they should not have docstrings.
195 | """
196 |
197 | def _modules(obj: griffe.Module) -> Iterator[griffe.Module]:
198 | for member in obj.modules.values():
199 | yield member
200 | yield from _modules(member)
201 |
202 | for obj in _modules(internal_api):
203 | assert not obj.docstring
204 |
--------------------------------------------------------------------------------
/tests/test_base_formatter.py:
--------------------------------------------------------------------------------
1 | """Tests for the base formatter."""
2 |
3 | import os
4 | import subprocess
5 |
6 | import pytest
7 | from markdown import Markdown
8 |
9 | from markdown_exec import base_format
10 |
11 |
12 | def test_no_p_around_html(md: Markdown) -> None:
13 | """Assert HTML isn't wrapped in a `p` tag.
14 |
15 | Parameters:
16 | md: A Markdown instance (fixture).
17 | """
18 | code = "hello
"
19 | html = base_format(
20 | language="whatever",
21 | run=lambda code, **_: code,
22 | code=code,
23 | md=md,
24 | html=True,
25 | )
26 | assert html == code
27 |
28 |
29 | @pytest.mark.parametrize("html", [True, False])
30 | def test_render_source(md: Markdown, html: bool) -> None:
31 | """Assert source is rendered.
32 |
33 | Parameters:
34 | md: A Markdown instance (fixture).
35 | html: Whether output is HTML or not.
36 | """
37 | markup = base_format(
38 | language="python",
39 | run=lambda code, **_: code,
40 | code="hello",
41 | md=md,
42 | html=html,
43 | source="tabbed-left",
44 | )
45 | assert "Source" in markup
46 |
47 |
48 | def test_render_console_plus_ansi_result(md: Markdown) -> None:
49 | """Assert we can render source as console style with `ansi` highlight.
50 |
51 | Parameters:
52 | md: A Markdown instance (fixture).
53 | """
54 | markup = base_format(
55 | language="bash",
56 | run=lambda code, **_: code,
57 | code="echo -e '\033[31mhello'",
58 | md=md,
59 | html=False,
60 | source="console",
61 | result="ansi",
62 | )
63 | assert "ansi" in markup
64 |
65 |
66 | def test_dont_render_anything_if_output_is_empty(md: Markdown) -> None:
67 | """Assert nothing is rendered if output is empty.
68 |
69 | Parameters:
70 | md: A Markdown instance (fixture).
71 | """
72 | markup = base_format(
73 | language="bash",
74 | run=lambda code, **_: "",
75 | code="whatever",
76 | md=md,
77 | )
78 | assert not markup
79 |
80 |
81 | def test_render_source_even_if_output_is_empty(md: Markdown) -> None:
82 | """Assert source is rendered even if output is empty.
83 |
84 | Parameters:
85 | md: A Markdown instance (fixture).
86 | """
87 | markup = base_format(
88 | language="bash",
89 | run=lambda code, **_: "",
90 | code="whatever",
91 | md=md,
92 | source="tabbed-left",
93 | )
94 | assert "Source" in markup
95 |
96 |
97 | @pytest.mark.skipif(os.name != "posix", reason="No time for the annoying OS.")
98 | def test_changing_working_directory(md: Markdown) -> None:
99 | """Assert we can change the working directory with `workdir`.
100 |
101 | Parameters:
102 | md: A Markdown instance (fixture).
103 | """
104 | markup = base_format(
105 | language="python",
106 | run=lambda code, **_: subprocess.check_output(code, shell=True, text=True), # noqa: S602
107 | code="pwd",
108 | md=md,
109 | workdir="/",
110 | )
111 | assert markup == "/
"
112 |
113 |
114 | @pytest.mark.skipif(os.name != "posix", reason="No time for the annoying OS.")
115 | def test_console_width(md: Markdown) -> None:
116 | """Assert we can change the console width with `width`.
117 |
118 | Parameters:
119 | md: A Markdown instance (fixture).
120 | """
121 | for width in (10, 1000):
122 | markup = base_format(
123 | language="bash",
124 | run=lambda code, **_: subprocess.check_output(code, shell=True, text=True), # noqa: S602,
125 | code="echo width: $COLUMNS",
126 | md=md,
127 | width=width,
128 | )
129 | assert f"width: {width}" in markup
130 |
--------------------------------------------------------------------------------
/tests/test_converter.py:
--------------------------------------------------------------------------------
1 | """Tests for the Markdown converter."""
2 |
3 | from __future__ import annotations
4 |
5 | import re
6 | from textwrap import dedent
7 | from typing import TYPE_CHECKING
8 |
9 | import pytest
10 | from markdown.extensions.toc import TocExtension
11 |
12 | from markdown_exec import MarkdownConfig, markdown_config
13 |
14 | if TYPE_CHECKING:
15 | from markdown import Markdown
16 |
17 |
18 | def test_rendering_nested_blocks(md: Markdown) -> None:
19 | """Assert nested blocks are properly handled.
20 |
21 | Parameters:
22 | md: A Markdown instance (fixture).
23 | """
24 | html = md.convert(
25 | dedent(
26 | """
27 | ````md exec="1"
28 | ```python exec="1"
29 | print("**Bold!**")
30 | ```
31 | ````
32 | """,
33 | ),
34 | )
35 | assert html == "Bold!
"
36 |
37 |
38 | def test_instantiating_config_singleton() -> None:
39 | """Assert that the Markdown config instances act as a singleton."""
40 | assert MarkdownConfig() is markdown_config
41 | markdown_config.save([], {})
42 | markdown_config.reset()
43 |
44 |
45 | @pytest.mark.parametrize(
46 | ("id", "id_prefix", "expected"),
47 | [
48 | ("", None, 'id="exec-\\d+--heading"'),
49 | ("", "", 'id="heading"'),
50 | ("", "some-prefix-", 'id="some-prefix-heading"'),
51 | ("some-id", None, 'id="some-id-heading"'),
52 | ("some-id", "", 'id="heading"'),
53 | ("some-id", "some-prefix-", 'id="some-prefix-heading"'),
54 | ],
55 | )
56 | def test_prefixing_headings(md: Markdown, id: str, id_prefix: str | None, expected: str) -> None: # noqa: A002
57 | """Assert that we prefix headings as specified.
58 |
59 | Parameters:
60 | md: A Markdown instance (fixture).
61 | id: The code block id.
62 | id_prefix: The code block id prefix.
63 | expected: The id we expect to find in the HTML.
64 | """
65 | TocExtension().extendMarkdown(md)
66 | prefix = f'idprefix="{id_prefix}"' if id_prefix is not None else ""
67 | html = md.convert(
68 | dedent(
69 | f"""
70 | ```python exec="1" id="{id}" {prefix}
71 | print("# HEADING")
72 | ```
73 | """,
74 | ),
75 | )
76 | assert re.search(expected, html)
77 |
--------------------------------------------------------------------------------
/tests/test_headings.py:
--------------------------------------------------------------------------------
1 | """Tests for headings."""
2 |
3 | from __future__ import annotations
4 |
5 | from textwrap import dedent
6 | from typing import TYPE_CHECKING
7 |
8 | if TYPE_CHECKING:
9 | from markdown import Markdown
10 |
11 |
12 | def test_headings_removal(md: Markdown) -> None:
13 | """Headings should leave no trace behind.
14 |
15 | Parameters:
16 | md: A Markdown instance (fixture).
17 | """
18 | html = md.convert(
19 | dedent(
20 | """
21 | === "File layout"
22 |
23 | ```tree
24 | ./
25 | hello.md
26 | ```
27 | """,
28 | ),
29 | )
30 | assert 'class="markdown-exec"' not in html
31 |
--------------------------------------------------------------------------------
/tests/test_python.py:
--------------------------------------------------------------------------------
1 | """Tests for the Python formatters."""
2 |
3 | from __future__ import annotations
4 |
5 | import re
6 | from textwrap import dedent
7 | from typing import TYPE_CHECKING
8 |
9 | if TYPE_CHECKING:
10 | import pytest
11 | from markdown import Markdown
12 |
13 |
14 | def test_output_markdown(md: Markdown) -> None:
15 | """Assert Markdown is converted to HTML.
16 |
17 | Parameters:
18 | md: A Markdown instance (fixture).
19 | """
20 | html = md.convert(
21 | dedent(
22 | """
23 | ```python exec="yes"
24 | print("**Bold!**")
25 | ```
26 | """,
27 | ),
28 | )
29 | assert html == "Bold!
"
30 |
31 |
32 | def test_output_html(md: Markdown) -> None:
33 | """Assert HTML is injected as is.
34 |
35 | Parameters:
36 | md: A Markdown instance (fixture).
37 | """
38 | html = md.convert(
39 | dedent(
40 | """
41 | ```python exec="yes" html="yes"
42 | print("**Bold!**")
43 | ```
44 | """,
45 | ),
46 | )
47 | assert html == "**Bold!**\n
"
48 |
49 |
50 | def test_error_raised(md: Markdown, caplog: pytest.LogCaptureFixture) -> None:
51 | """Assert errors properly log a warning and return a formatted traceback.
52 |
53 | Parameters:
54 | md: A Markdown instance (fixture).
55 | caplog: Pytest fixture to capture logs.
56 | """
57 | html = md.convert(
58 | dedent(
59 | """
60 | ```python exec="yes"
61 | raise ValueError("oh no!")
62 | ```
63 | """,
64 | ),
65 | )
66 | assert "Traceback" in html
67 | assert "ValueError" in html
68 | assert "oh no!" in html
69 | assert "Execution of python code block exited with errors" in caplog.text
70 |
71 |
72 | def test_can_print_non_string_objects(md: Markdown) -> None:
73 | """Assert we can print non-string objects.
74 |
75 | Parameters:
76 | md: A Markdown instance (fixture).
77 | """
78 | html = md.convert(
79 | dedent(
80 | """
81 | ```python exec="yes"
82 | class NonString:
83 | def __str__(self):
84 | return "string"
85 |
86 | nonstring = NonString()
87 | print(nonstring, nonstring)
88 | ```
89 | """,
90 | ),
91 | )
92 | assert "Traceback" not in html
93 |
94 |
95 | def test_sessions(md: Markdown) -> None:
96 | """Assert sessions can be reused.
97 |
98 | Parameters:
99 | md: A Markdown instance (fixture).
100 | """
101 | html = md.convert(
102 | dedent(
103 | """
104 | ```python exec="1" session="a"
105 | a = 1
106 | ```
107 |
108 | ```pycon exec="1" session="b"
109 | >>> b = 2
110 | ```
111 |
112 | ```pycon exec="1" session="a"
113 | >>> print(f"a = {a}")
114 | >>> try:
115 | ... print(b)
116 | ... except NameError:
117 | ... print("ok")
118 | ... else:
119 | ... print("ko")
120 | ```
121 |
122 | ```python exec="1" session="b"
123 | print(f"b = {b}")
124 | try:
125 | print(a)
126 | except NameError:
127 | print("ok")
128 | else:
129 | print("ko")
130 | ```
131 | """,
132 | ),
133 | )
134 | assert "a = 1" in html
135 | assert "b = 2" in html
136 | assert "ok" in html
137 | assert "ko" not in html
138 |
139 |
140 | def test_reporting_errors_in_sessions(md: Markdown, caplog: pytest.LogCaptureFixture) -> None:
141 | """Assert errors and source lines are correctly reported across sessions.
142 |
143 | Parameters:
144 | md: A Markdown instance (fixture).
145 | caplog: Pytest fixture to capture logs.
146 | """
147 | html = md.convert(
148 | dedent(
149 | """
150 | ```python exec="1" session="a"
151 | def fraise():
152 | raise RuntimeError("strawberry")
153 | ```
154 |
155 | ```python exec="1" session="a"
156 | print("hello")
157 | fraise()
158 | ```
159 | """,
160 | ),
161 | )
162 | assert "Traceback" in html
163 | assert "strawberry" in html
164 | assert "fraise()" in caplog.text
165 | assert 'raise RuntimeError("strawberry")' in caplog.text
166 |
167 |
168 | def test_removing_output_from_pycon_code(md: Markdown) -> None:
169 | """Assert output lines are removed from pycon snippets.
170 |
171 | Parameters:
172 | md: A Markdown instance (fixture).
173 | """
174 | html = md.convert(
175 | dedent(
176 | """
177 | ```pycon exec="1" source="console"
178 | >>> print("ok")
179 | ko
180 | ```
181 | """,
182 | ),
183 | )
184 | assert "ok" in html
185 | assert "ko" not in html
186 |
187 |
188 | def test_functions_have_a_module_attribute(md: Markdown) -> None:
189 | """Assert functions have a `__module__` attribute.
190 |
191 | Parameters:
192 | md: A Markdown instance (fixture).
193 | """
194 | html = md.convert(
195 | dedent(
196 | """
197 | ```python exec="1"
198 | def func():
199 | pass
200 |
201 | print(f"`{func.__module__}`")
202 | ```
203 | """,
204 | ),
205 | )
206 | assert "_code_block_n" in html
207 |
208 |
209 | def test_future_annotations_do_not_leak_into_user_code(md: Markdown) -> None:
210 | """Assert future annotations do not leak into user code.
211 |
212 | Parameters:
213 | md: A Markdown instance (fixture).
214 | """
215 | html = md.convert(
216 | dedent(
217 | """
218 | ```python exec="1"
219 | class Int:
220 | ...
221 |
222 | def f(x: Int) -> None:
223 | return x + 1.0
224 |
225 | print(f"`{f.__annotations__['x']}`")
226 | ```
227 | """,
228 | ),
229 | )
230 | assert "Int
" not in html
231 | assert re.search(r"class '_code_block_n\d+_\.Int'", html)
232 |
--------------------------------------------------------------------------------
/tests/test_shell.py:
--------------------------------------------------------------------------------
1 | """Tests for the shell formatters."""
2 |
3 | from __future__ import annotations
4 |
5 | from textwrap import dedent
6 | from typing import TYPE_CHECKING
7 |
8 | if TYPE_CHECKING:
9 | import pytest
10 | from markdown import Markdown
11 |
12 |
13 | def test_output_markdown(md: Markdown) -> None:
14 | """Assert Markdown is converted to HTML.
15 |
16 | Parameters:
17 | md: A Markdown instance (fixture).
18 | """
19 | html = md.convert(
20 | dedent(
21 | """
22 | ```sh exec="yes"
23 | echo "**Bold!**"
24 | ```
25 | """,
26 | ),
27 | )
28 | assert html == "Bold!
"
29 |
30 |
31 | def test_output_html(md: Markdown) -> None:
32 | """Assert HTML is injected as is.
33 |
34 | Parameters:
35 | md: A Markdown instance (fixture).
36 | """
37 | html = md.convert(
38 | dedent(
39 | """
40 | ```sh exec="yes" html="yes"
41 | echo "**Bold!**"
42 | ```
43 | """,
44 | ),
45 | )
46 | assert html == "**Bold!**\n
"
47 |
48 |
49 | def test_error_raised(md: Markdown, caplog: pytest.LogCaptureFixture) -> None:
50 | """Assert errors properly log a warning and return a formatted traceback.
51 |
52 | Parameters:
53 | md: A Markdown instance (fixture).
54 | caplog: Pytest fixture to capture logs.
55 | """
56 | html = md.convert(
57 | dedent(
58 | """
59 | ```sh exec="yes"
60 | echo("wrong syntax")
61 | ```
62 | """,
63 | ),
64 | )
65 | assert "error" in html
66 | assert "Execution of sh code block exited with unexpected code 2" in caplog.text
67 |
68 |
69 | def test_return_code(md: Markdown, caplog: pytest.LogCaptureFixture) -> None:
70 | """Assert return code is used correctly.
71 |
72 | Parameters:
73 | md: A Markdown instance (fixture).
74 | caplog: Pytest fixture to capture logs.
75 | """
76 | html = md.convert(
77 | dedent(
78 | """
79 | ```sh exec="yes" returncode="1"
80 | echo Not in the mood
81 | exit 1
82 | ```
83 | """,
84 | ),
85 | )
86 | assert "Not in the mood" in html
87 | assert "exited with" not in caplog.text
88 |
--------------------------------------------------------------------------------
/tests/test_toc.py:
--------------------------------------------------------------------------------
1 | """Tests for the logic updating the table of contents."""
2 |
3 | from __future__ import annotations
4 |
5 | from textwrap import dedent
6 | from typing import TYPE_CHECKING
7 |
8 | from markdown.extensions.toc import TocExtension
9 |
10 | if TYPE_CHECKING:
11 | from markdown import Markdown
12 |
13 |
14 | def test_updating_toc(md: Markdown) -> None:
15 | """Assert ToC is updated with generated headings.
16 |
17 | Parameters:
18 | md: A Markdown instance (fixture).
19 | """
20 | TocExtension().extendMarkdown(md)
21 | html = md.convert(
22 | dedent(
23 | """
24 | ```python exec="yes"
25 | print("# big heading")
26 | ```
27 | """,
28 | ),
29 | )
30 | assert " None:
35 | """Assert ToC is not updated with generated headings.
36 |
37 | Parameters:
38 | md: A Markdown instance (fixture).
39 | """
40 | TocExtension().extendMarkdown(md)
41 | html = md.convert(
42 | dedent(
43 | """
44 | ```python exec="yes" updatetoc="no"
45 | print("# big heading")
46 | ```
47 | """,
48 | ),
49 | )
50 | assert " None:
55 | """Assert ToC is not updated with generated headings.
56 |
57 | Parameters:
58 | md: A Markdown instance (fixture).
59 | """
60 | TocExtension().extendMarkdown(md)
61 | html = md.convert(
62 | dedent(
63 | """
64 | ```python exec="yes" updatetoc="no"
65 | print("# big heading")
66 | ```
67 |
68 | ```python exec="yes" updatetoc="yes"
69 | print("## medium heading")
70 | ```
71 |
72 | ```python exec="yes" updatetoc="no"
73 | print("### small heading")
74 | ```
75 |
76 | ```python exec="yes" updatetoc="yes"
77 | print("#### tiny heading")
78 | ```
79 | """,
80 | ),
81 | )
82 | assert " None:
9 | """Assert we can highlight lines in the output.
10 |
11 | Parameters:
12 | md: A Markdown instance (fixture).
13 | """
14 | html = md.convert(
15 | dedent(
16 | """
17 | ```tree hl_lines="2"
18 | 1
19 | 2
20 | 3
21 | ```
22 | """,
23 | ),
24 | )
25 | assert '' in html
26 |
--------------------------------------------------------------------------------
/tests/test_validator.py:
--------------------------------------------------------------------------------
1 | """Tests for the `validator` function."""
2 |
3 | import pytest
4 | from markdown.core import Markdown
5 |
6 | from markdown_exec import validator
7 |
8 |
9 | @pytest.mark.parametrize(
10 | ("exec_value", "expected"),
11 | [
12 | ("yes", True),
13 | ("YES", True),
14 | ("on", True),
15 | ("ON", True),
16 | ("whynot", True),
17 | ("true", True),
18 | ("TRUE", True),
19 | ("1", True),
20 | ("-1", True),
21 | ("0", False),
22 | ("no", False),
23 | ("NO", False),
24 | ("off", False),
25 | ("OFF", False),
26 | ("false", False),
27 | ("FALSE", False),
28 | ],
29 | )
30 | def test_validate(md: Markdown, exec_value: str, expected: bool) -> None:
31 | """Assert the validator returns True or False given inputs.
32 |
33 | Parameters:
34 | md: A Markdown instance.
35 | exec_value: The exec option value, passed from the code block.
36 | expected: Expected validation result.
37 | """
38 | assert validator("whatever", inputs={"exec": exec_value}, options={}, attrs={}, md=md) is expected
39 |
--------------------------------------------------------------------------------