Skip to content

Commit

Permalink
finish docs, fix a few remaining issues
Browse files Browse the repository at this point in the history
  • Loading branch information
bckohan committed Jun 5, 2024
1 parent 030288d commit a828de0
Show file tree
Hide file tree
Showing 16 changed files with 747 additions and 132 deletions.
172 changes: 171 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,174 @@
[![Documentation Status](https://readthedocs.org/projects/django-routines/badge/?version=latest)](http://django-routines.readthedocs.io/?badge=latest/)
[![Code Cov](https://codecov.io/gh/bckohan/django-routines/branch/main/graph/badge.svg?token=0IZOKN2DYL)](https://codecov.io/gh/bckohan/django-routines)
[![Test Status](https://github.com/bckohan/django-routines/workflows/test/badge.svg)](https://github.com/bckohan/django-routines/actions/workflows/test.yml)
[![Lint Status](https://github.com/bckohan/django-routines/workflows/lint/badge.svg)](https://github.com/bckohan/django-routines/actions/workflows/lint.yml)
[![Lint Status](https://github.com/bckohan/django-routines/workflows/lint/badge.svg)](https://github.com/bckohan/django-routines/actions/workflows/lint.yml)


Configure batches of Django management commands in your settings files and run them all at once.
For example, batch together your common database maintenance tasks, deployment routines or any
other set of commands you need to run together. This helps single source general site maintenance
into your settings files keeping your code base [DRY]().

## Example

Let's define two named routines, "package" and "deploy". The package routine will be a collection
of commands that we typically run to generate package artifacts (like migrations and transpiled
javascript). The deploy routine will be a collection of commands we typically run when deploying
the site for the first time on a new server or when we deploy version updates on the server.

**Routines commands are run in the order they are registered, or by priority.**

```python
from django_routines import RoutineCommand, command, routine

# register routines and their help text
routine(
name="package",
help_text=(
"Generate pre-package artifacts like migrations and transpiled "
"javascript."
)
)
# you may register commands on a routine after defining a routine (or before!)
command("package", "makemigrations")
command("package", "renderstatic")

routine(
"deploy",
"Deploy the site application into production.",

# you may also specify commands inline using the RoutineCommand dataclass
RoutineCommand(
("routine", "package"), switches=["prepare"]
), # routine commands can be other routines!
RoutineCommand("migrate"),
RoutineCommand("collectstatic"),
RoutineCommand(("shellcompletion", "install"), switches=["initial"]),
RoutineCommand(("loaddata", "./fixtures/demo.json"), switches=["demo"]),

# define switches that toggle commands on and off
prepare="Generate artifacts like migrations and transpiled javascript.",
initial="Things to do on the very first deployment on a new server.",
demo="Load the demo data.",
)
```

The routine command will read our settings file and generate two subcommands, one called deploy and one called package:

![package](https://raw.githubusercontent.com/bckohan/django-routines/main/examples/package.svg)

Now we can run all of our package routines with one command:

```bash
?> ./manage.py routine package
makemigrations
...
renderstatic
...
```

The deploy command has several switches that we can enable to run additional commands.

![deploy](https://raw.githubusercontent.com/bckohan/django-routines/main/examples/deploy.svg)

For example to deploy our demo on a new server we would run:

```bash
?> ./manage.py routine deploy --initial --demo
migrate
...
collectstatic
...
shellcompletion install
...
loaddata ./fixtures/demo.json
...
```

## Settings

The [RoutineCommand](https://django-routines.readthedocs.io/en/latest/reference.html#django_routines.RoutineCommand) dataclass, [routine](https://django-routines.readthedocs.io/en/latest/reference.html#django_routines.routine) and [command](https://django-routines.readthedocs.io/en/latest/reference.html#django_routines.command) helper functions in the example above make it easier for us to work with the native configuration format which is a dictionary structure defined in the ``DJANGO_ROUTINES`` setting attribute. For example the above configuration is equivalent to:

```python
DJANGO_ROUTINES = {
"deploy": {
"commands": [
{"command": ("routine", "package"), "switches": ["prepare"]},
{"command": "migrate"},
{"command": "collectstatic"},
{
"command": ("shellcompletion", "install"),
"switches": ["initial"],
},
{
"command": ("loaddata", "./fixtures/demo.json"),
"switches": ["demo"],
},
],
"help_text": "Deploy the site application into production.",
"name": "deploy",
"switch_helps": {
"demo": "Load the demo data.",
"initial": "Things to do on the very first deployment on a new server.",
"prepare": "Generate artifacts like migrations and transpiled javascript.",
},
},
"package": {
"commands": [
{"command": "makemigrations"},
{"command": "renderstatic"},
],
"help_text": "Generate pre-package artifacts like migrations and transpiled javascript.",
"name": "package",
},
}
```


## Priorities

If you are composing settings from multiple apps or source files using a utility like [django-split-settings](https://pypi.org/project/django-split-settings/) you may not be able to define all routines at once. You can use priorities to make sure commands defined in a de-coupled way run in the correct order.

```python
command("deploy", "makemigrations", priority=1)
command("deploy", "migrate", priority=2)
```

## Options

When specifying arguments you may add them to the command tuple OR specify them as named options in the style that will be passed to [call_command](https://docs.djangoproject.com/en/stable/ref/django-admin/#django.core.management.call_command):

```python
# these two are equivalent
command("package", ("makemigrations", "--no-header"))
command("package", "makemigrations", no_header=True)
```


## Installation


1. Clone django-routines from [GitHub](https://github.com/bckohan/django-routines) or install a release off [PyPI](https://pypi.python.org/pypi/django-routines) :

```bash
pip install django-routines
```

[rich](https://rich.readthedocs.io/) is a powerful library for rich text and beautiful formatting in the terminal. It is not required, but highly recommended for the best experience:

```bash
pip install "django-routines[rich]"
```


2. Add ``django_routines`` to your ``INSTALLED_APPS`` setting:

```python
INSTALLED_APPS = [
...
'django_routines',
'django_typer', # optional!
]
```

*You only need to install django_typer as an app if you want to use the shellcompletion command to* [enable tab-completion](https://django-typer.readthedocs.io/en/latest/shell_completion.html) *or if you would like django-typer to install* [rich traceback rendering](https://django-typer.readthedocs.io/en/latest/howto.html#configure-rich-stack-traces) *for you - which it does by default if rich is also installed.*
40 changes: 37 additions & 3 deletions django_routines/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class RoutineCommand:
Dataclass to hold the routine command information.
"""

command: t.Tuple[str, ...]
command: t.Union[str, t.Tuple[str, ...]]
"""
The command and its arguments to run the routine, all strings or
coercible to strings that the command will parse correctly.
Expand All @@ -75,6 +75,18 @@ class RoutineCommand:
t.Any options to pass to the command via call_command.
"""

@property
def command_name(self) -> str:
return self.command if isinstance(self.command, str) else self.command[0]

@property
def command_args(self) -> t.Tuple[str, ...]:
return tuple() if isinstance(self.command, str) else self.command[1:]

@property
def command_str(self) -> str:
return self.command if isinstance(self.command, str) else " ".join(self.command)

@classmethod
def from_dict(
cls, obj: t.Union["RoutineCommand", t.Dict[str, t.Any]]
Expand Down Expand Up @@ -161,7 +173,12 @@ def from_dict(cls, obj: t.Union["Routine", t.Dict[str, t.Any]]) -> "Routine":
)


def routine(name: str, help_text: str = "", *commands: RoutineCommand, **switch_helps):
def routine(
name: str,
help_text: t.Union[str, Promise] = "",
*commands: RoutineCommand,
**switch_helps,
):
"""
Register a routine to the t.List of routines in settings to be run.
Expand All @@ -172,7 +189,24 @@ def routine(name: str, help_text: str = "", *commands: RoutineCommand, **switch_
settings = sys._getframe(1).f_globals # noqa: WPS437
if not settings.get(ROUTINE_SETTING, {}):
settings[ROUTINE_SETTING] = {}
routine = Routine(name, help_text, switch_helps=switch_helps)

existing: t.List[RoutineCommand] = []
try:
routine = get_routine(name)
help_text = (
help_text
# don't trigger translation - we're in settings!
if isinstance(help_text, Promise) or help_text
else routine.help_text
)
switch_helps = {**routine.switch_helps, **switch_helps}
existing = routine.commands
except KeyError:
pass
routine = Routine(
name, help_text=help_text, commands=existing, switch_helps=switch_helps
)

for command in commands:
routine.add(command)
settings[ROUTINE_SETTING][name] = asdict(routine)
Expand Down
14 changes: 7 additions & 7 deletions django_routines/management/commands/routine.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ def _run_routine(self):
assert self.routine
for command in self.plan:
if self.verbosity > 0:
self.secho(" ".join(command.command), fg="cyan")
self.secho(command.command_str, fg="cyan")
try:
cmd = get_command(
command.command[0],
command.command_name,
BaseCommand,
stdout=t.cast(t.IO[str], self.stdout._out),
stderr=t.cast(t.IO[str], self.stderr._out),
Expand All @@ -105,19 +105,19 @@ def _run_routine(self):
"verbosity" in arg
for arg in getattr(cmd, "suppressed_base_arguments", [])
)
and not any("--verbosity" in arg for arg in command.command[1:])
and not any("--verbosity" in arg for arg in command.command_args)
):
# only pass verbosity if it was passed to routines, not suppressed
# by the command class and not passed by the configured command
options = {"verbosity": self.verbosity, **options}
call_command(cmd, *command.command[1:], **options)
call_command(cmd, *command.command_args, **options)
except KeyError:
raise CommandError(f"Command not found: {command.command[0]}")
raise CommandError(f"Command not found: {command.command_name}")

def _list(self) -> None:
for command in self.plan:
priority = str(command.priority)
cmd_str = " ".join(command.command)
cmd_str = command.command_str
switches_str = " | " if command.switches else ""
opt_str = " ".join([f"{k}={v}" for k, v in command.options.items()])
if self.force_color or not self.no_color:
Expand Down Expand Up @@ -162,7 +162,7 @@ def _list(self) -> None:
command_strings = []
for command in routine.commands:
priority = f"{'[green]' if use_rich else ''}{command.priority}{'[/green]' if use_rich else ''}"
cmd_str = f"{'[cyan]' if use_rich else ''}{' '.join(command.command)}{'[/cyan]' if use_rich else ''}"
cmd_str = f"{'[cyan]' if use_rich else ''}{command.command_str}{'[/cyan]' if use_rich else ''}"
if command.options:
if use_rich:
opt_str = " ".join(
Expand Down
Binary file added doc/.DS_Store
Binary file not shown.
4 changes: 4 additions & 0 deletions doc/source/_static/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.big {
font-size: 24px; /* Or any size you prefer */
font-weight: bold; /* If you want it bold */
}
6 changes: 4 additions & 2 deletions doc/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import django_routines

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "examples.readme")
django.setup()

sys.path.append(str(Path(__file__).parent.parent / "tests"))
Expand Down Expand Up @@ -70,7 +70,9 @@
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".

html_static_path = [
'_static',
]
html_css_files = [
"style.css",
]
Expand Down
Loading

0 comments on commit a828de0

Please sign in to comment.