Python-Markdown / markdown

A Python implementation of John Gruber’s Markdown with Extension support.
https://python-markdown.github.io/
BSD 3-Clause "New" or "Revised" License
3.74k stars 858 forks source link

Add API docs #1379

Closed pawamoy closed 11 months ago

pawamoy commented 1 year ago

Following our discussion in #1220, this PR adds API docs to the documentation. The API docs are auto-generated with mkdocstrings. We use the Google-style in docstrings to document parameters, return values, exceptions raised, etc..

Important note: (and I realize it's a bit weird to talk about this only now, sorry) the proposed mkdocstrings configuration uses options that are only available to Insiders. Since you maintain Python-Markdown, I will gladly give you access for free. The Insiders version is not published on PyPI, it has to be installed from GitHub, or built and stored in a private index. We provide a solution to do exactly this, PyPI Insiders, but there is a bit of setup to use it locally (see docs), so I understand if you do not want to do that. The alternative is to rely on the community version of mkdocstrings-python, and override it in CI with the Insiders one (if CI does indeed build and publish docs). Or we can do nothing of that and just use the community version. In that case, the options that won't be available are:

Apologies for not mentioning this earlier. It can feel like I had an agenda behind this, but really I was just focused on the technical side of things (making the docs look good) and I totally forgot to speak about Insiders :bow: Happy to hear your thoughts on this matter.


There are still some inconsistencies in docstrings (leading space in front of summary, trailing blank line at the end of the docstring). Imports are probably not ordered.

I've just based my PR on the branch as is, but I can of course rework commits to cleanly separate type changes (in function signatures) from docs changes.

waylan commented 1 year ago

the proposed mkdocstrings configuration uses options that are only available to Insiders. Since you maintain Python-Markdown, I will gladly give you access for free.

I'm not sure what to think about this. I am annoyed this wasn't mentioned earlier. If it had been, I would have said no thanks. But now we are here and I'm not sure what I want to do. So let's start with this...

the options that won't be available are:

  • cross-refs in separate signatures (we don't use this one anyway since we render signatures in headings)
  • symbol types in headings and in the ToC (module, function, attribute, etc.)
  • auto-summaries

I am fine with or without the symbol types. But I guess I don't know what auto-summaries are. What would we loose there?

pawamoy commented 1 year ago

Auto-summaries are the bullet point lists of submodules, classes, functions and attributes added automatically at the end of each docstring. It's not a big loss since these links also appear in the ToC (except for submodules).

waylan commented 1 year ago

I'm inclined to just go with the community version. However, if we leave the settings in place for the now unsupported features will that work, or will we get an error?

pawamoy commented 1 year ago

I do my best to always make insiders features compatible with the community version. Having these features enabled in the community version will not cause any error, the features will just do nothing.

waylan commented 1 year ago

Okay. Let's leave the config as-is but move forward with the community version then. That way, if we decide to change in the future, we will already have the config as we want it (barring any future changes).

The next step is to work through the failing tests and get them to be passing.

Regarding the spellchecking test, it never runs because the build fails with a warning which comes from mcdocstrings (the build is run in strict mode). We will need to address that before moving on to spellchecking, which will be a chore in itself. As a general rule, we have always spelled out words rather than using abbreviations and we wrap anything in a code span which can conceivably be "code" to avoid the spellchecker tripping on it. I expect a bunch of edits to the doc strings will be needed to address reported spelling errors. In the few rare cases were there is a legitimate word that fails, it can be added to the custom dictionary.

pawamoy commented 1 year ago

I'm not sure what to think about this. I am annoyed this wasn't mentioned earlier. If it had been, I would have said no thanks.

Just wanted to note that there was no Insiders program in place when I opened the issue in February 2022 :slightly_smiling_face: I've only started it 5-6 months ago. Now working full-time on my own projects, and this is what allowed me to iterate quickly based on your feedback :slightly_smiling_face:

pawamoy commented 1 year ago

Ah, I missed your last comment when posting mine. Alright, I'll get to making tests pass in CI :slightly_smiling_face:

pawamoy commented 1 year ago

I've updated the templates in mkdocstrings-python to wrap parameter names (any object name really) in code spans, this way the spell checkers do not consider them. There were just a few words in docstrings that had to be corrected (indent, ints), and I replaced every occurrence of double backticks with single backticks (``thing`` -> `thing`). The last remaining warning, issued by mkdocs-section-index, will be fixed in its next release. With all this, the spelling check will pass :slightly_smiling_face:

pawamoy commented 1 year ago

Moving out of draft since we're already reviewing the PR.

waylan commented 1 year ago

This is looking good. The only CI test failing is the check for a changelog entry. Adding an entry in docs/changelog.md will result in all tests passing. I know that we don't generally include changelog entries for changes which don't effect the behavior of the code, but this touches a lot of code (by adding type annotations) and a clear explanation should exist in the timeline of changes.

However, before I merge, I want to do a read-through of the generated documentation. I may be making a few changes to the various docstrings. Unless I see a repeating pattern, I expect I'll make the changes myself as I find them. Of course, my time is limited to I don't know how long this will take.

pawamoy commented 12 months ago

I want to do a read-through of the generated documentation

That is great, thanks. Take your time of course, there's no rush merging this :slightly_smiling_face: Definitely feel free to push directly on this branch again.

waylan commented 12 months ago

I've been looking at the generated site locally and it bugs me that reference/markdown/core/ and reference/markdown/ both contain docs for the exact same classes/methods/etc. In fact, in the code everything in markdown/core.py is imported into markdown/__init__.py and listed in __all__ in that module. I don't think there is any reason for anyone to ever import from markdown.core directly.

I wonder if we should just exclude core from the API docs. Any thoughts or observations about how others have handled this?

pawamoy commented 12 months ago

If everything from markdown.core is importable directly from markdown, then yes we could probably not render the markdown.core module in the API docs.

I'm not sure how others handle this. In some of my projects, some objects are documented twice because I expose some of them in the top-level module. It bugs me as well but I don't really mind it, as I allow users to import from both locations.

waylan commented 11 months ago

As I've been working through the various modules, something I have noticed is that the objects get ordered alphabetically. However, in the code, they are ordered logically. For example, in each pf the processor modules, the classes follow the same pattern. First the base class, then each of the classes in their order of precedence. Sometimes some of these classes even subclass other subclasses. With the logical order, it all fits together really well.

However, in the documentation, it feels like a jumbled mess. The base class is seemingly randomly in the middle and subclasses often are listed before their base class. Related objects are not near each other, being separated by unrelated objects. In the end, the relationships between objects is not as clear from reading the docs.

I realize that one way to address this is in how objects are named. But we have a legacy code case which many existing extensions already rely on. We can't be mixing everything up by renaming all our objects so that they get ordered correctly in the docs.

Therefore, I would much prefer if the order in the code was maintained in the docs. Is that possible?

Frustratingly, there does not seem to be any single location in mkdocstrings' documentation were all of the config options are listed. I'm hoping this is an option and I just haven't found where it is documented.

pawamoy commented 11 months ago

Since mkdocstrings is language agnostic, and Python is just one of the supported languages, each language handler is responsible for documenting its options. The docs for the Python handler options are here: https://mkdocstrings.github.io/python/usage/configuration/general/.

In particular, you're looking at members_order: https://mkdocstrings.github.io/python/usage/configuration/members/#members_order :)

waylan commented 11 months ago

Thank you. That was a great help. And now I have a better understanding of what the various options do.

BTW, it appears that there is a bug. We have some module-attributes in markdown.utils and they are getting a symbol in their headings despite how show_symbol_type_heading is set. And besides, that feature should only be for Insiders which we are not using. Strangely, the symbols are not using the correct styling and are using the full name rather than the configured abbreviation. See the screenshot below.

Untitled

pawamoy commented 11 months ago

These are actually labels, such as property, cached, writable, instance-attribute, etc. If you don't want to show any label, it's probably possible to override the labels template and put nothing in it. It should be something like this:

docs/
    templates/
        python/
            nature/  # if it doesn't work, try renaming this folder "material" instead
                labels.html  # empty
# mkdocs.yml
plugins:
- mkdocstrings:
    custom_templates: docs/templates
waylan commented 11 months ago

So it appears that some things do not get carried over to the subclass from a parent class, while other things do. I'm not sure if this was intentional or an oversight. For example, fully documented methods defined in a parent class show up in every subclass. However, __init__ methods do not. Consider this simple example:

class Foo:
    """ Foo class """
    def __init__(self, foo: str):
        """
        Create a Foo instance

        Arguments:
            foo: A foo.
        """
        self.foo = foo

    def foo(self) -> str:
        """ Return foo """
        return self.foo

class Bar(Foo):
    """ Bar class """
    def bar(self) -> str:
        """ Return bar """
        return 'bar'

On the Foo class we get the type annotations and the list of arguments as defined in __init__ (circled in blue below). However, those are missing from the Bar class (as noted in red). However, the foo method is fully documented on both classes (circled in green) even though it is defined in Foo.

Untitled

To be clear, I expected the foo method to be included in both locations (as it is). But I also expected the __init__ annotations and arguments to be included in both as well.

pawamoy commented 11 months ago

It's an oversight :slightly_smiling_face: The renderer currently doesn't look into inherited members for __init__ methods when merging them in the class docstring. Will fix!

waylan commented 11 months ago

I have gotten through all of the files. The inlinepatterns are a little incomplete, mostly because of the issue mentioned in the previous comment. So if that issue is fixed, that will help a lot.

I didn't spend a lot of time on the built-in extensions. Still missing a lot of type annotations. I'm not really convinced that they (extension docs) add much value. Maybe we do not include them???

I am curious why you decided to go with generated pages rather than injecting documentation into Markdown pages. I see that some config options could be unique per injected object. I'm thinking that might be desirable in some instances.

waylan commented 11 months ago

I just pushed a changelog entry. All tests should pass now. This is mostly ready to go. Ideally, we would see a fix for mkdocstrings/python#106 and mkdocstrings/python#110 before we publish the docs.

I also need to decide what we are going to do with the extension docs. Specifically, the API docs for each builtin extension. For the most part, I don't see much value in them. A couple have some potentially helpful functions which could be utilized by other extensions, but for the most part, looking at the source code is going to be more helpful to potential extension devs. And then there is the weirdness of the Extension subclass for every extension repeating all of the methods of the base class. While I generally prefer that behavior and want it to remain elsewhere in the API docs, in the case of the Extension class, I would prefer the base class methods to not be repeated. It makes it less clear (at a glance) what specifically has been defined by the extension (a label which identified a method/attribute/etc as being inherited might help here). If we could define a different set of options for the extensions, that might be helpful. Otherwise, I'm inclined to remove them from the documentation.

Finally, It would be nice if there was a label for deprecated objects. Sure, we can include a note in the docstring (even wrapping it in an admonition), but a mkdocstring label seems like an appropriate thing as well. The issue, I suppose, is that there is no standard way of marking an object as deprecated in Python. In our case, we provide our own decorator. Ideally, any object which is wrapped with that decorator would get a deprecated label (see an example here).

pawamoy commented 11 months ago

I'm not really convinced that they (extension docs) add much value

Having worked on mkdocstrins and markdown-exec and their ability to report headings from nested Markdown instances to upper layers in the conversion stack, I'd say that any API doc for extensions like toc are super, super welcome. Yes, we can read the code, and with type annotations it will be even more helpful, but I really believe having everything in the same place (API docs) provides additional value.

I am curious why you decided to go with generated pages rather than injecting documentation into Markdown pages.

I went with generated pages because I absolutely do not have your knowledge regarding Python-Markdown's code base, so wouldn't have been able to do what you did here (added/improved docstrings), but yes we can definitely switch to manual injection if you prefer that.

I would prefer the base class methods to not be repeated.

With manual injection (or additional logic in the pages generation script), it's possible to exclude members from the rendered docs.

inherited labels

I'll think about it. This would be a nice addition indeed.

deprecated labels

Definitely possible with a Griffe extension! I'll check the sources and come up with a working example :)

waylan commented 11 months ago

I'd say that any API doc for extensions like toc are super, super welcome.

I find it interesting that you called out toc specifically, as that is the most obvious one among 2 or 3. For the rest, though, I think my point stands. Although, it doesn't make sense to only document some and not others.

With manual injection (or additional logic in the pages generation script), it's possible to exclude members from the rendered docs.

However it is accomplished, I would like to see inherited members excluded from the Extension subclasses. Possibly, we could exclude inherited members from everything under markdown/extensions/.

yes we can definitely switch to manual injection if you prefer that.

Back in the beginning I would have said absolutely yes. Now that we have everything working, it seems like a lot of work for no immediate gain. The one thing that I keep coming back to though, is that future edits (or even reorganization) would require less understanding of the innerworkings of mkdocstrings if we were using manual injection. As it stands now, we have a custom script which uses an API I have not seen any documentation for.

pawamoy commented 11 months ago

For the rest, though, I think my point stands.

I don't have a strong opinion on this, you know best!

custom script

This script is a modified version of the one we can find in mkdocstrings' docs: https://mkdocstrings.github.io/recipes/#automatic-code-reference-pages.

It uses mkdocs-gen-files, mkdocs-literate-nav and file paths manipulation to achieve auto-generation of pages and navigation.

It sure makes things less straight-forward and a bit harder to understand.


I've pushed two commits, one to support the deprecated decorator, but it needs a bugfix release of Griffe (done in a few minutes), and a second one to hide inherited members in extension subclasses.

I've also fixed both issues mentioned above in mkdocstrings-python v1.7.1 :slightly_smiling_face:

pawamoy commented 11 months ago

Griffe 0.36.4 is live. You'll now see two deprecation warnings in the UnescapePostprocessor docs, one coming from the Griffe extension, and one coming from the docstring itself. Feel free to remove the dynamically inserted one by removing the self._insert_message(cls, message) lines in the extension code.

waylan commented 11 months ago

Awesome. Those changes helped a lot. With the Extension class not showing all of the inherited methods, it is much cleaner and easier to read.

Which leads me to a new issue. Various extensions have default config options defined. It would seem that the API docs should include those so I added a docstring for each. As a reminder, configs is a class attribute. so I added a docstring and got this:

Untitled

That is not easy to read. I realize we can also define class attributes with an Attributes section in the class docstring. But that doesn't show the values assigned. I'm not sure of how to address this.

waylan commented 11 months ago

I think perhaps the last issue I raised would be addressed if attributes got the behavior of separate_signature: true. Maybe that option should not apply to attributes and they should always be separate??

Untitled

Although, that isn't perfect as we don't get a code block with preserved newlines.

pawamoy commented 11 months ago

If you install Black in the environment, mkdocstrings will use it to format the attribute value.

It's not currently possible to render separate signatures for attributes only. I'll think about it. There might be a way to hide the value, but I'm not sure you'd want that.

waylan commented 11 months ago

If you install Black in the environment, mkdocstrings will use it to format the attribute value.

But it is already formatted the way I want it. mkdocstrings is failing to wrap the code in a pre and so the formatting is lost when rendered in a browser. I don't see how Black can fix that.

To b clear, I understand that we don't want a pre tag nested in a h1-6 tag. However, when the signature is separate from the header, then the signature should be a normal code block. Specifically, it should be wrapped in a pre tag so that whitespace is preserved. I haven't checked, but is there a separate template we could override for separate signatures?

It's not currently possible to render separate signatures for attributes only. I'll think about it.

That is very unfortunate, They look so very different from other objects that I don't see how anyone would consider it okay to render them the same.

I'm thinking maybe we do need to switch to injected documentation. Then, rather than rendering a whole module, we can render individual objects and define custom settings for each.

waylan commented 11 months ago

Turns out it was easy to make attributes always have a separate signature. I just overrid the relevant template blocks and removed the if config.separate_signature check (and the else block) from each (see 13e6254). Much better IMO.

The problem remains that the signature is all on one line and the formatting in the source code is not preserved. I have taken a closer look and see that the newlines are not preserved in the rendered HTML so the issue is not a missing pre tag (actually the pre tag is present). I also tried installing Black as I see the template filter actually calls Black if it is installed. However, that made no difference. The signature for each attribute is still a single line.

waylan commented 11 months ago

Problem resolved in fdbcd4f. I just used attribute.source directly which preserves the original formatting. This is what I want. As an added bonus, comments intermingled in a value get included also.

Untitled

Now I need to clean up the formatting of some of those multiline attribute definitions. And then I think maybe this will be done.

pawamoy commented 11 months ago

As you noticed, there's always a <pre> tag. mkdocstrings doesn't use the source directly, it uses a custom AST built from the source to be able to render cross-references within code blocks (function signatures, attribute values), as well as allowing configuration options (whether to show type annotations, etc.).

Glad you found a solution with overridden templates, however note that by displaying the source directly, you lose the cross-references functionality (which will become free when we reach the $500/month goal). This particular attribute value is not affected though, as it contains only string literals.

waylan commented 11 months ago

however note that by displaying the source directly, you lose the cross-references functionality

If there is a way to retain formatting and have cross-references, then that would be great. However, if I only get to have one, I'll take formatting. The cross references do little to no good when the code is unreadable.

waylan commented 11 months ago

I know I broke things. Ran out of time. I will fix it when I have more time.

waylan commented 11 months ago

I think this is almost ready to go. I need to take another pass at inlinepatterns.py (which has a lot of stuff). Other than that, I am happy with it now.

@facelessuser could you take a look at the docstring for EmStringItem namedtuple. It's not clear to me how to document that better.

https://github.com/Python-Markdown/markdown/blob/5a2fee340845ee5a70f6570c35b53fd4a37ade1c/markdown/inlinepatterns.py#L184-L185

This is what it currently looks like in the documentation.

Screenshot 2023-10-02 092225

Or maybe we need to document its use on the processors which make use of it. Not sure how to proceed here.

facelessuser commented 11 months ago

@facelessuser could you take a look at the docstring for EmStringItem namedtuple. It's not clear to me how to document that better.

I'll try to take a look sometime this week.

waylan commented 11 months ago

Something that we need to document for extension devs is the collections of processors and the specific name and precedence given to each so that an extension can properly insert itself in the proper location. Thus far, extension devs have had to read the source. But all of the relevant info is in the various build_* functions:

There are a few ways we could document this.

  1. Use the show_source feature of mkdocstrings. Each of the functions only contains the relevant info so there is no info overload. But I'd rather not have show_source enabled for everything; only these specific functions. Not sure how to do that. Maybe there is a way to define custom options for specific objects??
  2. Insert a table in the docstring for each of the functions. However, a manually edited table could become out-of-sync with the code if we are not careful. Therefore, I would prefer an automatically generated table. I'm assuming some sort of plugin could do this. But I'm not sure where to start.

I'm thinking I would prefer the second option, but am open to the first if its an easy fix.

pawamoy commented 11 months ago

I'm not sure exactly what you would like the table to look like, but I suppose it would be possible to build it dynamically, at build time, using markdown-exec or similar extensions/plugins:

```python exec="on"
# import all processors and sort them by priority
# then output a markdown table
print("Priority | Processor")
print("--- | ---")
for priority, processor in processors:
    print(f"{priority} | {processor}")
waylan commented 11 months ago

Well, I worked it out with an extension.

waylan commented 11 months ago

I believe this is sufficiently complete to merge. We can always tweak various things in future PRs.

Thanks to @pawamoy for getting this going and following through over many months. Without all your work (and the mkdocstrings plugin to make it possible) this never would have happened.

pawamoy commented 11 months ago

Very nice last commit, adding link to the source on GitHub :+1:

You're welcome, thanks to you @waylan for your valuable feedback, we improved and fixed many things in mkdocstrings thanks to it :slightly_smiling_face: I enjoyed working on this PR with you!

pawamoy commented 11 months ago

Ha, too bad the docs deployment job failed!

waylan commented 11 months ago

Ignoring the auth key problem with the automated deploy, there is still an issue.

We deploy using MkDocs' organization setup, which means deploying from a directory other than the root directory of the project (see linked docs for details). However, trying to build results in a failure:

λ mkdocs build --config-file ../md/mkdocs.yml
INFO    -  Cleaning site directory
INFO    -  Building documentation to directory: C:\code\md\site
ERROR   -  Error reading page 'reference/markdown/index.md': Extension module
           'scripts/griffe_extensions.py' could not be found
Traceback (most recent call last):
  File "c:\code\md\venv\lib\site-packages\griffe\extensions\base.py", line 415, in _load_extension
    ext_object = dynamic_import(import_path)
  File "c:\code\md\venv\lib\site-packages\griffe\importer.py", line 61, in dynamic_import
    module = import_module(module_path)
  File "C:\Users\wlimberg\AppData\Local\Programs\Python\Python38\lib\importlib\__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 973, in _find_and_load_unlocked
ModuleNotFoundError: No module named 'scripts/griffe_extensions'

It seems that extensions paths are not relative to the config file like they should be. Presumably this would be a bug in mkdocstrings. It should be altering the path to be absolute based on the location of the config file before passing it on to griffe.

As a reminder, MkDocs validates its own known config options and ensures that paths are properly adjusted. However, as this is a config setting for a plugin, MkDocs doesn't know about it and therefore doesn't validate or adjust it. That needs to be handled by the plugin.

Until this bug is fixed, we can't deploy the documentation. I would file a bug, but I'm not sure if this goes in mkdocsstring, mkdocstring-python or elsewhere.

pawamoy commented 11 months ago

Ah, I see, thanks for the explanation. I wonder if MkDocs 1.5's new feature would solve this issue:

plugins:
- mkdocstrings:
    handlers:
      python:
        options:
          extensions:
          - !relative $config_dir/scripts/griffe_extensions.py
waylan commented 11 months ago

That might provide a workaround. Although I would encourage you to fix the issue within your plugin and not rely on that feature. That feature needs to exist for Markdown extensions which don't have any knowledge of MkDocs. However, a MkDocs plugin certainly has knowledge of MkDocs and should work out-of-the-box with any supported configuration and/or environment.

pawamoy commented 11 months ago

I mostly agree. However, now that this feature exists, I wonder if it is worth it to add code in mkdocstrings-python to transform the extensions path as relative to the config file directory. You know, the less code to maintain, the better. We should definitely document this though. I'll think a bit more about it :slightly_smiling_face:

waylan commented 11 months ago

I would consider relying on that feature to be a bug. In fact, I would have objected to the feature being added if it wasn't for the fact that there is no workaround for Markdown extensions. I might have argued for the feature to only be available to Markdown extensions within the config file if I had known it was being considered before release.

pawamoy commented 11 months ago

That represents a lot of boilerplate code in every plugin using file paths, recording MkDocs config file path in on_config, passing it down the call chain to the objects that need it to build relative paths. Many different implementations, many missing ones, probably buggy ones. Could be alleviated with a guide on how to implement paths so that they are relative to the config file, sure, but that would probably be hard to write well since plugins are all very different. Instead of this guide we have the !relative feature, and I think that's just as good, if not better. Users have to use it anyway for Markdown extensions, so it doesn't cost them that much (one line changes) to do the same thing for plugins. And they only have to do it if they need it. Most users have no issues relying on the current working directory. Those who want their docs to work with -f can use !relative. It also creates more consistent and more explicit configurations IMO, as you don't have to wonder "is this path relative to the config file directory? or to the current working directory? or something else?"

pawamoy commented 11 months ago

One annoying aspect is that projects like ReadTheDocs have to implement support for such custom tags, since they don't want to use MkDocs' loader to avoid running arbitrary Python code. I can't find any related issue on their bugtracker, will open a discussion.

waylan commented 11 months ago

Well I strongly disagree. I spent a lot of time working on MkDocs config validation framework and any plugin can (and probably should) use that framework (as documented here) to address this and many other potential issues with configs. In other words, you can reuse the same validator that MkDocs uses for path based config options and get the correct expected behavior for free (see FilesystemObject and its various subclasses). But, anyway, this is getting off-topic.

Back to the issue at hand... I tried using the !relative feature so I could at least get the current version deployed and it doesn't work. I can build my documentation just fine until I add the !relative tag to the config. With that one change, I get the error: Error reading page 'reference/markdown/index.md': cannot pickle '_thread.RLock' object. I'm not sure what exactly is going on here, but the error specifically relates to the first of the mkdocstrings generated pages. It could be a bug with mkdocstrings, a bug with MkDocs, or a bug with one of the extensions. The full traceback follows...

% mkdocs build                            
INFO    -  Cleaning site directory
INFO    -  Building documentation to directory: /Users/waylan/code/md/site
ERROR   -  Error reading page 'reference/markdown/index.md': cannot pickle
           '_thread.RLock' object
Traceback (most recent call last):
  File "/Users/waylan/code/md/venv/bin/mkdocs", line 8, in <module>
    sys.exit(cli())
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/click/core.py", line 1157, in __call__
    return self.main(*args, **kwargs)
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/click/core.py", line 1078, in main
    rv = self.invoke(ctx)
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/click/core.py", line 1688, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/click/core.py", line 1434, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/click/core.py", line 783, in invoke
    return __callback(*args, **kwargs)
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/mkdocs/__main__.py", line 286, in build_command
    build.build(cfg, dirty=not clean)
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/mkdocs/commands/build.py", line 322, in build
    _populate_page(file.page, config, files, dirty)
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/mkdocs/commands/build.py", line 175, in _populate_page
    page.render(config, files)
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/mkdocs/structure/pages.py", line 271, in render
    self.content = md.convert(self.markdown)
  File "/Users/waylan/code/md/markdown/core.py", line 353, in convert
    root = self.parser.parseDocument(self.lines).getroot()
  File "/Users/waylan/code/md/markdown/blockparser.py", line 116, in parseDocument
    self.parseChunk(self.root, '\n'.join(lines))
  File "/Users/waylan/code/md/markdown/blockparser.py", line 135, in parseChunk
    self.parseBlocks(parent, text.split('\n\n'))
  File "/Users/waylan/code/md/markdown/blockparser.py", line 157, in parseBlocks
    if processor.run(parent, blocks) is not False:
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/mkdocstrings/extension.py", line 125, in run
    html, handler, data = self._process_block(identifier, block, heading_level)
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/mkdocstrings/extension.py", line 207, in _process_block
    data: CollectorItem = handler.collect(identifier, options)
  File "/Users/waylan/code/md/venv/lib/python3.9/site-packages/mkdocstrings_handlers/python/handler.py", line 264, in collect
    mutable_config = dict(copy.deepcopy(config))
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 270, in _reconstruct
    state = deepcopy(state, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 205, in _deepcopy_list
    append(deepcopy(a, memo))
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 205, in _deepcopy_list
    append(deepcopy(a, memo))
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 270, in _reconstruct
    state = deepcopy(state, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 270, in _reconstruct
    state = deepcopy(state, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 270, in _reconstruct
    state = deepcopy(state, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 205, in _deepcopy_list
    append(deepcopy(a, memo))
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 237, in _deepcopy_method
    return type(x)(x.__func__, deepcopy(x.__self__, memo))
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 270, in _reconstruct
    state = deepcopy(state, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 270, in _reconstruct
    state = deepcopy(state, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 270, in _reconstruct
    state = deepcopy(state, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/copy.py", line 161, in deepcopy
    rv = reductor(4)
TypeError: cannot pickle '_thread.RLock' object