From 4b91902856866686678645af1a500b2aa7fd9828 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Fri, 26 Jan 2024 15:38:45 -0800 Subject: [PATCH] add completers, try fix github CI tests --- django_typer/__init__.py | 76 +++-- django_typer/apps.py | 15 +- django_typer/completers.py | 262 ++++++++++++++++++ .../management/commands/shellcompletion.py | 35 +-- django_typer/parsers.py | 96 +++++++ django_typer/tests/completion_tests.py | 0 django_typer/tests/polls/__init__.py | 0 django_typer/tests/polls/admin.py | 5 + django_typer/tests/polls/apps.py | 6 + .../tests/polls/management/__init__.py | 0 .../polls/management/commands/__init__.py | 0 .../polls/management/commands/closepoll.py | 29 ++ .../management/commands/closepoll_django.py | 33 +++ .../tests/polls/migrations/0001_initial.py | 52 ++++ .../tests/polls/migrations/__init__.py | 0 django_typer/tests/polls/models.py | 18 ++ django_typer/tests/polls/urls.py | 1 + django_typer/tests/settings.py | 3 + .../management/commands/completion.py | 20 +- .../tests/test_app/migrations/0001_initial.py | 47 ++++ .../tests/test_app/migrations/__init__.py | 0 django_typer/tests/test_app/models.py | 11 + .../test_app2/management/commands/groups.py | 6 +- django_typer/tests/tests.py | 21 ++ django_typer/tests/urls.py | 6 + django_typer/types.py | 35 ++- 26 files changed, 687 insertions(+), 90 deletions(-) create mode 100644 django_typer/completers.py create mode 100644 django_typer/parsers.py create mode 100644 django_typer/tests/completion_tests.py create mode 100644 django_typer/tests/polls/__init__.py create mode 100644 django_typer/tests/polls/admin.py create mode 100644 django_typer/tests/polls/apps.py create mode 100644 django_typer/tests/polls/management/__init__.py create mode 100644 django_typer/tests/polls/management/commands/__init__.py create mode 100644 django_typer/tests/polls/management/commands/closepoll.py create mode 100644 django_typer/tests/polls/management/commands/closepoll_django.py create mode 100644 django_typer/tests/polls/migrations/0001_initial.py create mode 100644 django_typer/tests/polls/migrations/__init__.py create mode 100644 django_typer/tests/polls/models.py create mode 100644 django_typer/tests/polls/urls.py create mode 100644 django_typer/tests/test_app/migrations/0001_initial.py create mode 100644 django_typer/tests/test_app/migrations/__init__.py create mode 100644 django_typer/tests/test_app/models.py create mode 100644 django_typer/tests/urls.py diff --git a/django_typer/__init__.py b/django_typer/__init__.py index b2f8c70..71db3b4 100644 --- a/django_typer/__init__.py +++ b/django_typer/__init__.py @@ -60,8 +60,7 @@ "initialize", "command", "group", - "get_command", - "COMPLETE_VAR", + "get_command" ] """ @@ -80,19 +79,29 @@ behavior should align with native django commands """ -# def get_color_system(default): -# ctx = click.get_current_context(silent=True) -# if ctx: -# return None if ctx.django_command.style == no_style() else default -# return default - -# COLOR_SYSTEM = lazy(get_color_system, str) -# rich_utils.COLOR_SYSTEM = COLOR_SYSTEM(rich_utils.COLOR_SYSTEM) - -COMPLETE_VAR = "_COMPLETE_INSTRUCTION" - +# try: +# from typer import rich_utils +# def get_color_system(default): +# return None +# ctx = click.get_current_context(silent=True) +# if ctx: +# return None if ctx.django_command.style == no_style() else default +# return default + +# COLOR_SYSTEM = lazy(get_color_system, str) +# rich_utils.COLOR_SYSTEM = COLOR_SYSTEM(rich_utils.COLOR_SYSTEM) +# except ImportError: +# pass def traceback_config(): + """ + Fetch the rich traceback installation parameters from our settings. By default + rich tracebacks are on with show_locals = True. If the config is set to False + or None rich tracebacks will not be installed even if the library is present. + + This allows us to have a common traceback configuration for all commands. If rich + tracebacks are managed separately this setting can also be switched off. + """ cfg = getattr(settings, "DT_RICH_TRACEBACK_CONFIG", {"show_locals": True}) if cfg: return {"show_locals": True, **cfg} @@ -129,7 +138,7 @@ def _common_options( force_color: ForceColor = False, skip_checks: SkipChecks = False, ): - pass + pass # pragma: no cover # cache common params to avoid this extra work on every command @@ -243,6 +252,9 @@ def shell_complete(self, ctx: Context, incomplete: str) -> t.List[CompletionItem By default if the incomplete string is a space and there are no completions the click infrastructure will return _files. We'd rather return parameters for the command if there are any available. + + TODO - remove parameters that are already provided and do not allow multiple + specifications. """ completions = super().shell_complete(ctx, incomplete) if ( @@ -320,7 +332,7 @@ def common_params(self): return [ param for param in _get_common_params() - if param.name in (self.django_command.django_params or []) + if param.name not in (self.django_command.suppressed_base_arguments or []) ] return super().common_params() @@ -336,7 +348,7 @@ def common_params(self): return [ param for param in _get_common_params() - if param.name in (self.django_command.django_params or []) + if param.name not in (self.django_command.suppressed_base_arguments or []) ] return super().common_params() @@ -689,7 +701,7 @@ def handle(self, *args, **options): "_handle": attrs.pop("handle", None), **attrs, "handle": handle, - "typer_app": typer_app, + "typer_app": typer_app } return super().__new__(mcs, name, bases, attrs) @@ -700,6 +712,10 @@ def __init__(cls, name, bases, attrs, **kwargs): """ if cls.typer_app is not None: cls.typer_app.info.name = cls.__module__.rsplit(".", maxsplit=1)[-1] + cls.suppressed_base_arguments = { + arg.lstrip('--').replace('-', '_') + for arg in cls.suppressed_base_arguments + } # per django docs - allow these to be specified by either the option or param name def get_ctor(attr): return getattr( @@ -811,17 +827,7 @@ def parse_args(self, args=None, namespace=None): django_command=self.django_command, args=list(args or []), ) 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) - - # discover_parsed_args(ctx) - - return _ParsedArgs(args=args or [], **{**COMMON_DEFAULTS, **params}) + return _ParsedArgs(args=args or [], **{**COMMON_DEFAULTS, **ctx.params}) except click.exceptions.Exit: sys.exit() @@ -863,19 +869,7 @@ class Command(TyperCommand, attach='app_label.command_name.subcommand1.subcomman # we do not use verbosity because the base command does not do anything with it # if users want to use a verbosity flag like the base django command adds # they can use the type from django_typer.types.Verbosity - django_params: t.Optional[ - t.List[ - t.Literal[ - "version", - "settings", - "pythonpath", - "traceback", - "no_color", - "force_color", - "skip_checks", - ] - ] - ] = [name for name in COMMON_DEFAULTS.keys() if name != "verbosity"] + suppressed_base_arguments: t.Optional[t.Iterable[str]] = {'verbosity'} class CommandNode: name: str diff --git a/django_typer/apps.py b/django_typer/apps.py index 959cbf6..06d49d3 100644 --- a/django_typer/apps.py +++ b/django_typer/apps.py @@ -1,3 +1,7 @@ +""" +Django Typer app config. This module includes settings check and rich traceback +installation logic. +""" import inspect from django.apps import AppConfig @@ -12,6 +16,7 @@ tb_config = traceback_config() if isinstance(tb_config, dict) and not tb_config.get("no_install", False): + # install rich tracebacks if we've been configured to do so (default) rich.traceback.install( **{ param: value @@ -26,6 +31,10 @@ @register("settings") def check_traceback_config(app_configs, **kwargs): + """ + A system check that validates that the traceback config is valid and + contains only the expected parameters. + """ warnings = [] tb_cfg = traceback_config() if isinstance(tb_cfg, dict): @@ -51,6 +60,10 @@ def check_traceback_config(app_configs, **kwargs): class DjangoTyperConfig(AppConfig): + """ + Django Typer app config. + """ + name = "django_typer" - label = name.replace(".", "_") + label = name verbose_name = "Django Typer" diff --git a/django_typer/completers.py b/django_typer/completers.py new file mode 100644 index 0000000..1cdcc0e --- /dev/null +++ b/django_typer/completers.py @@ -0,0 +1,262 @@ +""" +A collection of completer classes that can be used to quickly add shell completion +for various kinds of django objects. +""" + +import typing as t +from types import MethodType +from click import Context, Parameter +from click.shell_completion import CompletionItem +from django.db.models import Q, Model, Max +from django.db.models import ( + IntegerField, + FloatField, + DecimalField, + CharField, + TextField, + UUIDField, + # TODO: + # GenericIPAddressField, + # TimeField, + # DateField, + # DateTimeField, + # DurationField, + # FilePathField, + # FileField +) +from django.apps import apps + + +class ModelObjectCompleter: + """ + A completer for generic Django model objects. This completer will work + for any Django core model field where completion makes sense. For example, + it will work for IntegerField, CharField, TextField, and UUIDField, but + not for ForeignKey, ManyToManyField or BinaryField. + + The completer query logic is pluggable, but the defaults cover most use cases. + + :param model_cls: The Django model class to query. + :param lookup_field: The name of the model field to use for lookup. + :param help_field: The name of the model field to use for help text or None if + no help text should be provided. + :param query: A callable that accepts the completer object instance, the click + context, the click parameter, and the incomplete string and returns a Q + object to use for filtering the queryset. The default query will use the + relevant class methods depending on the lookup field class. See the + query methods for details. + :param limit: The maximum number of completion items to return. If None, all + matching items will be returned. When offering completion for large tables + you'll want to set this to a reasonable limit. Default: 50 + :param case_insensitive: Whether or not to perform case insensitive matching when + completing text-based fields. Defaults to True. + :param distinct: Whether or not to filter out duplicate values. Defaults to True. + This is not the same as calling distinct() on the queryset - which will happen + regardless - but rather whether or not to filter out values that are already + given for the parameter on the command line. + """ + + QueryBuilder = t.Callable[['ModelObjectCompleter', Context, Parameter, str], Q] + + model_cls: t.Type[Model] + lookup_field: str = 'id' + help_field: t.Optional[str] = None + query: QueryBuilder + limit: t.Optional[int] = 50 + case_insensitive: bool = True + distinct: bool = True + + def default_query(self, + context: Context, + parameter: Parameter, + incomplete: str + ) -> Q: + """ + The default completion query builder. This method will route to the + appropriate query method based on the lookup field class. + + :param context: The click context. + :param parameter: The click parameter. + :param incomplete: The incomplete string. + :return: A Q object to use for filtering the queryset. + :raises ValueError: If the lookup field class is not supported or there + is a problem using the incomplete string as a lookup given the field + class. + :raises TypeError: If there is a problem using the incomplete string as a + lookup given the field class. + """ + field = self.model_cls._meta.get_field(self.lookup_field) + if issubclass(field.__class__, IntegerField): + return self.int_query(context, parameter, incomplete) + elif issubclass(field.__class__, (CharField, TextField)): + return self.text_query(context, parameter, incomplete) + elif issubclass(field.__class__, UUIDField): + return self.uuid_query(context, parameter, incomplete) + elif issubclass(field.__class__, (FloatField, DecimalField)): + return self.float_query(context, parameter, incomplete) + raise ValueError(f'Unsupported lookup field class: {field.__class__.__name__}') + + def int_query( + self, + context: Context, + parameter: Parameter, + incomplete: str + ) -> Q: + """ + The default completion query builder for integer fields. This method will + return a Q object that will match any value that starts with the incomplete + string. For example, if the incomplete string is "1", the query will match + 1, 10-19, 100-199, 1000-1999, etc. + + :param context: The click context. + :param parameter: The click parameter. + :param incomplete: The incomplete string. + :return: A Q object to use for filtering the queryset. + :raises ValueError: If the incomplete string is not a valid integer. + :raises TypeError: If the incomplete string is not a valid integer. + """ + lower = int(incomplete) + upper = lower+1 + max_val = self.model_cls.objects.aggregate(Max(self.lookup_field))['id__max'] + qry = Q(**{f'{self.lookup_field}': lower}) + while (lower:=lower*10) <= max_val: + upper *= 10 + qry |= Q(**{f'{self.lookup_field}__gte': lower}) & Q(**{f'{self.lookup_field}__lt': upper}) + return qry + + def float_query( + self, + context: Context, + parameter: Parameter, + incomplete: str + ): + """ + The default completion query builder for float fields. This method will + return a Q object that will match any value that starts with the incomplete + string. For example, if the incomplete string is "1.1", the query will match + 1.1 <= float(incomplete) < 1.2 + + :param context: The click context. + :param parameter: The click parameter. + :param incomplete: The incomplete string. + :return: A Q object to use for filtering the queryset. + :raises ValueError: If the incomplete string is not a valid float. + :raises TypeError: If the incomplete string is not a valid float. + """ + if '.' not in incomplete: + return self.int_query(context, parameter, incomplete) + incomplete = incomplete.rstrip('0') + lower = float(incomplete) + upper = lower + float(f'0.{"0"*(len(incomplete)-incomplete.index(".")-2)}1') + return Q(**{f'{self.lookup_field}__gte': lower}) & Q(**{f'{self.lookup_field}__lt': upper}) + + def text_query(self, context: Context, parameter: Parameter, incomplete: str) -> Q: + """ + The default completion query builder for text-based fields. This method will + return a Q object that will match any value that starts with the incomplete + string. Case sensitivity is determined by the case_insensitive constructor parameter. + + :param context: The click context. + :param parameter: The click parameter. + :param incomplete: The incomplete string. + :return: A Q object to use for filtering the queryset. + """ + if self.case_insensitive: + return Q(**{f'{self.lookup_field}__istartswith': incomplete}) + return Q(**{f'{self.lookup_field}__startswith': incomplete}) + + def uuid_query(self, context: Context, parameter: Parameter, incomplete: str) -> Q: + """ + The default completion query builder for UUID fields. This method will + return a Q object that will match any value that starts with the incomplete + string. The incomplete string will be stripped of all non-alphanumeric + characters and padded with zeros to 32 characters. For example, if the + incomplete string is "a", the query will match + a0000000-0000-0000-0000-000000000000 to affffffff-ffff-ffff-ffff-ffffffffffff. + + :param context: The click context. + :param parameter: The click parameter. + :param incomplete: The incomplete string. + :return: A Q object to use for filtering the queryset. + :raises ValueError: If the incomplete string is too long or contains invalid + UUID characters. Anything other than (0-9a-fA-F). + """ + uuid = '' + for char in incomplete: + if char.isalnum(): + uuid += char + if len(uuid) > 32: + raise ValueError(f'Too many UUID characters: {incomplete}') + min_uuid = uuid + '0'*(32-len(uuid)) + max_uuid = uuid + 'f'*(32-len(uuid)) + return Q(**{f'{self.lookup_field}__gte': min_uuid}) & Q(**{f'{self.lookup_field}__lte': max_uuid}) + + def __init__( + self, + model_cls: t.Type[Model], + lookup_field: str = lookup_field, + help_field: t.Optional[str] = help_field, + query: QueryBuilder = default_query, + limit: t.Optional[int] = limit, + case_insensitive: bool = case_insensitive, + distinct: bool = distinct + ): + self.model_cls = model_cls + self.lookup_field = lookup_field + self.help_field = help_field + self.query = MethodType(query, self) + self.limit = limit + self.case_insensitive = case_insensitive + self.distinct = distinct + + def __call__( + self, + context: Context, + parameter: Parameter, + incomplete: str + ) -> t.Union[t.List[CompletionItem], t.List[str]]: + """ + The completer method. This method will return a list of CompletionItem + objects. If the help_field constructor parameter is not None, the help + text will be set on the CompletionItem objects. The configured query + method will be used to filter the queryset. distinct() will also be + applied and if the distinct constructor parameter is True, values already + present for the parameter on the command line will be filtered out. + + :param context: The click context. + :param parameter: The click parameter. + :param incomplete: The incomplete string. + :return: A list of CompletionItem objects. + """ + + completion_qry = Q() + + if incomplete: + try: + completion_qry &= self.query(context, parameter, incomplete) + except (ValueError, TypeError): + return [] + + return [ + CompletionItem( + value=str(getattr(obj, self.lookup_field)), + help=getattr(obj, self.help_field, None) if self.help_field else '' + ) for obj in self.model_cls.objects.filter(completion_qry).distinct()[0:self.limit] + ] + + +def complete_app_label(ctx: Context, param: Parameter, incomplete: str): + """ + A case-insensitive completer for Django app labels. + + :param ctx: The click context. + :param param: The click parameter. + :param incomplete: The incomplete string. + :return: A list of matching app labels. Labels already present for the parameter + on the command line will be filtered out. + """ + present = [app.label for app in (ctx.params.get(param.name) or [])] + return [ + app.label for app in apps.get_app_configs() + if app.label.lower().startswith(incomplete.lower()) and app.label not in present + ] diff --git a/django_typer/management/commands/shellcompletion.py b/django_typer/management/commands/shellcompletion.py index 496e6a4..67b049d 100644 --- a/django_typer/management/commands/shellcompletion.py +++ b/django_typer/management/commands/shellcompletion.py @@ -20,7 +20,7 @@ from typer import Argument, Option, echo from typer.completion import Shells, completion_init -from django_typer import COMPLETE_VAR, TyperCommand, command, get_command, initialize +from django_typer import TyperCommand, command, get_command, initialize try: from shellingham import detect_shell @@ -61,14 +61,12 @@ class Command(TyperCommand): requires_migrations_checks = False # remove unnecessary django command base parameters - these just clutter the help - django_params = [ - cmd - for cmd in TyperCommand.django_params - if cmd not in ["version", "skip_checks", "no_color", "force_color"] - ] + suppressed_base_arguments = {'version', 'skip_checks', 'no_color', 'force_color', 'verbosity'} _shell: Shells + COMPLETE_VAR = "_COMPLETE_INSTRUCTION" + @cached_property def manage_script(self) -> t.Union[str, Path]: """ @@ -135,8 +133,8 @@ def shell(self): self, "_shell", Shells( - os.environ[COMPLETE_VAR].partition("_")[2] - if COMPLETE_VAR in os.environ + os.environ[self.COMPLETE_VAR].partition("_")[2] + if self.COMPLETE_VAR in os.environ else detect_shell()[0] ), ) @@ -277,7 +275,7 @@ def install( install_path = install( shell=self.shell.value, prog_name=manage_script or self.manage_script_name, - complete_var=COMPLETE_VAR, + complete_var=self.COMPLETE_VAR, )[1] self.stdout.write( self.style.SUCCESS( @@ -378,6 +376,8 @@ def complete( # because our entry point was not an installed completion script def get_completion_args(self) -> t.Tuple[t.List[str], str]: cwords = split_arg_string(command) + if command[-1].isspace(): + cwords.append('') # allow users to not specify the manage script, but allow for it # if they do by lopping it off - same behavior as upstream classes try: @@ -387,7 +387,7 @@ def get_completion_args(self) -> t.Tuple[t.List[str], str]: pass return ( cwords[:-1], - cwords[-1] if len(cwords) and not command[-1].isspace() else " ", + cwords[-1] if len(cwords) else "", ) CompletionClass.get_completion_args = get_completion_args @@ -408,9 +408,12 @@ def get_completions(self, args, incomplete): cli=self.noop_command.command, ctx_args={}, prog_name=sys.argv[0], - complete_var=COMPLETE_VAR, + complete_var=self.COMPLETE_VAR, ).get_completion_args() + with open('test.txt', 'w') as f: + f.write(f'{args}\n"{incomplete}"') + def call_fallback(fb): fallback = import_string(fb) if fb else self.django_fallback if command and inspect.signature(fallback).parameters: @@ -422,8 +425,8 @@ def call_fallback(fb): call_fallback(fallback) else: try: - os.environ[COMPLETE_VAR] = os.environ.get( - COMPLETE_VAR, f"complete_{self.shell.value}" + os.environ[self.COMPLETE_VAR] = os.environ.get( + self.COMPLETE_VAR, f"complete_{self.shell.value}" ) cmd = get_command(args[0]) except Exception: @@ -435,7 +438,7 @@ def call_fallback(fb): args=args[1:], standalone_mode=True, django_command=cmd, - complete_var=COMPLETE_VAR, + complete_var=self.COMPLETE_VAR, prog_name=f"{sys.argv[0]} {self.typer_app.info.name}", ) return @@ -477,7 +480,7 @@ def get_completions(self, args, incomplete): cli=self.noop_command.command, ctx_args={}, prog_name=self.manage_script_name, - complete_var=COMPLETE_VAR, + complete_var=self.COMPLETE_VAR, ).complete() ) @@ -492,7 +495,7 @@ def get_completions(self, args, incomplete): def noop(self): """ This is a no-op command that is used to bootstrap click Completion classes. It - has no use other than to avoid any potential attribute errors when we spoof + has no use other than to avoid any potential attribute access errors when we spoof completion logic """ pass diff --git a/django_typer/parsers.py b/django_typer/parsers.py new file mode 100644 index 0000000..1871a1d --- /dev/null +++ b/django_typer/parsers.py @@ -0,0 +1,96 @@ +""" +A collection of parsers that turn strings into useful Django types. +Pass these parsers to the `parser` argument of typer.Option and +typer.Argument. +""" +import typing as t +from django.core.management import CommandError +from django.db.models import Model +from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import gettext_lazy as _ +from django.apps import apps, AppConfig + + +class ModelObjectParser: + """ + A parser that will turn strings into model object instances based on the + configured lookup field and model class. + + :param model_cls: The model class to use for lookup. + :param lookup_field: The field to use for lookup. Defaults to 'pk'. + :param on_error: A callable that will be called if the lookup fails. + The callable should accept three arguments: the model class, the + value that failed to lookup, and the exception that was raised. + If not provided, a CommandError will be raised. + """ + + error_handler = t.Callable[[t.Type[Model], str, ObjectDoesNotExist], None] + + model_cls: t.Type[Model] + lookup_field: str = 'pk' + on_error: t.Optional[error_handler] = None + + __name__ = 'ModelObjectParser' # typer internals expect this + + def __init__( + self, + model_cls: t.Type[Model], + lookup_field: str = lookup_field, + on_error: t.Optional[error_handler] = on_error + ): + self.model_cls = model_cls + self.lookup_field = lookup_field + self.on_error = on_error + + def __call__(self, value: t.Union[str, Model]) -> Model: + """ + Invoke the parsing action on the given string. If the value is + already a model instance of the expected type the value will + be returned. Otherwise the value will be treated as a value to query + against the lookup_field. If no model object is found the error + handler is invoked if one was provided. + + :param value: The value to parse. + :raises CommandError: If the lookup fails and no error handler is + provided. + """ + try: + if isinstance(value, self.model_cls): + return value + return self.model_cls.objects.get(**{self.lookup_field: value}) + except self.model_cls.DoesNotExist as err: + if self.on_error: + self.on_error(self.model_cls, value, err) + else: + raise CommandError( + _('{model} "{value}" does not exist!'.format( + model=self.model_cls.__name__, + value=value + )) + ) + + +def parse_app_label(label: t.Union[str, AppConfig]): + """ + A parser for app labels. If the label is already an AppConfig instance, + the instance is returned. The label will be tried first, if that fails + the label will be treated as the app name. Lookups are case insensitive. + + :param label: The label to map to an AppConfig instance. + :raises CommandError: If no matching app can be found. + """ + if isinstance(label, AppConfig): + return label + try: + return apps.get_app_config(label) + except LookupError: + label = label.lower() + for cfg in apps.get_app_configs(): + if cfg.label.lower() == label: + return cfg + elif cfg.name.lower() == label: + return cfg + + raise CommandError( + _('{label} does not match any installed app label.'.format(label=label)) + ) diff --git a/django_typer/tests/completion_tests.py b/django_typer/tests/completion_tests.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/polls/__init__.py b/django_typer/tests/polls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/polls/admin.py b/django_typer/tests/polls/admin.py new file mode 100644 index 0000000..6af8ff6 --- /dev/null +++ b/django_typer/tests/polls/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import Question + +admin.site.register(Question) diff --git a/django_typer/tests/polls/apps.py b/django_typer/tests/polls/apps.py new file mode 100644 index 0000000..9dd0efe --- /dev/null +++ b/django_typer/tests/polls/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PollsConfig(AppConfig): + name = 'django_typer.tests.polls' + label = name.replace('.', '_') diff --git a/django_typer/tests/polls/management/__init__.py b/django_typer/tests/polls/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/polls/management/commands/__init__.py b/django_typer/tests/polls/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/polls/management/commands/closepoll.py b/django_typer/tests/polls/management/commands/closepoll.py new file mode 100644 index 0000000..ee60122 --- /dev/null +++ b/django_typer/tests/polls/management/commands/closepoll.py @@ -0,0 +1,29 @@ +from django_typer.tests.polls.models import Question as Poll +from django_typer import TyperCommand, parsers, completers +from typer import Argument, Option +import typing as t + + +class Command(TyperCommand): + help = "Closes the specified poll for voting" + + def handle( + self, + polls: t.Annotated[ + t.List[Poll], + Argument( + parser=parsers.ModelObjectParser(Poll), + shell_complete=completers.ModelObjectCompleter(Poll, help_field='question_text') + ) + ], + delete: t.Annotated[ + bool, + Option(help='Delete poll instead of closing it.') + ] = False + ): + for poll in polls: + poll.opened = False + poll.save() + self.stdout.write(self.style.SUCCESS(f'Successfully closed poll "{{ poll.id }}"')) + if delete: + poll.delete() diff --git a/django_typer/tests/polls/management/commands/closepoll_django.py b/django_typer/tests/polls/management/commands/closepoll_django.py new file mode 100644 index 0000000..f5898bc --- /dev/null +++ b/django_typer/tests/polls/management/commands/closepoll_django.py @@ -0,0 +1,33 @@ +from django.core.management.base import BaseCommand, CommandError +from polls.models import Question as Poll + + +class Command(BaseCommand): + help = "Closes the specified poll for voting" + + def add_arguments(self, parser): + parser.add_argument("poll_ids", nargs="+", type=int) + + # Named (optional) arguments + parser.add_argument( + "--delete", + action="store_true", + help="Delete poll instead of closing it", + ) + + def handle(self, *args, **options): + for poll_id in options["poll_ids"]: + try: + poll = Poll.objects.get(pk=poll_id) + except Poll.DoesNotExist: + raise CommandError('Poll "%s" does not exist' % poll_id) + + poll.opened = False + poll.save() + + self.stdout.write( + self.style.SUCCESS('Successfully closed poll "%s"' % poll_id) + ) + + if options["delete"]: + poll.delete() \ No newline at end of file diff --git a/django_typer/tests/polls/migrations/0001_initial.py b/django_typer/tests/polls/migrations/0001_initial.py new file mode 100644 index 0000000..b5afdf2 --- /dev/null +++ b/django_typer/tests/polls/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.8 on 2024-01-24 18:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Question", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("question_text", models.CharField(max_length=200)), + ("pub_date", models.DateTimeField(verbose_name="date published")), + ], + ), + migrations.CreateModel( + name="Choice", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("choice_text", models.CharField(max_length=200)), + ("votes", models.IntegerField(default=0)), + ( + "question", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="django_typer_tests_polls.question", + ), + ), + ], + ), + ] diff --git a/django_typer/tests/polls/migrations/__init__.py b/django_typer/tests/polls/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/polls/models.py b/django_typer/tests/polls/models.py new file mode 100644 index 0000000..e2501bd --- /dev/null +++ b/django_typer/tests/polls/models.py @@ -0,0 +1,18 @@ +from django.db import models + + +class Question(models.Model): + question_text = models.CharField(max_length=200) + pub_date = models.DateTimeField("date published") + + def __str__(self): + return self.question_text + + +class Choice(models.Model): + question = models.ForeignKey(Question, on_delete=models.CASCADE) + choice_text = models.CharField(max_length=200) + votes = models.IntegerField(default=0) + + def __str__(self): + return self.choice_text diff --git a/django_typer/tests/polls/urls.py b/django_typer/tests/polls/urls.py new file mode 100644 index 0000000..637600f --- /dev/null +++ b/django_typer/tests/polls/urls.py @@ -0,0 +1 @@ +urlpatterns = [] diff --git a/django_typer/tests/settings.py b/django_typer/tests/settings.py index 012e9a3..cd1d74b 100644 --- a/django_typer/tests/settings.py +++ b/django_typer/tests/settings.py @@ -36,6 +36,7 @@ INSTALLED_APPS = [ "django_typer.tests.test_app", "django_typer", + "django_typer.tests.polls.apps.PollsConfig", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -126,3 +127,5 @@ SETTINGS_FILE = 1 + +ROOT_URLCONF = "django_typer.tests.urls" diff --git a/django_typer/tests/test_app/management/commands/completion.py b/django_typer/tests/test_app/management/commands/completion.py index 953b3a8..e800946 100644 --- a/django_typer/tests/test_app/management/commands/completion.py +++ b/django_typer/tests/test_app/management/commands/completion.py @@ -1,25 +1,11 @@ import json import typing as t -from functools import partial import typer from django.apps import AppConfig, apps from django.utils.translation import gettext_lazy as _ -from django_typer import TyperCommand - - -def parse_app_label(label: t.Union[str, AppConfig]): - if isinstance(label, AppConfig): - return label - return apps.get_app_config(label) - - -def complete_app_label(ctx: typer.Context, incomplete: str): - names = ctx.params.get("django_apps") or [] - for name in [app.label for app in apps.get_app_configs()]: - if name.startswith(incomplete) and name not in names: - yield name +from django_typer import TyperCommand, completers, parsers class Command(TyperCommand): @@ -28,9 +14,9 @@ def handle( django_apps: t.Annotated[ t.List[AppConfig], typer.Argument( - parser=parse_app_label, + parser=parsers.parse_app_label, help=_("One or more application labels."), - autocompletion=complete_app_label, + shell_complete=completers.complete_app_label, ), ], ): diff --git a/django_typer/tests/test_app/migrations/0001_initial.py b/django_typer/tests/test_app/migrations/0001_initial.py new file mode 100644 index 0000000..16168f0 --- /dev/null +++ b/django_typer/tests/test_app/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.8 on 2024-01-26 23:05 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="ShellCompleteTester", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "char_field", + models.CharField(db_index=True, default="", max_length=15), + ), + ("text_field", models.TextField(default="")), + ( + "float_field", + models.FloatField(db_index=True, default=None, null=True), + ), + ( + "decimal_field", + models.DecimalField( + db_index=True, + decimal_places=2, + default=None, + max_digits=10, + null=True, + ), + ), + ("uuid_field", models.UUIDField(default=uuid.uuid1, unique=True)), + ], + ), + ] diff --git a/django_typer/tests/test_app/migrations/__init__.py b/django_typer/tests/test_app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/test_app/models.py b/django_typer/tests/test_app/models.py new file mode 100644 index 0000000..6d14d11 --- /dev/null +++ b/django_typer/tests/test_app/models.py @@ -0,0 +1,11 @@ +from django.db import models +from uuid import uuid1 + + +class ShellCompleteTester(models.Model): + + char_field = models.CharField(max_length=15, db_index=True, default='') + text_field = models.TextField(default='') + float_field = models.FloatField(db_index=True, default=None, null=True) + decimal_field = models.DecimalField(db_index=True, default=None, null=True, max_digits=10, decimal_places=2) + uuid_field = models.UUIDField(default=uuid1, unique=True) diff --git a/django_typer/tests/test_app2/management/commands/groups.py b/django_typer/tests/test_app2/management/commands/groups.py index 09e42c7..a8d41e1 100644 --- a/django_typer/tests/test_app2/management/commands/groups.py +++ b/django_typer/tests/test_app2/management/commands/groups.py @@ -16,11 +16,7 @@ class Command(GroupsCommand, epilog="Overridden from test_app."): precision = 2 verbosity = 1 - django_params = [ - param - for param in GroupsCommand.django_params - if param != 'version' - ] + suppressed_base_arguments = ['--version'] @initialize() def init(self, verbosity: types.Verbosity = verbosity): diff --git a/django_typer/tests/tests.py b/django_typer/tests/tests.py index 1ab086f..26b435d 100644 --- a/django_typer/tests/tests.py +++ b/django_typer/tests/tests.py @@ -764,6 +764,27 @@ class TestGroups(TestCase): command inheritance behaves as expected. """ + default_color_system: str + + def setUp(self) -> None: + # colors in terminal output screw up github CI runs - todo less intrusive + # way around this?? + try: + from typer import rich_utils + self.default_color_system = rich_utils.COLOR_SYSTEM + rich_utils.COLOR_SYSTEM = None + except ImportError: + pass + return super().setUp() + + def tearDown(self) -> None: + try: + from typer import rich_utils + rich_utils.COLOR_SYSTEM = self.default_color_system + except ImportError: + pass + return super().tearDown() + def test_group_call(self): with self.assertRaises(NotImplementedError): get_command("groups")() diff --git a/django_typer/tests/urls.py b/django_typer/tests/urls.py new file mode 100644 index 0000000..fdd5749 --- /dev/null +++ b/django_typer/tests/urls.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/django_typer/types.py b/django_typer/types.py index f8cb03d..e579b10 100644 --- a/django_typer/types.py +++ b/django_typer/types.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import Annotated, Optional -from django.apps import AppConfig from django.utils.translation import gettext_lazy as _ from typer import Option @@ -23,6 +22,9 @@ def print_version(context, _, value): sys.exit() +""" +https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand.get_version +""" Version = Annotated[ bool, Option( @@ -34,6 +36,9 @@ def print_version(context, _, value): ), ] +""" +https://docs.djangoproject.com/en/stable/ref/django-admin/#cmdoption-verbosity +""" Verbosity = Annotated[ int, Option( @@ -48,6 +53,9 @@ def print_version(context, _, value): ), ] +""" +https://docs.djangoproject.com/en/stable/ref/django-admin/#cmdoption-settings +""" Settings = Annotated[ str, Option( @@ -60,6 +68,9 @@ def print_version(context, _, value): ), ] +""" +https://docs.djangoproject.com/en/stable/ref/django-admin/#cmdoption-pythonpath +""" PythonPath = Annotated[ Optional[Path], Option( @@ -71,6 +82,9 @@ def print_version(context, _, value): ), ] +""" +https://docs.djangoproject.com/en/stable/ref/django-admin/#cmdoption-traceback +""" Traceback = Annotated[ bool, Option( @@ -80,6 +94,9 @@ def print_version(context, _, value): ), ] +""" +https://docs.djangoproject.com/en/stable/ref/django-admin/#cmdoption-no-color +""" NoColor = Annotated[ bool, Option( @@ -89,6 +106,10 @@ def print_version(context, _, value): ), ] + +""" +https://docs.djangoproject.com/en/stable/ref/django-admin/#cmdoption-force-color +""" ForceColor = Annotated[ bool, Option( @@ -98,18 +119,12 @@ def print_version(context, _, value): ), ] +""" +https://docs.djangoproject.com/en/stable/ref/django-admin/#cmdoption-skip-checks +""" SkipChecks = Annotated[ bool, Option( "--skip-checks", help=_("Skip system checks."), rich_help_panel=COMMON_PANEL ), ] - - -AppLabel = Annotated[ - AppConfig, - Option( - help=_("Specifies the application configuration to use."), - rich_help_panel=COMMON_PANEL, - ), -]