mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +00:00
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:
parent
3a139127cd
commit
d3d2d0ab08
28 changed files with 2320 additions and 397 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
0
src/django_components/commands/__init__.py
Normal file
0
src/django_components/commands/__init__.py
Normal file
29
src/django_components/commands/components.py
Normal file
29
src/django_components/commands/components.py
Normal 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,
|
||||
]
|
220
src/django_components/commands/create.py
Normal file
220
src/django_components/commands/create.py
Normal 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")
|
22
src/django_components/commands/ext.py
Normal file
22
src/django_components/commands/ext.py
Normal 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,
|
||||
]
|
55
src/django_components/commands/ext_list.py
Normal file
55
src/django_components/commands/ext_list.py
Normal 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)
|
113
src/django_components/commands/ext_run.py
Normal file
113
src/django_components/commands/ext_run.py
Normal 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
|
11
src/django_components/commands/startcomponent.py
Normal file
11
src/django_components/commands/startcomponent.py
Normal 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."
|
79
src/django_components/commands/upgrade.py
Normal file
79
src/django_components/commands/upgrade.py
Normal 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")
|
11
src/django_components/commands/upgradecomponent.py
Normal file
11
src/django_components/commands/upgradecomponent.py
Normal 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."
|
0
src/django_components/compat/__init__.py
Normal file
0
src/django_components/compat/__init__.py
Normal file
116
src/django_components/compat/django.py
Normal file
116
src/django_components/compat/django.py
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
#############################
|
||||
|
|
5
src/django_components/management/commands/components.py
Normal file
5
src/django_components/management/commands/components.py
Normal 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)
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
407
src/django_components/util/command.py
Normal file
407
src/django_components/util/command.py
Normal 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
|
66
tests/test_command_components.py
Normal file
66
tests/test_command_components.py
Normal 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
|
|
@ -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
241
tests/test_command_ext.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue