Skip to content

Commit 0d8a20e

Browse files
committed
add logical plugin example to tutorial fix #122
1 parent a2ff31a commit 0d8a20e

File tree

29 files changed

+397
-52
lines changed

29 files changed

+397
-52
lines changed

django_typer/management/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1951,6 +1951,7 @@ def __new__(
19511951
attr_help = base.help
19521952

19531953
def command_bases() -> t.Generator[t.Type[TyperCommand], None, None]:
1954+
# the mro is not yet resolved so we have to do it manually
19541955
seen = set()
19551956
for first_level in reversed(bases):
19561957
for base in reversed(first_level.__mro__):

doc/source/changelog.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
Change Log
33
==========
44

5-
v2.2.3 (2024-10-xx)
5+
v2.3.0 (2024-10-xx)
66
===================
77

88
* Implemented `Drop python 3.8 support. <https://github.com/django-commons/django-typer/issues/130>`_
99
* Implemented `Command help order should respect definition order for class based commands. <https://github.com/django-commons/django-typer/issues/129>`_
1010
* Fixed `Overriding the command group class does not work. <https://github.com/django-commons/django-typer/issues/128>`_
1111
* Completed `Add project to test PyPI <https://github.com/django-commons/django-typer/issues/126>`_
1212
* Completed `Open up vulnerability reporting and add security policy. <https://github.com/django-commons/django-typer/issues/124>`_
13+
* Completed `Add example of custom plugin logic to plugins tutorial. <https://github.com/django-commons/django-typer/issues/122>`_
1314
* Completed `Move architecture in docs to ARCHITECTURE.md <https://github.com/django-commons/django-typer/issues/121>`_
1415
* Completed `Transfer to django-commons <https://github.com/django-commons/django-typer/issues/117>`_
1516
* Completed `Add howto for how to change the display order of commands in help. <https://github.com/django-commons/django-typer/issues/116>`_

doc/source/extensions.rst

Lines changed: 163 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,27 @@
66
Tutorial: Inheritance & Plugins
77
===============================
88

9-
You may need to change the behavior of an
10-
`upstream command <https://en.wikipedia.org/wiki/Upstream_(software_development)>`_ or wish
11-
you could add an additional subcommand or group to it. django-typer_ offers two patterns for
12-
changing or extending the behavior of commands. :class:`~django_typer.management.TyperCommand`
13-
classes :ref:`support inheritance <inheritance>`, even multiple inheritance. This can be a way to
14-
override or add additional commands to a command implemented elsewhere. You can then use Django's
15-
built in command override precedence (INSTALLED_APPS) to ensure your command is used instead of the
16-
upstream command or give it a different name if you would like the upstream command to still be
17-
available. The :ref:`plugin pattern <plugin>` allows commands and groups to be added or overridden
18-
directly on upstream commands without inheritance. This mechanism is useful when you might expect
19-
other apps to also modify the original command. Conflicts are resolved in INSTALLED_APPS order.
9+
Adding to, or altering the behavior of, commands from upstream Django_ apps is a common use case.
10+
Doing so allows you to keep your CLI_ stable while adding additional behaviors through your site's
11+
configuration settings files. There are three main extension patterns you may wish to employ:
12+
13+
1. Override the behavior of a command in an upstream app.
14+
2. Add additional subcommands or groups to a command in an upstream app.
15+
3. Hook implementations of custom logic into upstream command extension points.
16+
(`Inversion of Control <https://en.wikipedia.org/wiki/Inversion_of_control>`_)
17+
18+
The django-typer_ plugin mechanism supports all three of these use cases in a way that respects
19+
the precedence order of apps in the ``INSTALLED_APPS`` setting. In this tutorial we walk through
20+
an example of each using a :ref:`generic backup command <generic_backup>`. First we'll see how we
21+
might :ref:`use inheritance (1) <inheritance>` to override and change the behavior of a
22+
subcommand. Then we'll see how we can :ref:`add subcommands (2) <plugin>` to an upstream command
23+
using plugins. Finally we'll use pluggy_ to implement a hook system that allows us to
24+
:ref:`add custom logic (3) <hooks>` to an upstream command.
25+
26+
.. _generic_backup:
27+
28+
A Generic Backup Command
29+
-------------------------
2030

2131
Consider the task of backing up a Django website. State is stored in the database, in media files
2232
on disk, potentially in other files, and also in the software stack running on the server. If we
@@ -68,8 +78,8 @@ Inheritance
6878
-----------
6979

7080
The first option we have is simple inheritance. Lets say the base command is defined in
71-
an app called backup. Now say we have another app that adds functionality that uses media
72-
files to our site. This means we'll want to add a media backup routine to the backup command.
81+
an app called backup. Now say we have another app that uses media files. This means we'll
82+
want to add a media backup routine to the backup command.
7383

7484
.. note::
7585

@@ -162,17 +172,14 @@ backup batch:
162172
[.............................................]
163173
Backing up ./media to ./media.tar.gz
164174
165-
.. warning::
166-
167-
Inheritance is not supported from typical Django commands that used argparse to define their
168-
interface.
169175
176+
.. _inheritance_rationale:
170177

171178
When Does Inheritance Make Sense?
172179
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
173180

174-
Inheritance is a good choice when you want to tweak the behavior of a specific command and do not
175-
expect other apps to also modify the same command. It's also a good choice when you want to offer
181+
Inheritance is a good choice when you want to tweak the behavior of a specific command and **do not
182+
expect other apps to also modify the same command**. It's also a good choice when you want to offer
176183
a different flavor of a command under a different name.
177184

178185
What if other apps want to alter the same command and we don't know about them, but they may end up
@@ -181,7 +188,7 @@ installed along with our app? This is where the plugin pattern will serve us bet
181188

182189
.. _plugin:
183190

184-
Plugins
191+
CLI Plugins
185192
-----------
186193

187194
**The plugin pattern allows us to add or override commands and groups on an upstream command
@@ -194,10 +201,10 @@ than ``commands``. Let us suppose we are developing a site that uses the backup
194201
upstream and we've implemented most of our custom site functionality in a new app called my_app.
195202
Because we're now mostly working at the level of our particular site we may want to add more custom
196203
backup logic. For instance, lets say we know our site will always run on sqlite and we prefer
197-
to just copy the file to backup our database. Lets also pretend that it is useful for us to backup
198-
the python stack (e.g. requirements.txt) running on our server. To do that we can use the
199-
plugin pattern to add our environment backup routine and override the database routine from
200-
the upstream backup app. Our app tree now might look like this:
204+
to just copy the file to backup our database. It is also useful for us to capture the python stack
205+
(e.g. requirements.txt) running on our server. To do that we can use the plugin pattern to add our
206+
environment backup routine and override the database routine from the upstream backup app. Our app
207+
tree now might look like this:
201208

202209
.. code-block:: text
203210
@@ -230,10 +237,9 @@ the upstream backup app. Our app tree now might look like this:
230237
└── backup.py
231238
232239
233-
Note that we've added an ``plugins`` directory to the management directory of the media and
234-
my_app apps. This is where we'll place our extension commands. There is an additional step we must
235-
take. In the ``apps.py`` file of the media and my_app apps we must register our plugins like
236-
this:
240+
Note that we've added a ``plugins`` directory to the management directory of the ``media`` and
241+
``my_app`` apps. This is where we'll place our command extensions. We must register our plugins
242+
directory in the ``apps.py`` file of the media and my_app apps like this:
237243

238244
.. code-block:: python
239245
@@ -253,14 +259,14 @@ this:
253259
Because we explicitly register our plugins we can call the package whatever we want.
254260
django-typer does not require it to be named ``plugins``. It is also important to
255261
do this inside ready() because conflicts are resolved in the order in which the extension
256-
modules are registered and ready() methods are called in INSTALLED_APPS order.
262+
modules are registered and ready() methods are called in ``INSTALLED_APPS`` order.
257263

258-
For plugins to work, we'll need to re-mplement media from above as a composed extension
259-
and that would look like this:
264+
For plugins to work, we'll need to re-implement media from above as a composed extension
265+
like this:
260266

261267
.. tabs::
262268

263-
.. tab:: django-typer
269+
.. tab:: Django-style
264270

265271
.. literalinclude:: ../../tests/apps/examples/plugins/media2/management/plugins/backup.py
266272
:language: python
@@ -425,8 +431,10 @@ You may even override the initializer of a predefined group:
425431
(i.e. ``Command.grp1.grp2.grp3.cmd``), if there is only one cmd you can simply write
426432
``Command.cmd``. However, using the strict hierarchy will be robust to future changes.
427433

428-
When Do Plugins Make Sense?
429-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
434+
.. _cli_plugin_rationale:
435+
436+
When Do CLI Plugins Make Sense?
437+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
430438

431439
Plugins can be used to group like behavior together under a common root command. This can be
432440
thought of as a way to namespace CLI tools or easily share significant code between tools that have
@@ -441,3 +449,124 @@ Plugins can be a good way to organize commands in a code base that follows this
441449
also allows for deployments that install a subset of those apps and is therefore a good way to
442450
organize commands in code bases that serve as a framework for a particular kind of site or that
443451
support selecting the features to install by the inclusion or exclusion of specific apps.
452+
453+
454+
.. _hooks:
455+
456+
Logic Plugins
457+
-------------
458+
459+
`Inversion of Control (IoC) <https://en.wikipedia.org/wiki/Inversion_of_control>`_ is a design
460+
pattern that allows you to inject custom logic into a framework or library. The framework defines
461+
the general execution flow with extension points along the way that downstream applications can
462+
provide the implementations for. Django uses IoC all over the place. Extension points are often
463+
called ``hooks``. **You may use a third party library to manage hooks or implement your own
464+
mechanism but you will always need to register hook implementations. The same plugin mechanism we
465+
used in the** :ref:`last section <plugin>` **provides a natural place to do this.**
466+
467+
Some Django_ apps may keep state in files in places on the filesystem unknown to other parts of
468+
your code base. In this section we'll use pluggy_ to define a hook for other apps to implement to
469+
backup their own files. Let's:
470+
471+
1. Create a new app ``backup_files`` and inherit from our the extended media backup command we
472+
created in the :ref:`inheritance section <inheritance>`.
473+
2. Define a pluggy_ interface for backing up arbitrary files
474+
3. Add a ``files`` command to our backup command that will call all registered
475+
hooks to backup their own files.
476+
477+
.. tabs::
478+
479+
.. tab:: Django-style
480+
481+
.. literalinclude:: ../../tests/apps/examples/plugins/backup_files/management/commands/backup.py
482+
:language: python
483+
:caption: backup_files/management/commands/backup.py
484+
:linenos:
485+
:replace:
486+
tests.apps.examples.plugins.media1: media
487+
488+
.. tab:: Typer-style
489+
490+
.. literalinclude:: ../../tests/apps/examples/plugins/backup_files/management/commands/backup_typer.py
491+
:language: python
492+
:caption: backup_files/management/commands/backup.py
493+
:linenos:
494+
:replace:
495+
tests.apps.examples.plugins.media1: media
496+
497+
Now lets define two new apps, files1 and files2 that will provide and register implementations of
498+
the backup_files hook:
499+
500+
.. tabs::
501+
502+
.. tab:: Django-style
503+
504+
.. literalinclude:: ../../tests/apps/examples/plugins/files1/management/plugins/backup.py
505+
:language: python
506+
:caption: files1/management/plugins/backup.py
507+
:linenos:
508+
:replace:
509+
tests.apps.examples.plugins.backup_files: backup_files
510+
511+
.. tab:: Typer-style
512+
513+
.. literalinclude:: ../../tests/apps/examples/plugins/files1/management/plugins/backup_typer.py
514+
:language: python
515+
:caption: files1/management/plugins/backup.py
516+
:linenos:
517+
:replace:
518+
tests.apps.examples.plugins.backup_files: backup_files
519+
520+
.. tabs::
521+
522+
.. tab:: Django-style
523+
524+
.. literalinclude:: ../../tests/apps/examples/plugins/files2/management/plugins/backup.py
525+
:language: python
526+
:caption: files2/management/plugins/backup.py
527+
:linenos:
528+
:replace:
529+
tests.apps.examples.plugins.backup_files: backup_files
530+
531+
.. tab:: Typer-style
532+
533+
.. literalinclude:: ../../tests/apps/examples/plugins/files2/management/plugins/backup_typer.py
534+
:language: python
535+
:caption: files2/management/plugins/backup.py
536+
:linenos:
537+
:replace:
538+
tests.apps.examples.plugins.backup_files: backup_files
539+
540+
Both ``files1`` and ``files2`` will need to register their plugin packages in their ``apps.py``
541+
file:
542+
543+
.. literalinclude:: ../../tests/apps/examples/plugins/files1/apps.py
544+
:language: python
545+
:caption: files1/apps.py
546+
:linenos:
547+
:replace:
548+
tests.apps.examples.plugins.files1: files1
549+
550+
Now when we run we see:
551+
552+
.. code-block:: bash
553+
554+
$> python manage.py backup
555+
Backing up database [default] to: ./default.json
556+
[.............................................]
557+
Backing up ./media to ./media.tar.gz
558+
Backed up files to ./files2.zip
559+
Backed up files to ./files1.tar.gz
560+
561+
562+
When Do Logic Plugins Make Sense?
563+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
564+
565+
:ref:`CLI plugins make sense <cli_plugin_rationale>` when you want to add additional commands or
566+
under a common namespace or to override the entire behavior of a command. Logical plugins make
567+
more sense in the weeds of a particular subroutine. Our example above has the following qualities
568+
which makes it a good candidate:
569+
570+
1. The logic makes sense under a common root name (e.g. ``./manage.py backup files``).
571+
2. Multiple apps may need to execute their own version of the logic to complete the operation.
572+
3. The logic is amenable to a common interface that all plugins can implement.

doc/source/refs.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@
1717
.. _Options: https://typer.tiangolo.com/tutorial/options/
1818
.. _call_command: https://docs.djangoproject.com/en/5.0/ref/django-admin/#running-management-commands-from-your-code
1919
.. _sphinxcontrib-typer: https://pypi.org/project/sphinxcontrib-typer/
20+
.. _pluggy: https://pluggy.readthedocs.io/
21+
.. _CLI: https://en.wikipedia.org/wiki/Command-line_interface

examples/plugins/backup/management/commands/backup_typer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
app = Typer()
1414

15+
# these two lines are not necessary but will make your type checker happy
16+
assert app.django_command
17+
Command = app.django_command
18+
1519
Command.suppressed_base_arguments = {"verbosity", "skip_checks"}
1620
Command.requires_migrations_checks = False
1721
Command.requires_system_checks = []

examples/plugins/backup_files/__init__.py

Whitespace-only changes.

examples/plugins/backup_files/apps.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.apps import AppConfig
2+
3+
from django_typer.utils import register_command_plugins
4+
5+
6+
class BackupFilesConfig(AppConfig):
7+
name = "tests.apps.examples.plugins.backup_files"
8+
label = name.replace(".", "_")

examples/plugins/backup_files/management/__init__.py

Whitespace-only changes.

examples/plugins/backup_files/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import sys
2+
import typing as t
3+
from pathlib import Path
4+
5+
import typer
6+
import pluggy
7+
8+
from tests.apps.examples.plugins.media1.management.commands.backup import (
9+
Command as Backup,
10+
)
11+
12+
13+
class Command(Backup): # inherit from the extended media backup command
14+
plugins = pluggy.PluginManager("backup")
15+
hookspec = pluggy.HookspecMarker("backup")
16+
hookimpl = pluggy.HookimplMarker("backup")
17+
18+
# add a new command called files that delegates file backups to plugins
19+
@Backup.command()
20+
def files(self):
21+
"""
22+
Backup app specific non-media files.
23+
"""
24+
for archive in self.plugins.hook.backup_files(command=self):
25+
if archive:
26+
typer.echo(f"Backed up files to {archive}")
27+
28+
29+
@Command.hookspec
30+
def backup_files(command: Command) -> t.Optional[Path]:
31+
"""
32+
A hook for backing up app specific files.
33+
34+
Must return the path to the archive file or None if no files were backed up.
35+
36+
:param command: the backup command instance
37+
:return: The path to the archived backup file
38+
"""
39+
40+
41+
Command.plugins.add_hookspecs(sys.modules[__name__])

0 commit comments

Comments
 (0)