Skip to content

Commit

Permalink
fix direct function calls for nested group commands, add additional t…
Browse files Browse the repository at this point in the history
…ests
  • Loading branch information
bckohan committed Jan 13, 2024
1 parent bae73ae commit df45676
Show file tree
Hide file tree
Showing 11 changed files with 275 additions and 62 deletions.
75 changes: 45 additions & 30 deletions django_typer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,9 @@
__all__ = [
"TyperCommand",
"Context",
"TyperGroupWrapper",
"TyperCommandWrapper",
"initialize",
"command",
"group",
"get_command",
]

Expand Down Expand Up @@ -180,8 +179,8 @@ def __setitem__(self, key, value):
@property
def supplied_params(self):
"""
Get the parameters that were supplied when the command was invoked via call_command,
only the root context has these.
Get the parameters that were supplied when the command was invoked via
call_command, only the root context has these.
"""
if self.parent:
return self.parent.supplied_params
Expand Down Expand Up @@ -215,6 +214,7 @@ class DjangoAdapterMixin: # pylint: disable=too-few-public-methods
context_class: t.Type[click.Context] = Context
django_command: "TyperCommand"
callback_is_method: bool = True
param_converters: t.Dict[str, t.Callable[..., t.Any]] = {}

def common_params(self):
return []
Expand All @@ -235,19 +235,15 @@ def __init__(

def call_with_self(*args, **kwargs):
ctx = click.get_current_context()

# process supplied parameters incase they need type conversion
def process_value(name, value):
if name in ctx.supplied_params:
for prm in self.params:
if prm.name == name:
return prm.process_value(ctx, value)
return value

return callback(
*args,
**{
param: process_value(param, val)
# process supplied parameters incase they need type conversion
param: self.param_converters.get(param, lambda _, value: value)(
ctx, val
)
if param in ctx.supplied_params
else val
for param, val in kwargs.items()
if param in expected
},
Expand All @@ -271,6 +267,9 @@ def process_value(name, value):
callback=call_with_self,
**kwargs,
)
self.param_converters = {
param.name: param.process_value for param in self.params
}


class TyperCommandWrapper(DjangoAdapterMixin, CoreTyperCommand):
Expand Down Expand Up @@ -300,11 +299,25 @@ def common_params(self):
return super().common_params()


class TyperWrapper(Typer):
class GroupFunction(Typer):
bound: bool = False
django_command_cls: t.Type["TyperCommand"]
_callback: t.Callable[..., t.Any]

def __get__(self, obj, obj_type=None):
"""
Our Typer app wrapper also doubles as a descriptor, so when
it is accessed on the instance, we return the wrapped function
so it may be called directly - but when accessed on the class
the app itself is returned so it can modified by other decorators
on the class and subclasses.
"""
if obj is None:
return self
return MethodType(self._callback, obj)

def __init__(self, *args, **kwargs):
self._callback = kwargs["callback"]
super().__init__(*args, **kwargs)

def bind(self, django_command_cls: t.Type["TyperCommand"]):
Expand Down Expand Up @@ -379,7 +392,7 @@ def group(
**kwargs,
):
def create_app(func: CommandFunctionType):
app = TyperWrapper(
grp = GroupFunction(
name=name,
cls=cls,
invoke_without_command=invoke_without_command,
Expand All @@ -399,9 +412,9 @@ def create_app(func: CommandFunctionType):
rich_help_panel=rich_help_panel,
**kwargs,
)
self.add_typer(app)
app.bound = True
return app
self.add_typer(grp)
grp.bound = True
return grp

return create_app

Expand Down Expand Up @@ -517,7 +530,7 @@ def group(
**kwargs,
):
def create_app(func: CommandFunctionType):
return TyperWrapper(
grp = GroupFunction(
name=name,
cls=cls,
invoke_without_command=invoke_without_command,
Expand All @@ -537,6 +550,7 @@ def create_app(func: CommandFunctionType):
rich_help_panel=rich_help_panel,
**kwargs,
)
return grp

return create_app

Expand Down Expand Up @@ -650,8 +664,8 @@ def get_ctor(attr):
attr, "_typer_command_", getattr(attr, "_typer_callback_", None)
)

# because we're mapping a non-class based interface onto a class based interface, we have to
# handle this class mro stuff manually here
# because we're mapping a non-class based interface onto a class based
# interface, we have to handle this class mro stuff manually here
for cmd_cls, cls_attrs in [
*[(base, vars(base)) for base in reversed(bases)],
(cls, attrs),
Expand All @@ -661,7 +675,7 @@ def get_ctor(attr):
for attr in [*cls_attrs.values(), cls._handle]:
cls._num_commands += hasattr(attr, "_typer_command_")
cls._has_callback |= hasattr(attr, "_typer_callback_")
if isinstance(attr, TyperWrapper) and not attr.bound:
if isinstance(attr, GroupFunction) and not attr.bound:
attr.bind(cls)
cls._root_groups += 1

Expand Down Expand Up @@ -747,13 +761,13 @@ def parse_args(self, args=None, namespace=None):
) as ctx:
params = ctx.params

def discover_parsed_args(ctx):
# todo is this necessary?
for child in ctx.children:
discover_parsed_args(child)
params.update(child.params)
# def discover_parsed_args(ctx):
# # todo is this necessary?
# for child in ctx.children:
# discover_parsed_args(child)
# params.update(child.params)

discover_parsed_args(ctx)
# discover_parsed_args(ctx)

return _ParsedArgs(args=args or [], **{**COMMON_DEFAULTS, **params})
except click.exceptions.Exit:
Expand Down Expand Up @@ -930,6 +944,7 @@ def __call__(self, *args, **kwargs):
return self._handle(*args, **kwargs)
raise NotImplementedError(
_(
"{cls} does not implement handle(), you must call the other command functions directly."
"{cls} does not implement handle(), you must call the other command "
"functions directly."
).format(cls=self.__class__)
)
4 changes: 0 additions & 4 deletions django_typer/tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,6 @@

TIME_ZONE = "UTC"

USE_I18N = True

USE_L10N = True

USE_TZ = True


Expand Down
14 changes: 6 additions & 8 deletions django_typer/tests/test_app/helps/lower.txt
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@

Usage: ./manage.py groups string STRING case lower [OPTIONS] [BEGIN] [END]
Usage: ./manage.py groups string STRING case lower [OPTIONS]

Convert the given string to upper case.

╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ begin [BEGIN] The starting index of the string to operate on. │
│ [default: 0] │
│ end [END] The ending index of the string to operate on. │
│ [default: None] │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --help Show this message and exit. │
│ --begin INTEGER The starting index of the string to operate on. │
│ [default: 0] │
│ --end INTEGER The ending index of the string to operate on. │
│ [default: None] │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
6 changes: 3 additions & 3 deletions django_typer/tests/test_app/management/commands/groups.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import typing as t

from django.utils.translation import gettext_lazy as _
from typer import Argument
from typer import Argument, Option

from django_typer import TyperCommand, command, group

Expand Down Expand Up @@ -120,11 +120,11 @@ def upper(
def lower(
self,
begin: t.Annotated[
int, Argument(help=_("The starting index of the string to operate on."))
int, Option(help=_("The starting index of the string to operate on."))
] = 0,
end: t.Annotated[
t.Optional[int],
Argument(help=_("The ending index of the string to operate on.")),
Option(help=_("The ending index of the string to operate on.")),
] = None,
):
"""
Expand Down
1 change: 1 addition & 0 deletions django_typer/tests/test_app2/helps/groups.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ echo Echo the given message the given number of times. │
│ math Do some math at the given precision. │
│ setting Get or set Django settings. │
│ string String operations. │
╰──────────────────────────────────────────────────────────────────────────────╯

Expand Down
14 changes: 6 additions & 8 deletions django_typer/tests/test_app2/helps/lower.txt
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@

Usage: ./manage.py groups string STRING case lower [OPTIONS] [BEGIN] [END]
Usage: ./manage.py groups string STRING case lower [OPTIONS]

Convert the given string to upper case.

╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ begin [BEGIN] The starting index of the string to operate on. │
│ [default: 0] │
│ end [END] The ending index of the string to operate on. │
│ [default: None] │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --help Show this message and exit. │
│ --begin INTEGER The starting index of the string to operate on. │
│ [default: 0] │
│ --end INTEGER The ending index of the string to operate on. │
│ [default: None] │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
10 changes: 10 additions & 0 deletions django_typer/tests/test_app2/helps/print.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Usage: ./manage.py groups setting SETTING print [OPTIONS]

Print the setting value.

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ * --safe --no-safe Do not assume the setting exists. │
│ [default: no-safe] │
│ [required] │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
15 changes: 15 additions & 0 deletions django_typer/tests/test_app2/helps/setting.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@

Usage: ./manage.py groups setting [OPTIONS] SETTING COMMAND [ARGS]...

Get or set Django settings.

╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ * setting TEXT The setting variable name. [default: None] │
│ [required] │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ print Print the setting value. │
╰──────────────────────────────────────────────────────────────────────────────╯
29 changes: 27 additions & 2 deletions django_typer/tests/test_app2/management/commands/groups.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import typing as t

from typer import Argument
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from typer import Argument, Option

from django_typer import command, initialize, types
from django_typer import command, group, initialize, types
from django_typer.tests.test_app.management.commands.groups import (
Command as GroupsCommand,
)
Expand Down Expand Up @@ -45,3 +47,26 @@ def upper(self):
def strip(self):
"""Strip white space off the ends of the string"""
return self.op_string.strip()

@group()
def setting(
self, setting: t.Annotated[str, Argument(help=_("The setting variable name."))]
):
"""
Get or set Django settings.
"""
assert self.__class__ is Command
self.setting = setting

@setting.command()
def print(
self,
safe: t.Annotated[bool, Option(help=_("Do not assume the setting exists."))],
):
"""
Print the setting value.
"""
assert self.__class__ is Command
if safe:
return getattr(settings, self.setting, None)
return getattr(settings, self.setting)
Loading

0 comments on commit df45676

Please sign in to comment.