Coverage for __init__.py: 99%
294 statements
« prev ^ index » next coverage.py v7.3.4, created at 2024-01-20 18:02 +0000
« prev ^ index » next coverage.py v7.3.4, created at 2024-01-20 18:02 +0000
1r"""
2 ___ _ _____
3 / (_) __ _ _ __ __ _ ___ /__ \_ _ _ __ ___ _ __
4 / /\ / |/ _` | '_ \ / _` |/ _ \ / /\/ | | | '_ \ / _ \ '__|
5 / /_//| | (_| | | | | (_| | (_) | / / | |_| | |_) | __/ |
6/___,'_/ |\__,_|_| |_|\__, |\___/ \/ \__, | .__/ \___|_|
7 |__/ |___/ |___/|_|
9"""
11import contextlib
12import inspect
13import sys
14import typing as t
15from copy import deepcopy
16from dataclasses import dataclass
17from importlib import import_module
18from types import MethodType, SimpleNamespace
20import click
21from django.conf import settings
22from django.core.management import get_commands
23from django.core.management.base import BaseCommand
24from django.core.management.color import no_style
25from django.utils.translation import gettext_lazy as _
26from django.utils.functional import lazy
27from typer import Typer
28from typer.core import TyperCommand as CoreTyperCommand
29from typer.core import TyperGroup as CoreTyperGroup
30from typer.main import MarkupMode
31from typer.main import get_command as get_typer_command
32from typer.main import get_params_convertors_ctx_param_name_from_function
33from typer.models import CommandFunctionType
34from typer.models import Context as TyperContext
35from typer.models import Default, DefaultPlaceholder
37from .types import (
38 ForceColor,
39 NoColor,
40 PythonPath,
41 Settings,
42 SkipChecks,
43 Traceback,
44 Verbosity,
45 Version,
46)
48VERSION = (0, 4, "0b")
50__title__ = "Django Typer"
51__version__ = ".".join(str(i) for i in VERSION)
52__author__ = "Brian Kohan"
53__license__ = "MIT"
54__copyright__ = "Copyright 2023 Brian Kohan"
57__all__ = [
58 "TyperCommand",
59 "Context",
60 "initialize",
61 "command",
62 "group",
63 "get_command",
64]
66"""
67TODO
68- useful django types (app label, etc)
69- documentation
70- linting
71- type hints
73design decision: no monkey-patching for call_command. call_command converts arguments to
74strings. This is unavoidable and will just be a noted caveat that is also consistent with
75how native django commands work. For calls with previously resolved types - the direct
76callbacks should be invoked - either Command() or Command.group(). call_command however
77*requires* options to be passed by value instead of by string. This should be fixed.
79behavior should align with native django commands
80"""
82# def get_color_system(default):
83# ctx = click.get_current_context(silent=True)
84# if ctx:
85# return None if ctx.django_command.style == no_style() else default
86# return default
88# COLOR_SYSTEM = lazy(get_color_system, str)
89# rich_utils.COLOR_SYSTEM = COLOR_SYSTEM(rich_utils.COLOR_SYSTEM)
92def traceback_config():
93 cfg = getattr(settings, "DT_RICH_TRACEBACK_CONFIG", {"show_locals": True})
94 if cfg:
95 return {"show_locals": True, **cfg}
96 return cfg
99def get_command(
100 command_name: str,
101 *subcommand: str,
102 stdout: t.Optional[t.IO[str]] = None,
103 stderr: t.Optional[t.IO[str]] = None,
104 no_color: bool = False,
105 force_color: bool = False,
106):
107 module = import_module(
108 f"{get_commands()[command_name]}.management.commands.{command_name}"
109 )
110 cmd = module.Command(
111 stdout=stdout, stderr=stderr, no_color=no_color, force_color=force_color
112 )
113 if subcommand:
114 method = cmd.get_subcommand(*subcommand).command._callback.__wrapped__
115 return MethodType(method, cmd) # return the bound method
116 return cmd
119def _common_options(
120 version: Version = False,
121 verbosity: Verbosity = 1,
122 settings: Settings = "",
123 pythonpath: PythonPath = None,
124 traceback: Traceback = False,
125 no_color: NoColor = False,
126 force_color: ForceColor = False,
127 skip_checks: SkipChecks = False,
128):
129 pass
132# cache common params to avoid this extra work on every command
133# we cant resolve these at module scope because translations break it
134_common_params = []
137def _get_common_params():
138 global _common_params
139 if not _common_params:
140 _common_params = get_params_convertors_ctx_param_name_from_function(
141 _common_options
142 )[0]
143 return _common_params
146COMMON_DEFAULTS = {
147 key: value.default
148 for key, value in inspect.signature(_common_options).parameters.items()
149}
152class _ParsedArgs(SimpleNamespace): # pylint: disable=too-few-public-methods
153 def __init__(self, args, **kwargs):
154 super().__init__(**kwargs)
155 self.args = args
157 def _get_kwargs(self):
158 return {"args": self.args, **COMMON_DEFAULTS}
161# class _Augment:
162# pass
165# def augment(cls):
166# return type('', (_Augment, cls), {})
169class Context(TyperContext):
170 """
171 An extension of the click.Context class that adds a reference to
172 the TyperCommand instance so that the Django command can be accessed
173 from within click/typer callbacks that take a context.
175 e.g. This is necessary so that get_version() behavior can be implemented
176 within the Version type itself.
177 """
179 django_command: "TyperCommand"
180 children: t.List["Context"]
181 _supplied_params: t.Dict[str, t.Any]
183 class ParamDict(dict):
184 """
185 An extension of dict we use to block updates to parameters that were supplied
186 when the command was invoked via call_command. This complexity is introduced
187 by the hybrid parsing and option passing inherent to call_command.
188 """
190 def __init__(self, *args, supplied):
191 super().__init__(*args)
192 self.supplied = supplied
194 def __setitem__(self, key, value):
195 if key not in self.supplied:
196 super().__setitem__(key, value)
198 @property
199 def supplied_params(self):
200 """
201 Get the parameters that were supplied when the command was invoked via
202 call_command, only the root context has these.
203 """
204 if self.parent:
205 return self.parent.supplied_params
206 return getattr(self, "_supplied_params", {})
208 def __init__(
209 self,
210 command: click.Command, # pylint: disable=redefined-outer-name
211 parent: t.Optional["Context"] = None,
212 django_command: t.Optional["TyperCommand"] = None,
213 supplied_params: t.Optional[t.Dict[str, t.Any]] = None,
214 **kwargs,
215 ):
216 super().__init__(command, parent=parent, **kwargs)
217 if supplied_params:
218 self._supplied_params = supplied_params
219 self.django_command = django_command
220 if not django_command and parent:
221 self.django_command = parent.django_command
223 self.params = self.ParamDict(
224 {**self.params, **self.supplied_params},
225 supplied=list(self.supplied_params.keys()),
226 )
227 self.children = []
228 if parent:
229 parent.children.append(self)
232class DjangoAdapterMixin: # pylint: disable=too-few-public-methods
233 context_class: t.Type[click.Context] = Context
234 django_command: "TyperCommand"
235 callback_is_method: bool = True
236 param_converters: t.Dict[str, t.Callable[..., t.Any]] = {}
238 def common_params(self):
239 return []
241 def __init__(
242 self,
243 *args,
244 callback: t.Optional[ # pylint: disable=redefined-outer-name
245 t.Callable[..., t.Any]
246 ],
247 params: t.Optional[t.List[click.Parameter]] = None,
248 **kwargs,
249 ):
250 params = params or []
251 self._callback = callback
252 expected = [param.name for param in params[1:]]
253 self_arg = params[0].name if params else "self"
255 def call_with_self(*args, **kwargs):
256 ctx = click.get_current_context()
257 return callback( 257 ↛ exitline 257 didn't jump to the function exit
258 *args,
259 **{
260 # process supplied parameters incase they need type conversion
261 param: self.param_converters.get(param, lambda _, value: value)(
262 ctx, val
263 )
264 if param in ctx.supplied_params
265 else val
266 for param, val in kwargs.items()
267 if param in expected
268 },
269 **(
270 {self_arg: getattr(ctx, "django_command", None)}
271 if self.callback_is_method
272 else {}
273 ),
274 )
276 super().__init__( # type: ignore
277 *args,
278 params=[
279 *(params[1:] if self.callback_is_method else params),
280 *[
281 param
282 for param in self.common_params()
283 if param.name not in expected
284 ],
285 ],
286 callback=call_with_self,
287 **kwargs,
288 )
289 self.param_converters = {
290 param.name: param.process_value for param in self.params
291 }
294class TyperCommandWrapper(DjangoAdapterMixin, CoreTyperCommand):
295 def common_params(self):
296 if (
297 hasattr(self, "django_command")
298 and self.django_command._num_commands < 2
299 and not self.django_command._has_callback
300 and not self.django_command._root_groups
301 ):
302 return [
303 param
304 for param in _get_common_params()
305 if param.name in (self.django_command.django_params or [])
306 ]
307 return super().common_params()
310class TyperGroupWrapper(DjangoAdapterMixin, CoreTyperGroup):
311 def common_params(self):
312 if hasattr(self, "django_command") and self.django_command._has_callback:
313 return [
314 param
315 for param in _get_common_params()
316 if param.name in (self.django_command.django_params or [])
317 ]
318 return super().common_params()
321class GroupFunction(Typer):
322 bound: bool = False
323 django_command_cls: t.Type["TyperCommand"]
324 _callback: t.Callable[..., t.Any]
326 def __get__(self, obj, obj_type=None):
327 """
328 Our Typer app wrapper also doubles as a descriptor, so when
329 it is accessed on the instance, we return the wrapped function
330 so it may be called directly - but when accessed on the class
331 the app itself is returned so it can modified by other decorators
332 on the class and subclasses.
333 """
334 if obj is None:
335 return self
336 return MethodType(self._callback, obj)
338 def __init__(self, *args, **kwargs):
339 self._callback = kwargs["callback"]
340 super().__init__(*args, **kwargs)
342 def bind(self, django_command_cls: t.Type["TyperCommand"]):
343 self.django_command_cls = django_command_cls
344 # the deepcopy is necessary for instances where classes derive
345 # from Command classes and replace/extend commands on groups
346 # defined in the base class - this avoids the extending class
347 # polluting the base class's command tree
348 self.django_command_cls.typer_app.add_typer(deepcopy(self))
350 def callback(self, *args, **kwargs):
351 raise NotImplementedError(
352 _(
353 "callback is not supported - the function decorated by group() is the callback."
354 )
355 )
357 def command(
358 self,
359 name: t.Optional[str] = None,
360 *,
361 cls: t.Type[TyperCommandWrapper] = TyperCommandWrapper,
362 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = None,
363 help: t.Optional[str] = None,
364 epilog: t.Optional[str] = None,
365 short_help: t.Optional[str] = None,
366 options_metavar: str = "[OPTIONS]",
367 add_help_option: bool = True,
368 no_args_is_help: bool = False,
369 hidden: bool = False,
370 deprecated: bool = False,
371 # Rich settings
372 rich_help_panel: t.Union[str, None] = Default(None),
373 **kwargs,
374 ):
375 return super().command(
376 name=name,
377 cls=cls,
378 context_settings=context_settings,
379 help=help,
380 epilog=epilog,
381 short_help=short_help,
382 options_metavar=options_metavar,
383 add_help_option=add_help_option,
384 no_args_is_help=no_args_is_help,
385 hidden=hidden,
386 deprecated=deprecated,
387 rich_help_panel=rich_help_panel,
388 **kwargs,
389 )
391 def group(
392 self,
393 name: t.Optional[str] = Default(None),
394 cls: t.Type[TyperGroupWrapper] = TyperGroupWrapper,
395 invoke_without_command: bool = Default(False),
396 no_args_is_help: bool = Default(False),
397 subcommand_metavar: t.Optional[str] = Default(None),
398 chain: bool = Default(False),
399 result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None),
400 # Command
401 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None),
402 help: t.Optional[str] = Default(None),
403 epilog: t.Optional[str] = Default(None),
404 short_help: t.Optional[str] = Default(None),
405 options_metavar: str = Default("[OPTIONS]"),
406 add_help_option: bool = Default(True),
407 hidden: bool = Default(False),
408 deprecated: bool = Default(False),
409 # Rich settings
410 rich_help_panel: t.Union[str, None] = Default(None),
411 **kwargs,
412 ):
413 def create_app(func: CommandFunctionType):
414 grp = GroupFunction(
415 name=name,
416 cls=cls,
417 invoke_without_command=invoke_without_command,
418 no_args_is_help=no_args_is_help,
419 subcommand_metavar=subcommand_metavar,
420 chain=chain,
421 result_callback=result_callback,
422 callback=func,
423 context_settings=context_settings,
424 help=help,
425 epilog=epilog,
426 short_help=short_help,
427 options_metavar=options_metavar,
428 add_help_option=add_help_option,
429 hidden=hidden,
430 deprecated=deprecated,
431 rich_help_panel=rich_help_panel,
432 **kwargs,
433 )
434 self.add_typer(grp)
435 grp.bound = True
436 return grp
438 return create_app
441def initialize( # pylint: disable=too-mt.Any-local-variables
442 name: t.Optional[str] = Default(None),
443 *,
444 cls: t.Type[TyperGroupWrapper] = TyperGroupWrapper,
445 invoke_without_command: bool = Default(False),
446 no_args_is_help: bool = Default(False),
447 subcommand_metavar: t.Optional[str] = Default(None),
448 chain: bool = Default(False),
449 result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None),
450 # Command
451 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None),
452 help: t.Optional[str] = Default(None),
453 epilog: t.Optional[str] = Default(None),
454 short_help: t.Optional[str] = Default(None),
455 options_metavar: str = Default("[OPTIONS]"),
456 add_help_option: bool = Default(True),
457 hidden: bool = Default(False),
458 deprecated: bool = Default(False),
459 # Rich settings
460 rich_help_panel: t.Union[str, None] = Default(None),
461 **kwargs,
462):
463 def decorator(func: CommandFunctionType):
464 func._typer_callback_ = lambda cmd, **extra: cmd.typer_app.callback(
465 name=name or extra.pop("name", None),
466 cls=type("_AdaptedCallback", (cls,), {"django_command": cmd}),
467 invoke_without_command=invoke_without_command,
468 subcommand_metavar=subcommand_metavar,
469 chain=chain,
470 result_callback=result_callback,
471 context_settings=context_settings,
472 help=cmd.typer_app.info.help or help,
473 epilog=epilog,
474 short_help=short_help,
475 options_metavar=options_metavar,
476 add_help_option=add_help_option,
477 no_args_is_help=no_args_is_help,
478 hidden=hidden,
479 deprecated=deprecated,
480 rich_help_panel=rich_help_panel,
481 **kwargs,
482 **extra,
483 )(func)
484 return func
486 return decorator
489def command(
490 name: t.Optional[str] = None,
491 *args,
492 cls: t.Type[TyperCommandWrapper] = TyperCommandWrapper,
493 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = None,
494 help: t.Optional[str] = None,
495 epilog: t.Optional[str] = None,
496 short_help: t.Optional[str] = None,
497 options_metavar: str = "[OPTIONS]",
498 add_help_option: bool = True,
499 no_args_is_help: bool = False,
500 hidden: bool = False,
501 deprecated: bool = False,
502 # Rich settings
503 rich_help_panel: t.Union[str, None] = Default(None),
504 **kwargs,
505):
506 def decorator(func: CommandFunctionType):
507 func._typer_command_ = lambda cmd, **extra: cmd.typer_app.command(
508 name=name or extra.pop("name", None),
509 *args,
510 cls=type("_AdaptedCommand", (cls,), {"django_command": cmd}),
511 context_settings=context_settings,
512 help=help,
513 epilog=epilog,
514 short_help=short_help,
515 options_metavar=options_metavar,
516 add_help_option=add_help_option,
517 no_args_is_help=no_args_is_help,
518 hidden=hidden,
519 deprecated=deprecated,
520 # Rich settings
521 rich_help_panel=rich_help_panel,
522 **kwargs,
523 **extra,
524 )(func)
525 return func
527 return decorator
530def group(
531 name: t.Optional[str] = Default(None),
532 cls: t.Type[TyperGroupWrapper] = TyperGroupWrapper,
533 invoke_without_command: bool = Default(False),
534 no_args_is_help: bool = Default(False),
535 subcommand_metavar: t.Optional[str] = Default(None),
536 chain: bool = Default(False),
537 result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None),
538 # Command
539 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None),
540 help: t.Optional[str] = Default(None),
541 epilog: t.Optional[str] = Default(None),
542 short_help: t.Optional[str] = Default(None),
543 options_metavar: str = Default("[OPTIONS]"),
544 add_help_option: bool = Default(True),
545 hidden: bool = Default(False),
546 deprecated: bool = Default(False),
547 # Rich settings
548 rich_help_panel: t.Union[str, None] = Default(None),
549 **kwargs,
550):
551 def create_app(func: CommandFunctionType):
552 grp = GroupFunction(
553 name=name,
554 cls=cls,
555 invoke_without_command=invoke_without_command,
556 no_args_is_help=no_args_is_help,
557 subcommand_metavar=subcommand_metavar,
558 chain=chain,
559 result_callback=result_callback,
560 callback=func,
561 context_settings=context_settings,
562 help=help,
563 epilog=epilog,
564 short_help=short_help,
565 options_metavar=options_metavar,
566 add_help_option=add_help_option,
567 hidden=hidden,
568 deprecated=deprecated,
569 rich_help_panel=rich_help_panel,
570 **kwargs,
571 )
572 return grp
574 return create_app
577class _TyperCommandMeta(type):
578 def __new__(
579 mcs,
580 name,
581 bases,
582 attrs,
583 cls: t.Optional[t.Type[CoreTyperGroup]] = TyperGroupWrapper,
584 invoke_without_command: bool = Default(False),
585 no_args_is_help: bool = Default(False),
586 subcommand_metavar: t.Optional[str] = Default(None),
587 chain: bool = Default(False),
588 result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None),
589 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None),
590 callback: t.Optional[t.Callable[..., t.Any]] = Default(None),
591 help: t.Optional[str] = Default(None),
592 epilog: t.Optional[str] = Default(None),
593 short_help: t.Optional[str] = Default(None),
594 options_metavar: str = Default("[OPTIONS]"),
595 add_help_option: bool = Default(True),
596 hidden: bool = Default(False),
597 deprecated: bool = Default(False),
598 rich_markup_mode: MarkupMode = None,
599 rich_help_panel: t.Union[str, None] = Default(None),
600 pretty_exceptions_enable: bool = Default(True),
601 pretty_exceptions_show_locals: bool = Default(True),
602 pretty_exceptions_short: bool = Default(True),
603 ):
604 """
605 This method is called when a new class is created.
606 """
607 try:
608 TyperCommand
609 is_base_init = False
610 except NameError:
611 is_base_init = True
612 typer_app = None
614 if not is_base_init:
615 # conform the pretty exception defaults to the settings traceback config
616 tb_config = traceback_config()
617 if isinstance(pretty_exceptions_enable, DefaultPlaceholder):
618 pretty_exceptions_enable = isinstance(tb_config, dict)
620 tb_config = tb_config or {}
621 if isinstance(pretty_exceptions_show_locals, DefaultPlaceholder):
622 pretty_exceptions_show_locals = tb_config.get(
623 "show_locals", pretty_exceptions_show_locals
624 )
625 if isinstance(pretty_exceptions_short, DefaultPlaceholder):
626 pretty_exceptions_short = tb_config.get(
627 "short", pretty_exceptions_short
628 )
630 typer_app = Typer(
631 name=mcs.__module__.rsplit(".", maxsplit=1)[-1],
632 cls=cls,
633 help=help or attrs.get("help", Default(None)),
634 invoke_without_command=invoke_without_command,
635 no_args_is_help=no_args_is_help,
636 subcommand_metavar=subcommand_metavar,
637 chain=chain,
638 result_callback=result_callback,
639 context_settings=context_settings,
640 callback=callback,
641 epilog=epilog,
642 short_help=short_help,
643 options_metavar=options_metavar,
644 add_help_option=add_help_option,
645 hidden=hidden,
646 deprecated=deprecated,
647 add_completion=False, # see autocomplete command instead!
648 rich_markup_mode=rich_markup_mode,
649 rich_help_panel=rich_help_panel,
650 pretty_exceptions_enable=pretty_exceptions_enable,
651 pretty_exceptions_show_locals=pretty_exceptions_show_locals,
652 pretty_exceptions_short=pretty_exceptions_short,
653 )
655 def handle(self, *args, **options):
656 return self.typer_app(
657 args=args,
658 standalone_mode=False,
659 supplied_params=options,
660 django_command=self,
661 prog_name=f"{sys.argv[0]} {self.typer_app.info.name}",
662 )
664 attrs = {
665 "_handle": attrs.pop("handle", None),
666 **attrs,
667 "handle": handle,
668 "typer_app": typer_app,
669 }
671 return super().__new__(mcs, name, bases, attrs)
673 def __init__(cls, name, bases, attrs, **kwargs):
674 """
675 This method is called after a new class is created.
676 """
677 if cls.typer_app is not None:
678 cls.typer_app.info.name = cls.__module__.rsplit(".", maxsplit=1)[-1]
680 def get_ctor(attr):
681 return getattr(
682 attr, "_typer_command_", getattr(attr, "_typer_callback_", None)
683 )
685 # because we're mapping a non-class based interface onto a class based
686 # interface, we have to handle this class mro stuff manually here
687 for cmd_cls, cls_attrs in [
688 *[(base, vars(base)) for base in reversed(bases)],
689 (cls, attrs),
690 ]:
691 if not issubclass(cmd_cls, TyperCommand) or cmd_cls is TyperCommand:
692 continue
693 for attr in [*cls_attrs.values(), cls._handle]:
694 cls._num_commands += hasattr(attr, "_typer_command_")
695 cls._has_callback |= hasattr(attr, "_typer_callback_")
696 if isinstance(attr, GroupFunction) and not attr.bound:
697 attr.bind(cls)
698 cls._root_groups += 1
700 if cmd_cls._handle:
701 ctor = get_ctor(cmd_cls._handle)
702 if ctor:
703 ctor(cls, name=cls.typer_app.info.name)
704 else:
705 cls._num_commands += 1
706 cls.typer_app.command(
707 cls.typer_app.info.name,
708 cls=type(
709 "_AdaptedCommand",
710 (TyperCommandWrapper,),
711 {"django_command": cls},
712 ),
713 help=cls.typer_app.info.help or None,
714 )(cmd_cls._handle)
716 for attr in cls_attrs.values():
717 (get_ctor(attr) or (lambda _: None))(cls)
719 if (
720 cls._num_commands > 1 or cls._root_groups > 0
721 ) and not cls.typer_app.registered_callback:
722 cls.typer_app.callback(
723 cls=type(
724 "_AdaptedCallback",
725 (TyperGroupWrapper,),
726 {"django_command": cls, "callback_is_method": False},
727 )
728 )(_common_options)
730 super().__init__(name, bases, attrs, **kwargs)
733class TyperParser:
734 @dataclass(frozen=True)
735 class Action:
736 dest: str
737 nargs: int
738 required: bool = False
740 @property
741 def option_strings(self):
742 return [self.dest]
744 _actions: t.List[t.Any]
745 _mutually_exclusive_groups: t.List[t.Any] = []
747 django_command: "TyperCommand"
748 prog_name: str
749 subcommand: str
751 def __init__(self, django_command: "TyperCommand", prog_name, subcommand):
752 self._actions = []
753 self.django_command = django_command
754 self.prog_name = prog_name
755 self.subcommand = subcommand
757 def populate_params(node):
758 for param in node.command.params:
759 self._actions.append(self.Action(param.name, param.nargs))
760 for child in node.children.values():
761 populate_params(child)
763 populate_params(self.django_command.command_tree)
765 def print_help(self, *command_path: str):
766 self.django_command.command_tree.context.info_name = (
767 f"{self.prog_name} {self.subcommand}"
768 )
769 command_node = self.django_command.get_subcommand(*command_path)
770 with contextlib.redirect_stdout(self.django_command.stdout):
771 command_node.print_help()
773 def parse_args(self, args=None, namespace=None):
774 try:
775 cmd = get_typer_command(self.django_command.typer_app)
776 with cmd.make_context(
777 info_name=f"{self.prog_name} {self.subcommand}",
778 django_command=self.django_command,
779 args=list(args or []),
780 ) as ctx:
781 params = ctx.params
783 # def discover_parsed_args(ctx):
784 # # todo is this necessary?
785 # for child in ctx.children:
786 # discover_parsed_args(child)
787 # params.update(child.params)
789 # discover_parsed_args(ctx)
791 return _ParsedArgs(args=args or [], **{**COMMON_DEFAULTS, **params})
792 except click.exceptions.Exit:
793 sys.exit()
795 def add_argument(self, *args, **kwargs):
796 raise NotImplementedError(_("add_argument() is not supported"))
799class TyperCommand(BaseCommand, metaclass=_TyperCommandMeta):
800 """
801 A BaseCommand extension class that uses the Typer library to parse
802 arguments and options. This class adapts BaseCommand using a light touch
803 that relies on most of the original BaseCommand implementation to handle
804 default arguments and behaviors.
806 The goal of django_typer is to provide full typer style functionality
807 while maintaining compatibility with the Django management command system.
808 This means that the BaseCommand interface is preserved and the Typer
809 interface is added on top of it. This means that this code base is more
810 robust to changes in the Django management command system - because most
811 of the base class functionality is preserved but mt.Any typer and click
812 internals are used directly to achieve this. We rely on robust CI to
813 catch breaking changes in the click/typer dependencies.
816 TODO - there is a problem with subcommand resolution and make_context()
817 that needs to be addressed. Need to understand exactly how click/typer
818 does this so it can be broken apart and be interface compatible with
819 Django. Also when are callbacks invoked, etc - during make_context? or
820 invoke? There is a complexity here with execute().
822 TODO - lazy loaded command overrides.
823 Should be able to attach to another TyperCommand like this and conflicts would resolve
824 based on INSTALLED_APP precedence.
826 class Command(TyperCommand, attach='app_label.command_name.subcommand1.subcommand2'):
827 ...
828 """
830 # we do not use verbosity because the base command does not do anything with it
831 # if users want to use a verbosity flag like the base django command adds
832 # they can use the type from django_typer.types.Verbosity
833 django_params: t.Optional[
834 t.List[
835 t.Literal[
836 "version",
837 "settings",
838 "pythonpath",
839 "traceback",
840 "no_color",
841 "force_color",
842 "skip_checks",
843 ]
844 ]
845 ] = [name for name in COMMON_DEFAULTS.keys() if name != "verbosity"]
847 class CommandNode:
848 name: str
849 command: t.Union[TyperCommandWrapper, TyperGroupWrapper]
850 context: TyperContext
851 parent: t.Optional["CommandNode"] = None
852 children: t.Dict[str, "CommandNode"]
854 def __init__(
855 self,
856 name: str,
857 command: t.Union[TyperCommandWrapper, TyperGroupWrapper],
858 context: TyperContext,
859 parent: t.Optional["CommandNode"] = None,
860 ):
861 self.name = name
862 self.command = command
863 self.context = context
864 self.parent = parent
865 self.children = {}
867 def print_help(self):
868 self.command.get_help(self.context)
870 def get_command(self, *command_path: str):
871 if not command_path:
872 return self
873 try:
874 return self.children[command_path[0]].get_command(*command_path[1:])
875 except KeyError:
876 raise ValueError(f'No such command "{command_path[0]}"')
878 typer_app: t.Optional[Typer] = None
879 _num_commands: int = 0
880 _has_callback: bool = False
881 _root_groups: int = 0
883 command_tree: CommandNode
885 def __init__(
886 self,
887 stdout: t.Optional[t.IO[str]] = None,
888 stderr: t.Optional[t.IO[str]] = None,
889 no_color: bool = False,
890 force_color: bool = False,
891 **kwargs,
892 ):
893 super().__init__(
894 stdout=stdout,
895 stderr=stderr,
896 no_color=no_color,
897 force_color=force_color,
898 **kwargs,
899 )
900 self.command_tree = self._build_cmd_tree(get_typer_command(self.typer_app))
902 def get_subcommand(self, *command_path: str):
903 return self.command_tree.get_command(*command_path)
905 def _filter_commands(
906 self, ctx: TyperContext, cmd_filter: t.Optional[t.List[str]] = None
907 ):
908 return sorted(
909 [
910 cmd
911 for name, cmd in getattr(
912 ctx.command,
913 "commands",
914 {
915 name: ctx.command.get_command(ctx, name)
916 for name in getattr(ctx.command, "list_commands", lambda _: [])(
917 ctx
918 )
919 or cmd_filter
920 or []
921 },
922 ).items()
923 if not cmd_filter or name in cmd_filter
924 ],
925 key=lambda item: item.name,
926 )
928 def _build_cmd_tree(
929 self,
930 cmd: CoreTyperCommand,
931 parent: t.Optional[Context] = None,
932 info_name: t.Optional[str] = None,
933 node: t.Optional[CommandNode] = None,
934 ):
935 ctx = Context(cmd, info_name=info_name, parent=parent, django_command=self)
936 current = self.CommandNode(cmd.name, cmd, ctx, parent=node)
937 if node:
938 node.children[cmd.name] = current
939 for cmd in self._filter_commands(ctx):
940 self._build_cmd_tree(cmd, parent=ctx, info_name=cmd.name, node=current)
941 return current
943 def __init_subclass__(cls, **_):
944 """Avoid passing typer arguments up the subclass init chain"""
945 return super().__init_subclass__()
947 def create_parser(self, prog_name: str, subcommand: str, **_):
948 return TyperParser(self, prog_name, subcommand)
950 def print_help(self, prog_name: str, subcommand: str, *cmd_path: str):
951 """
952 Print the help message for this command, derived from
953 ``self.usage()``.
954 """
955 parser = self.create_parser(prog_name, subcommand)
956 parser.print_help(*cmd_path)
958 def __call__(self, *args, **kwargs):
959 """
960 Call this command's handle() directly.
961 """
962 if getattr(self, "_handle", None):
963 return self._handle(*args, **kwargs)
964 raise NotImplementedError(
965 _(
966 "{cls} does not implement handle(), you must call the other command "
967 "functions directly."
968 ).format(cls=self.__class__)
969 )