Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compatibility with mkdocs-macros #615

Open
llucax opened this issue Sep 19, 2023 · 21 comments
Open

Compatibility with mkdocs-macros #615

llucax opened this issue Sep 19, 2023 · 21 comments
Labels
feature New feature or request

Comments

@llucax
Copy link

llucax commented Sep 19, 2023

Is your feature request related to a problem? Please describe.
To be able to write more powerful documentation and avoid repetition, it is very useful to use the mkdocs-macros plugin, but it seems the documentation written inside docstrings is not being processed by the macros plugin.

Describe the solution you'd like
It would be nice if there is a way to make mkdocstrings extract the docstrings before the macros plugin runs, so macros can be expanded also inside the documentation hosted in docstrings.

Describe alternatives you've considered
None really, just avoid using the macros plugin.

Additional context
I could show a way I'm using the macros plugin, but I guess it is irrelevant, as there are many many uses for it, like showing the current git tag, etc.

@llucax llucax added the feature New feature or request label Sep 19, 2023
@pawamoy
Copy link
Member

pawamoy commented Sep 19, 2023

This has been discussed several times already, let me try to find the relevant discussions. In short, while you wait: it's not possible without hacks. mkdocs-macros and mkdocstrings work at different levels: mkdocs-macros at the plugin level (in something like the on_page_markdown hook) while mkdocstrings renders docstrings at the Markdown conversion level, so in-between on_page_markdown and on_page_content. That means mkdocs-macros never sees the docstrings.

@llucax
Copy link
Author

llucax commented Sep 19, 2023

OK, I did search for macros in the issues and didn't find anything relevant, but I must confess I didn't went through the couple of issues that matched but the title was completely unrelated. 😇

@pawamoy
Copy link
Member

pawamoy commented Sep 19, 2023

OK I was thinking about this one: #441.
The crux of it:

If we somehow gave access to our mkdocstrings Jinja env, I guess users could indeed add mkdocs-macros' render method into it, and then override the docstrings templates to use that method.

To expand a bit, the idea here is to:

  • expose the Jinja env that mkdocstrings uses to render collected data to HTML
  • therefore allow users to modify it, adding filters or global variables
  • by adding the render method of mkdocs-macros into it and by overriding the mkdocstrings templates that render docstring sections, one could first call render then convert_markdown on the markup parts

@llucax
Copy link
Author

llucax commented Sep 19, 2023

Ah, sorry, I missed searching the discussions.

So, it is hard to do but possible if I understand correctly?

@pawamoy
Copy link
Member

pawamoy commented Sep 19, 2023

I think it's possible yes. Based on https://mkdocs-macros-plugin.readthedocs.io/en/latest/macros/#manipulating-the-mkdocs-configuration-information, it looks like one could do the following:

def define_env(env):
    # get the Jinja environment of mkdocstrings' Python handler
    python_env = env.config["plugins"]["mkdocstrings"].get_handler("python").env

    # get the `convert_markdown` filter of the Python handler
    convert_markdown = python_env.filters["convert_markdown"]

    # get the `render` method of the macros plugin
    macros_render = env.config["plugins"]["macros"].render

    # build a chimera made of both
    def render_convert(markdown: str, *args, **kwargs):
        return convert_markdown(macros_render(markdown), *args, **kwargs)

    # override the original `convert_markdown` filter
    python_env.filters["convert_markdown"] = render_convert

Not tested!

Tagging @fralau because it's exciting 😂

@fralau
Copy link

fralau commented Sep 19, 2023

Yes it is exciting. 😄 I asked DallE to draw a chimera between an mkdocs extension and an mkdocs plugin. Kinda cool:

_64655822-b6e0-4c53-a828-637877a1515b

We had conversation on MkDocs/Community on 22 August, on the order of events, and we refered to this diagram.

The key is that mkdocstrings is an extension, and MkdocsMacros is a plugin.

Here us a table giving the relationship of MkDocs-Macros with MkDocs events.

@pawamoy It would be a good idea to include the import statements, for the guys like me who are a little less familiar with mkdocstrings?

@pawamoy
Copy link
Member

pawamoy commented Sep 19, 2023

So cool haha! 😀

What do you mean by including the import statements 🤔?

@fralau
Copy link

fralau commented Sep 19, 2023

Here is the indication that @squidfunk had given me:

From what I understand, Markdown extensions are executed during Page.render, which is between on_page_markdown and on_page_content from a plugin perspective.

So that's after MkDocMacros has executed. 🤔

So yes, the solution would be to capture the output of MkdocsString before it MkDocs-Macros goes through. A solution would be to make a macro, of course, or rather a filter.

But since define_env() is executed by on config we could go the other way round with our chimera?

At that point the Jinja2 should already be operational (minus the page variables, but we don't need them anyway). You would have to go through all your files produced by MkdocsString and produce a rendered copy of them.

I am not sure what will happen if trying to execute env.render() at the tail end of the define_env(env) function... 🤔 I haven't tested. It could work. 😄

@llucax
Copy link
Author

llucax commented Sep 19, 2023

Amazing, good to hear! I guess the chimera wasn't ready for public consumption, but I was too eager so I tried out, and sadly it didn't work. I had to change env.config["plugins"] to env.conf["plugins"] (are the mkdocs-macros docs outdated? it seems like env.config holds the configuration of the plugin itself and env.plugin is not defined).

Chimera failing...
INFO    -  Building documentation...
INFO    -  [macros] - Macros arguments: {'module_name': 'docs/_scripts/macros', 'modules': [], 'render_by_default': True, 'include_dir': '', 'include_yaml': [], 'j2_block_start_string': '', 'j2_block_end_string': '', 'j2_variable_start_string': '', 'j2_variable_end_string': '', 'on_undefined': 'strict',
           'on_error_fail': True, 'verbose': False}
INFO    -  [macros] - Found local Python module 'docs/_scripts/macros' in: /home/luca/devel/sdk
INFO    -  [macros] - Found external Python module 'docs/_scripts/macros' in: /home/luca/devel/sdk
Traceback (most recent call last):
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/bin/mkdocs", line 8, in <module>
    sys.exit(cli())
             ^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/click/core.py", line 1157, in __call__
    return self.main(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/click/core.py", line 1078, in main
    rv = self.invoke(ctx)
         ^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/click/core.py", line 1688, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/click/core.py", line 1434, in invoke
    return ctx.invoke(self.callback, **ctx.params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/click/core.py", line 783, in invoke
    return __callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/__main__.py", line 270, in serve_command
    serve.serve(**kwargs)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/commands/serve.py", line 86, in serve
    builder(config)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/commands/serve.py", line 67, in builder
    build(config, live_server=None if is_clean else server, dirty=is_dirty)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/commands/build.py", line 277, in build
    config = config.plugins.on_config(config)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/plugins.py", line 527, in on_config
    return self.run_event('config', config)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/plugins.py", line 507, in run_event
    result = method(item, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs_macros/plugin.py", line 585, in on_config
    self._load_modules()
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs_macros/plugin.py", line 458, in _load_modules
    self._load_module(module, local_module_name)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs_macros/plugin.py", line 398, in _load_module
    module.define_env(self)
  File "/home/luca/devel/sdk/docs/_scripts/macros.py", line 46, in define_env
    python_env = env.conf["plugins"]["mkdocstrings"].get_handler("python").env
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocstrings/plugin.py", line 297, in get_handler
    return self.handlers.get_handler(handler_name)
           ^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocstrings/plugin.py", line 129, in handlers
    raise RuntimeError("The plugin hasn't been initialized with a config yet")
RuntimeError: The plugin hasn't been initialized with a config yet

@pawamoy
Copy link
Member

pawamoy commented Sep 19, 2023

@llucax thanks for testing! Try putting mkdocstrings above/before macros in mkdocs.yml.

@fralau
Copy link

fralau commented Sep 19, 2023

@pawamoy

What do you mean by including the import statements 🤔?

Sorry, I re-read the code, and there is no need, since it is only defining a filter (btw, env.render should also work).

But, then @llucax, what is the Markdown/Jinja2 code in the page?

@llucax
Copy link
Author

llucax commented Sep 19, 2023

    convert_markdown = python_env.filters["convert_markdown"]
                       ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
KeyError: 'convert_markdown'

@pawamoy
Copy link
Member

pawamoy commented Sep 19, 2023

@llucax hmmm yes, I should have expected that. This filter is only created once the Markdown conversion process has started, because it needs access to the current Markdown instance. Hmmm 🤔 Maybe patching the method that creates the filter would work then.

def define_env(env):
    # get mkdocstrings' Python handler
    python_handler = env.config["plugins"]["mkdocstrings"].get_handler("python")
 
    # get the `render` method of the macros plugin
    macros_render = env.config["plugins"]["macros"].render

    # get the `update_env` method of the Python handler
    update_env = python_handler.update_env

    # patch the `update_env` method of the Python handler
    def patched_update_env(self, md, config):
        update_env(md, config)

        # get the `convert_markdown` filter of the env
        convert_markdown = self.env.filters["convert_markdown"]

        # build a chimera made of macros+mkdocstrings
        def render_convert(markdown: str, *args, **kwargs):
            return convert_markdown(macros_render(markdown), *args, **kwargs)

        # patch the filter
        self.env.filters["convert_markdown"] = render_convert
    
    # patch the method
    python_handler.update_env = patched_update_env

Something like this 🥵

@llucax
Copy link
Author

llucax commented Sep 19, 2023

I had to fix again config -> conf (not sure what's going on with that). It seems to have advanced more, but still failing:

INFO    -  Building documentation...
INFO    -  [macros] - Macros arguments: {'module_name': 'docs/_scripts/macros', 'modules': [], 'render_by_default': True, 'include_dir': '', 'include_yaml': [], 'j2_block_start_string': '', 'j2_block_end_string': '', 'j2_variable_start_string': '', 'j2_variable_end_string': '', 'on_undefined': 'strict',
           'on_error_fail': True, 'verbose': False}
INFO    -  [macros] - Found local Python module 'docs/_scripts/macros' in: /home/luca/devel/sdk
INFO    -  [macros] - Found external Python module 'docs/_scripts/macros' in: /home/luca/devel/sdk
INFO    -  [macros] - Extra variables (config file): ['social', 'version']
INFO    -  [macros] - Extra filters (module): ['pretty']
INFO    -  Cleaning site directory
INFO    -  Doc file 'SUMMARY.md' contains an unrecognized relative link 'intro/', it was left as is. Did you mean 'intro/index.md'?
INFO    -  Doc file 'SUMMARY.md' contains an unrecognized relative link 'reference/', it was left as is.
ERROR   -  Error reading page 'intro/actors.md': define_env.<locals>.patched_update_env() missing 1 required positional argument: 'config'
[... click stuff ...]
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/__main__.py", line 270, in serve_command
    serve.serve(**kwargs)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/commands/serve.py", line 86, in serve
    builder(config)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/commands/serve.py", line 67, in builder
    build(config, live_server=None if is_clean else server, dirty=is_dirty)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/commands/build.py", line 322, in build
    _populate_page(file.page, config, files, dirty)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/commands/build.py", line 175, in _populate_page
    page.render(config, files)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocs/structure/pages.py", line 271, in render
    self.content = md.convert(self.markdown)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/markdown/core.py", line 254, in convert
    root = self.parser.parseDocument(self.lines).getroot()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/markdown/blockparser.py", line 84, in parseDocument
    self.parseChunk(self.root, '\n'.join(lines))
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/markdown/blockparser.py", line 99, in parseChunk
    self.parseBlocks(parent, text.split('\n\n'))
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/markdown/blockparser.py", line 117, in parseBlocks
    if processor.run(parent, blocks) is not False:
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocstrings/extension.py", line 125, in run
    html, handler, data = self._process_block(identifier, block, heading_level)
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocstrings/extension.py", line 216, in _process_block
    handler._update_env(self.md, self._config)
  File "/home/luca/devel/sdk/.direnv/python-3.11.5/lib/python3.11/site-packages/mkdocstrings/handlers/base.py", line 370, in _update_env
    self.update_env(new_md, config)
TypeError: define_env.<locals>.patched_update_env() missing 1 required positional argument: 'config'

(I need to leave now, will be able to do some more testing tomorrow)

@pawamoy
Copy link
Member

pawamoy commented Sep 19, 2023

OK I've ran some tests and this is working now 😄

def define_env(env):
    @env.macro
    def is_it_working():
        return '!!! success "WORKING"'

    # get mkdocstrings' Python handler
    python_handler = env.conf["plugins"]["mkdocstrings"].get_handler("python")
 
    # get the `update_env` method of the Python handler
    update_env = python_handler.update_env

    # override the `update_env` method of the Python handler
    def patched_update_env(md, config):
        update_env(md, config)

        # get the `convert_markdown` filter of the env
        convert_markdown = python_handler.env.filters["convert_markdown"]

        # build a chimera made of macros+mkdocstrings
        def render_convert(markdown: str, *args, **kwargs):
            return convert_markdown(env.render(markdown), *args, **kwargs)

        # patch the filter
        python_handler.env.filters["convert_markdown"] = render_convert
    
    # patch the method
    python_handler.update_env = patched_update_env

Example module docstring:

"""Debugging utilities.

{{ is_it_working() }}
"""

...getting properly rendered to:

chimera

@fralau
Copy link

fralau commented Sep 19, 2023

Ah, I think I am finally catching up. So you are modifying the mkdocstrings' Python handler, and basically decorating it with env.render(). And you are using env.render() in define_env().

That's bold wizardry. 🫡

When you are happy with it, maybe you want to package that into a pluglet for mkdocs-macros, so that anybody could use it? I would gladly put a reference to it in Mkdocs-Macros doc.

@pawamoy
Copy link
Member

pawamoy commented Sep 19, 2023

Sorry @fralau I wanted to try and write a better explanation of what we were trying to achieve. If you got it by looking at my final code snippet, that works too!

@llucax
Copy link
Author

llucax commented Sep 20, 2023

@pawamoy Amazing that you got it working. I tried it, and now mkdocs serve doesn't raise any errors, but my macros in docstrings are still not expanded. Any idea of what could be going on?

Update: Oh, no it does! I guess I had some copy&paste error! Amazing! 🎉

llucax added a commit to llucax/frequenz-sdk-python that referenced this issue Sep 20, 2023
According to @pawamoy:

> `mkdocs-macros` and `mkdocstrings` work at different levels:
> `mkdocs-macros` at the plugin level (in something like the
> `on_page_markdown` hook) while `mkdocstrings` renders docstrings at
> the Markdown conversion level, so in-between `on_page_markdown` and
> `on_page_content`. That means `mkdocs-macros` never sees the
> docstrings.

To fix this, we can implement a hack to plug both together in the
`define_env()` function for `mkdocs-macros`.

Eventually a `mkdocs-macros` *pluglet* might be implemented so that this
hack is not necessary anymore.

For more context see:

* mkdocstrings/mkdocstrings#615

Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
@pawamoy
Copy link
Member

pawamoy commented Sep 20, 2023

@fralau I'd like to refactor mkdocstrings a bit to make the patching easier (ideally refactor so that no patching is necessary), and then I'd be happy to provide a pluglet, yes!

llucax added a commit to llucax/frequenz-sdk-python that referenced this issue Sep 20, 2023
According to @pawamoy:

> `mkdocs-macros` and `mkdocstrings` work at different levels:
> `mkdocs-macros` at the plugin level (in something like the
> `on_page_markdown` hook) while `mkdocstrings` renders docstrings at
> the Markdown conversion level, so in-between `on_page_markdown` and
> `on_page_content`. That means `mkdocs-macros` never sees the
> docstrings.

To fix this, we can implement a hack to plug both together in the
`define_env()` function for `mkdocs-macros`.

Eventually a `mkdocs-macros` *pluglet* might be implemented so that this
hack is not necessary anymore.

For more context see:

* mkdocstrings/mkdocstrings#615

Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
github-merge-queue bot pushed a commit to frequenz-floss/frequenz-sdk-python that referenced this issue Sep 20, 2023
According to @pawamoy:

> `mkdocs-macros` and `mkdocstrings` work at different levels:
`mkdocs-macros` at the plugin level (in something like the
`on_page_markdown` hook) while `mkdocstrings` renders docstrings at the
Markdown conversion level, so in-between `on_page_markdown` and
`on_page_content`. That means `mkdocs-macros` never sees the docstrings.

To fix this, we can implement a hack to plug both together in the
`define_env()` function for `mkdocs-macros`.

Eventually a `mkdocs-macros` *pluglet* might be implemented so that this
hack is not necessary anymore.

For more context see:

* mkdocstrings/mkdocstrings#615
@larsoner
Copy link

@pawamoy just chiming in that it would be great to have some way to hook into the update_env step -- personally I'm going to monkey-patch it so I can set self.env.globals[...] to a Python function that I can later call in my attributes.html template:

Code
def on_pre_build(config: MkDocsConfig) -> None:
    """Monkey patch mkdocstrings-python jinja template to have global vars."""
    import mkdocstrings_handlers.python.handler

    old_update_env = mkdocstrings_handlers.python.handler.PythonHandler.update_env

    def update_env(self, md, config: dict) -> None:
        old_update_env(self, md=md, config=config)
        self.env.globals["pipeline_steps"] = _ParseConfigSteps()

    mkdocstrings_handlers.python.handler.PythonHandler.update_env = update_env

At least for the suggestion:

  • expose the Jinja env that mkdocstrings uses to render collected data to HTML

Would it make sense to add a mkdocstrings option for some hook to call? Or maybe better, could mkdocstrings look in the standard hooks: location for a specially named function like mkdocstrings_on_update_env(handler: BaseHandler) or similar? Then my snippet above would just become:

def mkdocstrings_on_update_env(handler):
    handler.env.globals["pipeline_steps"] = _ParseConfigSteps()

@pawamoy
Copy link
Member

pawamoy commented Apr 28, 2024

Hey @larsoner, sorry for the late answer.

expose the Jinja env that mkdocstrings uses to render collected data to HTML

It's actually exposed:

def on_pre_build(config: MkDocsConfig) -> None:
    """Monkey patch mkdocstrings-python jinja template to have global vars."""
    python_handler = config["plugins"]["mkdocstrings"].get_handler("python")
    python_handler.env.globals["pipeline_steps"] = _ParseConfigSteps()

The point of patching update_env is to have access to the Markdown instance used to convert Markdown to HTML, which is the only way to reach docstrings 🙂 If you don't care about docstrings and just want to add globals to the Jinja env, you should be able to use the code above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature New feature or request
Projects
None yet
Development

No branches or pull requests

4 participants