feat: allow extensions to add commands (#1017)

* feat: allow extensions to add commands

* refactor: fix tests

* refactor: more test fix

* refactor: more test fixes

* refactor: more linter fixes
This commit is contained in:
Juro Oravec 2025-03-16 12:03:16 +01:00 committed by GitHub
parent 3a139127cd
commit d3d2d0ab08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2320 additions and 397 deletions

View file

@ -42,14 +42,15 @@ 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 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 ComponentVars, TagFormatterABC
from django_components.component import Component
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`.
@ -462,13 +463,6 @@ def gen_reference_commands():
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"
@ -477,13 +471,72 @@ def gen_reference_commands():
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)
# 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: ArgumentParser = cmd_cls().create_parser("manage.py", cmd_name)
cmd_usage: str = cmd_parser.format_usage()
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
@ -850,6 +903,36 @@ def _gen_command_args(parser: ArgumentParser) -> str:
# {'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]] = {}
@ -893,7 +976,7 @@ def _parse_command_args(cmd_inputs: str) -> Dict[str, List[Dict]]:
return data
def _format_command_args(cmd_parser: ArgumentParser):
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)
@ -901,9 +984,15 @@ def _format_command_args(cmd_parser: ArgumentParser):
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"
)
# 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