django-components/docs/scripts/reference.py
Juro Oravec 7dfcb447c4
feat: add decorator for writing component tests (#1008)
* feat: add decorator for writing component tests

* refactor: udpate changelog + update deps pins

* refactor: fix deps

* refactor: make cached_ref into generic and fix linter errors

* refactor: fix coverage testing

* refactor: use global var instead of env var for is_testing state
2025-03-02 19:46:12 +01:00

780 lines
29 KiB
Python

"""
Generate reference for all the different kinds of public API that we expose,
like regular Python imports, middleware, 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, Type, Union
from django.core.management.base import BaseCommand
from django.urls import URLPattern, URLResolver
from django_components import ComponentVars, TagFormatterABC
from django_components.component import Component
from django_components.node import BaseNode
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)
):
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)
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_middlewares():
"""
Generate documentation for all available middleware of django-components,
as listed in module `django_components.middleware`.
"""
module = import_module("django_components.middleware")
preface = "<!-- Autogenerated by reference.py -->\n\n"
preface += (root / "docs/templates/reference_middlewares.md").read_text()
out_file = root / "docs/reference/middlewares.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 inspect.isclass(obj):
continue
class_name = get_import_path(obj)
# For each entry, generate a mkdocstrings entry, e.g.
# ```
# ::: django_components.middleware.ComponentDependencyMiddleware
# 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.write("\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_hash>.<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`.
"""
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("_")
]
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")
for cmd_name, cmd_path in command_modules:
cmd_module = import_module(cmd_path)
cmd_cls: BaseCommand = cmd_module.Command
cmd_summary = cmd_cls.help
cmd_desc = dedent(cmd_cls.__doc__ or "")
cmd_parser: ArgumentParser = cmd_cls().create_parser("manage.py", cmd_name)
cmd_usage: str = 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")
# 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.1/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]]:
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_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:
formatted_args += (
"- " + ", ".join([f"`{name}`" for name in arg["names"]]) + 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 gen_reference():
"""The entrypoint to generate all the reference documentation."""
gen_reference_api()
gen_reference_exceptions()
gen_reference_components()
gen_reference_middlewares()
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()
# This is run when `gen-files` plugin is run in mkdocs.yml
gen_reference()