mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +00:00

* chore: util to manage URLs in the codebase * docs: mentiion validate_links and supported_versions in docs * refactor: fix linter errors
1119 lines
42 KiB
Python
1119 lines
42 KiB
Python
"""
|
|
Generate reference for all the different kinds of public API that we expose,
|
|
like regular Python imports, template tags, settings, Django URLs, etc.
|
|
|
|
All pages are generated inside `docs/reference/`.
|
|
|
|
Generation flow:
|
|
1. For each section, like `commands`, we look up the corresponding template
|
|
named `reference_<section>.md`, e.g. `docs/templates/reference_commands.md`.
|
|
|
|
This template contains the "preface" or text that will be rendered BEFORE
|
|
the auto-generated docs.
|
|
|
|
2. For each section, we try to import it same way as the user would. And for each
|
|
section we do filtering and post-processing, to pick only those symbols (e.g. func, class, ...)
|
|
from the public API, that are relevant for that section.
|
|
|
|
3. Once we have our classes / functions, etc, we generate the mkdocstring entries like
|
|
so:
|
|
|
|
```md
|
|
::: my_library.my_module.my_class
|
|
```
|
|
|
|
See https://mkdocstrings.github.io/
|
|
|
|
4. These generated files in `docs/reference` are then picked up by mkdocs / mkdocstrings
|
|
when we build or serve mkdocs, e.g. with:
|
|
|
|
```sh
|
|
mkdocs serve
|
|
```
|
|
|
|
Note that this file is set in the `gen-files` plugin in `mkdocs.yml`. That means that
|
|
we don't have to run it manually. It will be run each time mkdocs is built.
|
|
"""
|
|
|
|
import inspect
|
|
import re
|
|
import sys
|
|
from argparse import ArgumentParser
|
|
from importlib import import_module
|
|
from pathlib import Path
|
|
from textwrap import dedent
|
|
from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple, Type, Union
|
|
|
|
from django.core.management.base import BaseCommand
|
|
from django.urls import URLPattern, URLResolver
|
|
|
|
from django_components import Component, ComponentVars, ComponentCommand, TagFormatterABC
|
|
from django_components.commands.components import ComponentsRootCommand
|
|
from django_components.node import BaseNode
|
|
from django_components.util.command import setup_parser_from_command
|
|
from django_components.util.misc import get_import_path
|
|
|
|
# NOTE: This file is an entrypoint for the `gen-files` plugin in `mkdocs.yml`.
|
|
# However, `gen-files` plugin runs this file as a script, NOT as a module.
|
|
# That means that:
|
|
# - By default we can't do relative imports (e.g. `.extensions`)
|
|
# - We can't import from packages found in the `src` directory (e.g. `docs.scripts.extensions`)
|
|
#
|
|
# But we need to import from another module in ./docs/scripts.
|
|
# Hence we add the directory of this file to `sys.path` ourselves.
|
|
|
|
current_dir = str(Path(__file__).parent)
|
|
sys.path.append(current_dir)
|
|
from extensions import _format_source_code_html # noqa: E402
|
|
|
|
root = Path(__file__).parent.parent.parent
|
|
|
|
|
|
def gen_reference_api():
|
|
"""
|
|
Generate documentation for the Python API of `django_components`.
|
|
|
|
This takes all public symbols exported from `django_components`, except for those
|
|
that are handled in other sections, like components, exceptions, etc.
|
|
"""
|
|
module = import_module("django_components")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_api.md").read_text()
|
|
out_file = root / "docs/reference/api.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
for name, obj in inspect.getmembers(module):
|
|
if (
|
|
name.startswith("_")
|
|
or inspect.ismodule(obj)
|
|
# Skip entries which are handled in other sections
|
|
or _is_component_cls(obj)
|
|
or _is_error_cls(obj)
|
|
or _is_tag_formatter_instance(obj)
|
|
or _is_tag_formatter_cls(obj)
|
|
or _is_extension_command_api(obj)
|
|
or _is_extension_hook_api(obj)
|
|
or _is_extension_url_api(obj)
|
|
):
|
|
continue
|
|
|
|
# For each entry, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.Component
|
|
# options:
|
|
# show_if_no_docstring: true
|
|
# ```
|
|
f.write(f"::: {module.__name__}.{name}\n" f" options:\n" f" show_if_no_docstring: true\n")
|
|
|
|
f.write("\n")
|
|
|
|
|
|
def gen_reference_testing_api():
|
|
"""
|
|
Generate documentation for the Python API of `django_components.testing`.
|
|
|
|
This takes all public symbols exported from `django_components.testing`.
|
|
"""
|
|
module = import_module("django_components.testing")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_testing_api.md").read_text()
|
|
out_file = root / "docs/reference/testing_api.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
for name, obj in inspect.getmembers(module):
|
|
if name.startswith("_") or inspect.ismodule(obj):
|
|
continue
|
|
|
|
# For each entry, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.testing.djc_test
|
|
# options:
|
|
# show_if_no_docstring: true
|
|
# ```
|
|
f.write(f"::: {module.__name__}.{name}\n" f" options:\n" f" show_if_no_docstring: true\n")
|
|
|
|
f.write("\n")
|
|
|
|
|
|
def gen_reference_exceptions():
|
|
"""
|
|
Generate documentation for the Exception classes included in the Python API of `django_components`.
|
|
"""
|
|
module = import_module("django_components")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_exceptions.md").read_text()
|
|
out_file = root / "docs/reference/exceptions.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
for name, obj in inspect.getmembers(module):
|
|
if (
|
|
name.startswith("_")
|
|
or inspect.ismodule(obj)
|
|
# Skip entries which are handled in other sections
|
|
or not _is_error_cls(obj)
|
|
):
|
|
continue
|
|
|
|
# For each entry, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.Component
|
|
# options:
|
|
# show_if_no_docstring: true
|
|
# ```
|
|
f.write(f"::: {module.__name__}.{name}\n" f" options:\n" f" show_if_no_docstring: true\n")
|
|
|
|
f.write("\n")
|
|
|
|
|
|
def gen_reference_components():
|
|
"""
|
|
Generate documentation for the Component classes (AKA pre-defined components) included
|
|
in the Python API of `django_components`.
|
|
"""
|
|
module = import_module("django_components.components")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_components.md").read_text()
|
|
out_file = root / "docs/reference/components.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
for name, obj in inspect.getmembers(module):
|
|
if not _is_component_cls(obj):
|
|
continue
|
|
|
|
class_name = get_import_path(obj)
|
|
|
|
# If the component classes define any extra methods, we want to show them.
|
|
# BUT, we don't to show the methods that belong to the base Component class.
|
|
unique_methods = _get_unique_methods(Component, obj)
|
|
|
|
# NOTE: `class_id` is declared on the `Component` class, only as a type,
|
|
# so it's not picked up by `_get_unique_methods`.
|
|
if "class_id" in unique_methods:
|
|
unique_methods.remove("class_id")
|
|
|
|
if unique_methods:
|
|
members = ", ".join(unique_methods)
|
|
members = f"[{unique_methods}]"
|
|
else:
|
|
# Use `false` to hide all members to show no methods
|
|
members = "false"
|
|
|
|
# For each entry, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.components.dynamic.DynamicComponent
|
|
#
|
|
# options:
|
|
# ...
|
|
# ```
|
|
f.write(
|
|
f"::: {class_name}\n"
|
|
f" options:\n"
|
|
f" inherited_members: false\n"
|
|
f" show_root_heading: true\n"
|
|
f" show_signature: false\n"
|
|
f" separate_signature: false\n"
|
|
f" members: {members}\n"
|
|
)
|
|
|
|
f.write("\n")
|
|
|
|
|
|
def gen_reference_settings():
|
|
"""
|
|
Generate documentation for the settings of django-components, as defined by the `ComponentsSettings` class.
|
|
"""
|
|
module = import_module("django_components.app_settings")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_settings.md").read_text()
|
|
out_file = root / "docs/reference/settings.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
# 1. Insert section from `reference_settings.md`
|
|
f.write(preface + "\n\n")
|
|
|
|
# 2. Insert code snippet with default settings from `app_settings.py`
|
|
if not module.__file__:
|
|
raise RuntimeError(f"Failed to get filepath for module '{module.__name__}'")
|
|
|
|
default_settings_markdown = _gen_default_settings_section(module.__file__)
|
|
f.write(default_settings_markdown)
|
|
|
|
# 3. Print each setting and their descriptions
|
|
setting_cls = module.ComponentsSettings
|
|
class_name = get_import_path(setting_cls)
|
|
|
|
# NOTE: If no unique methods, just document the class itself without methods
|
|
unique_methods = _get_unique_methods(NamedTuple, setting_cls)
|
|
|
|
for name in sorted(unique_methods):
|
|
# Ignore - these belong to NamedTuple
|
|
if name in ("count", "index"):
|
|
continue
|
|
|
|
# For each entry, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.app_settings.ComponentsSettings.autodiscover
|
|
# options:
|
|
# ...
|
|
# ```
|
|
f.write(
|
|
f"::: {class_name}.{name}\n"
|
|
f" options:\n"
|
|
f" show_root_heading: true\n"
|
|
f" show_signature: true\n"
|
|
f" separate_signature: true\n"
|
|
f" show_symbol_type_heading: false\n"
|
|
f" show_symbol_type_toc: false\n"
|
|
f" show_if_no_docstring: true\n"
|
|
f" show_labels: false\n"
|
|
)
|
|
f.write("\n")
|
|
|
|
|
|
# Get attributes / methods that are unique to the subclass
|
|
def _get_unique_methods(base_class: Type, sub_class: Type):
|
|
base_methods = set(dir(base_class))
|
|
subclass_methods = set(dir(sub_class))
|
|
unique_methods = subclass_methods - base_methods
|
|
|
|
return [method for method in unique_methods if not method.startswith("_")]
|
|
|
|
|
|
def _gen_default_settings_section(app_settings_filepath: str) -> str:
|
|
# In the source code (`app_settings.py`), we've inserted following strings
|
|
# to mark the start and end of the where we define the default settings.
|
|
# We copy this as a plain string, so that the comments are preserved.
|
|
settings_sourcecode = Path(app_settings_filepath).read_text()
|
|
defaults_snippet = settings_sourcecode.split("--snippet:defaults--")[1].split("--endsnippet:defaults--")[0]
|
|
|
|
# Next we need to clean up the snippet:
|
|
|
|
# Remove single line from both ends to remove comments and the snippet strings
|
|
defaults_snippet_lines = defaults_snippet.split("\n")[1:-1]
|
|
|
|
# Also remove escape/formatter comments at the end of the lines like
|
|
# `# noqa` or `# type: ignore`
|
|
comment_re = re.compile(r"#\s+(?:type\:|noqa)")
|
|
|
|
# Some settings are dynamic in a sense that their value depends on the Django settings,
|
|
# and thus may change anytime. Because the default settings are defined at the top-level
|
|
# of the module, we want to delay evaluation of `settings.my_setting`. For that we use the
|
|
# `Dynamic` class and a lambda function.
|
|
#
|
|
# However, for the documentation, we need to remove those.
|
|
dynamic_re = re.compile(r"Dynamic\(lambda\: (?P<code>.+)\)")
|
|
cleaned_snippet_lines = []
|
|
for line in defaults_snippet_lines:
|
|
line = comment_re.split(line)[0].rstrip()
|
|
line = dynamic_re.sub(
|
|
lambda m: m.group("code"),
|
|
line,
|
|
)
|
|
cleaned_snippet_lines.append(line)
|
|
clean_defaults_snippet = "\n".join(cleaned_snippet_lines)
|
|
|
|
return (
|
|
"### Settings defaults\n\n"
|
|
"Here's overview of all available settings and their defaults:\n\n"
|
|
+ f"```py\n{clean_defaults_snippet}\n```"
|
|
+ "\n\n"
|
|
)
|
|
|
|
|
|
def gen_reference_tagformatters():
|
|
"""
|
|
Generate documentation for all pre-defined TagFormatters included
|
|
in the Python API of `django_components`.
|
|
"""
|
|
module = import_module("django_components")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_tagformatters.md").read_text()
|
|
out_file = root / "docs/reference/tag_formatters.md"
|
|
|
|
tag_formatter_classes: Dict[str, Type[TagFormatterABC]] = {}
|
|
tag_formatter_instances: Dict[str, TagFormatterABC] = {}
|
|
for name, obj in inspect.getmembers(module):
|
|
if _is_tag_formatter_instance(obj):
|
|
tag_formatter_instances[name] = obj
|
|
elif _is_tag_formatter_cls(obj):
|
|
tag_formatter_classes[name] = obj
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
# Generate a summary of available tag formatters.
|
|
# For each pre-defined TagFormatter entry, generate e.g.
|
|
# ```markdown
|
|
# - `django_components.component_formatter` for [ComponentFormatter](#django_components.ComponentFormatter)
|
|
# ```
|
|
formatted_instances_lines: List[str] = []
|
|
for name, inst in tag_formatter_instances.items():
|
|
cls = inst.__class__
|
|
cls_link_hash = f"#{get_import_path(cls)}"
|
|
formatted_instances_lines.append(f"- `django_components.{name}` for [{cls.__name__}]({cls_link_hash})\n")
|
|
|
|
formatted_instances = "\n".join(formatted_instances_lines)
|
|
f.write("### Available tag formatters\n\n" + formatted_instances)
|
|
|
|
for name, obj in tag_formatter_classes.items():
|
|
class_name = get_import_path(obj)
|
|
|
|
# Generate reference entry for each TagFormatter class.
|
|
# For each entry TagFormatter class, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.tag_formatter.ComponentFormatter
|
|
# options:
|
|
# ...
|
|
# ```
|
|
f.write(
|
|
f"::: {class_name}\n"
|
|
f" options:\n"
|
|
f" inherited_members: false\n"
|
|
f" show_root_heading: true\n"
|
|
f" show_signature: false\n"
|
|
f" separate_signature: false\n"
|
|
f" show_symbol_type_heading: false\n"
|
|
f" show_symbol_type_toc: false\n"
|
|
f" show_if_no_docstring: true\n"
|
|
f" show_labels: false\n"
|
|
f" members: false\n"
|
|
)
|
|
|
|
f.write("\n")
|
|
|
|
|
|
def gen_reference_urls():
|
|
"""
|
|
Generate documentation for all URLs (`urlpattern` entries) defined by django-components.
|
|
"""
|
|
module = import_module("django_components.urls")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_urls.md").read_text()
|
|
out_file = root / "docs/reference/urls.md"
|
|
|
|
all_urls = _list_urls(module.urlpatterns)
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
# Simply list all URLs, e.g.
|
|
# `- components/cache/<str:comp_cls_id>.<str:script_type>/`
|
|
f.write("\n".join([f"- `{url_path}`\n" for url_path in all_urls]))
|
|
|
|
|
|
def gen_reference_commands():
|
|
"""
|
|
Generate documentation for all Django admin commands defined by django-components.
|
|
|
|
These are discovered by looking at the files defined inside `management/commands`.
|
|
"""
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_commands.md").read_text()
|
|
out_file = root / "docs/reference/commands.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
# Document all commands defined by django-components
|
|
# All our commands are scoped under `components` (e.g. `components create`, `components upgrade`, etc.)
|
|
# Furthermore, all subcommands are declared statically, so we can walk down the tree of subcommands.
|
|
commands_stack: List[Tuple[Type[ComponentCommand], Tuple[str, ...]]] = [(ComponentsRootCommand, ())]
|
|
while commands_stack:
|
|
cmd_def_cls, cmd_path = commands_stack.pop()
|
|
# NOTE: Argparse formats the help string, and so it uses `%%` to escape `%` characters.
|
|
# So we need to replace them with `%`
|
|
cmd_summary = cmd_def_cls.help.replace("%%", "%") if cmd_def_cls.help else ""
|
|
cmd_desc = dedent(cmd_def_cls.__doc__ or "")
|
|
cmd_name = " ".join(cmd_path) + " " + cmd_def_cls.name
|
|
|
|
cmd_parser = setup_parser_from_command(cmd_def_cls)
|
|
cmd_usage = cmd_parser.format_usage()
|
|
# NOTE: The generated usage shows only the command name, not the full path.
|
|
# So we need to add it manually.
|
|
#
|
|
# So this:
|
|
# `usage: ext run [-h]`
|
|
#
|
|
# becomes this:
|
|
# `usage: python manage.py components ext run [-h]`
|
|
cmd_usage = cmd_usage[:7] + "python manage.py " + " ".join(cmd_path) + " " + cmd_usage[7:]
|
|
formatted_args = _format_command_args(cmd_parser, cmd_path + (cmd_def_cls.name,))
|
|
|
|
# Add link to source code
|
|
module_abs_path = import_module(cmd_def_cls.__module__).__file__
|
|
module_rel_path = Path(module_abs_path).relative_to(Path.cwd()).as_posix() # type: ignore[arg-type]
|
|
obj_lineno = inspect.findsource(cmd_def_cls)[1]
|
|
source_code_link = _format_source_code_html(module_rel_path, obj_lineno)
|
|
|
|
# NOTE: For the commands we have to generate the markdown entries ourselves,
|
|
# instead of delegating to mkdocs, for two reasons:
|
|
# 1. All commands have to use the class name `Command` for Django to pick them up
|
|
# 2. The command name is actually defined by the file name.
|
|
f.write(
|
|
f"## `{cmd_name}`\n\n"
|
|
f"```txt\n{cmd_usage}\n```\n\n"
|
|
f"{source_code_link}\n\n"
|
|
f"{cmd_summary}\n\n"
|
|
f"{formatted_args}\n\n"
|
|
f"{cmd_desc}\n\n"
|
|
)
|
|
|
|
# Add subcommands
|
|
for subcmd_cls in reversed(cmd_def_cls.subcommands):
|
|
commands_stack.append((subcmd_cls, cmd_path + (cmd_def_cls.name,)))
|
|
|
|
# TODO_v1 - REMOVE - This this section as it only for legacy commands `startcomponent` and `upgradecomponent`
|
|
command_files = Path("./src/django_components/management/commands").glob("*.py")
|
|
command_modules = [
|
|
(p.stem, f"django_components.management.commands.{p.stem}")
|
|
for p in command_files
|
|
if not p.stem.startswith("_")
|
|
]
|
|
for cmd_name, cmd_import_path in command_modules:
|
|
# NOTE: `components` command is already documented in the non-legacy section
|
|
if cmd_name == "components":
|
|
continue
|
|
|
|
cmd_module = import_module(cmd_import_path)
|
|
cmd_cls: BaseCommand = cmd_module.Command
|
|
cmd_summary = cmd_cls.help
|
|
cmd_desc = dedent(cmd_cls.__doc__ or "")
|
|
cmd_parser = cmd_cls().create_parser("manage.py", cmd_name)
|
|
cmd_usage = cmd_parser.format_usage()
|
|
formatted_args = _format_command_args(cmd_parser)
|
|
|
|
# Add link to source code
|
|
module_rel_path = Path(cmd_module.__file__).relative_to(Path.cwd()).as_posix() # type: ignore[arg-type]
|
|
obj_lineno = inspect.findsource(cmd_cls)[1]
|
|
source_code_link = _format_source_code_html(module_rel_path, obj_lineno)
|
|
|
|
# NOTE: For the commands we have to generate the markdown entries ourselves,
|
|
# instead of delegating to mkdocs, for two reasons:
|
|
# 1. All commands have to use the class name `Command` for Django to pick them up
|
|
# 2. The command name is actually defined by the file name.
|
|
f.write(
|
|
f"## `{cmd_name}`\n\n"
|
|
f"```txt\n{cmd_usage}\n```\n\n"
|
|
f"{source_code_link}\n\n"
|
|
f"{cmd_summary}\n\n"
|
|
f"{formatted_args}\n\n"
|
|
f"{cmd_desc}\n\n"
|
|
)
|
|
|
|
|
|
def gen_reference_templatetags():
|
|
"""
|
|
Generate documentation for all Django template tags defined by django-components,
|
|
like `{% slot %}`, `{% component %}`, etc.
|
|
|
|
These are discovered by looking at the files defined inside `django_components/template_tags`.
|
|
"""
|
|
tags_files = Path("./src/django_components/templatetags").glob("*.py")
|
|
tags_modules = [
|
|
(p.stem, f"django_components.templatetags.{p.stem}") for p in tags_files if not p.stem.startswith("_")
|
|
]
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_templatetags.md").read_text()
|
|
out_file = root / "docs/reference/template_tags.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
for mod_name, mod_path in tags_modules:
|
|
tags_module = import_module(mod_path)
|
|
module_rel_path = Path(tags_module.__file__).relative_to(Path.cwd()).as_posix() # type: ignore[arg-type]
|
|
|
|
f.write(
|
|
f"All following template tags are defined in\n\n"
|
|
f"`{mod_path}`\n\n"
|
|
f"Import as\n```django\n{{% load {mod_name} %}}\n```\n\n"
|
|
)
|
|
|
|
for _, obj in inspect.getmembers(tags_module):
|
|
if not _is_template_tag(obj):
|
|
continue
|
|
|
|
node_cls: BaseNode = obj
|
|
name = node_cls.tag
|
|
tag_signature = _format_tag_signature(node_cls)
|
|
obj_lineno = inspect.findsource(node_cls)[1]
|
|
source_code_link = _format_source_code_html(module_rel_path, obj_lineno)
|
|
|
|
# Use the tag's function's docstring
|
|
docstring = dedent(node_cls.__doc__ or "").strip()
|
|
|
|
# Rebuild (almost) the same documentation than as if we used
|
|
# mkdocstrings' `::: path.to.module` syntax.
|
|
# Instead we rebuild it, so we can format the function signature as template tag,
|
|
# e.g.
|
|
# ```django
|
|
# {% component [arg, ...] **kwargs [only] %}
|
|
# {% endcomponent %}
|
|
# ```
|
|
f.write(
|
|
f"## {name}\n\n"
|
|
f"```django\n"
|
|
f"{tag_signature}\n"
|
|
f"```\n\n"
|
|
f"{source_code_link}\n\n"
|
|
f"{docstring}\n\n"
|
|
)
|
|
|
|
|
|
def gen_reference_templatevars():
|
|
"""
|
|
Generate documentation for all variables that are available inside the component templates
|
|
under the `{{ component_vars }}` variable, as defined by `ComponentVars`.
|
|
"""
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_templatevars.md").read_text()
|
|
out_file = root / "docs/reference/template_vars.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
for field in ComponentVars._fields:
|
|
f.write(f"::: {ComponentVars.__module__}.{ComponentVars.__name__}.{field}\n\n")
|
|
|
|
|
|
def gen_reference_extension_hooks():
|
|
"""
|
|
Generate documentation for the hooks that are available to the extensions.
|
|
"""
|
|
module = import_module("django_components.extension")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_extension_hooks.md").read_text()
|
|
out_file = root / "docs/reference/extension_hooks.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
# 1. Insert section from `reference_extension_hooks.md`
|
|
f.write(preface + "\n\n")
|
|
|
|
# 2. Print each hook and their descriptions
|
|
extension_cls = module.ComponentExtension
|
|
class_name = get_import_path(extension_cls)
|
|
|
|
# NOTE: If no unique methods, just document the class itself without methods
|
|
unique_methods = _get_unique_methods(NamedTuple, extension_cls)
|
|
# All hooks start with `on_`, so filter out the rest
|
|
unique_methods = [name for name in unique_methods if name.startswith("on_")]
|
|
|
|
f.write("## Hooks\n\n")
|
|
for name in sorted(unique_methods):
|
|
# Programmatically get the data available inside the hook, so we can generate
|
|
# a table of available data.
|
|
# Each hook receives a second "ctx" argument, so we access the typing of this "ctx",
|
|
# and get its fields.
|
|
method = getattr(extension_cls, name)
|
|
ctx_type = method.__annotations__["ctx"]
|
|
# The Context data class is defined in the same module as the hook, so we can
|
|
# import it dynamically.
|
|
ctx_class = getattr(module, ctx_type.__name__)
|
|
fields = ctx_class._fields
|
|
field_docstrings = _extract_property_docstrings(ctx_class)
|
|
|
|
# Generate the available data table
|
|
available_data = "**Available data:**\n\n"
|
|
available_data += "name | type | description\n"
|
|
available_data += "--|--|--\n"
|
|
for field in sorted(fields):
|
|
field_type = _format_hook_type(str(ctx_class.__annotations__[field]))
|
|
field_desc = field_docstrings[field]
|
|
available_data += f"`{field}` | {field_type} | {field_desc}\n"
|
|
|
|
# For each entry, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.extension.ComponentExtension.on_component_registered
|
|
# options:
|
|
# ...
|
|
# ```
|
|
f.write(
|
|
f"::: {class_name}.{name}\n"
|
|
f" options:\n"
|
|
f" heading_level: 3\n"
|
|
f" show_root_heading: true\n"
|
|
f" show_signature: true\n"
|
|
f" separate_signature: true\n"
|
|
f" show_symbol_type_heading: false\n"
|
|
f" show_symbol_type_toc: false\n"
|
|
f" show_if_no_docstring: true\n"
|
|
f" show_labels: false\n"
|
|
)
|
|
f.write("\n")
|
|
f.write(available_data)
|
|
f.write("\n")
|
|
|
|
# 3. Print the context objects for each hook
|
|
f.write("## Objects\n\n")
|
|
main_module = import_module("django_components")
|
|
for name, obj in inspect.getmembers(main_module):
|
|
if not _is_extension_hook_api(obj):
|
|
continue
|
|
|
|
# For each entry, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.extension.OnComponentClassCreatedContext
|
|
# options:
|
|
# show_if_no_docstring: true
|
|
# ```
|
|
f.write(
|
|
f"::: {module.__name__}.{name}\n"
|
|
f" options:\n"
|
|
f" heading_level: 3\n"
|
|
f" show_if_no_docstring: true\n"
|
|
)
|
|
|
|
f.write("\n")
|
|
|
|
|
|
def gen_reference_extension_commands():
|
|
"""
|
|
Generate documentation for the objects related to defining extension commands.
|
|
"""
|
|
module = import_module("django_components")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_extension_commands.md").read_text()
|
|
out_file = root / "docs/reference/extension_commands.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
# 1. Insert section from `reference_extension_commands.md`
|
|
f.write(preface + "\n\n")
|
|
|
|
# 2. Print the context objects for each hook
|
|
main_module = import_module("django_components")
|
|
for name, obj in inspect.getmembers(main_module):
|
|
if not _is_extension_command_api(obj):
|
|
continue
|
|
|
|
# For each entry, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.util.command.CommandLiteralAction
|
|
# options:
|
|
# show_if_no_docstring: true
|
|
# ```
|
|
f.write(
|
|
f"::: {module.__name__}.{name}\n"
|
|
f" options:\n"
|
|
f" heading_level: 3\n"
|
|
f" show_if_no_docstring: true\n"
|
|
)
|
|
|
|
f.write("\n")
|
|
|
|
|
|
def gen_reference_extension_urls():
|
|
"""
|
|
Generate documentation for the objects related to defining extension URLs.
|
|
"""
|
|
module = import_module("django_components")
|
|
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_extension_urls.md").read_text()
|
|
out_file = root / "docs/reference/extension_urls.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
# 1. Insert section from `reference_extension_urls.md`
|
|
f.write(preface + "\n\n")
|
|
|
|
# 2. Print the context objects for each hook
|
|
main_module = import_module("django_components")
|
|
for name, obj in inspect.getmembers(main_module):
|
|
if not _is_extension_url_api(obj):
|
|
continue
|
|
|
|
# For each entry, generate a mkdocstrings entry, e.g.
|
|
# ```
|
|
# ::: django_components.util.routing.URLRoute
|
|
# options:
|
|
# show_if_no_docstring: true
|
|
# ```
|
|
f.write(
|
|
f"::: {module.__name__}.{name}\n"
|
|
f" options:\n"
|
|
f" heading_level: 3\n"
|
|
f" show_if_no_docstring: true\n"
|
|
)
|
|
|
|
f.write("\n")
|
|
|
|
|
|
forward_ref_pattern = re.compile(r"ForwardRef\('(.+?)'\)")
|
|
class_repr_pattern = re.compile(r"<class '(.+?)'>")
|
|
typing_pattern = re.compile(r"typing\.(.+?)")
|
|
|
|
|
|
def _format_hook_type(type_str: str) -> str:
|
|
# Clean up the type string
|
|
type_str = forward_ref_pattern.sub(r"\1", type_str)
|
|
type_str = class_repr_pattern.sub(r"\1", type_str)
|
|
type_str = typing_pattern.sub(r"\1", type_str)
|
|
type_str = type_str.replace("django.template.context.Context", "Context")
|
|
type_str = "`" + type_str + "`"
|
|
|
|
# Add links to non-builtin types
|
|
if "ComponentRegistry" in type_str:
|
|
type_str = f"[{type_str}](../api#django_components.ComponentRegistry)"
|
|
elif "Component" in type_str:
|
|
type_str = f"[{type_str}](../api#django_components.Component)"
|
|
elif "Context" in type_str:
|
|
type_str = f"[{type_str}](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)"
|
|
|
|
return type_str
|
|
|
|
|
|
def _extract_property_docstrings(cls: Type) -> Dict[str, str]:
|
|
"""
|
|
Python doesn't provide a way to access docstrings of properties, e.g.:
|
|
|
|
```python
|
|
class MyComponent(Component):
|
|
my_property: str = "Hello, world!"
|
|
'''
|
|
My property docstring
|
|
'''
|
|
```
|
|
|
|
This function extracts the docstrings of properties from the source code.
|
|
|
|
Returns a dictionary with the property name as the key and the docstring as the value.
|
|
|
|
```python
|
|
{
|
|
"my_property": "My property docstring"
|
|
}
|
|
```
|
|
|
|
NOTE: This is a naive implementation and may not work for all cases:
|
|
|
|
- The function expects NO colons (`:`) inside class bases definition.
|
|
- The function assumes that the docstring is defined with `\"\"\"` or `'''`, and that
|
|
the docstring begins on a separate line.
|
|
- The function assumes that the class is defined at the global scope (module level)
|
|
and that the body is indented with 4 spaces.
|
|
"""
|
|
lines, start_line_index = inspect.getsourcelines(cls)
|
|
attrs_lines = []
|
|
ignore = True
|
|
for line in lines:
|
|
if ignore:
|
|
if line.endswith("):\n"):
|
|
ignore = False
|
|
continue
|
|
# Ignore comments
|
|
elif line.strip().startswith("#"):
|
|
continue
|
|
else:
|
|
attrs_lines.append(line)
|
|
|
|
attrs_docstrings = {}
|
|
curr_attr = None
|
|
docstring_delimiter = None
|
|
state = "before_attr"
|
|
|
|
while attrs_lines:
|
|
line = attrs_lines.pop(0)
|
|
# Exactly 1 indentation and not empty line
|
|
is_one_indent = line.startswith(" " * 4) and not line.startswith(" " * 5) and line.strip()
|
|
line = line.strip()
|
|
|
|
if state == "before_attr":
|
|
if not is_one_indent:
|
|
continue
|
|
curr_attr = line.split(":", maxsplit=1)[0].strip()
|
|
attrs_docstrings[curr_attr] = ""
|
|
state = "before_attr_docstring"
|
|
elif state == "before_attr_docstring":
|
|
if not is_one_indent or not (line.startswith("'''") or line.startswith('"""')):
|
|
continue
|
|
# Found start of docstring
|
|
docstring_delimiter = line[0:3]
|
|
line = line[3:]
|
|
attrs_lines.insert(0, line)
|
|
state = "attr_docstring"
|
|
elif state == "attr_docstring":
|
|
# Not end of docstring
|
|
if docstring_delimiter not in line: # type: ignore[operator]
|
|
attrs_docstrings[curr_attr] += line # type: ignore[index]
|
|
continue
|
|
# Found end of docstring
|
|
last_docstring_line, _ = line.split(docstring_delimiter, maxsplit=1)
|
|
attrs_docstrings[curr_attr] += last_docstring_line # type: ignore[index]
|
|
attrs_docstrings[curr_attr] = dedent(attrs_docstrings[curr_attr]) # type: ignore[index]
|
|
state = "before_attr"
|
|
|
|
return attrs_docstrings
|
|
|
|
|
|
# NOTE: Unlike other references, the API of Signals is not yet codified (AKA source of truth defined
|
|
# as Python code). Instead, we manually list all signals that are sent by django-components.
|
|
def gen_reference_signals():
|
|
"""
|
|
Generate documentation for all [Django Signals](https://docs.djangoproject.com/en/5.2/ref/signals) that are
|
|
send by or during the use of django-components.
|
|
"""
|
|
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
|
preface += (root / "docs/templates/reference_signals.md").read_text()
|
|
out_file = root / "docs/reference/signals.md"
|
|
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_file.open("w", encoding="utf-8") as f:
|
|
f.write(preface + "\n\n")
|
|
|
|
|
|
def _list_urls(urlpatterns: Sequence[Union[URLPattern, URLResolver]], prefix=""):
|
|
"""Recursively extract all URLs and their associated views from Django's urlpatterns"""
|
|
urls: List[str] = []
|
|
|
|
for pattern in urlpatterns:
|
|
if isinstance(pattern, URLPattern):
|
|
# Direct view pattern
|
|
path = prefix + str(pattern.pattern)
|
|
urls.append(path)
|
|
elif isinstance(pattern, URLResolver):
|
|
# Included URLs, resolve recursively
|
|
nested_patterns = pattern.url_patterns
|
|
nested_prefix = prefix + str(pattern.pattern)
|
|
urls += _list_urls(nested_patterns, nested_prefix)
|
|
|
|
return urls
|
|
|
|
|
|
def _format_tag_signature(node_cls: BaseNode) -> str:
|
|
"""
|
|
Given the Node class, format the tag's function signature like:
|
|
```django
|
|
{% component arg1: int, arg2: str, *args, **kwargs: Any [only] %}
|
|
{% endcomponent %}
|
|
```
|
|
"""
|
|
# The signature returns a string like:
|
|
# `(arg: Any, **kwargs: Any) -> None`
|
|
params_str = str(node_cls._signature)
|
|
# Remove the return type annotation, the `-> None` part
|
|
params_str = params_str.rsplit("->", 1)[0]
|
|
# Remove brackets around the params, to end up only with `arg: Any, **kwargs: Any`
|
|
params_str = params_str.strip()[1:-1]
|
|
|
|
if node_cls.allowed_flags:
|
|
params_str += " " + " ".join([f"[{name}]" for name in node_cls.allowed_flags])
|
|
|
|
# Create the function signature
|
|
full_tag = "{% " + node_cls.tag + " " + params_str + " %}"
|
|
if node_cls.end_tag:
|
|
full_tag += f"\n{{% {node_cls.end_tag} %}}"
|
|
|
|
return full_tag
|
|
|
|
|
|
# For simplicity, we let `ArgumentParser` format the command args properly.
|
|
# And then we parse that to extract the available args and their descriptions.
|
|
#
|
|
# NOTE: Based on `ArgumentParser.format_help()`, but skips usage and description,
|
|
# and prints only the inputs.
|
|
def _gen_command_args(parser: ArgumentParser) -> str:
|
|
formatter = parser._get_formatter()
|
|
|
|
# positionals, optionals and user-defined groups
|
|
for action_group in parser._action_groups:
|
|
formatter.start_section(action_group.title)
|
|
formatter.add_text(action_group.description)
|
|
formatter.add_arguments(action_group._group_actions)
|
|
formatter.end_section()
|
|
|
|
return formatter.format_help()
|
|
|
|
|
|
# PARSE THIS:
|
|
# ```
|
|
# options:
|
|
# -h, --help show this help message and exit
|
|
# --path PATH Path to search for components
|
|
# ```
|
|
#
|
|
# INTO THIS:
|
|
# ```
|
|
# {'options': [{'desc': 'show this help message and exit',
|
|
# 'names': ['-h', '--help']},
|
|
# {'desc': 'Path to search for components',
|
|
# 'names': ['--path PATH']},
|
|
# {'desc': "Show program's version number and exit.",
|
|
# ```
|
|
def _parse_command_args(cmd_inputs: str) -> Dict[str, List[Dict]]:
|
|
# Replace
|
|
# ```
|
|
# subcommands:
|
|
# {create,upgrade,ext}
|
|
# create Create a new django component.
|
|
# upgrade Upgrade django components syntax from ...
|
|
# ext Run extension commands.
|
|
# ```
|
|
#
|
|
# Into:
|
|
# ```
|
|
# subcommands:
|
|
# create Create a new django component.
|
|
# upgrade Upgrade django components syntax from ...
|
|
# ext Run extension commands.
|
|
# ```
|
|
if "subcommands:" in cmd_inputs:
|
|
cmd_inputs = re.compile(r"subcommands:\n.*?\}", re.DOTALL).sub("subcommands:", cmd_inputs)
|
|
|
|
# Dedent the lines that contain subcommands from 4 spaces to 2 spaces
|
|
text_before_subcommands, text_after_subcommands = cmd_inputs.split("subcommands:\n")
|
|
lines_after_subcommands = text_after_subcommands.split("\n")
|
|
new_text_after_subcommands = ""
|
|
for line in lines_after_subcommands:
|
|
if line.startswith(" " * 4):
|
|
new_text_after_subcommands += line[2:] + "\n"
|
|
else:
|
|
new_text_after_subcommands += line + "\n"
|
|
cmd_inputs = text_before_subcommands + "subcommands:\n" + new_text_after_subcommands
|
|
|
|
section: Optional[str] = None
|
|
data: Dict[str, List[Dict]] = {}
|
|
|
|
for line in cmd_inputs.split("\n"):
|
|
if not line:
|
|
section = None
|
|
continue
|
|
|
|
if section is None:
|
|
if not line.endswith(":"):
|
|
raise RuntimeError("Expected a new section")
|
|
section = line[:-1]
|
|
data[section] = []
|
|
continue
|
|
|
|
# New entry, e.g.
|
|
# ` -h, --help show this help message and exit`
|
|
if re.compile(r"^ \S").match(line):
|
|
# ["-h, --help", " show this help message and exit"]
|
|
if " " in line.strip():
|
|
arg_input, arg_desc = line.strip().split(" ", 1)
|
|
else:
|
|
arg_input = line.strip()
|
|
arg_desc = ""
|
|
|
|
# ["-h", "--help"]
|
|
arg_inputs = arg_input.split(", ")
|
|
arg = {
|
|
"names": arg_inputs,
|
|
"desc": arg_desc.strip(),
|
|
}
|
|
data[section].append(arg)
|
|
|
|
# Description of argument that was defined on the previous line(s). E.g.
|
|
# ` your Django settings.`
|
|
else:
|
|
# Append the description to the last argument
|
|
desc = line.strip()
|
|
data[section][-1]["desc"] += " " + desc
|
|
|
|
return data
|
|
|
|
|
|
def _format_command_args(cmd_parser: ArgumentParser, cmd_path: Optional[Sequence[str]] = None):
|
|
cmd_inputs: str = _gen_command_args(cmd_parser)
|
|
parsed_cmd_inputs = _parse_command_args(cmd_inputs)
|
|
|
|
formatted_args = ""
|
|
for section, args in parsed_cmd_inputs.items():
|
|
formatted_args += f"**{section.title()}:**\n\n"
|
|
for arg in args:
|
|
# Add link to the subcommand
|
|
if section == "subcommands":
|
|
name = "`" + arg["names"][0] + "`"
|
|
if cmd_path:
|
|
name = "[" + name + "](../commands#" + "-".join(cmd_path) + "-" + name + ")"
|
|
else:
|
|
name = ", ".join([f"`{name}`" for name in arg["names"]])
|
|
|
|
formatted_args += "- " + name + f"\n - {arg['desc']}" + "\n"
|
|
formatted_args += "\n"
|
|
|
|
return formatted_args
|
|
|
|
|
|
def _is_component_cls(obj: Any) -> bool:
|
|
return inspect.isclass(obj) and issubclass(obj, Component) and obj is not Component
|
|
|
|
|
|
def _is_error_cls(obj: Any) -> bool:
|
|
return inspect.isclass(obj) and issubclass(obj, Exception) and obj is not Exception
|
|
|
|
|
|
def _is_tag_formatter_cls(obj: Any) -> bool:
|
|
return inspect.isclass(obj) and issubclass(obj, TagFormatterABC) and obj is not TagFormatterABC
|
|
|
|
|
|
def _is_tag_formatter_instance(obj: Any) -> bool:
|
|
return isinstance(obj, TagFormatterABC)
|
|
|
|
|
|
def _is_template_tag(obj: Any) -> bool:
|
|
return inspect.isclass(obj) and issubclass(obj, BaseNode)
|
|
|
|
|
|
def _is_extension_hook_api(obj: Any) -> bool:
|
|
return inspect.isclass(obj) and getattr(obj, "_extension_hook_api", False)
|
|
|
|
|
|
def _is_extension_command_api(obj: Any) -> bool:
|
|
return inspect.isclass(obj) and getattr(obj, "_extension_command_api", False)
|
|
|
|
|
|
def _is_extension_url_api(obj: Any) -> bool:
|
|
return inspect.isclass(obj) and getattr(obj, "_extension_url_api", False)
|
|
|
|
|
|
def gen_reference():
|
|
"""The entrypoint to generate all the reference documentation."""
|
|
gen_reference_api()
|
|
gen_reference_exceptions()
|
|
gen_reference_components()
|
|
gen_reference_settings()
|
|
gen_reference_tagformatters()
|
|
gen_reference_urls()
|
|
gen_reference_commands()
|
|
gen_reference_templatetags()
|
|
gen_reference_templatevars()
|
|
gen_reference_signals()
|
|
gen_reference_testing_api()
|
|
gen_reference_extension_hooks()
|
|
gen_reference_extension_commands()
|
|
gen_reference_extension_urls()
|
|
|
|
|
|
# This is run when `gen-files` plugin is run in mkdocs.yml
|
|
gen_reference()
|