Skip to content

Commit

Permalink
add fail behavior control and atomicity control, fix #10 and fix #24
Browse files Browse the repository at this point in the history
  • Loading branch information
bckohan committed Jul 27, 2024
1 parent 88d628f commit 3998ea7
Show file tree
Hide file tree
Showing 15 changed files with 566 additions and 29 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,15 @@ When specifying arguments you may add them to the command tuple OR specify them
command("package", "makemigrations", no_header=True)
```

## Execution Controls

There are several switches that can be used to control the execution of routines. Pass these parameters when you define the Routine.

- ``atomic``: Run the routine in a transaction.
- ``continue_on_error``: Continue running the routine even if a command fails.

The default routine behavior for these execution controls can be overridden on the command line.


## Installation

Expand Down
18 changes: 17 additions & 1 deletion django_routines/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from django.core.exceptions import ImproperlyConfigured
from django.utils.functional import Promise

VERSION = (1, 1, 3)
VERSION = (1, 2, 0)

__title__ = "Django Routines"
__version__ = ".".join(str(i) for i in VERSION)
Expand Down Expand Up @@ -207,6 +207,16 @@ class Routine:
If true run each of the commands in a subprocess.
"""

atomic: bool = False
"""
Run all commands in the same transaction.
"""

continue_on_error: bool = False
"""
Keep going if a command fails.
"""

def __post_init__(self):
self.name = to_symbol(self.name)
self.switch_helps = {
Expand Down Expand Up @@ -266,6 +276,8 @@ def to_dict(self) -> t.Dict[str, t.Any]:
"commands": [cmd.to_dict() for cmd in self.commands],
"switch_helps": self.switch_helps,
"subprocess": self.subprocess,
"atomic": self.atomic,
"continue_on_error": self.continue_on_error,
}


Expand All @@ -274,6 +286,8 @@ def routine(
help_text: t.Union[str, Promise] = "",
*commands: Command,
subprocess: bool = False,
atomic: bool = False,
continue_on_error: bool = False,
**switch_helps,
):
"""
Expand Down Expand Up @@ -306,6 +320,8 @@ def routine(
commands=existing,
switch_helps=switch_helps,
subprocess=subprocess,
atomic=atomic,
continue_on_error=continue_on_error,
)

for command in commands:
Expand Down
87 changes: 73 additions & 14 deletions django_routines/management/commands/routine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import subprocess
import sys
import typing as t
from contextlib import contextmanager
from importlib.util import find_spec

import click
import typer
from django.core.management import CommandError, call_command
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.translation import gettext as _
from django_typer.management import TyperCommand, get_command, initialize
from django_typer.types import Verbosity
Expand Down Expand Up @@ -42,6 +44,8 @@ def {routine_func}(
self,
ctx: typer.Context,
subprocess: Annotated[bool, typer.Option("{subprocess_opt}", help="{subprocess_help}", show_default=False)] = {subprocess},
atomic: Annotated[bool, typer.Option("{atomic_opt}", help="{atomic_help}", show_default=False)] = {atomic},
continue_on_error: Annotated[bool, typer.Option("{continue_opt}", help="{continue_help}", show_default=False)] = {continue_on_error},
all: Annotated[bool, typer.Option("--all", help="{all_help}")] = False,
{switch_args}
):
Expand All @@ -52,8 +56,20 @@ def {routine_func}(
ctx.get_parameter_source("subprocess")
is not click.core.ParameterSource.DEFAULT
) else None
atomic = atomic if (
ctx.get_parameter_source("atomic")
is not click.core.ParameterSource.DEFAULT
) else None
continue_on_error = continue_on_error if (
ctx.get_parameter_source("continue_on_error")
is not click.core.ParameterSource.DEFAULT
) else None
if not ctx.invoked_subcommand:
return self._run_routine(subprocess=subprocess)
return self._run_routine(
subprocess=subprocess,
atomic=atomic,
continue_on_error=continue_on_error
)
return self.{routine_func}
"""

Expand Down Expand Up @@ -124,21 +140,42 @@ def init(
)
self.manage_script = manage_script

def _run_routine(self, subprocess: t.Optional[bool] = None):
def _run_routine(
self,
subprocess: t.Optional[bool] = None,
atomic: t.Optional[bool] = None,
continue_on_error: t.Optional[bool] = None,
):
"""
Execute the current routine plan. If verbosity is zero, do not print the
commands as they are run. Also use the stdout/stderr streams and color
configuration of the routine command for each of the commands in the execution
plan.
"""
assert self.routine
for command in self.plan:
if isinstance(command, SystemCommand) or (
(self.routine.subprocess and subprocess is None) or subprocess
):
self._subprocess(command)
else:
self._call_command(command)

@contextmanager
def noop():
yield

subprocess = subprocess if subprocess is not None else self.routine.subprocess
is_atomic = atomic if atomic is not None else self.routine.atomic
continue_on_error = (
continue_on_error
if continue_on_error is not None
else self.routine.continue_on_error
)
ctx = transaction.atomic if is_atomic else noop
with ctx(): # type: ignore
for command in self.plan:
try:
if isinstance(command, SystemCommand) or subprocess:
self._subprocess(command)
else:
self._call_command(command)
except Exception as e:
if not continue_on_error:
raise e

def _call_command(self, command: ManagementCommand):
try:
Expand Down Expand Up @@ -226,6 +263,12 @@ def _subprocess(self, command: RCommand):
result = subprocess.run(args, env=os.environ.copy(), capture_output=True)
self.stdout.write(result.stdout.decode())
self.stderr.write(result.stderr.decode())
if result.returncode > 0:
raise CommandError(
_(
"Subprocess command failed: {command} with return code {code}"
).format(command=" ".join(args), code=result.returncode)
)
return result.returncode

def _list(self) -> None:
Expand Down Expand Up @@ -289,11 +332,27 @@ def _list(self) -> None:
switch_args=switch_args,
add_switches=add_switches,
subprocess_opt="--no-subprocess" if routine.subprocess else "--subprocess",
subprocess_help=_("Do not run commands as subprocesses.")
if routine.subprocess
else _("Run commands as subprocesses."),
all_help=_("Include all switched commands."),
subprocess_help=(
_("Do not run commands as subprocesses.")
if routine.subprocess
else _("Run commands as subprocesses.")
),
subprocess=routine.subprocess,
atomic_opt="--non-atomic" if routine.atomic else "--atomic",
atomic_help=(
_("Do not run all commands in the same transaction.")
if routine.atomic
else _("Run all commands in the same transaction.")
),
atomic=routine.atomic,
continue_opt="--halt" if routine.continue_on_error else "--continue",
continue_help=(
_("Halt if any command fails.")
if routine.continue_on_error
else _("Continue through the routine if any commands fail.")
),
continue_on_error=routine.continue_on_error,
all_help=_("Include all switched commands."),
)

command_strings = []
Expand All @@ -320,7 +379,7 @@ def _list(self) -> None:

exec(cmd_code)

if not use_rich:
if not use_rich and command_strings:
width = max([len(cmd) for cmd in command_strings])
ruler = f"[underline]{' ' * width}[/underline]\n" if use_rich else "-" * width
cmd_strings = "\n".join(command_strings)
Expand Down
7 changes: 7 additions & 0 deletions doc/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
Change Log
==========

v1.2.0 (27-JUL-2024)
====================

* `Option to run routine within a transaction. <https://github.com/bckohan/django-routines/issues/24>`_
* `Option to fail fast or proceed on failures. <https://github.com/bckohan/django-routines/issues/10>`_


v1.1.3 (17-JUL-2024)
====================

Expand Down
13 changes: 13 additions & 0 deletions doc/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ options in the style that will be passed to call_command_:
Lazy translations work as help_text for routines and switches.


.. _execution_controls:

:big:`Execution Controls`

There are several switches that can be used to control the execution of routines. Pass
these parameters when you define the Routine.

- ``atomic``: Run the routine in a transaction.
- ``continue_on_error``: Continue running the routine even if a command fails.

The default routine behavior for these execution controls can be overridden on the command
line.

.. _rationale:

:big:`Rationale`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "django-routines"
version = "1.1.3"
version = "1.2.0"
description = "Define named groups of management commands in Django settings files for batched execution."
authors = ["Brian Kohan <bckohan@gmail.com>"]
license = "MIT"
Expand Down
14 changes: 14 additions & 0 deletions tests/django_routines_tests/management/commands/edit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from .track import Command as TrackCommand
from ...models import TestModel


class Command(TrackCommand):
def add_arguments(self, parser):
super().add_arguments(parser)
parser.add_argument("name", type=str)

def handle(self, *args, **options):
super().handle(*args, **options)
TestModel.objects.update_or_create(
id=options["id"], defaults={"name": options["name"]}
)
9 changes: 8 additions & 1 deletion tests/django_routines_tests/management/commands/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
passed_options = []


class TestError(Exception):
pass


class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("id", type=int)
parser.add_argument("--demo", type=int)
parser.add_argument("--flag", action="store_true", default=False)
parser.add_argument("--raise", action="store_true", default=False)

def handle(self, *args, **options):
global invoked
Expand All @@ -23,4 +28,6 @@ def handle(self, *args, **options):
track = json.loads(track_file.read_text())
track["invoked"].append(options["id"])
track["passed_options"].append(options)
track_file.write_text(json.dumps(track))
track_file.write_text(json.dumps(track, indent=4))
if options["raise"]:
raise TestError("Kill the op.")
27 changes: 27 additions & 0 deletions tests/django_routines_tests/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.2.13 on 2024-07-27 08:13

from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="TestModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
],
),
]
Empty file.
5 changes: 5 additions & 0 deletions tests/django_routines_tests/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.db import models


class TestModel(models.Model):
name = models.CharField(max_length=255)
31 changes: 31 additions & 0 deletions tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@

DJANGO_ROUTINES = None

DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

STATIC_URL = "static/"

SECRET_KEY = "fake"
Expand Down Expand Up @@ -124,3 +126,32 @@
hyphen_ok="Test hyphen.",
hyphen_ok_prefix="Test hyphen with -- prefix.",
)

routine(
"atomic_pass",
"Atomic test routine.",
RoutineCommand(command=("edit", "0", "Name1")),
RoutineCommand(command=("edit", "0", "Name2")),
RoutineCommand(command=("edit", "0", "Name3")),
RoutineCommand(command=("edit", "1", "Name4")),
atomic=True,
)

routine(
"atomic_fail",
"Atomic test routine failure.",
RoutineCommand(command=("edit", "0", "Name1")),
RoutineCommand(command=("edit", "0", "Name2")),
RoutineCommand(command=("edit", "0", "Name3")),
RoutineCommand(command=("edit", "1", "Name4", "--raise")),
atomic=True,
)

routine(
"test_continue",
"Test continue option.",
RoutineCommand(command=("edit", "0", "Name1")),
RoutineCommand(command=("edit", "0", "Name2", "--raise")),
RoutineCommand(command=("edit", "0", "Name3")),
continue_on_error=True,
)
Loading

0 comments on commit 3998ea7

Please sign in to comment.