Skip to content

Commit

Permalink
fix #6
Browse files Browse the repository at this point in the history
  • Loading branch information
bckohan committed Jun 9, 2024
1 parent 88f6788 commit 7da7e25
Show file tree
Hide file tree
Showing 12 changed files with 410 additions and 121 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

**/.DS_Store
**/.DS_Store
**/track.json
8 changes: 7 additions & 1 deletion django_routines/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from django.core.exceptions import ImproperlyConfigured
from django.utils.functional import Promise

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

__title__ = "Django Routines"
__version__ = ".".join(str(i) for i in VERSION)
Expand Down Expand Up @@ -133,6 +133,11 @@ class Routine:

switch_helps: t.Dict[str, t.Union[str, Promise]] = field(default_factory=dict)

subprocess: bool = False
"""
If true run each of the commands in a subprocess.
"""

def __len__(self):
return len(self.commands)

Expand Down Expand Up @@ -177,6 +182,7 @@ def routine(
name: str,
help_text: t.Union[str, Promise] = "",
*commands: RoutineCommand,
subprocess: bool = False,
**switch_helps,
):
"""
Expand Down
152 changes: 118 additions & 34 deletions django_routines/management/commands/routine.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os
import subprocess
import sys
import typing as t
from importlib.util import find_spec

Expand All @@ -23,7 +26,6 @@

width = Console().width


COMMAND_TMPL = """
import sys
if sys.version_info < (3, 9):
Expand All @@ -33,15 +35,20 @@
def {routine}(
self,
context: typer.Context,
ctx: typer.Context,
subprocess: Annotated[bool, typer.Option("{subprocess_opt}", help="{subprocess_help}", show_default=False)] = {subprocess},
all: Annotated[bool, typer.Option("--all", help="{all_help}")] = False,
{switch_args}
):
self.routine = "{routine}"
self.switches = []
{add_switches}
if not context.invoked_subcommand:
return self._run_routine()
subprocess = subprocess if (
ctx.get_parameter_source("subprocess")
is not click.core.ParameterSource.DEFAULT
) else None
if not ctx.invoked_subcommand:
return self._run_routine(subprocess=subprocess)
return self.{routine}
"""

Expand Down Expand Up @@ -70,6 +77,8 @@ class Command(TyperCommand, rich_markup_mode="rich"): # type: ignore
_routine: t.Optional[Routine] = None
_verbosity_passed: bool = False

manage_script: str = sys.argv[0]

@property
def routine(self) -> t.Optional[Routine]:
return self._routine
Expand All @@ -90,48 +99,118 @@ def plan(self) -> t.List[RoutineCommand]:
return self.routine.plan(self.switches)

@initialize()
def init(self, ctx: typer.Context, verbosity: Verbosity = verbosity):
def init(
self,
ctx: typer.Context,
manage_script: t.Annotated[
str,
typer.Option(
help=_(
"The manage script to use if running management commands as subprocesses."
)
),
] = manage_script,
verbosity: Verbosity = verbosity,
):
self.verbosity = verbosity
self._pass_verbosity = (
ctx.get_parameter_source("verbosity")
is not click.core.ParameterSource.DEFAULT
)
self.manage_script = manage_script

def _run_routine(self):
def _run_routine(self, subprocess: 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
configurion of the routine command for each of the commands in the execution
configuration of the routine command for each of the commands in the execution
plan.
"""
assert self.routine
for command in self.plan:
if (self.routine.subprocess and subprocess is None) or subprocess:
self._subprocess(command)
else:
self._call_command(command)

def _call_command(self, command: RoutineCommand):
try:
cmd = get_command(
command.command_name,
BaseCommand,
stdout=t.cast(t.IO[str], self.stdout._out),
stderr=t.cast(t.IO[str], self.stderr._out),
force_color=self.force_color,
no_color=self.no_color,
)
options = command.options
if (
self._pass_verbosity
and not any(
"verbosity" in arg
for arg in getattr(cmd, "suppressed_base_arguments", [])
)
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}
if self.verbosity > 0:
self.secho(command.command_str, fg="cyan")
try:
cmd = get_command(
command.command_name,
BaseCommand,
stdout=t.cast(t.IO[str], self.stdout._out),
stderr=t.cast(t.IO[str], self.stderr._out),
force_color=self.force_color,
no_color=self.no_color,
call_command(cmd, *command.command_args, **options)
except KeyError:
raise CommandError(f"Command not found: {command.command_name}")

def _subprocess(self, command: RoutineCommand):
options = []
if command.options:
# Make a good faith effort to convert options to cli compatible format
# this is not very reliable which is why commands should avoid use of
# options and instead use CLI strings
cmd = get_command(command.command_name, BaseCommand)
actions = getattr(
cmd.create_parser(self.manage_script, command.command_name),
"_actions",
[],
)
for opt, value in command.options.items():
for action in actions:
opt_strs = getattr(action, "option_strings", [])
if opt == getattr(action, "dest", None) and opt_strs:
if isinstance(value, bool):
if value:
options.append(opt_strs[-1])
else:
options.append(f"--{opt}={str(value)}")
break

if len(options) != len(command.options):
raise CommandError(
_(
"Failed to convert {command} options to CLI format: {unconverted}"
).format(command=command.command_name, unconverted=command.options)
)
options = command.options
if (
self._pass_verbosity
and not any(
"verbosity" in arg
for arg in getattr(cmd, "suppressed_base_arguments", [])
)
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_args, **options)
except KeyError:
raise CommandError(f"Command not found: {command.command_name}")

args = [
*(
[sys.executable, self.manage_script]
if self.manage_script.endswith(".py")
else [self.manage_script]
),
*(
[command.command]
if isinstance(command.command, str)
else command.command
),
*options,
]
if self.verbosity > 0:
self.secho(" ".join(args), fg="cyan")

result = subprocess.run(args, env=os.environ.copy(), capture_output=True)
self.stdout.write(result.stdout.decode())
self.stderr.write(result.stderr.decode())
return result.returncode

def _list(self) -> None:
"""
Expand All @@ -142,11 +221,11 @@ def _list(self) -> None:
priority = str(command.priority)
cmd_str = command.command_str
switches_str = " | " if command.switches else ""
opt_str = " ".join([f"{k}={v}" for k, v in command.options.items()])
opt_str = ", ".join([f"{k}={v}" for k, v in command.options.items()])
if self.force_color or not self.no_color:
priority = click.style(priority, fg="green")
cmd_str = click.style(cmd_str, fg="cyan", bold=True)
opt_str = " ".join(
opt_str = ", ".join(
[
f"{click.style(k, 'blue')}={click.style(v, 'magenta')}"
for k, v in command.options.items()
Expand Down Expand Up @@ -179,7 +258,12 @@ def _list(self) -> None:
routine=routine.name,
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=routine.subprocess,
)

command_strings = []
Expand All @@ -188,14 +272,14 @@ def _list(self) -> None:
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(
opt_str = ", ".join(
[
f"[blue]{k}[/blue]=[magenta]{v}[/magenta]"
for k, v in command.options.items()
]
)
else:
opt_str = " ".join([f"{k}={v}" for k, v in command.options.items()])
opt_str = ", ".join([f"{k}={v}" for k, v in command.options.items()])
cmd_str += f" ({opt_str})"
switches_str = " | " if command.switches else ""
switches_str += ", ".join(
Expand Down
5 changes: 5 additions & 0 deletions doc/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
Change Log
==========

v1.1.0
======

* `Option to run management commands as subprocesses instead of in the same process space. <https://github.com/bckohan/django-routines/issues/6>`_

v1.0.2
======

Expand Down
18 changes: 7 additions & 11 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.0.2"
version = "1.1.0"
description = "Define named groups of management commands in Django settings files for batched execution."
authors = ["Brian Kohan <[email protected]>"]
license = "MIT"
Expand Down
3 changes: 3 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from pathlib import Path

track_file = Path(__file__).parent / "track.json"
9 changes: 9 additions & 0 deletions tests/django_routines_tests/management/commands/track.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from django.core.management import BaseCommand
from tests import track_file
import json


invoked = []
Expand All @@ -9,9 +11,16 @@ 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)

def handle(self, *args, **options):
global invoked
global passed_options
invoked.append(options["id"])
passed_options.append(options)
if not track_file.is_file():
track_file.write_text(json.dumps({"invoked": [], "passed_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))
2 changes: 1 addition & 1 deletion tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
)

command("test", "track", "3", priority=3, demo=2)
command("test", "track", "4", priority=3, demo=6)
command("test", "track", "4", priority=3, demo=6, flag=True)
command("test", "track", "5", priority=6, switches=["demo"])

names = set()
Expand Down
Loading

0 comments on commit 7da7e25

Please sign in to comment.