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

@ -6,8 +6,19 @@
- Support for extensions (plugins) for django-components!
- Hook into lifecycle events of django-components
- Pre-/post-process component inputs, outputs, and templates
- Add extra methods or attributes to Components
- Add custom CLI commands to django-components
Read more on [Extensions](https://django-components.github.io/django-components/0.131/concepts/advanced/extensions/).
- New CLI commands:
- `components create <name>` - Create a new component (supersedes `startcomponent`)
- `components upgrade <name>` - Upgrade a component (supersedes `upgradecomponent`)
- `components ext list` - List all extensions
- `components ext run <extension> <command>` - Run a command added by an extension
- `@djc_test` decorator for writing tests that involve Components.
- The decorator manages global state, ensuring that tests don't leak.
@ -16,6 +27,12 @@
See the API reference for [`@djc_test`](https://django-components.github.io/django-components/0.131/reference/testing_api/#djc_test) for more details.
#### Refactor
- The `startcomponent` and `upgradecomponent` commands are deprecated, and will be removed in v1.
Instead, use `components start <name>` and `components upgrade`.
#### Internal
- Settings are now loaded only once, and thus are considered immutable once loaded. Previously,

View file

@ -4,6 +4,7 @@ Django-components functionality can be extended with "extensions". Extensions al
- Tap into lifecycle events, such as when a component is created, deleted, registered, or unregistered.
- Add new attributes and methods to the components under an extension-specific nested class.
- Define custom commands that can be executed via the Django management command interface.
## Setting up extensions
@ -328,3 +329,286 @@ class MyComponent(Component):
```
This will log the component name and color when the component is created, deleted, or rendered.
## Extension Commands
Extensions in django-components can define custom commands that can be executed via the Django management command interface. This allows for powerful automation and customization capabilities.
For example, if you have an extension that defines a command that prints "Hello world", you can run the command with:
```bash
python manage.py components ext run my_ext hello
```
Where:
- `python manage.py components ext run` - is the Django command run
- `my_ext` - is the extension name
- `hello` - is the command name
### Defining Commands
To define a command, subclass from [`ComponentCommand`](../../../reference/api#django_components.ComponentCommand).
This subclass should define:
- `name` - the command's name
- `help` - the command's help text
- `handle` - the logic to execute when the command is run
```python
from django_components import ComponentCommand, ComponentsExtension
class HelloCommand(ComponentCommand):
name = "hello"
help = "Say hello"
def handle(self, *args, **kwargs):
print("Hello, world!")
class MyExt(ComponentsExtension):
name = "my_ext"
commands = [HelloCommand]
```
### Defining Command Arguments and Options
Commands can accept positional arguments and options (e.g. `--foo`), which are defined using the
[`arguments`](../../../reference/api#django_components.ComponentCommand.arguments)
attribute of the [`ComponentCommand`](../../../reference/api#django_components.ComponentCommand) class.
The arguments are parsed with [`argparse`](https://docs.python.org/3/library/argparse.html)
into a dictionary of arguments and options. These are then available
as keyword arguments to the [`handle`](../../../reference/api#django_components.ComponentCommand.handle)
method of the command.
```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension
class HelloCommand(ComponentCommand):
name = "hello"
help = "Say hello"
arguments = [
# Positional argument
CommandArg(
name_or_flags="name",
help="The name to say hello to",
),
# Optional argument
CommandArg(
name_or_flags=["--shout", "-s"],
action="store_true",
help="Shout the hello",
),
]
def handle(self, name: str, *args, **kwargs):
shout = kwargs.get("shout", False)
msg = f"Hello, {name}!"
if shout:
msg = msg.upper()
print(msg)
```
You can run the command with arguments and options:
```bash
python manage.py components ext run my_ext hello John --shout
>>> HELLO, JOHN!
```
!!! note
Command definitions are parsed with `argparse`, so you can use all the features of `argparse` to define your arguments and options.
See the [argparse documentation](https://docs.python.org/3/library/argparse.html) for more information.
django-components defines types as
[`CommandArg`](../../../reference/api#django_components.CommandArg),
[`CommandArgGroup`](../../../reference/api#django_components.CommandArgGroup),
[`CommandSubcommand`](../../../reference/api#django_components.CommandSubcommand),
and [`CommandParserInput`](../../../reference/api#django_components.CommandParserInput)
to help with type checking.
!!! note
If a command doesn't have the [`handle`](../../../reference/api#django_components.ComponentCommand.handle)
method defined, the command will print a help message and exit.
### Grouping Arguments
Arguments can be grouped using [`CommandArgGroup`](../../../reference/api#django_components.CommandArgGroup)
to provide better organization and help messages.
Read more on [argparse argument groups](https://docs.python.org/3/library/argparse.html#argument-groups).
```python
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentsExtension
class HelloCommand(ComponentCommand):
name = "hello"
help = "Say hello"
# Argument parsing is managed by `argparse`.
arguments = [
# Positional argument
CommandArg(
name_or_flags="name",
help="The name to say hello to",
),
# Optional argument
CommandArg(
name_or_flags=["--shout", "-s"],
action="store_true",
help="Shout the hello",
),
# When printing the command help message, `--bar` and `--baz`
# will be grouped under "group bar".
CommandArgGroup(
title="group bar",
description="Group description.",
arguments=[
CommandArg(
name_or_flags="--bar",
help="Bar description.",
),
CommandArg(
name_or_flags="--baz",
help="Baz description.",
),
],
),
]
def handle(self, name: str, *args, **kwargs):
shout = kwargs.get("shout", False)
msg = f"Hello, {name}!"
if shout:
msg = msg.upper()
print(msg)
```
### Subcommands
Extensions can define subcommands, allowing for more complex command structures.
Subcommands are defined similarly to root commands, as subclasses of
[`ComponentCommand`](../../../reference/api#django_components.ComponentCommand) class.
However, instead of defining the subcommands in the
[`commands`](../../../reference/api#django_components.ComponentExtension.commands)
attribute of the extension, you define them in the
[`subcommands`](../../../reference/api#django_components.ComponentCommand.subcommands)
attribute of the parent command:
```python
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentsExtension
class ChildCommand(ComponentCommand):
name = "child"
help = "Child command"
def handle(self, *args, **kwargs):
print("Child command")
class ParentCommand(ComponentCommand):
name = "parent"
help = "Parent command"
subcommands = [
ChildCommand,
]
def handle(self, *args, **kwargs):
print("Parent command")
```
In this example, we can run two commands.
Either the parent command:
```bash
python manage.py components ext run parent
>>> Parent command
```
Or the child command:
```bash
python manage.py components ext run parent child
>>> Child command
```
!!! warning
Subcommands are independent of the parent command. When a subcommand runs, the parent command is NOT executed.
As such, if you want to pass arguments to both the parent and child commands, e.g.:
```bash
python manage.py components ext run parent --foo child --bar
```
You should instead pass all the arguments to the subcommand:
```bash
python manage.py components ext run parent child --foo --bar
```
### Print command help
By default, all commands will print their help message when run with the `--help` / `-h` flag.
```bash
python manage.py components ext run my_ext --help
```
The help message prints out all the arguments and options available for the command, as well as any subcommands.
### Testing Commands
Commands can be tested using Django's [`call_command()`](https://docs.djangoproject.com/en/5.1/ref/django-admin/#running-management-commands-from-your-code)
function, which allows you to simulate running the command in tests.
```python
from django.core.management import call_command
call_command('components', 'ext', 'run', 'my_ext', 'hello', '--name', 'John')
```
To capture the output of the command, you can use the [`StringIO`](https://docs.python.org/3/library/io.html#io.StringIO)
module to redirect the output to a string:
```python
from io import StringIO
out = StringIO()
with patch("sys.stdout", new=out):
call_command('components', 'ext', 'run', 'my_ext', 'hello', '--name', 'John')
output = out.getvalue()
```
And to temporarily set the extensions, you can use the [`@djc_test`](../../../reference/testing_api#djc_test) decorator.
Thus, a full test example can then look like this:
```python
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django_components.testing import djc_test
@djc_test(
components_settings={
"extensions": [
"my_app.extensions.MyExtension",
],
},
)
def test_hello_command(self):
out = StringIO()
with patch("sys.stdout", new=out):
call_command('components', 'ext', 'run', 'my_ext', 'hello', '--name', 'John')
output = out.getvalue()
assert output == "Hello, John!\n"
```

View file

@ -7,6 +7,30 @@
options:
show_if_no_docstring: true
::: django_components.CommandArg
options:
show_if_no_docstring: true
::: django_components.CommandArgGroup
options:
show_if_no_docstring: true
::: django_components.CommandHandler
options:
show_if_no_docstring: true
::: django_components.CommandLiteralAction
options:
show_if_no_docstring: true
::: django_components.CommandParserInput
options:
show_if_no_docstring: true
::: django_components.CommandSubcommand
options:
show_if_no_docstring: true
::: django_components.Component
options:
show_if_no_docstring: true
@ -39,6 +63,10 @@
options:
show_if_no_docstring: true
::: django_components.ComponentCommand
options:
show_if_no_docstring: true
::: django_components.ComponentsSettings
options:
show_if_no_docstring: true

View file

@ -6,21 +6,361 @@ These are all the [Django management commands](https://docs.djangoproject.com/en
that will be added by installing `django_components`:
## `upgradecomponent`
## ` components`
```txt
usage: manage.py upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
[--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] [--skip-checks]
usage: python manage.py components [-h] {create,upgrade,ext} ...
```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/management/commands/upgradecomponent.py#L12" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/commands/components.py#L9" target="_blank">See source code</a>
Updates component and component_block tags to the new syntax
The entrypoint for the 'components' commands.
**Options:**
- `-h`, `--help`
- show this help message and exit
**Subcommands:**
- [`create`](../commands#components-`create`)
- Create a new django component.
- [`upgrade`](../commands#components-`upgrade`)
- Upgrade django components syntax from '{% component_block ... %}' to '{% component ... %}'.
- [`ext`](../commands#components-`ext`)
- Run extension commands.
The entrypoint for the "components" commands.
```bash
python manage.py components start <name>
python manage.py components upgrade <name>
python manage.py components ext list
python manage.py components ext run <extension> <command>
```
## `components create`
```txt
usage: python manage.py components create [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run] name
```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/commands/create.py#L11" target="_blank">See source code</a>
Create a new django component.
**Positional Arguments:**
- `name`
- The name of the component to create. This is a required argument.
**Options:**
- `-h`, `--help`
- show this help message and exit
- `--path PATH`
- The path to the component's directory. This is an optional argument. If not provided, the command will use the `COMPONENTS.dirs` setting from your Django settings.
- `--js JS`
- The name of the JavaScript file. This is an optional argument. The default value is `script.js`.
- `--css CSS`
- The name of the CSS file. This is an optional argument. The default value is `style.css`.
- `--template TEMPLATE`
- The name of the template file. This is an optional argument. The default value is `template.html`.
- `--force`
- This option allows you to overwrite existing files if they exist. This is an optional argument.
- `--verbose`
- This option allows the command to print additional information during component creation. This is an optional argument.
- `--dry-run`
- This option allows you to simulate component creation without actually creating any files. This is an optional argument. The default value is `False`.
### Usage
To use the command, run the following command in your terminal:
```bash
python manage.py components create <name> --path <path> --js <js_filename> --css <css_filename> --template <template_filename> --force --verbose --dry-run
```
Replace `<name>`, `<path>`, `<js_filename>`, `<css_filename>`, and `<template_filename>` with your desired values.
### Examples
Here are some examples of how you can use the command:
**Creating a Component with Default Settings**
To create a component with the default settings, you only need to provide the name of the component:
```bash
python manage.py components create my_component
```
This will create a new component named `my_component` in the `components` directory of your Django project. The JavaScript, CSS, and template files will be named `script.js`, `style.css`, and `template.html`, respectively.
**Creating a Component with Custom Settings**
You can also create a component with custom settings by providing additional arguments:
```bash
python manage.py components create new_component --path my_components --js my_script.js --css my_style.css --template my_template.html
```
This will create a new component named `new_component` in the `my_components` directory. The JavaScript, CSS, and template files will be named `my_script.js`, `my_style.css`, and `my_template.html`, respectively.
**Overwriting an Existing Component**
If you want to overwrite an existing component, you can use the `--force` option:
```bash
python manage.py components create my_component --force
```
This will overwrite the existing `my_component` if it exists.
**Simulating Component Creation**
If you want to simulate the creation of a component without actually creating any files, you can use the `--dry-run` option:
```bash
python manage.py components create my_component --dry-run
```
This will simulate the creation of `my_component` without creating any files.
## `components upgrade`
```txt
usage: python manage.py components upgrade [-h] [--path PATH]
```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/commands/upgrade.py#L13" target="_blank">See source code</a>
Upgrade django components syntax from '{% component_block ... %}' to '{% component ... %}'.
**Options:**
- `-h`, `--help`
- show this help message and exit
- `--path PATH`
- Path to search for components
## `components ext`
```txt
usage: python manage.py components ext [-h] {list,run} ...
```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/commands/ext.py#L5" target="_blank">See source code</a>
Run extension commands.
**Options:**
- `-h`, `--help`
- show this help message and exit
**Subcommands:**
- [`list`](../commands#components-ext-`list`)
- List all extensions.
- [`run`](../commands#components-ext-`run`)
- Run a command added by an extension.
Run extension commands.
```bash
python manage.py components ext list
python manage.py components ext run <extension> <command>
```
## `components ext list`
```txt
usage: python manage.py components ext list [-h] [-v {0,1}]
```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/commands/ext_list.py#L6" target="_blank">See source code</a>
List all extensions.
**Options:**
- `-h`, `--help`
- show this help message and exit
- `-v {0,1}`, `--verbosity {0,1}`
- Verbosity level; 0=minimal output, 1=normal output
List all extensions.
```bash
python manage.py components ext list
```
Prints the list of installed extensions:
```txt
Installed extensions:
view
my_extension
```
If you need to omit the title in order to programmatically post-process the output,
you can use the `--verbosity` (or `-v`) flag:
```bash
python manage.py components ext list -v 0
```
Which prints just:
```txt
view
my_extension
```
## `components ext run`
```txt
usage: python manage.py components ext run [-h]
```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/commands/ext_run.py#L48" target="_blank">See source code</a>
Run a command added by an extension.
**Options:**
- `-h`, `--help`
- show this help message and exit
Run a command added by an [extension](../../concepts/advanced/extensions).
Each extension can add its own commands, which will be available to run with this command.
For example, if you define and install the following extension:
```python
from django_components ComponentCommand, ComponentsExtension
class HelloCommand(ComponentCommand):
name = "hello"
help = "Say hello"
def handle(self, *args, **kwargs):
print("Hello, world!")
class MyExt(ComponentsExtension):
name = "my_ext"
commands = [HelloCommand]
```
You can run the `hello` command with:
```bash
python manage.py components ext run my_ext hello
```
You can also define arguments for the command, which will be passed to the command's `handle` method.
```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension
class HelloCommand(ComponentCommand):
name = "hello"
help = "Say hello"
arguments = [
CommandArg(name="name", help="The name to say hello to"),
CommandArg(name=["--shout", "-s"], action="store_true"),
]
def handle(self, name: str, *args, **kwargs):
shout = kwargs.get("shout", False)
msg = f"Hello, {name}!"
if shout:
msg = msg.upper()
print(msg)
```
You can run the command with:
```bash
python manage.py components ext run my_ext hello --name John --shout
```
!!! note
Command arguments and options are based on Python's `argparse` module.
For more information, see the [argparse documentation](https://docs.python.org/3/library/argparse.html).
## `upgradecomponent`
```txt
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
[--traceback] [--no-color] [--force-color] [--skip-checks]
```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/management/commands/upgradecomponent.py#L83" target="_blank">See source code</a>
Deprecated. Use `components upgrade` instead.
**Options:**
@ -48,24 +388,26 @@ Updates component and component_block tags to the new syntax
**Deprecated**. Use [`components upgrade`](../commands#components-upgrade) instead.
## `startcomponent`
```txt
usage: manage.py startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose]
[--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
[--traceback] [--no-color] [--force-color] [--skip-checks]
name
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run]
[--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback]
[--no-color] [--force-color] [--skip-checks]
name
```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/management/commands/startcomponent.py#L8" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/management/commands/startcomponent.py#L83" target="_blank">See source code</a>
Create a new django component.
Deprecated. Use `components create` instead.
**Positional Arguments:**
@ -110,58 +452,6 @@ Create a new django component.
### Management Command Usage
To use the command, run the following command in your terminal:
```bash
python manage.py startcomponent <name> --path <path> --js <js_filename> --css <css_filename> --template <template_filename> --force --verbose --dry-run
```
Replace `<name>`, `<path>`, `<js_filename>`, `<css_filename>`, and `<template_filename>` with your desired values.
### Management Command Examples
Here are some examples of how you can use the command:
#### Creating a Component with Default Settings
To create a component with the default settings, you only need to provide the name of the component:
```bash
python manage.py startcomponent my_component
```
This will create a new component named `my_component` in the `components` directory of your Django project. The JavaScript, CSS, and template files will be named `script.js`, `style.css`, and `template.html`, respectively.
#### Creating a Component with Custom Settings
You can also create a component with custom settings by providing additional arguments:
```bash
python manage.py startcomponent new_component --path my_components --js my_script.js --css my_style.css --template my_template.html
```
This will create a new component named `new_component` in the `my_components` directory. The JavaScript, CSS, and template files will be named `my_script.js`, `my_style.css`, and `my_template.html`, respectively.
#### Overwriting an Existing Component
If you want to overwrite an existing component, you can use the `--force` option:
```bash
python manage.py startcomponent my_component --force
```
This will overwrite the existing `my_component` if it exists.
#### Simulating Component Creation
If you want to simulate the creation of a component without actually creating any files, you can use the `--dry-run` option:
```bash
python manage.py startcomponent my_component --dry-run
```
This will simulate the creation of `my_component` without creating any files.
**Deprecated**. Use [`components create`](../commands#components-create) instead.

View file

@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1568" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1573" target="_blank">See source code</a>

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

View file

@ -6,6 +6,15 @@
# isort: off
from django_components.app_settings import ContextBehavior, ComponentsSettings
from django_components.autodiscovery import autodiscover, import_libraries
from django_components.util.command import (
CommandArg,
CommandArgGroup,
CommandHandler,
CommandLiteralAction,
CommandParserInput,
CommandSubcommand,
ComponentCommand,
)
from django_components.component import Component, ComponentVars
from django_components.component_media import ComponentMediaInput, ComponentMediaInputPath
from django_components.component_registry import (
@ -52,9 +61,15 @@ from django_components.util.types import EmptyTuple, EmptyDict
__all__ = [
"AlreadyRegistered",
"autodiscover",
"cached_template",
"BaseNode",
"ContextBehavior",
"cached_template",
"CommandArg",
"CommandArgGroup",
"CommandHandler",
"CommandLiteralAction",
"CommandParserInput",
"CommandSubcommand",
"ComponentCommand",
"ComponentsSettings",
"Component",
"ComponentExtension",
@ -67,6 +82,7 @@ __all__ = [
"ComponentView",
"component_formatter",
"component_shorthand_formatter",
"ContextBehavior",
"DynamicComponent",
"EmptyTuple",
"EmptyDict",

View file

@ -0,0 +1,29 @@
from django_components.commands.create import CreateCommand
from django_components.commands.ext import ExtCommand
from django_components.commands.upgrade import UpgradeCommand
from django_components.util.command import ComponentCommand
# TODO_V3 - This command should be called standalone as "components":
# `python -m components start <name>`
# `components start <name>`
class ComponentsRootCommand(ComponentCommand):
"""
The entrypoint for the "components" commands.
```bash
python manage.py components start <name>
python manage.py components upgrade <name>
python manage.py components ext list
python manage.py components ext run <extension> <command>
```
"""
name = "components"
help = "The entrypoint for the 'components' commands."
subcommands = [
CreateCommand,
UpgradeCommand,
ExtCommand,
]

View file

@ -0,0 +1,220 @@
import os
import sys
from textwrap import dedent
from typing import Any
from django.conf import settings
from django.core.management.base import CommandError
from django_components.util.command import CommandArg, ComponentCommand, style_success, style_warning
class CreateCommand(ComponentCommand):
"""
### Usage
To use the command, run the following command in your terminal:
```bash
python manage.py components create <name> --path <path> --js <js_filename> --css <css_filename> --template <template_filename> --force --verbose --dry-run
```
Replace `<name>`, `<path>`, `<js_filename>`, `<css_filename>`, and `<template_filename>` with your desired values.
### Examples
Here are some examples of how you can use the command:
**Creating a Component with Default Settings**
To create a component with the default settings, you only need to provide the name of the component:
```bash
python manage.py components create my_component
```
This will create a new component named `my_component` in the `components` directory of your Django project. The JavaScript, CSS, and template files will be named `script.js`, `style.css`, and `template.html`, respectively.
**Creating a Component with Custom Settings**
You can also create a component with custom settings by providing additional arguments:
```bash
python manage.py components create new_component --path my_components --js my_script.js --css my_style.css --template my_template.html
```
This will create a new component named `new_component` in the `my_components` directory. The JavaScript, CSS, and template files will be named `my_script.js`, `my_style.css`, and `my_template.html`, respectively.
**Overwriting an Existing Component**
If you want to overwrite an existing component, you can use the `--force` option:
```bash
python manage.py components create my_component --force
```
This will overwrite the existing `my_component` if it exists.
**Simulating Component Creation**
If you want to simulate the creation of a component without actually creating any files, you can use the `--dry-run` option:
```bash
python manage.py components create my_component --dry-run
```
This will simulate the creation of `my_component` without creating any files.
""" # noqa: E501
name = "create"
help = "Create a new django component."
arguments = [
CommandArg(
name_or_flags="name",
help="The name of the component to create. This is a required argument.",
),
CommandArg(
name_or_flags="--path",
help=(
"The path to the component's directory. This is an optional argument. "
"If not provided, the command will use the `COMPONENTS.dirs` setting from your Django settings."
),
default=None,
),
CommandArg(
name_or_flags="--js",
help="The name of the JavaScript file. This is an optional argument. The default value is `script.js`.",
default="script.js",
),
CommandArg(
name_or_flags="--css",
help="The name of the CSS file. This is an optional argument. The default value is `style.css`.",
default="style.css",
),
CommandArg(
name_or_flags="--template",
help="The name of the template file. This is an optional argument. The default value is `template.html`.",
default="template.html",
),
CommandArg(
name_or_flags="--force",
help="This option allows you to overwrite existing files if they exist. This is an optional argument.",
action="store_true",
),
CommandArg(
name_or_flags="--verbose",
help=(
"This option allows the command to print additional information during component creation. "
"This is an optional argument."
),
action="store_true",
),
CommandArg(
name_or_flags="--dry-run",
help=(
"This option allows you to simulate component creation without actually creating any files. "
"This is an optional argument. The default value is `False`."
),
action="store_true",
),
]
def handle(self, *args: Any, **kwargs: Any) -> None:
name = kwargs["name"]
if not name:
raise CommandError("You must specify a component name")
# TODO_V3 - BASE_DIR should be taken from Components' settings
base_dir = getattr(settings, "BASE_DIR", None)
path = kwargs["path"]
js_filename = kwargs["js"]
css_filename = kwargs["css"]
template_filename = kwargs["template"]
force = kwargs["force"]
verbose = kwargs["verbose"]
dry_run = kwargs["dry_run"]
if path:
component_path = os.path.join(path, name)
elif base_dir:
component_path = os.path.join(base_dir, "components", name)
else:
raise CommandError("You must specify a path or set BASE_DIR in your django settings")
if os.path.exists(component_path):
if not force:
raise CommandError(
f'The component "{name}" already exists at {component_path}. Use --force to overwrite.'
)
if verbose:
msg = f'The component "{name}" already exists at {component_path}. Overwriting...'
else:
msg = f'The component "{name}" already exists. Overwriting...'
sys.stdout.write(style_warning(msg) + "\n")
if not dry_run:
os.makedirs(component_path, exist_ok=force)
with open(os.path.join(component_path, js_filename), "w") as f:
script_content = dedent(
f"""
window.addEventListener('load', (event) => {{
console.log("{name} component is fully loaded");
}});
"""
)
f.write(script_content.strip())
with open(os.path.join(component_path, css_filename), "w") as f:
style_content = dedent(
f"""
.component-{name} {{
background: red;
}}
"""
)
f.write(style_content.strip())
with open(os.path.join(component_path, template_filename), "w") as f:
template_content = dedent(
f"""
<div class="component-{name}">
Hello from {name} component!
<br>
This is {{ param }} context value.
</div>
"""
)
f.write(template_content.strip())
with open(os.path.join(component_path, f"{name}.py"), "w") as f:
py_content = dedent(
f"""
from django_components import Component, register
@register("{name}")
class {name.capitalize()}(Component):
template_file = "{name}/{template_filename}"
def get_context_data(self, value):
return {{
"param": "sample value",
}}
class Media:
css = "{name}/{css_filename}"
js = "{name}/{js_filename}"
"""
)
f.write(py_content.strip())
if verbose:
msg = f"Successfully created {name} component at {component_path}"
else:
msg = f"Successfully created {name} component"
sys.stdout.write(style_success(msg) + "\n")

View file

@ -0,0 +1,22 @@
from django_components.commands.ext_list import ExtListCommand
from django_components.commands.ext_run import ExtRunCommand
from django_components.util.command import ComponentCommand
class ExtCommand(ComponentCommand):
"""
Run extension commands.
```bash
python manage.py components ext list
python manage.py components ext run <extension> <command>
```
"""
name = "ext"
help = "Run extension commands."
subcommands = [
ExtListCommand,
ExtRunCommand,
]

View file

@ -0,0 +1,55 @@
from typing import Any
from django_components.extension import extensions
from django_components.util.command import CommandArg, ComponentCommand
class ExtListCommand(ComponentCommand):
"""
List all extensions.
```bash
python manage.py components ext list
```
Prints the list of installed extensions:
```txt
Installed extensions:
view
my_extension
```
If you need to omit the title in order to programmatically post-process the output,
you can use the `--verbosity` (or `-v`) flag:
```bash
python manage.py components ext list -v 0
```
Which prints just:
```txt
view
my_extension
```
"""
name = "list"
help = "List all extensions."
arguments = [
CommandArg(
["-v", "--verbosity"],
default=1,
type=int,
choices=[0, 1],
help=("Verbosity level; 0=minimal output, 1=normal output"),
),
]
def handle(self, *args: Any, **kwargs: Any) -> None:
if kwargs["verbosity"] > 0:
print("Installed extensions:")
for extension in extensions.extensions:
print(extension.name)

View file

@ -0,0 +1,113 @@
from typing import Any, List, Optional, Type
from django_components.extension import extensions
from django_components.util.command import ComponentCommand
# Scope the extension-specific commands to the extension name, so users call these commands like:
# `python manage.py components ext run <extension> <command>`
#
# We achieve that by creating temporary `ComponentCommand` subclasses for each extension. E.g.
# ```python
# class ExtCommand(ComponentCommand):
# name = "my_ext"
# help = "Run commands added by the 'my_ext' extension."
# subcommands = [
# ...commands
# ]
# ```
def _gen_subcommands() -> List[Type[ComponentCommand]]:
commands: List[Type[ComponentCommand]] = []
for extension in extensions.extensions:
ExtCommand = type(
"ExtCommand",
(ComponentCommand,),
{
"name": extension.name,
"help": f"Run commands added by the '{extension.name}' extension.",
"subcommands": extension.commands,
},
)
commands.append(ExtCommand)
return commands
# This descriptor generates the list of subcommands of the `components ext run` command dynamically when accessed.
# This is because this list depends on the settings and extensions. In tests, the settings and available extensions
# may change between each test case. So we have to ensure we access the latest settings when accessing this property.
#
# NOTE: This is possible, because Django sets up the project and settings BEFORE the commands are loaded.
class SubcommandsDescriptor:
def __get__(self, obj: Any, objtype: Optional[Type] = None) -> List[Type[ComponentCommand]]:
# This will be called when accessing ExtRunCommand.subcommands
# or instance.subcommands
return _gen_subcommands()
class ExtRunCommand(ComponentCommand):
"""
Run a command added by an [extension](../../concepts/advanced/extensions).
Each extension can add its own commands, which will be available to run with this command.
For example, if you define and install the following extension:
```python
from django_components ComponentCommand, ComponentsExtension
class HelloCommand(ComponentCommand):
name = "hello"
help = "Say hello"
def handle(self, *args, **kwargs):
print("Hello, world!")
class MyExt(ComponentsExtension):
name = "my_ext"
commands = [HelloCommand]
```
You can run the `hello` command with:
```bash
python manage.py components ext run my_ext hello
```
You can also define arguments for the command, which will be passed to the command's `handle` method.
```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension
class HelloCommand(ComponentCommand):
name = "hello"
help = "Say hello"
arguments = [
CommandArg(name="name", help="The name to say hello to"),
CommandArg(name=["--shout", "-s"], action="store_true"),
]
def handle(self, name: str, *args, **kwargs):
shout = kwargs.get("shout", False)
msg = f"Hello, {name}!"
if shout:
msg = msg.upper()
print(msg)
```
You can run the command with:
```bash
python manage.py components ext run my_ext hello --name John --shout
```
!!! note
Command arguments and options are based on Python's `argparse` module.
For more information, see the [argparse documentation](https://docs.python.org/3/library/argparse.html).
"""
name = "run"
help = "Run a command added by an extension."
subcommands = SubcommandsDescriptor() # type: ignore

View file

@ -0,0 +1,11 @@
from django_components.commands.create import CreateCommand
# TODO_REMOVE_IN_V1 - Superseded by `components create`
class StartComponentCommand(CreateCommand):
"""
**Deprecated**. Use [`components create`](../commands#components-create) instead.
"""
name = "startcomponent"
help = "Deprecated. Use `components create` instead."

View file

@ -0,0 +1,79 @@
import os
import re
from pathlib import Path
from typing import Any
from django.conf import settings
from django.template.engine import Engine
from django_components.template_loader import Loader
from django_components.util.command import CommandArg, ComponentCommand
# TODO_V1 - Remove, no longer needed?
class UpgradeCommand(ComponentCommand):
name = "upgrade"
help = "Upgrade django components syntax from '{%% component_block ... %%}' to '{%% component ... %%}'."
arguments = [
CommandArg(
name_or_flags="--path",
help="Path to search for components",
),
]
def handle(self, *args: Any, **options: Any) -> None:
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs(include_apps=False)
if settings.BASE_DIR:
dirs.append(Path(settings.BASE_DIR) / "templates")
if options["path"]:
dirs = [options["path"]]
all_files = []
for dir_path in dirs:
print(f"Searching for components in {dir_path}...")
for root, _, files in os.walk(dir_path):
for file in files:
if not file.endswith((".html", ".py")):
continue
file_path = os.path.join(root, file)
all_files.append(file_path)
for file_path in all_files:
with open(file_path, "r+", encoding="utf-8") as f:
content = f.read()
content_with_closed_components, step0_count = re.subn(
r'({%\s*component\s*"(\w+?)"(.*?)%})(?!.*?{%\s*endcomponent\s*%})',
r"\1{% endcomponent %}",
content,
flags=re.DOTALL,
)
updated_content, step1_count_opening = re.subn(
r'{%\s*component_block\s*"(\w+?)"\s*(.*?)%}',
r'{% component "\1" \2%}',
content_with_closed_components,
flags=re.DOTALL,
)
updated_content, step2_count_closing = re.subn(
r'{%\s*endcomponent_block\s*"(\w+?)"\s*%}',
r"{% endcomponent %}",
updated_content,
flags=re.DOTALL,
)
updated_content, step2_count_closing_no_name = re.subn(
r"{%\s*endcomponent_block\s*%}",
r"{% endcomponent %}",
updated_content,
flags=re.DOTALL,
)
total_updates = step0_count + step1_count_opening + step2_count_closing + step2_count_closing_no_name
if total_updates > 0:
f.seek(0)
f.write(updated_content)
f.truncate()
print(f"Updated {file_path}: {total_updates} changes made")

View file

@ -0,0 +1,11 @@
from django_components.commands.upgrade import UpgradeCommand
# TODO_REMOVE_IN_V1 - No longer needed?
class UpgradeComponentCommand(UpgradeCommand):
"""
**Deprecated**. Use [`components upgrade`](../commands#components-upgrade) instead.
"""
name = "upgradecomponent"
help = "Deprecated. Use `components upgrade` instead."

View file

View file

@ -0,0 +1,116 @@
from argparse import ArgumentParser
from typing import Any, Optional, Type
import django
from django.core.management.base import BaseCommand as DjangoCommand
from django_components.util.command import (
CommandArg,
ComponentCommand,
_setup_command_arg,
setup_parser_from_command,
)
# Django command arguments added to all commands
# NOTE: Many of these MUST be present for the command to work with Django
DJANGO_COMMAND_ARGS = [
CommandArg(
"--version",
action="version",
version=django.get_version(),
help="Show program's version number and exit.",
),
CommandArg(
["-v", "--verbosity"],
default=1,
type=int,
choices=[0, 1, 2, 3],
help=("Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, " "3=very verbose output"),
),
CommandArg(
"--settings",
help=(
"The Python path to a settings module, e.g. "
'"myproject.settings.main". If this isn\'t provided, the '
"DJANGO_SETTINGS_MODULE environment variable will be used."
),
),
CommandArg(
"--pythonpath",
help=("A directory to add to the Python path, e.g. " '"/home/djangoprojects/myproject".'),
),
CommandArg(
"--traceback",
action="store_true",
help="Raise on CommandError exceptions.",
),
CommandArg(
"--no-color",
action="store_true",
help="Don't colorize the command output.",
),
CommandArg(
"--force-color",
action="store_true",
help="Force colorization of the command output.",
),
CommandArg(
"--skip-checks",
action="store_true",
help="Skip system checks.",
),
]
def load_as_django_command(command: Type[ComponentCommand]) -> Type[DjangoCommand]:
"""
Create a Django `Command` class from a `ComponentCommand` class.
The created class can be used as a Django command by placing it in
the `management/commands/` module.
```python
# management/commands/mycommand.py
from django_components.compat.django import load_as_django_command
from myapp.commands.mycommand import MyCommand
# NOTE: MUST be assigned to a variable named `Command`
Command = load_as_django_command(MyCommand)
```
"""
# Define a Command class that delegates to our command_class
class Command(DjangoCommand):
help = command.help
def __init__(self) -> None:
self._command = command()
def create_parser(self, prog_name: str, subcommand: str, **kwargs: Any) -> ArgumentParser:
parser = setup_parser_from_command(command)
for arg in DJANGO_COMMAND_ARGS:
_setup_command_arg(parser, arg.asdict())
return parser
# This is the entrypoint for the command - After argparser has resolved the args,
# this is where we forward the args to the command handler.
def handle(self, *args: Any, **options: Any) -> None:
# Case: (Sub)command matched and it HAS handler
resolved_command: Optional[ComponentCommand] = options.get("_command", None)
if resolved_command and resolved_command.handle:
resolved_command.handle(*args, **options)
return
# Case: (Sub)command matched and it DOES NOT have handler (e.g. subcommand used for routing)
cmd_parser: Optional[ArgumentParser] = options.get("_parser", None)
if cmd_parser:
cmd_parser.print_help()
return
# Case: (Sub)command did not match - Print help for the main command
self.print_help(self._command.name, "")
Command.__doc__ = command.__doc__
return Command

View file

@ -891,17 +891,24 @@ class Component(
"""
Shortcut for calling `Component.View.as_view` and passing component instance to it.
"""
# This method may be called as class method or as instance method.
# If called as class method, create a new instance.
if isinstance(cls, Component):
comp: Component = cls
else:
comp = cls()
# `view` is a built-in extension defined in `extensions.view`. It subclasses
# from Django's `View` class, and adds the `component` attribute to it.
view_inst = cast(View, comp.view) # type: ignore[attr-defined]
return view_inst.__class__.as_view(**initkwargs, component=comp)
# NOTE: `Component.View` may not be available at the time that URLs are being
# defined. So we return a view that calls `View.as_view()` only once it's actually called.
def outer_view(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# This method may be called as class method or as instance method.
# If called as class method, create a new instance.
if isinstance(cls, Component):
comp: Component = cls
else:
comp = cls()
# `view` is a built-in extension defined in `extensions.view`. It subclasses
# from Django's `View` class, and adds the `component` attribute to it.
view_cls = cast(View, cls.View) # type: ignore[attr-defined]
inner_view = view_cls.as_view(**initkwargs, component=comp)
return inner_view(request, *args, **kwargs)
return outer_view
# #####################################
# RENDERING

View file

@ -1,4 +1,4 @@
# NOTE: Components exported here are documented in
# NOTE: Components exported here are documented in the API reference
from django_components.components.dynamic import DynamicComponent
__all__ = ["DynamicComponent"]

View file

@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Tuple,
from django.template import Context
from django_components.app_settings import app_settings
from django_components.util.command import ComponentCommand
from django_components.util.misc import snake_to_pascal
if TYPE_CHECKING:
@ -184,6 +185,65 @@ class ComponentExtension:
This setting decides what the extension class will inherit from.
"""
commands: List[Type[ComponentCommand]] = []
"""
List of commands that can be run by the extension.
These commands will be available to the user as `components ext run <extension> <command>`.
Commands are defined as subclasses of [`ComponentCommand`](../api#django_components.ComponentCommand).
**Example:**
This example defines an extension with a command that prints "Hello world". To run the command,
the user would run `components ext run hello_world hello`.
```python
from django_components import ComponentCommand, ComponentExtension, CommandArg, CommandArgGroup
class HelloWorldCommand(ComponentCommand):
name = "hello"
help = "Hello world command."
# Allow to pass flags `--foo`, `--bar` and `--baz`.
# Argument parsing is managed by `argparse`.
arguments = [
CommandArg(
name_or_flags="--foo",
help="Foo description.",
),
# When printing the command help message, `bar` and `baz`
# will be grouped under "group bar".
CommandArgGroup(
title="group bar",
description="Group description.",
arguments=[
CommandArg(
name_or_flags="--bar",
help="Bar description.",
),
CommandArg(
name_or_flags="--baz",
help="Baz description.",
),
],
),
]
# Callback that receives the parsed arguments and options.
def handle(self, *args, **kwargs):
print(f"HelloWorldCommand.handle: args={args}, kwargs={kwargs}")
# Associate the command with the extension
class HelloWorldExtension(ComponentExtension):
name = "hello_world"
commands = [
HelloWorldCommand,
]
```
"""
def __init_subclass__(cls) -> None:
if not cls.name.isidentifier():
raise ValueError(f"Extension name must be a valid Python identifier, got {cls.name}")
@ -529,6 +589,19 @@ class ExtensionManager:
getattr(self, hook)(data)
self._events = []
def get_extension(self, name: str) -> ComponentExtension:
for extension in self.extensions:
if extension.name == name:
return extension
raise ValueError(f"Extension {name} not found")
def get_extension_command(self, name: str, command_name: str) -> Type[ComponentCommand]:
extension = self.get_extension(name)
for command in extension.commands:
if command.name == command_name:
return command
raise ValueError(f"Command {command_name} not found in extension {name}")
#############################
# Component lifecycle hooks
#############################

View file

@ -0,0 +1,5 @@
from django_components.commands.components import ComponentsRootCommand
from django_components.compat.django import load_as_django_command
# TODO_V3
Command = load_as_django_command(ComponentsRootCommand)

View file

@ -1,219 +1,5 @@
import os
from textwrap import dedent
from typing import Any
from django_components.commands.startcomponent import StartComponentCommand
from django_components.compat.django import load_as_django_command
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError, CommandParser
class Command(BaseCommand):
"""
### Management Command Usage
To use the command, run the following command in your terminal:
```bash
python manage.py startcomponent <name> --path <path> --js <js_filename> --css <css_filename> --template <template_filename> --force --verbose --dry-run
```
Replace `<name>`, `<path>`, `<js_filename>`, `<css_filename>`, and `<template_filename>` with your desired values.
### Management Command Examples
Here are some examples of how you can use the command:
#### Creating a Component with Default Settings
To create a component with the default settings, you only need to provide the name of the component:
```bash
python manage.py startcomponent my_component
```
This will create a new component named `my_component` in the `components` directory of your Django project. The JavaScript, CSS, and template files will be named `script.js`, `style.css`, and `template.html`, respectively.
#### Creating a Component with Custom Settings
You can also create a component with custom settings by providing additional arguments:
```bash
python manage.py startcomponent new_component --path my_components --js my_script.js --css my_style.css --template my_template.html
```
This will create a new component named `new_component` in the `my_components` directory. The JavaScript, CSS, and template files will be named `my_script.js`, `my_style.css`, and `my_template.html`, respectively.
#### Overwriting an Existing Component
If you want to overwrite an existing component, you can use the `--force` option:
```bash
python manage.py startcomponent my_component --force
```
This will overwrite the existing `my_component` if it exists.
#### Simulating Component Creation
If you want to simulate the creation of a component without actually creating any files, you can use the `--dry-run` option:
```bash
python manage.py startcomponent my_component --dry-run
```
This will simulate the creation of `my_component` without creating any files.
""" # noqa: E501
help = "Create a new django component."
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
"name",
type=str,
help="The name of the component to create. This is a required argument.",
)
parser.add_argument(
"--path",
type=str,
help=(
"The path to the component's directory. This is an optional argument. If not provided, "
"the command will use the `COMPONENTS.dirs` setting from your Django settings."
),
default=None,
)
parser.add_argument(
"--js",
type=str,
help="The name of the JavaScript file. This is an optional argument. The default value is `script.js`.",
default="script.js",
)
parser.add_argument(
"--css",
type=str,
help="The name of the CSS file. This is an optional argument. The default value is `style.css`.",
default="style.css",
)
parser.add_argument(
"--template",
type=str,
help="The name of the template file. This is an optional argument. The default value is `template.html`.",
default="template.html",
)
parser.add_argument(
"--force",
action="store_true",
help="This option allows you to overwrite existing files if they exist. This is an optional argument.",
)
parser.add_argument(
"--verbose",
action="store_true",
help=(
"This option allows the command to print additional information during component "
"creation. This is an optional argument."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help=(
"This option allows you to simulate component creation without actually creating any files. "
"This is an optional argument. The default value is `False`."
),
)
def handle(self, *args: Any, **kwargs: Any) -> None:
name = kwargs["name"]
if name:
path = kwargs["path"]
js_filename = kwargs["js"]
css_filename = kwargs["css"]
template_filename = kwargs["template"]
base_dir = getattr(settings, "BASE_DIR", None)
force = kwargs["force"]
verbose = kwargs["verbose"]
dry_run = kwargs["dry_run"]
if path:
component_path = os.path.join(path, name)
elif base_dir:
component_path = os.path.join(base_dir, "components", name)
else:
raise CommandError("You must specify a path or set BASE_DIR in your django settings")
if os.path.exists(component_path):
if force:
if verbose:
self.stdout.write(
self.style.WARNING(
f'The component "{name}" already exists at {component_path}. Overwriting...'
)
)
else:
self.stdout.write(self.style.WARNING(f'The component "{name}" already exists. Overwriting...'))
else:
raise CommandError(
f'The component "{name}" already exists at {component_path}. Use --force to overwrite.'
)
if not dry_run:
os.makedirs(component_path, exist_ok=force)
with open(os.path.join(component_path, js_filename), "w") as f:
script_content = dedent(
f"""
window.addEventListener('load', (event) => {{
console.log("{name} component is fully loaded");
}});
"""
)
f.write(script_content.strip())
with open(os.path.join(component_path, css_filename), "w") as f:
style_content = dedent(
f"""
.component-{name} {{
background: red;
}}
"""
)
f.write(style_content.strip())
with open(os.path.join(component_path, template_filename), "w") as f:
template_content = dedent(
f"""
<div class="component-{name}">
Hello from {name} component!
<br>
This is {{ param }} context value.
</div>
"""
)
f.write(template_content.strip())
with open(os.path.join(component_path, f"{name}.py"), "w") as f:
py_content = dedent(
f"""
from django_components import Component, register
@register("{name}")
class {name.capitalize()}(Component):
template_file = "{name}/{template_filename}"
def get_context_data(self, value):
return {{
"param": "sample value",
}}
class Media:
css = "{name}/{css_filename}"
js = "{name}/{js_filename}"
"""
)
f.write(py_content.strip())
if verbose:
self.stdout.write(self.style.SUCCESS(f"Successfully created {name} component at {component_path}"))
else:
self.stdout.write(self.style.SUCCESS(f"Successfully created {name} component"))
else:
raise CommandError("You must specify a component name")
# TODO_V3
Command = load_as_django_command(StartComponentCommand)

View file

@ -1,69 +1,5 @@
import os
import re
from pathlib import Path
from typing import Any
from django_components.commands.upgradecomponent import UpgradeComponentCommand
from django_components.compat.django import load_as_django_command
from django.conf import settings
from django.core.management.base import BaseCommand, CommandParser
from django.template.engine import Engine
from django_components.template_loader import Loader
class Command(BaseCommand):
help = "Updates component and component_block tags to the new syntax"
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument("--path", type=str, help="Path to search for components")
def handle(self, *args: Any, **options: Any) -> None:
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs(include_apps=False)
if settings.BASE_DIR:
dirs.append(Path(settings.BASE_DIR) / "templates")
if options["path"]:
dirs = [options["path"]]
for dir_path in dirs:
self.stdout.write(f"Searching for components in {dir_path}...")
for root, _, files in os.walk(dir_path):
for file in files:
if file.endswith((".html", ".py")):
file_path = os.path.join(root, file)
with open(file_path, "r+", encoding="utf-8") as f:
content = f.read()
content_with_closed_components, step0_count = re.subn(
r'({%\s*component\s*"(\w+?)"(.*?)%})(?!.*?{%\s*endcomponent\s*%})',
r"\1{% endcomponent %}",
content,
flags=re.DOTALL,
)
updated_content, step1_count_opening = re.subn(
r'{%\s*component_block\s*"(\w+?)"\s*(.*?)%}',
r'{% component "\1" \2%}',
content_with_closed_components,
flags=re.DOTALL,
)
updated_content, step2_count_closing = re.subn(
r'{%\s*endcomponent_block\s*"(\w+?)"\s*%}',
r"{% endcomponent %}",
updated_content,
flags=re.DOTALL,
)
updated_content, step2_count_closing_no_name = re.subn(
r"{%\s*endcomponent_block\s*%}",
r"{% endcomponent %}",
updated_content,
flags=re.DOTALL,
)
total_updates = (
step0_count + step1_count_opening + step2_count_closing + step2_count_closing_no_name
)
if total_updates > 0:
f.seek(0)
f.write(updated_content)
f.truncate()
self.stdout.write(f"Updated {file_path}: {total_updates} changes made")
# TODO_V3
Command = load_as_django_command(UpgradeComponentCommand)

View file

@ -0,0 +1,407 @@
import sys
from argparse import Action, ArgumentParser
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Protocol, Sequence, Type, Union
if TYPE_CHECKING:
from argparse import _ArgumentGroup, _FormatterClass
#############################
# Argparse typing
#############################
CommandLiteralAction = Literal[
"append", "append_const", "count", "extend", "store", "store_const", "store_true", "store_false", "version"
]
"""
The basic type of action to be taken when this argument is encountered at the command line.
This is a subset of the values for `action` in
[`ArgumentParser.add_argument()`](https://docs.python.org/3/library/argparse.html#the-add-argument-method).
"""
@dataclass
class CommandArg:
"""
Define a single positional argument or an option for a command.
Fields on this class correspond to the arguments for
[`ArgumentParser.add_argument()`](https://docs.python.org/3/library/argparse.html#the-add-argument-method)
""" # noqa: E501
name_or_flags: Union[str, Sequence[str]]
"""Either a name or a list of option strings, e.g. 'foo' or '-f', '--foo'."""
action: Optional[Union[CommandLiteralAction, Action]] = None
"""The basic type of action to be taken when this argument is encountered at the command line."""
nargs: Optional[Union[int, Literal["*", "+", "?"]]] = None
"""The number of command-line arguments that should be consumed."""
const: Any = None
"""A constant value required by some action and nargs selections."""
default: Any = None
"""
The value produced if the argument is absent from the command line and if it is absent from the namespace object.
"""
type: Optional[Union[Type, Callable[[str], Any]]] = None
"""The type to which the command-line argument should be converted."""
choices: Optional[Sequence[Any]] = None
"""A sequence of the allowable values for the argument."""
required: Optional[bool] = None
"""Whether or not the command-line option may be omitted (optionals only)."""
help: Optional[str] = None
"""A brief description of what the argument does."""
metavar: Optional[str] = None
"""A name for the argument in usage messages."""
dest: Optional[str] = None
"""The name of the attribute to be added to the object returned by parse_args()."""
version: Optional[str] = None
"""
The version string to be added to the object returned by parse_args().
MUST be used with `action='version'`.
See https://docs.python.org/3/library/argparse.html#action
"""
# NOTE: Support for deprecated was added in Python 3.13
# See https://docs.python.org/3/library/argparse.html#deprecated
deprecated: Optional[bool] = None
"""
Whether or not use of the argument is deprecated.
NOTE: This is supported only in Python 3.13+
"""
def asdict(self) -> dict:
"""Convert the dataclass to a dictionary, stripping out fields with `None` values"""
return _remove_none_values(asdict(self))
@dataclass
class CommandArgGroup:
"""
Define a group of arguments for a command.
Fields on this class correspond to the arguments for
[`ArgumentParser.add_argument_group()`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument_group)
""" # noqa: E501
title: Optional[str] = None
"""
Title for the argument group in help output; by default positional arguments if description is provided,
otherwise uses title for positional arguments.
"""
description: Optional[str] = None
"""
Description for the argument group in help output, by default None
"""
arguments: Sequence[CommandArg] = ()
def asdict(self) -> dict:
"""Convert the dataclass to a dictionary, stripping out fields with `None` values"""
return _remove_none_values(asdict(self))
@dataclass
class CommandSubcommand:
"""
Define a subcommand for a command.
Fields on this class correspond to the arguments for
[`ArgumentParser.add_subparsers.add_parser()`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_subparsers)
""" # noqa: E501
title: Optional[str] = None
"""
Title for the sub-parser group in help output; by default subcommands if description is provided,
otherwise uses title for positional arguments.
"""
description: Optional[str] = None
"""
Description for the sub-parser group in help output, by default `None`.
"""
prog: Optional[str] = None
"""
Usage information that will be displayed with sub-command help, by default the name of the program
and any positional arguments before the subparser argument.
"""
parser_class: Optional[Type[ArgumentParser]] = None
"""
Class which will be used to create sub-parser instances, by default the class of
the current parser (e.g. `ArgumentParser`).
"""
action: Optional[Union[CommandLiteralAction, Action]] = None
"""
The basic type of action to be taken when this argument is encountered at the command line.
"""
dest: Optional[str] = None
"""
Name of the attribute under which sub-command name will be stored; by default `None`
and no value is stored.
"""
required: Optional[bool] = None
"""
Whether or not a subcommand must be provided, by default `False` (added in 3.7)
"""
help: Optional[str] = None
"""
Help for sub-parser group in help output, by default `None`.
"""
metavar: Optional[str] = None
"""
String presenting available subcommands in help; by default it is None and
presents subcommands in form `{cmd1, cmd2, ..}`.
"""
def asdict(self) -> dict:
"""Convert the dataclass to a dictionary, stripping out fields with `None` values"""
return _remove_none_values(asdict(self))
@dataclass
class CommandParserInput:
"""
Typing for the input to the
[`ArgumentParser`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser)
constructor.
"""
prog: Optional[str] = None
"""The name of the program (default: `os.path.basename(sys.argv[0])`)"""
usage: Optional[str] = None
"""The string describing the program usage (default: generated from arguments added to parser)"""
description: Optional[str] = None
"""Text to display before the argument help (by default, no text)"""
epilog: Optional[str] = None
"""Text to display after the argument help (by default, no text)"""
parents: Optional[Sequence[ArgumentParser]] = None
"""A list of ArgumentParser objects whose arguments should also be included"""
formatter_class: Optional[Type["_FormatterClass"]] = None
"""A class for customizing the help output"""
prefix_chars: Optional[str] = None
"""The set of characters that prefix optional arguments (default: -)"""
fromfile_prefix_chars: Optional[str] = None
"""The set of characters that prefix files from which additional arguments should be read (default: `None`)"""
argument_default: Optional[Any] = None
"""The global default value for arguments (default: `None`)"""
conflict_handler: Optional[str] = None
"""The strategy for resolving conflicting optionals (usually unnecessary)"""
add_help: Optional[bool] = None
"""Add a -h/--help option to the parser (default: `True`)"""
allow_abbrev: Optional[bool] = None
"""Allows long options to be abbreviated if the abbreviation is unambiguous. (default: `True`)"""
exit_on_error: Optional[bool] = None
"""Determines whether or not ArgumentParser exits with error info when an error occurs. (default: `True`)"""
def asdict(self) -> dict:
"""Convert the dataclass to a dictionary, stripping out fields with `None` values"""
return _remove_none_values(asdict(self))
#############################
# Command logic
#############################
class CommandHandler(Protocol):
def __call__(self, *args: Any, **kwargs: Any) -> None: ... # noqa: E704
class ComponentCommand:
"""
Definition of a CLI command.
This class is based on Python's [`argparse`](https://docs.python.org/3/library/argparse.html)
module and Django's [`BaseCommand`](https://docs.djangoproject.com/en/5.1/howto/custom-management-commands/)
class. `ComponentCommand` allows you to define:
- Command name, description, and help text
- Arguments and options (e.g. `--name John`)
- Group arguments (see [argparse groups](https://docs.python.org/3/library/argparse.html#argument-groups))
- Subcommands (e.g. `components ext run my_ext hello`)
- Handler behavior
Each extension can add its own commands, which will be available to run with `components ext run`.
Extensions use the `ComponentCommand` class to define their commands.
For example, if you define and install the following extension:
```python
from django_components ComponentCommand, ComponentsExtension
class HelloCommand(ComponentCommand):
name = "hello"
help = "Say hello"
def handle(self, *args, **kwargs):
print("Hello, world!")
class MyExt(ComponentsExtension):
name = "my_ext"
commands = [HelloCommand]
```
You can run the `hello` command with:
```bash
python manage.py components ext run my_ext hello
```
You can also define arguments for the command, which will be passed to the command's `handle` method.
```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension
class HelloCommand(ComponentCommand):
name = "hello"
help = "Say hello"
arguments = [
CommandArg(name="name", help="The name to say hello to"),
CommandArg(name=["--shout", "-s"], action="store_true"),
]
def handle(self, name: str, *args, **kwargs):
shout = kwargs.get("shout", False)
msg = f"Hello, {name}!"
if shout:
msg = msg.upper()
print(msg)
```
You can run the command with:
```bash
python manage.py components ext run my_ext hello --name John --shout
```
!!! note
Command arguments and options are based on Python's `argparse` module.
For more information, see the [argparse documentation](https://docs.python.org/3/library/argparse.html).
"""
name: str
"""The name of the command - this is what is used to call the command"""
help: Optional[str] = None
"""The help text for the command"""
arguments: Sequence[Union[CommandArg, CommandArgGroup]] = ()
"""argparse arguments for the command"""
subcommands: Sequence[Type["ComponentCommand"]] = ()
"""Subcommands for the command"""
handle: Optional[CommandHandler] = None
"""
The function that is called when the command is run. If `None`, the command will
print the help message.
"""
parser_input: Optional[CommandParserInput] = None
"""
The input to use when creating the `ArgumentParser` for this command. If `None`,
the default values will be used.
"""
subparser_input: Optional[CommandSubcommand] = None
"""
The input to use when this command is a subcommand installed with `add_subparser()`.
If `None`, the default values will be used.
"""
def setup_parser_from_command(command: Type[ComponentCommand]) -> ArgumentParser:
"""
Create an `ArgumentParser` instance from a `ComponentCommand`.
The `ArgumentParser` will:
- Set help message and command name
- Add arguments to the parser
- Add subcommands recursively
"""
parser_kwargs = {}
if hasattr(command, "parser_input") and command.parser_input:
parser_kwargs = command.parser_input.asdict()
# NOTE: Command name is always present
parser_kwargs["prog"] = command.name
if command.help is not None:
parser_kwargs["description"] = command.help
parser = ArgumentParser(**parser_kwargs)
_setup_parser_from_command(parser, command)
return parser
# Recursively setup the parser and its subcommands
def _setup_parser_from_command(
parser: ArgumentParser,
command: Union[Type[ComponentCommand], Type[ComponentCommand]],
) -> ArgumentParser:
# Attach the command to the data returned by `parser.parse_args()`, so we know
# which command was matched.
parser.set_defaults(_command=command(), _parser=parser)
# Apply arguments to the parser. Arguments may be defined as a group.
for arg in command.arguments:
if isinstance(arg, CommandArgGroup):
group_data = arg.asdict()
group_args: List[Dict] = group_data.pop("arguments")
arg_group = parser.add_argument_group(**group_data)
for group_arg in group_args:
# NOTE: Seems that dataclass's `asdict()` calls `asdict()` also on the
# nested dataclass fields. Thus we need to apply `_remove_none_values()`
# to the nested dataclass fields.
group_arg = _remove_none_values(group_arg)
_setup_command_arg(arg_group, group_arg)
else:
_setup_command_arg(parser, arg.asdict())
# Add subcommands to the parser
if command.subcommands:
subparsers = parser.add_subparsers(title="subcommands")
for subcommand in command.subcommands:
subparser_data: Dict[str, Any] = {}
if getattr(subcommand, "subparser_input", None) and subcommand.subparser_input:
subparser_data = subcommand.subparser_input.asdict()
subparser_data["name"] = subcommand.name
if subcommand.help:
subparser_data["help"] = subcommand.help
subparser_data["description"] = subcommand.help
subparser = subparsers.add_parser(**subparser_data)
_setup_parser_from_command(subparser, subcommand)
return parser
def _setup_command_arg(parser: Union[ArgumentParser, "_ArgumentGroup"], arg: dict) -> None:
# NOTE: Support for deprecated was added in Python 3.13
# See https://docs.python.org/3/library/argparse.html#deprecated
if sys.version_info < (3, 13) and "deprecated" in arg:
raise ValueError("'deprecated' command argument requires Python 3.13+")
name_or_flags = arg.pop("name_or_flags")
if isinstance(name_or_flags, str):
name_or_flags = [name_or_flags]
parser.add_argument(*name_or_flags, **arg)
def _remove_none_values(data: dict) -> dict:
new_data = {}
for key, val in data.items():
if val is not None:
new_data[key] = val
return new_data
def style_success(message: str) -> str:
"""Style the message with green text"""
return f"\033[92m{message}\033[0m" # Green text
def style_warning(message: str) -> str:
"""Style the message with yellow text"""
return f"\033[93m{message}\033[0m" # Yellow text

View file

@ -0,0 +1,66 @@
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
@djc_test
class TestComponentCommand:
def test_root_command(self):
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components")
output = out.getvalue()
# NOTE: The full output is different in CI and locally, because of different whitespace wrapping
# (probably due to different terminal widths). So we check only for parts of the output.
#
# The full expected output is:
# ```
# usage: components [-h] [--version] [-v {{0,1,2,3}}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
# [--traceback] [--no-color] [--force-color] [--skip-checks]
# {{create,upgrade,ext}} ...
#
# The entrypoint for the 'components' commands.
#
# optional arguments:
# -h, --help show this help message and exit
# --version Show program's version number and exit.
# -v {{0,1,2,3}}, --verbosity {{0,1,2,3}}
# Verbosity level; 0=minimal output, 1=normal output, 2=verbose output,
# 3=very verbose output
# --settings SETTINGS The Python path to a settings module, e.g. "myproject.settings.main". If this
# isn't provided, the DJANGO_SETTINGS_MODULE environment variable will be used.
# --pythonpath PYTHONPATH
# A directory to add to the Python path, e.g. "/home/djangoprojects/myproject".
# --traceback Raise on CommandError exceptions.
# --no-color Don't colorize the command output.
# --force-color Force colorization of the command output.
# --skip-checks Skip system checks.
#
# subcommands:
# {{create,upgrade,ext}}
# create Create a new django component.
# upgrade Upgrade django components syntax from '{{% component_block ... %}}' to
# '{{% component ... %}}'.
# ext Run extension commands.
# ```
assert "usage: components" in output
assert "The entrypoint for the 'components' commands." in output
assert "-h, --help show this help message and exit" in output
assert "--version Show program's version number and exit." in output
assert "-v {0,1,2,3}" in output
assert "--settings SETTINGS The Python path to a settings module" in output
assert "--pythonpath PYTHONPATH" in output
assert "--traceback Raise on CommandError exceptions." in output
assert "--no-color Don't colorize the command output." in output
assert "--force-color Force colorization of the command output." in output
assert "--skip-checks Skip system checks." in output
assert "create Create a new django component." in output
assert "upgrade Upgrade django components syntax" in output
assert "ext Run extension commands." in output

View file

@ -2,15 +2,15 @@ import os
import tempfile
from io import StringIO
from shutil import rmtree
from unittest.mock import patch
import pytest
from django.core.management import call_command
from django.core.management.base import CommandError
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config()
setup_test_config({"autodiscover": False})
@djc_test
@ -19,7 +19,7 @@ class TestCreateComponentCommand:
temp_dir = tempfile.mkdtemp()
component_name = "defaultcomponent"
call_command("startcomponent", component_name, "--path", temp_dir)
call_command("components", "create", component_name, "--path", temp_dir)
expected_files = [
os.path.join(temp_dir, component_name, "script.js"),
@ -36,7 +36,8 @@ class TestCreateComponentCommand:
component_name = "testcomponent"
call_command(
"startcomponent",
"components",
"create",
component_name,
"--path",
temp_dir,
@ -65,7 +66,8 @@ class TestCreateComponentCommand:
component_name = "dryruncomponent"
call_command(
"startcomponent",
"components",
"create",
component_name,
"--path",
temp_dir,
@ -88,7 +90,8 @@ class TestCreateComponentCommand:
f.write("hello world")
call_command(
"startcomponent",
"components",
"create",
component_name,
"--path",
temp_dir,
@ -108,7 +111,7 @@ class TestCreateComponentCommand:
os.makedirs(component_path)
with pytest.raises(CommandError):
call_command("startcomponent", component_name, "--path", temp_dir)
call_command("components", "create", component_name, "--path", temp_dir)
rmtree(temp_dir)
@ -117,15 +120,34 @@ class TestCreateComponentCommand:
component_name = "verbosecomponent"
out = StringIO()
call_command(
"startcomponent",
component_name,
"--path",
temp_dir,
"--verbose",
stdout=out,
)
with patch("sys.stdout", new=out):
call_command(
"components",
"create",
component_name,
"--path",
temp_dir,
"--verbose",
stdout=out,
)
output = out.getvalue()
assert "component at" in output
rmtree(temp_dir)
# TODO_V1 - REMOVE - deprecated
def test_startcomponent(self):
temp_dir = tempfile.mkdtemp()
component_name = "defaultcomponent"
call_command("startcomponent", component_name, "--path", temp_dir)
expected_files = [
os.path.join(temp_dir, component_name, "script.js"),
os.path.join(temp_dir, component_name, "style.css"),
os.path.join(temp_dir, component_name, "template.html"),
]
for file_path in expected_files:
assert os.path.exists(file_path)
rmtree(temp_dir)

241
tests/test_command_ext.py Normal file
View file

@ -0,0 +1,241 @@
import sys
from io import StringIO
from textwrap import dedent
from unittest.mock import patch
from django.core.management import call_command
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
# NOTE: Argparse changed how the optional args are displayed in Python 3.11+
if sys.version_info >= (3, 10):
OPTIONS_TITLE = "options"
else:
OPTIONS_TITLE = "optional arguments"
class EmptyExtension(ComponentExtension):
name = "empty"
class DummyCommand(ComponentCommand):
name = "dummy_cmd"
help = "Dummy command description."
arguments = [
CommandArg(
name_or_flags="--foo",
help="Foo description.",
),
CommandArgGroup(
title="group bar",
description="Group description.",
arguments=[
CommandArg(
name_or_flags="--bar",
help="Bar description.",
),
CommandArg(
name_or_flags="--baz",
help="Baz description.",
),
],
),
]
def handle(self, *args, **kwargs):
kwargs.pop("_command")
kwargs.pop("_parser")
sorted_kwargs = dict(sorted(kwargs.items()))
print(f"DummyCommand.handle: args={args}, kwargs={sorted_kwargs}")
class DummyExtension(ComponentExtension):
name = "dummy"
commands = [
DummyCommand,
]
@djc_test
class TestExtensionsCommand:
def test_root_command(self):
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components", "ext")
output = out.getvalue()
assert (
output
== dedent(
f"""
usage: components ext [-h] {{list,run}} ...
Run extension commands.
{OPTIONS_TITLE}:
-h, --help show this help message and exit
subcommands:
{{list,run}}
list List all extensions.
run Run a command added by an extension.
"""
).lstrip()
)
@djc_test
class TestExtensionsListCommand:
def test_list_command_default(self):
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components", "ext", "list")
output = out.getvalue()
assert "Installed extensions:\nview\n" == output
# Check that it omits the title when verbose is 0
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components", "ext", "list", "-v", "0")
output = out.getvalue()
assert "view\n" == output
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
)
def test_list_command_extra(self):
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components", "ext", "list")
output = out.getvalue()
assert "Installed extensions:\nview\nempty\ndummy\n" == output
# Check that it omits the title when verbose is 0
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components", "ext", "list", "-v", "0")
output = out.getvalue()
assert "view\nempty\ndummy\n" == output
@djc_test
class TestExtensionsRunCommand:
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
)
def test_run_command_root(self):
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components", "ext", "run")
output = out.getvalue()
assert (
output
== dedent(
f"""
usage: components ext run [-h] {{view,empty,dummy}} ...
Run a command added by an extension.
{OPTIONS_TITLE}:
-h, --help show this help message and exit
subcommands:
{{view,empty,dummy}}
view Run commands added by the 'view' extension.
empty Run commands added by the 'empty' extension.
dummy Run commands added by the 'dummy' extension.
"""
).lstrip()
)
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
)
def test_run_command_ext_empty(self):
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components", "ext", "run", "empty")
output = out.getvalue()
assert (
output
== dedent(
f"""
usage: components ext run empty [-h]
Run commands added by the 'empty' extension.
{OPTIONS_TITLE}:
-h, --help show this help message and exit
"""
).lstrip()
)
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
)
def test_run_command_ext_with_commands(self):
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components", "ext", "run", "dummy")
output = out.getvalue()
assert (
output
== dedent(
f"""
usage: components ext run dummy [-h] {{dummy_cmd}} ...
Run commands added by the 'dummy' extension.
{OPTIONS_TITLE}:
-h, --help show this help message and exit
subcommands:
{{dummy_cmd}}
dummy_cmd Dummy command description.
"""
).lstrip()
)
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
)
def test_run_command_ext_command(self):
out = StringIO()
with patch("sys.stdout", new=out):
call_command("components", "ext", "run", "dummy", "dummy_cmd")
output = out.getvalue()
# NOTE: The dummy command prints out the kwargs, which is what we check for here
assert (
output
== dedent(
"""
DummyCommand.handle: args=(), kwargs={'bar': None, 'baz': None, 'foo': None, 'force_color': False, 'no_color': False, 'pythonpath': None, 'settings': None, 'skip_checks': True, 'traceback': False, 'verbosity': 1}
""" # noqa: E501
).lstrip()
)
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
)
def test_prints_error_if_command_not_found(self):
out = StringIO()
with patch("sys.stderr", new=out):
try:
call_command("components", "ext", "run", "dummy", "dummy_cmd_not_found")
except SystemExit:
output = out.getvalue()
assert "invalid choice: 'dummy_cmd_not_found'" in output