diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d59b332 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 ::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 +``` diff --git a/django_routines/__init__.py b/django_routines/__init__.py index dfc71be..30cf9ff 100644 --- a/django_routines/__init__.py +++ b/django_routines/__init__.py @@ -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) @@ -31,10 +33,11 @@ "routine", "command", "get_routine", + "routines", ] -ROUTINE_SETTING = "ROUTINES" +ROUTINE_SETTING = "DJANGO_ROUTINES" R = t.TypeVar("R") @@ -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: """ @@ -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): @@ -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( @@ -186,7 +192,7 @@ 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. @@ -194,9 +200,52 @@ def 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) diff --git a/django_routines/management/commands/routine.py b/django_routines/management/commands/routine.py index 79dcc6f..0f9ef23 100644 --- a/django_routines/management/commands/routine.py +++ b/django_routines/management/commands/routine.py @@ -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 @@ -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 @@ -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( [ @@ -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."), @@ -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): diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst new file mode 100644 index 0000000..d030232 --- /dev/null +++ b/doc/source/changelog.rst @@ -0,0 +1,9 @@ +========== +Change Log +========== + + +v1.0.0 +====== + +* Initial production/stable release. diff --git a/doc/source/index.rst b/doc/source/index.rst index 6723eaf..05721fc 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -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 diff --git a/doc/source/reference.rst b/doc/source/reference.rst new file mode 100644 index 0000000..f102834 --- /dev/null +++ b/doc/source/reference.rst @@ -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: + diff --git a/doc/source/refs.rst b/doc/source/refs.rst index 67b4880..25323d2 100644 --- a/doc/source/refs.rst +++ b/doc/source/refs.rst @@ -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 diff --git a/tests/settings.py b/tests/settings.py index 4d7a177..98789a3 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -3,8 +3,10 @@ from django_routines import ( routine, command, + Routine, RoutineCommand, get_routine, + routines, ) from django.utils.translation import gettext_lazy as _ @@ -132,17 +134,16 @@ routine( "deploy", _("Deploy the site application into production."), - RoutineCommand(("migrate",), priority=1), - RoutineCommand(("collectstatic",), priority=5), + RoutineCommand(("makemigrations",), switches=["prepare"]), + RoutineCommand(("migrate",)), + RoutineCommand(("renderstatic",)), + RoutineCommand(("collectstatic",)), prepare=_("Prepare the deployment."), demo="Deploy the demo.", ) -command("deploy", "makemigrations", priority=0, switches=["prepare"]) -command("deploy", "renderstatic", priority=4) -command( - "deploy", "loaddata", "./fixtures/initial_data.json", priority=6, switches=["demo"] -) +command("deploy", "shellcompletion", "install", switches=["initial"]) +command("deploy", "loaddata", "./fixtures/initial_data.json", switches=["demo"]) assert get_routine("deploy").name == "deploy" @@ -168,3 +169,11 @@ RoutineCommand(("does_not_exist",)), RoutineCommand(("track", "1")), ) + + +names = set() +for rtn in routines(): + assert isinstance(rtn, Routine) + names.add(rtn.name) + +assert names == {"deploy", "test", "bad"} diff --git a/tests/settings_no_typer.py b/tests/settings_no_typer.py deleted file mode 100644 index 165f35c..0000000 --- a/tests/settings_no_typer.py +++ /dev/null @@ -1,3 +0,0 @@ -from .settings import * - -INSTALLED_APPS = [app for app in INSTALLED_APPS if app != "django_typer"] diff --git a/tests/tests.py b/tests/tests.py index 88444d3..b08107b 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -4,7 +4,9 @@ from django.core.management import BaseCommand, call_command, CommandError from django_typer import get_command -from django.test import TestCase +from django.test import TestCase, override_settings +from django.conf import settings +from django_routines import ROUTINE_SETTING, Routine, RoutineCommand import re from tests.django_routines_tests.management.commands.track import ( @@ -370,3 +372,379 @@ def test_helps_no_rich(self): stdout.getvalue().strip().replace("\x08", ""), self.routine_test_help_no_rich.strip(), ) + + def test_settings_format(self): + routines = getattr(settings, ROUTINE_SETTING) + self.assertEqual( + routines, + { + "bad": { + "commands": [ + { + "command": ("track", "0"), + "options": {}, + "priority": 0, + "switches": (), + }, + { + "command": ("does_not_exist",), + "options": {}, + "priority": 0, + "switches": (), + }, + { + "command": ("track", "1"), + "options": {}, + "priority": 0, + "switches": (), + }, + ], + "help_text": "Bad command test routine", + "name": "bad", + "switch_helps": {}, + }, + "deploy": { + "commands": [ + { + "command": ("makemigrations",), + "options": {}, + "priority": 0, + "switches": ["prepare"], + }, + { + "command": ("migrate",), + "options": {}, + "priority": 0, + "switches": (), + }, + { + "command": ("renderstatic",), + "options": {}, + "priority": 0, + "switches": (), + }, + { + "command": ("collectstatic",), + "options": {}, + "priority": 0, + "switches": (), + }, + { + "command": ("shellcompletion", "install"), + "options": {}, + "priority": 0, + "switches": ("initial",), + }, + { + "command": ("loaddata", "./fixtures/initial_data.json"), + "options": {}, + "priority": 0, + "switches": ("demo",), + }, + ], + "help_text": "Deploy the site application into production.", + "name": "deploy", + "switch_helps": { + "demo": "Deploy the demo.", + "prepare": "Prepare the deployment.", + }, + }, + "test": { + "commands": [ + { + "command": ("track", "2"), + "options": {}, + "priority": 0, + "switches": ("initial", "demo"), + }, + { + "command": ("track", "0"), + "options": {"verbosity": 0}, + "priority": 1, + "switches": ("initial",), + }, + { + "command": ("track", "3"), + "options": {"demo": 2}, + "priority": 3, + "switches": (), + }, + { + "command": ("track", "4"), + "options": {"demo": 6}, + "priority": 3, + "switches": (), + }, + { + "command": ("track", "1"), + "options": {}, + "priority": 4, + "switches": (), + }, + { + "command": ("track", "5"), + "options": {}, + "priority": 6, + "switches": ("demo",), + }, + ], + "help_text": "Test Routine 1", + "name": "test", + "switch_helps": {}, + }, + }, + ) + + +@override_settings( + INSTALLED_APPS=[ + "tests.django_routines_tests", + "django_routines", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + ] +) +class NoDjangoTyperInstalledTests(Tests): + pass + + +@override_settings( + DJANGO_ROUTINES={ + "bad": { + "commands": [ + {"command": ("track", "0")}, + {"command": ("does_not_exist",)}, + {"command": ("track", "1")}, + ], + "help_text": "Bad command test routine", + "name": "bad", + }, + "deploy": { + "commands": [ + {"command": ("makemigrations",), "switches": ["prepare"]}, + {"command": ("migrate",)}, + {"command": ("renderstatic",)}, + {"command": ("collectstatic",)}, + {"command": ("shellcompletion", "install"), "switches": ("initial",)}, + { + "command": ("loaddata", "./fixtures/initial_data.json"), + "switches": ("demo",), + }, + ], + "help_text": "Deploy the site application into production.", + "name": "deploy", + "switch_helps": { + "demo": "Deploy the demo.", + "prepare": "Prepare the deployment.", + }, + }, + "test": { + "commands": [ + {"command": ("track", "2"), "switches": ("initial", "demo")}, + { + "command": ("track", "0"), + "options": {"verbosity": 0}, + "priority": 1, + "switches": ("initial",), + }, + {"command": ("track", "3"), "options": {"demo": 2}, "priority": 3}, + {"command": ("track", "4"), "options": {"demo": 6}, "priority": 3}, + {"command": ("track", "1"), "priority": 4}, + {"command": ("track", "5"), "priority": 6, "switches": ("demo",)}, + ], + "help_text": "Test Routine 1", + "name": "test", + }, + } +) +class SettingsAsDictTests(Tests): + def test_settings_format(self): + routines = getattr(settings, ROUTINE_SETTING) + self.assertEqual( + routines, + { + "bad": { + "commands": [ + {"command": ("track", "0")}, + {"command": ("does_not_exist",)}, + {"command": ("track", "1")}, + ], + "help_text": "Bad command test routine", + "name": "bad", + }, + "deploy": { + "commands": [ + {"command": ("makemigrations",), "switches": ["prepare"]}, + {"command": ("migrate",)}, + {"command": ("renderstatic",)}, + {"command": ("collectstatic",)}, + { + "command": ("shellcompletion", "install"), + "switches": ("initial",), + }, + { + "command": ("loaddata", "./fixtures/initial_data.json"), + "switches": ("demo",), + }, + ], + "help_text": "Deploy the site application into production.", + "name": "deploy", + "switch_helps": { + "demo": "Deploy the demo.", + "prepare": "Prepare the deployment.", + }, + }, + "test": { + "commands": [ + {"command": ("track", "2"), "switches": ("initial", "demo")}, + { + "command": ("track", "0"), + "options": {"verbosity": 0}, + "priority": 1, + "switches": ("initial",), + }, + { + "command": ("track", "3"), + "options": {"demo": 2}, + "priority": 3, + }, + { + "command": ("track", "4"), + "options": {"demo": 6}, + "priority": 3, + }, + {"command": ("track", "1"), "priority": 4}, + { + "command": ("track", "5"), + "priority": 6, + "switches": ("demo",), + }, + ], + "help_text": "Test Routine 1", + "name": "test", + }, + }, + ) + + +@override_settings( + DJANGO_ROUTINES={ + "bad": Routine( + commands=[ + RoutineCommand(command=("track", "0")), + RoutineCommand(command=("does_not_exist",)), + RoutineCommand(command=("track", "1")), + ], + help_text="Bad command test routine", + name="bad", + ), + "deploy": Routine( + commands=[ + RoutineCommand(command=("makemigrations",), switches=["prepare"]), + RoutineCommand(command=("migrate",)), + RoutineCommand(command=("renderstatic",)), + RoutineCommand(command=("collectstatic",)), + RoutineCommand( + command=("shellcompletion", "install"), switches=("initial",) + ), + RoutineCommand( + command=("loaddata", "./fixtures/initial_data.json"), + switches=("demo",), + ), + ], + help_text="Deploy the site application into production.", + name="deploy", + switch_helps={ + "demo": "Deploy the demo.", + "prepare": "Prepare the deployment.", + }, + ), + "test": Routine( + commands=[ + RoutineCommand(command=("track", "2"), switches=("initial", "demo")), + RoutineCommand( + command=("track", "0"), + options={"verbosity": 0}, + priority=1, + switches=("initial",), + ), + RoutineCommand(command=("track", "3"), options={"demo": 2}, priority=3), + RoutineCommand(command=("track", "4"), options={"demo": 6}, priority=3), + RoutineCommand(command=("track", "1"), priority=4), + RoutineCommand(command=("track", "5"), priority=6, switches=("demo",)), + ], + help_text="Test Routine 1", + name="test", + ), + } +) +class SettingsAsObjectsTests(Tests): + def test_settings_format(self): + routines = getattr(settings, ROUTINE_SETTING) + self.assertEqual( + routines, + { + "bad": Routine( + commands=[ + RoutineCommand(command=("track", "0")), + RoutineCommand(command=("does_not_exist",)), + RoutineCommand(command=("track", "1")), + ], + help_text="Bad command test routine", + name="bad", + ), + "deploy": Routine( + commands=[ + RoutineCommand( + command=("makemigrations",), switches=["prepare"] + ), + RoutineCommand(command=("migrate",)), + RoutineCommand(command=("renderstatic",)), + RoutineCommand(command=("collectstatic",)), + RoutineCommand( + command=("shellcompletion", "install"), + switches=("initial",), + ), + RoutineCommand( + command=("loaddata", "./fixtures/initial_data.json"), + switches=("demo",), + ), + ], + help_text="Deploy the site application into production.", + name="deploy", + switch_helps={ + "demo": "Deploy the demo.", + "prepare": "Prepare the deployment.", + }, + ), + "test": Routine( + commands=[ + RoutineCommand( + command=("track", "2"), switches=("initial", "demo") + ), + RoutineCommand( + command=("track", "0"), + options={"verbosity": 0}, + priority=1, + switches=("initial",), + ), + RoutineCommand( + command=("track", "3"), options={"demo": 2}, priority=3 + ), + RoutineCommand( + command=("track", "4"), options={"demo": 6}, priority=3 + ), + RoutineCommand(command=("track", "1"), priority=4), + RoutineCommand( + command=("track", "5"), priority=6, switches=("demo",) + ), + ], + help_text="Test Routine 1", + name="test", + ), + }, + )