Skip to content

Commit

Permalink
make it so DJANGO_ROUTINES setting is set as a dictionary, but that i…
Browse files Browse the repository at this point in the history
…t works if its set as dataclass objects
  • Loading branch information
bckohan committed Jun 4, 2024
1 parent 1ba8ebc commit 620e494
Show file tree
Hide file tree
Showing 10 changed files with 603 additions and 51 deletions.
71 changes: 71 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
[Poetry]: https://python-poetry.org/
[mypy]: http://mypy-lang.org/
[django-pytest]: https://pytest-django.readthedocs.io/en/latest/
[pytest]: https://docs.pytest.org/en/stable/
[Sphinx]: https://www.sphinx-doc.org/en/master/
[readthedocs]: https://readthedocs.org/
[me]: https://github.com/bckohan
[black]: https://black.readthedocs.io/en/stable/
[pyright]: https://github.com/microsoft/pyright
[ruff]: https://docs.astral.sh/ruff/

# Contributing

Contributions are encouraged! Please use the issue page to submit feature requests or bug reports. Issues with attached PRs will be given priority and have a much higher likelihood of acceptance. Please also open an issue and associate it with any submitted PRs. That said, the aim is to keep this library as lightweight as possible. Only features with broad-based use cases will be considered.

We are actively seeking additional maintainers. If you're interested, please [contact me](https://github.com/bckohan).

## Installation

`django-routines` uses [Poetry](https://python-poetry.org/) for environment, package, and dependency management:

```shell
poetry install
```

## Documentation

`django-routines` documentation is generated using [Sphinx](https://www.sphinx-doc.org/en/master/) with the [readthedocs](https://readthedocs.org/) theme. Any new feature PRs must provide updated documentation for the features added. To build the docs run doc8 to check for formatting issues then run Sphinx:

```bash
cd ./doc
poetry run doc8 --ignore-path build --max-line-length 100
poetry run make html
```

## Static Analysis

`django-routines` uses [ruff](https://docs.astral.sh/ruff/) for Python linting, header import standardization and code formatting. [mypy](http://mypy-lang.org/) and [pyright](https://github.com/microsoft/pyright) are used for static type checking. Before any PR is accepted the following must be run, and static analysis tools should not produce any errors or warnings. Disabling certain errors or warnings where justified is acceptable:

```bash
./check.sh
```

To run static analysis without automated fixing you can run:

```bash
./check.sh --no-fix
```

## Running Tests

`django-routines` is set up to use [pytest](https://docs.pytest.org/en/stable/) to run unit tests. All the tests are housed in `tests/tests.py`. Before a PR is accepted, all tests must be passing and the code coverage must be at 100%. A small number of exempted error handling branches are acceptable.

To run the full suite:

```shell
poetry run pytest
```

To run a single test, or group of tests in a class:

```shell
poetry run pytest <path_to_tests_file>::ClassName::FunctionName
```

For instance, to run all tests in Tests, and then just the test_command test you would do:

```shell
poetry run pytest tests/tests.py::Tests
poetry run pytest tests/tests.py::Tests::test_command
```
119 changes: 84 additions & 35 deletions django_routines/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
r"""
::
____ _ ____ __ _
/ __ \ (_)___ _____ ____ _____ / __ \____ __ __/ /_(_)___ ___ _____
/ / / / / / __ `/ __ \/ __ `/ __ \ / /_/ / __ \/ / / / __/ / __ \/ _ \/ ___/
/ /_/ / / / /_/ / / / / /_/ / /_/ / / _, _/ /_/ / /_/ / /_/ / / / / __(__ )
/_____/_/ /\__,_/_/ /_/\__, /\____/ /_/ |_|\____/\__,_/\__/_/_/ /_/\___/____/
/___/ /____/
____ _ ____ __ _
/ __ \ (_)___ _____ ____ _____ / __ \____ __ __/ /_(_)___ ___ _____
/ / / / / / __ `/ __ \/ __ `/ __ \ / /_/ / __ \/ / / / __/ / __ \/ _ \/ ___/
/ /_/ / / / /_/ / / / / /_/ / /_/ / / _, _/ /_/ / /_/ / /_/ / / / / __(__ )
/_____/_/ /\__,_/_/ /_/\__, /\____/ /_/ |_|\____/\__,_/\__/_/_/ /_/\___/____/
/___/ /____/
A simple Django app that allows you to specify batches of commands in your settings files
and then run them in sequence by name using the provied ``routine`` command.
"""

import bisect
import sys
import typing as t
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field

VERSION = (1, 0, 0)

Expand All @@ -31,10 +33,11 @@
"routine",
"command",
"get_routine",
"routines",
]


ROUTINE_SETTING = "ROUTINES"
ROUTINE_SETTING = "DJANGO_ROUTINES"


R = t.TypeVar("R")
Expand Down Expand Up @@ -69,6 +72,15 @@ class RoutineCommand:
t.Any options to pass to the command via call_command.
"""

@classmethod
def from_dict(
cls, obj: t.Union["RoutineCommand", t.Dict[str, t.Any]]
) -> "RoutineCommand":
"""
Return a RoutineCommand object from a dictionary representing it.
"""
return obj if isinstance(obj, RoutineCommand) else RoutineCommand(**obj)


def _insort_right_with_key(a: t.List[R], x: R, key: t.Callable[[R], t.Any]) -> None:
"""
Expand Down Expand Up @@ -125,31 +137,25 @@ def plan(self, switches: t.Set[str]) -> t.List[RoutineCommand]:
or any(switch in switches for switch in command.switches)
]

def command(self, command: RoutineCommand):
def add(self, command: RoutineCommand):
# python >= 3.10
# bisect.insort(self.commands, command, key=lambda cmd: cmd.priority)
_insort_right_with_key(self.commands, command, key=lambda cmd: cmd.priority)
_insort_right_with_key(
self.commands, command, key=lambda cmd: cmd.priority or 0
)
return command


def get_routine(name: str) -> Routine:
"""
Get the routine by name.
.. note::
If called before settings have been configured, this function must be called from settings.
:param name: The name of the routine to get.
:return: The routine.
:raises: KeyError if the routine does not exist, or routines have not been
configured.
"""
from django.conf import settings

if not settings.configured:
return sys._getframe(1).f_globals[ROUTINE_SETTING][name] # noqa: WPS437
return getattr(settings, ROUTINE_SETTING, {})[name]
@classmethod
def from_dict(cls, obj: t.Union["Routine", t.Dict[str, t.Any]]) -> "Routine":
"""
Return a RoutineCommand object from a dictionary representing it.
"""
if isinstance(obj, Routine):
return obj
return Routine(
**{attr: val for attr, val in obj.items() if attr != "commands"},
commands=[RoutineCommand.from_dict(cmd) for cmd in obj.get("commands", [])],
)


def routine(name: str, help_text: str = "", *commands: RoutineCommand, **switch_helps):
Expand All @@ -164,8 +170,8 @@ def routine(name: str, help_text: str = "", *commands: RoutineCommand, **switch_
settings.setdefault(ROUTINE_SETTING, {})
routine = Routine(name, help_text, switch_helps=switch_helps)
for command in commands:
routine.command(command)
settings[ROUTINE_SETTING][name] = routine
routine.add(command)
settings[ROUTINE_SETTING][name] = asdict(routine)


def command(
Expand All @@ -186,17 +192,60 @@ def command(
:param command: The command and its arguments to run the routine, all strings or
coercible to strings that the command will parse correctly.
:param priority: The order of the command in the routine. Priority ties will be run in
insertion order.
insertion order (defaults to zero).
:param switches: The command will run only when one of these switches is active,
or for all invocations of the routine if no switches are configured.
:param options: t.Any options to pass to the command via call_command.
:return: The new command.
"""
settings = sys._getframe(1).f_globals # noqa: WPS437
settings.setdefault(ROUTINE_SETTING, {})
settings[ROUTINE_SETTING].setdefault(routine, Routine(routine, "", []))
return settings[ROUTINE_SETTING][routine].command(
routines = settings[ROUTINE_SETTING]
routine_obj = (
Routine.from_dict(routines[routine])
if routine in routines
else Routine(routine, "", [])
)
new_cmd = routine_obj.add(
RoutineCommand(
t.cast(t.Tuple[str], command), priority, tuple(switches or []), options
)
)
settings[ROUTINE_SETTING][routine] = asdict(routine_obj)
return new_cmd


def get_routine(name: str) -> Routine:
"""
Get the routine by name.
.. note::
If called before settings have been configured, this function must be called from settings.
:param name: The name of the routine to get.
:return: The routine.
:raises: KeyError if the routine does not exist, or routines have not been
configured.
"""
from django.conf import settings

if not settings.configured:
Routine.from_dict(sys._getframe(1).f_globals[ROUTINE_SETTING][name]) # noqa: WPS437
return Routine.from_dict(getattr(settings, ROUTINE_SETTING, {})[name])


def routines() -> t.Generator[Routine, None, None]:
"""
A generator that yields Routine objects from settings.
:yield: Routine objects
"""
from django.conf import settings

routines: t.Dict[str, t.Any]
if not settings.configured:
routines = sys._getframe(1).f_globals[ROUTINE_SETTING] # noqa: WPS437
else:
routines = getattr(settings, ROUTINE_SETTING, {})
for routine in routines.values():
yield Routine.from_dict(routine)
18 changes: 13 additions & 5 deletions django_routines/management/commands/routine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@

import click
import typer
from django.conf import settings
from django.core.management import CommandError, call_command
from django.core.management.base import BaseCommand
from django.utils.translation import gettext as _
from django_typer import TyperCommand, get_command, initialize
from django_typer.types import Verbosity

from django_routines import ROUTINE_SETTING, Routine, RoutineCommand, get_routine
from django_routines import (
Routine,
RoutineCommand,
get_routine,
routines,
)

width = 80
use_rich = find_spec("rich") is not None
Expand Down Expand Up @@ -43,6 +47,10 @@ def {routine}(


class Command(TyperCommand, rich_markup_mode="rich"): # type: ignore
"""
A TyperCommand_
"""

help = _("Run batches of commands configured in settings.")

verbosity: int = 1
Expand Down Expand Up @@ -132,7 +140,7 @@ def _list(self) -> None:
self.secho(f"[{priority}] {cmd_str}{opt_str}{switches_str}")


for name, routine in getattr(settings, ROUTINE_SETTING, {}).items():
for routine in routines():
switches = routine.switches
switch_args = ", ".join(
[
Expand All @@ -145,7 +153,7 @@ def _list(self) -> None:
add_switches += f'\n if all or {switch}: self.switches.append("{switch}")'

cmd_code = COMMAND_TMPL.format(
routine=name,
routine=routine.name,
switch_args=switch_args,
add_switches=add_switches,
all_help=_("Include all switched commands."),
Expand Down Expand Up @@ -186,7 +194,7 @@ def _list(self) -> None:
)
grp = Command.group(
help=help_txt, short_help=routine.help_text, invoke_without_command=True
)(locals()[name])
)(locals()[routine.name])

@grp.command(name="list", help=_("List the commands that will be run."))
def list(self):
Expand Down
9 changes: 9 additions & 0 deletions doc/source/changelog.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
==========
Change Log
==========


v1.0.0
======

* Initial production/stable release.
8 changes: 8 additions & 0 deletions doc/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,11 @@ into your settings files keeping your code base DRY_.
:big:`Basic Example`

.. todo:: Add a basic example


.. toctree::
:maxdepth: 2
:caption: Contents:

reference
changelog
22 changes: 22 additions & 0 deletions doc/source/reference.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.. include:: ./refs.rst

.. _reference:

=========
Reference
=========

.. _base:

django_routines
---------------

.. automodule:: django_routines
:members:

routine command
---------------

.. automodule:: django_routines.management.commands.routine
:members:

1 change: 1 addition & 0 deletions doc/source/refs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
.. _rich: https://rich.readthedocs.io/
.. _django-routines: https://pypi.python.org/pypi/django-routines
.. _DRY: https://en.wikipedia.org/wiki/Don%27t_repeat_yourself
.. _TyperCommand: https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.TyperCommand
Loading

0 comments on commit 620e494

Please sign in to comment.