Skip to content

Commit 3998ea7

Browse files
committed
add fail behavior control and atomicity control, fix #10 and fix #24
1 parent 88d628f commit 3998ea7

File tree

15 files changed

+566
-29
lines changed

15 files changed

+566
-29
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,15 @@ When specifying arguments you may add them to the command tuple OR specify them
167167
command("package", "makemigrations", no_header=True)
168168
```
169169

170+
## Execution Controls
171+
172+
There are several switches that can be used to control the execution of routines. Pass these parameters when you define the Routine.
173+
174+
- ``atomic``: Run the routine in a transaction.
175+
- ``continue_on_error``: Continue running the routine even if a command fails.
176+
177+
The default routine behavior for these execution controls can be overridden on the command line.
178+
170179

171180
## Installation
172181

django_routines/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from django.core.exceptions import ImproperlyConfigured
2424
from django.utils.functional import Promise
2525

26-
VERSION = (1, 1, 3)
26+
VERSION = (1, 2, 0)
2727

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

210+
atomic: bool = False
211+
"""
212+
Run all commands in the same transaction.
213+
"""
214+
215+
continue_on_error: bool = False
216+
"""
217+
Keep going if a command fails.
218+
"""
219+
210220
def __post_init__(self):
211221
self.name = to_symbol(self.name)
212222
self.switch_helps = {
@@ -266,6 +276,8 @@ def to_dict(self) -> t.Dict[str, t.Any]:
266276
"commands": [cmd.to_dict() for cmd in self.commands],
267277
"switch_helps": self.switch_helps,
268278
"subprocess": self.subprocess,
279+
"atomic": self.atomic,
280+
"continue_on_error": self.continue_on_error,
269281
}
270282

271283

@@ -274,6 +286,8 @@ def routine(
274286
help_text: t.Union[str, Promise] = "",
275287
*commands: Command,
276288
subprocess: bool = False,
289+
atomic: bool = False,
290+
continue_on_error: bool = False,
277291
**switch_helps,
278292
):
279293
"""
@@ -306,6 +320,8 @@ def routine(
306320
commands=existing,
307321
switch_helps=switch_helps,
308322
subprocess=subprocess,
323+
atomic=atomic,
324+
continue_on_error=continue_on_error,
309325
)
310326

311327
for command in commands:

django_routines/management/commands/routine.py

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import subprocess
44
import sys
55
import typing as t
6+
from contextlib import contextmanager
67
from importlib.util import find_spec
78

89
import click
910
import typer
1011
from django.core.management import CommandError, call_command
1112
from django.core.management.base import BaseCommand
13+
from django.db import transaction
1214
from django.utils.translation import gettext as _
1315
from django_typer.management import TyperCommand, get_command, initialize
1416
from django_typer.types import Verbosity
@@ -42,6 +44,8 @@ def {routine_func}(
4244
self,
4345
ctx: typer.Context,
4446
subprocess: Annotated[bool, typer.Option("{subprocess_opt}", help="{subprocess_help}", show_default=False)] = {subprocess},
47+
atomic: Annotated[bool, typer.Option("{atomic_opt}", help="{atomic_help}", show_default=False)] = {atomic},
48+
continue_on_error: Annotated[bool, typer.Option("{continue_opt}", help="{continue_help}", show_default=False)] = {continue_on_error},
4549
all: Annotated[bool, typer.Option("--all", help="{all_help}")] = False,
4650
{switch_args}
4751
):
@@ -52,8 +56,20 @@ def {routine_func}(
5256
ctx.get_parameter_source("subprocess")
5357
is not click.core.ParameterSource.DEFAULT
5458
) else None
59+
atomic = atomic if (
60+
ctx.get_parameter_source("atomic")
61+
is not click.core.ParameterSource.DEFAULT
62+
) else None
63+
continue_on_error = continue_on_error if (
64+
ctx.get_parameter_source("continue_on_error")
65+
is not click.core.ParameterSource.DEFAULT
66+
) else None
5567
if not ctx.invoked_subcommand:
56-
return self._run_routine(subprocess=subprocess)
68+
return self._run_routine(
69+
subprocess=subprocess,
70+
atomic=atomic,
71+
continue_on_error=continue_on_error
72+
)
5773
return self.{routine_func}
5874
"""
5975

@@ -124,21 +140,42 @@ def init(
124140
)
125141
self.manage_script = manage_script
126142

127-
def _run_routine(self, subprocess: t.Optional[bool] = None):
143+
def _run_routine(
144+
self,
145+
subprocess: t.Optional[bool] = None,
146+
atomic: t.Optional[bool] = None,
147+
continue_on_error: t.Optional[bool] = None,
148+
):
128149
"""
129150
Execute the current routine plan. If verbosity is zero, do not print the
130151
commands as they are run. Also use the stdout/stderr streams and color
131152
configuration of the routine command for each of the commands in the execution
132153
plan.
133154
"""
134155
assert self.routine
135-
for command in self.plan:
136-
if isinstance(command, SystemCommand) or (
137-
(self.routine.subprocess and subprocess is None) or subprocess
138-
):
139-
self._subprocess(command)
140-
else:
141-
self._call_command(command)
156+
157+
@contextmanager
158+
def noop():
159+
yield
160+
161+
subprocess = subprocess if subprocess is not None else self.routine.subprocess
162+
is_atomic = atomic if atomic is not None else self.routine.atomic
163+
continue_on_error = (
164+
continue_on_error
165+
if continue_on_error is not None
166+
else self.routine.continue_on_error
167+
)
168+
ctx = transaction.atomic if is_atomic else noop
169+
with ctx(): # type: ignore
170+
for command in self.plan:
171+
try:
172+
if isinstance(command, SystemCommand) or subprocess:
173+
self._subprocess(command)
174+
else:
175+
self._call_command(command)
176+
except Exception as e:
177+
if not continue_on_error:
178+
raise e
142179

143180
def _call_command(self, command: ManagementCommand):
144181
try:
@@ -226,6 +263,12 @@ def _subprocess(self, command: RCommand):
226263
result = subprocess.run(args, env=os.environ.copy(), capture_output=True)
227264
self.stdout.write(result.stdout.decode())
228265
self.stderr.write(result.stderr.decode())
266+
if result.returncode > 0:
267+
raise CommandError(
268+
_(
269+
"Subprocess command failed: {command} with return code {code}"
270+
).format(command=" ".join(args), code=result.returncode)
271+
)
229272
return result.returncode
230273

231274
def _list(self) -> None:
@@ -289,11 +332,27 @@ def _list(self) -> None:
289332
switch_args=switch_args,
290333
add_switches=add_switches,
291334
subprocess_opt="--no-subprocess" if routine.subprocess else "--subprocess",
292-
subprocess_help=_("Do not run commands as subprocesses.")
293-
if routine.subprocess
294-
else _("Run commands as subprocesses."),
295-
all_help=_("Include all switched commands."),
335+
subprocess_help=(
336+
_("Do not run commands as subprocesses.")
337+
if routine.subprocess
338+
else _("Run commands as subprocesses.")
339+
),
296340
subprocess=routine.subprocess,
341+
atomic_opt="--non-atomic" if routine.atomic else "--atomic",
342+
atomic_help=(
343+
_("Do not run all commands in the same transaction.")
344+
if routine.atomic
345+
else _("Run all commands in the same transaction.")
346+
),
347+
atomic=routine.atomic,
348+
continue_opt="--halt" if routine.continue_on_error else "--continue",
349+
continue_help=(
350+
_("Halt if any command fails.")
351+
if routine.continue_on_error
352+
else _("Continue through the routine if any commands fail.")
353+
),
354+
continue_on_error=routine.continue_on_error,
355+
all_help=_("Include all switched commands."),
297356
)
298357

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

321380
exec(cmd_code)
322381

323-
if not use_rich:
382+
if not use_rich and command_strings:
324383
width = max([len(cmd) for cmd in command_strings])
325384
ruler = f"[underline]{' ' * width}[/underline]\n" if use_rich else "-" * width
326385
cmd_strings = "\n".join(command_strings)

doc/source/changelog.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
Change Log
33
==========
44

5+
v1.2.0 (27-JUL-2024)
6+
====================
7+
8+
* `Option to run routine within a transaction. <https://github.com/bckohan/django-routines/issues/24>`_
9+
* `Option to fail fast or proceed on failures. <https://github.com/bckohan/django-routines/issues/10>`_
10+
11+
512
v1.1.3 (17-JUL-2024)
613
====================
714

doc/source/index.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,19 @@ options in the style that will be passed to call_command_:
117117
Lazy translations work as help_text for routines and switches.
118118

119119

120+
.. _execution_controls:
121+
122+
:big:`Execution Controls`
123+
124+
There are several switches that can be used to control the execution of routines. Pass
125+
these parameters when you define the Routine.
126+
127+
- ``atomic``: Run the routine in a transaction.
128+
- ``continue_on_error``: Continue running the routine even if a command fails.
129+
130+
The default routine behavior for these execution controls can be overridden on the command
131+
line.
132+
120133
.. _rationale:
121134

122135
:big:`Rationale`

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-routines"
3-
version = "1.1.3"
3+
version = "1.2.0"
44
description = "Define named groups of management commands in Django settings files for batched execution."
55
authors = ["Brian Kohan <[email protected]>"]
66
license = "MIT"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from .track import Command as TrackCommand
2+
from ...models import TestModel
3+
4+
5+
class Command(TrackCommand):
6+
def add_arguments(self, parser):
7+
super().add_arguments(parser)
8+
parser.add_argument("name", type=str)
9+
10+
def handle(self, *args, **options):
11+
super().handle(*args, **options)
12+
TestModel.objects.update_or_create(
13+
id=options["id"], defaults={"name": options["name"]}
14+
)

tests/django_routines_tests/management/commands/track.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@
77
passed_options = []
88

99

10+
class TestError(Exception):
11+
pass
12+
13+
1014
class Command(BaseCommand):
1115
def add_arguments(self, parser):
1216
parser.add_argument("id", type=int)
1317
parser.add_argument("--demo", type=int)
1418
parser.add_argument("--flag", action="store_true", default=False)
19+
parser.add_argument("--raise", action="store_true", default=False)
1520

1621
def handle(self, *args, **options):
1722
global invoked
@@ -23,4 +28,6 @@ def handle(self, *args, **options):
2328
track = json.loads(track_file.read_text())
2429
track["invoked"].append(options["id"])
2530
track["passed_options"].append(options)
26-
track_file.write_text(json.dumps(track))
31+
track_file.write_text(json.dumps(track, indent=4))
32+
if options["raise"]:
33+
raise TestError("Kill the op.")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 4.2.13 on 2024-07-27 08:13
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
initial = True
8+
9+
dependencies = []
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="TestModel",
14+
fields=[
15+
(
16+
"id",
17+
models.AutoField(
18+
auto_created=True,
19+
primary_key=True,
20+
serialize=False,
21+
verbose_name="ID",
22+
),
23+
),
24+
("name", models.CharField(max_length=255)),
25+
],
26+
),
27+
]

tests/django_routines_tests/migrations/__init__.py

Whitespace-only changes.

tests/django_routines_tests/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.db import models
2+
3+
4+
class TestModel(models.Model):
5+
name = models.CharField(max_length=255)

tests/settings.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060

6161
DJANGO_ROUTINES = None
6262

63+
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
64+
6365
STATIC_URL = "static/"
6466

6567
SECRET_KEY = "fake"
@@ -124,3 +126,32 @@
124126
hyphen_ok="Test hyphen.",
125127
hyphen_ok_prefix="Test hyphen with -- prefix.",
126128
)
129+
130+
routine(
131+
"atomic_pass",
132+
"Atomic test routine.",
133+
RoutineCommand(command=("edit", "0", "Name1")),
134+
RoutineCommand(command=("edit", "0", "Name2")),
135+
RoutineCommand(command=("edit", "0", "Name3")),
136+
RoutineCommand(command=("edit", "1", "Name4")),
137+
atomic=True,
138+
)
139+
140+
routine(
141+
"atomic_fail",
142+
"Atomic test routine failure.",
143+
RoutineCommand(command=("edit", "0", "Name1")),
144+
RoutineCommand(command=("edit", "0", "Name2")),
145+
RoutineCommand(command=("edit", "0", "Name3")),
146+
RoutineCommand(command=("edit", "1", "Name4", "--raise")),
147+
atomic=True,
148+
)
149+
150+
routine(
151+
"test_continue",
152+
"Test continue option.",
153+
RoutineCommand(command=("edit", "0", "Name1")),
154+
RoutineCommand(command=("edit", "0", "Name2", "--raise")),
155+
RoutineCommand(command=("edit", "0", "Name3")),
156+
continue_on_error=True,
157+
)

0 commit comments

Comments
 (0)