django-components/docs/scripts/reference.py
Juro Oravec ccf02fa316
chore: util to manage URLs in the codebase (#1179)
* chore: util to manage URLs in the codebase

* docs: mentiion validate_links and supported_versions in docs

* refactor: fix linter errors
2025-05-11 14:59:34 +02:00

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()