Skip to content

Commit 6a77329

Browse files
committed
subclass ModelObjectParser from ParamType
1 parent d2c904a commit 6a77329

File tree

3 files changed

+93
-8
lines changed

3 files changed

+93
-8
lines changed

django_typer/completers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,29 @@ def complete_app_label(ctx: Context, param: Parameter, incomplete: str):
277277
A case-insensitive completer for Django app labels or names. The completer
278278
prefers labels but names will also work.
279279
280+
.. code-block:: python
281+
282+
import typing as t
283+
import typer
284+
from django_typer import TyperCommand
285+
from django_typer.parsers import parse_app_label
286+
from django_typer.completers import complete_app_label
287+
288+
class Command(TyperCommand):
289+
290+
def handle(
291+
self,
292+
django_apps: t.Annotated[
293+
t.List[AppConfig],
294+
typer.Argument(
295+
parser=parse_app_label,
296+
shell_complete=complete_app_label,
297+
help=_("One or more application labels.")
298+
)
299+
]
300+
):
301+
...
302+
280303
:param ctx: The click context.
281304
:param param: The click parameter.
282305
:param incomplete: The incomplete string.

django_typer/parsers.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,39 @@
11
"""
2-
A collection of parsers that turn strings into useful Django types.
3-
Pass these parsers to the `parser` argument of typer.Option and
4-
typer.Argument.
2+
Typer_ supports custom parsers for options and arguments. If you would
3+
like to type a parameter with a type that isn't supported by Typer_ you can
4+
`implement your own parser <https://typer.tiangolo.com/tutorial/parameter-types/custom-types>`_
5+
, or `ParamType <https://click.palletsprojects.com/en/8.1.x/api/#click.ParamType>`_
6+
in click_ parlance.
7+
8+
This module contains a collection of parsers that turn strings into useful
9+
Django types. Pass these parsers to the `parser` argument of typer.Option and
10+
typer.Argument. Parsers are provided for:
11+
12+
- **Model Objects**: Turn a string into a model object instance using :class:`ModelObjectParser`.
13+
- **App Labels**: Turn a string into an AppConfig instance using :func:`parse_app_label`.
14+
15+
16+
.. warning::
17+
18+
If you implement a custom parser, please take care to ensure that it:
19+
- Handles the case where the value is already the expected type.
20+
- Returns None if the value is None (already implemented if subclassing ParamType).
21+
- Raises a CommandError if the value is invalid.
22+
- Handles the case where the param and context are None.
523
"""
624

725
import typing as t
826
from uuid import UUID
927

28+
from click import Parameter, ParamType, Context
1029
from django.apps import AppConfig, apps
1130
from django.core.exceptions import ObjectDoesNotExist
1231
from django.core.management import CommandError
1332
from django.db.models import Field, Model, UUIDField
1433
from django.utils.translation import gettext as _
1534

1635

17-
class ModelObjectParser:
36+
class ModelObjectParser(ParamType):
1837
"""
1938
A parser that will turn strings into model object instances based on the
2039
configured lookup field and model class.
@@ -39,6 +58,11 @@ class ModelObjectParser:
3958

4059
__name__ = "ModelObjectParser" # typer internals expect this
4160

61+
@property
62+
def name(self):
63+
"""Descriptive name of the model object."""
64+
return self.model_cls._meta.verbose_name
65+
4266
def __init__(
4367
self,
4468
model_cls: t.Type[Model],
@@ -54,7 +78,9 @@ def __init__(
5478
if self.case_insensitive and "iexact" in self._field.get_lookups():
5579
self._lookup = "__iexact"
5680

57-
def __call__(self, value: t.Union[str, Model]) -> Model:
81+
def convert(
82+
self, value: t.Any, param: t.Optional[Parameter], ctx: t.Optional[Context]
83+
):
5884
"""
5985
Invoke the parsing action on the given string. If the value is
6086
already a model instance of the expected type the value will
@@ -63,11 +89,13 @@ def __call__(self, value: t.Union[str, Model]) -> Model:
6389
handler is invoked if one was provided.
6490
6591
:param value: The value to parse.
92+
:param param: The parameter that the value is associated with.
93+
:param ctx: The context of the command.
6694
:raises CommandError: If the lookup fails and no error handler is
6795
provided.
6896
"""
6997
try:
70-
if isinstance(value, self.model_cls): # pragma: no cover
98+
if isinstance(value, self.model_cls):
7199
return value
72100
if isinstance(self._field, UUIDField):
73101
uuid = ""
@@ -94,6 +122,27 @@ def parse_app_label(label: t.Union[str, AppConfig]):
94122
the instance is returned. The label will be tried first, if that fails
95123
the label will be treated as the app name.
96124
125+
.. code-block:: python
126+
127+
import typing as t
128+
import typer
129+
from django_typer import TyperCommand
130+
from django_typer.parsers import parse_app_label
131+
132+
class Command(TyperCommand):
133+
134+
def handle(
135+
self,
136+
django_apps: t.Annotated[
137+
t.List[AppConfig],
138+
typer.Argument(
139+
parser=parse_app_label,
140+
help=_("One or more application labels.")
141+
)
142+
]
143+
):
144+
...
145+
97146
:param label: The label to map to an AppConfig instance.
98147
:raises CommandError: If no matching app can be found.
99148
"""

django_typer/tests/tests.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,7 +1768,15 @@ def test_tutorial_parser_cmd(self):
17681768
# these don't work, maybe revisit in future?
17691769
# cmd([str(self.q1.id)])
17701770
# cmd([self.q1.id])
1771-
self.assertTrue(log.getvalue().count("Successfully"), 5)
1771+
self.assertEqual(log.getvalue().count("Successfully"), 3)
1772+
1773+
def test_tutorial_modelobjparser_cmd(self):
1774+
log = StringIO()
1775+
call_command("closepoll_t6", str(self.q1.id), stdout=log)
1776+
cmd = get_command("closepoll_t6", stdout=log)
1777+
cmd([self.q1])
1778+
cmd(polls=[self.q1])
1779+
self.assertEqual(log.getvalue().count("Successfully"), 3)
17721780

17731781
def test_poll_ex(self):
17741782
result = run_command("closepoll", str(self.q2.id))
@@ -1847,11 +1855,16 @@ def test_app_label_parser_completers(self):
18471855
call_command("completion", "django_typer_tests.polls")
18481856

18491857
poll_app = apps.get_app_config("django_typer_tests_polls")
1858+
cmd = get_command("completion")
18501859
self.assertEqual(
1851-
json.loads(get_command("completion")([poll_app])),
1860+
json.loads(cmd([poll_app])),
18521861
["django_typer_tests_polls"],
18531862
)
18541863

1864+
self.assertEqual(
1865+
json.loads(cmd(django_apps=[poll_app])), ["django_typer_tests_polls"]
1866+
)
1867+
18551868
def test_char_field(self):
18561869
result = StringIO()
18571870
with contextlib.redirect_stdout(result):

0 commit comments

Comments
 (0)