feat: extensions (#1009)

* feat: extensions

* refactor: remove support for passing in extensions as instances
This commit is contained in:
Juro Oravec 2025-03-08 09:41:28 +01:00 committed by GitHub
parent cff252c566
commit 4d35bc97a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1884 additions and 57 deletions

View file

@ -125,10 +125,7 @@ def gen_reference_testing_api():
f.write(preface + "\n\n")
for name, obj in inspect.getmembers(module):
if (
name.startswith("_")
or inspect.ismodule(obj)
):
if name.startswith("_") or inspect.ismodule(obj):
continue
# For each entry, generate a mkdocstrings entry, e.g.
@ -586,6 +583,178 @@ def gen_reference_templatevars():
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_")]
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" 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")
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.1/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
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():
@ -774,6 +943,7 @@ def gen_reference():
gen_reference_templatevars()
gen_reference_signals()
gen_reference_testing_api()
gen_reference_extension_hooks()
# This is run when `gen-files` plugin is run in mkdocs.yml