diff --git a/django_typer/completers.py b/django_typer/completers.py index 146e4222..3422d546 100644 --- a/django_typer/completers.py +++ b/django_typer/completers.py @@ -16,15 +16,12 @@ # pylint: disable=line-too-long -import inspect import os -import pkgutil import sys import typing as t from functools import partial from pathlib import Path from types import MethodType -from uuid import UUID from click import Context, Parameter from click.core import ParameterSource @@ -34,6 +31,7 @@ from django.core.management import get_commands from django.db.models import ( CharField, + DateField, DecimalField, Field, FileField, @@ -78,6 +76,7 @@ class ModelObjectCompleter: - `ImageField `_ - `FilePathField `_ - `TextField `_ + - `DateField `_ **(Must use ISO 8601 format (YYYY-MM-DD)** - `UUIDField `_ - `FloatField `_ - `DecimalField `_ @@ -247,6 +246,7 @@ def uuid_query(self, context: Context, parameter: Parameter, incomplete: str) -> :raises ValueError: If the incomplete string is too long or contains invalid UUID characters. Anything other than (0-9a-fA-F). """ + from uuid import UUID # the offset futzing is to allow users to ignore the - in the UUID # as a convenience of its implementation any non-alpha numeric character @@ -280,6 +280,50 @@ def uuid_query(self, context: Context, parameter: Parameter, incomplete: str) -> **{f"{self.lookup_field}__lte": max_uuid} ) + def date_query(self, context: Context, parameter: Parameter, incomplete: str) -> Q: + """ + Default completion query builder for date fields. This method will return a Q object that + will match any value that starts with the incomplete date string. All dates must be in + ISO8601 format (YYYY-MM-DD). + + :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 partial date. + :raises AssertionError: If the incomplete string is not a valid partial date. + """ + import calendar + from datetime import date + + parts = incomplete.split("-") + year_low = max(int(parts[0] + "0" * (4 - len(parts[0]))), 1) + year_high = int(parts[0] + "9" * (4 - len(parts[0]))) + month_high = 12 + month_low = 1 + day_low = 1 + day_high = None + if len(parts) > 1: + assert len(parts[0]) > 3, _("Year must be 4 digits") + month_high = min(int(parts[1] + "9" * (2 - len(parts[1]))), 12) + month_low = max(int(parts[1] + "0" * (2 - len(parts[1]))), 1) + if len(parts) > 2: + assert len(parts[1]) > 1, _("Month must be 2 digits") + day_low = max(int(parts[2] + "0" * (2 - len(parts[2]))), 1) + day_high = min( + int(parts[2] + "9" * (2 - len(parts[2]))), + calendar.monthrange(year_high, month_high)[1], + ) + lower_bound = date(year=year_low, month=month_low, day=day_low) + upper_bound = date( + year=year_high, + month=month_high, + day=day_high or calendar.monthrange(year_high, month_high)[1], + ) + return Q(**{f"{self.lookup_field}__gte": lower_bound}) & Q( + **{f"{self.lookup_field}__lte": upper_bound} + ) + def __init__( self, model_or_qry: t.Union[t.Type[Model], QuerySet], @@ -290,6 +334,8 @@ def __init__( case_insensitive: bool = case_insensitive, distinct: bool = distinct, ): + import inspect + if inspect.isclass(model_or_qry) and issubclass(model_or_qry, Model): self.model_cls = model_or_qry elif isinstance(model_or_qry, QuerySet): @@ -324,6 +370,8 @@ def __init__( self.query = self.uuid_query elif isinstance(self._field, (FloatField, DecimalField)): self.query = self.float_query + elif isinstance(self._field, DateField): + self.query = self.date_query else: raise ValueError( _("Unsupported lookup field class: {cls}").format( @@ -348,14 +396,14 @@ def __call__( :return: A list of CompletionItem objects. """ - completion_qry = Q() + completion_qry = Q(**{self.lookup_field + "__isnull": False}) if incomplete: try: completion_qry &= self.query( # pylint: disable=not-callable context, parameter, incomplete ) - except (ValueError, TypeError): + except (ValueError, TypeError, AssertionError): return [] excluded: t.List[t.Type[Model]] = [] @@ -454,6 +502,8 @@ def complete_import_path( :param incomplete: The incomplete string. :return: A list of available matching import paths """ + import pkgutil + incomplete = incomplete.strip() completions = [] packages = [pkg for pkg in incomplete.split(".") if pkg] diff --git a/django_typer/parsers.py b/django_typer/parsers.py index 273b7fba..9e6f4b37 100644 --- a/django_typer/parsers.py +++ b/django_typer/parsers.py @@ -23,6 +23,7 @@ """ import typing as t +from datetime import date from uuid import UUID from click import Context, Parameter, ParamType @@ -96,6 +97,10 @@ def _get_metavar(self) -> str: return "UUID" elif isinstance(self._field, (models.FloatField, models.DecimalField)): return "FLOAT" + elif isinstance(self._field, (models.FileField, models.FilePathField)): + return "PATH" + elif isinstance(self._field, models.DateField): + return "YYYY-MM-DD" return "TXT" def __init__( @@ -141,12 +146,14 @@ def convert( try: if isinstance(value, self.model_cls): return value - if isinstance(self._field, models.UUIDField): + elif isinstance(self._field, models.UUIDField): uuid = "" for char in value: if char.isalnum(): uuid += char value = UUID(uuid) + elif isinstance(self._field, models.DateField): + value = date.fromisoformat(value) return self.model_cls.objects.get( **{f"{self.lookup_field}{self._lookup}": value} ) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index cd9ee973..811488db 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -7,6 +7,7 @@ Change Log v3.0.0 (202X-XX-XX) =================== +* Fixed `Model objects with null lookup fields should not be included in model field completion output `_ * Implemented `Add security scans to CI. `_ * Implemented `Add a performance regression. `_ * Implemented `Use in-house shell completer classes. `_ @@ -25,6 +26,8 @@ v3.0.0 (202X-XX-XX) * Fixed `Installed shellcompletion scripts do not pass values of --settings or --pythonpath `_ * Fixed `shellcompletion complete should print to the command's stdout. `_ * Implemented `Add completer/parser for FileField and FilePathField `_ +* Implemented `Add completer/parser for DateField `_ + Migrating from 2.x to 3.x ------------------------- diff --git a/tests/apps/test_app/management/commands/model_fields.py b/tests/apps/test_app/management/commands/model_fields.py index 8517daa0..d8131172 100644 --- a/tests/apps/test_app/management/commands/model_fields.py +++ b/tests/apps/test_app/management/commands/model_fields.py @@ -163,6 +163,13 @@ def test( help=t.cast(str, _("Fetch objects by their file path fields.")), ), ] = None, + date: Annotated[ + t.Optional[ShellCompleteTester], + typer.Option( + **model_parser_completer(ShellCompleteTester, "date_field"), + help=t.cast(str, _("Fetch objects by their date fields.")), + ), + ] = None, ): assert self.__class__ is Command objects = {} @@ -204,6 +211,9 @@ def test( assert isinstance(file, ShellCompleteTester) objects["file"] = {file.id: str(file.file_field)} if file_path is not None: - assert isinstance(file, ShellCompleteTester) + assert isinstance(file_path, ShellCompleteTester) objects["file_path"] = {file_path.id: str(file_path.file_path_field)} + if date is not None: + assert isinstance(date, ShellCompleteTester) + objects["date"] = {date.id: str(date.date_field)} return json.dumps(objects) diff --git a/tests/apps/test_app/migrations/0001_initial.py b/tests/apps/test_app/migrations/0001_initial.py index 11fa710b..410abf44 100644 --- a/tests/apps/test_app/migrations/0001_initial.py +++ b/tests/apps/test_app/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.18 on 2025-01-17 02:41 +# Generated by Django 4.2.18 on 2025-01-17 05:46 from django.db import migrations, models @@ -65,6 +65,10 @@ class Migration(migrations.Migration): "file_path_field", models.FilePathField(db_index=True, default=None, null=True), ), + ( + "date_field", + models.DateField(db_index=True, default=None, null=True), + ), ], ), ] diff --git a/tests/apps/test_app/models.py b/tests/apps/test_app/models.py index ac1a9208..378a6934 100644 --- a/tests/apps/test_app/models.py +++ b/tests/apps/test_app/models.py @@ -23,3 +23,5 @@ class ShellCompleteTester(models.Model): ) file_path_field = models.FilePathField(null=True, default=None, db_index=True) + + date_field = models.DateField(null=True, default=None, db_index=True) diff --git a/tests/test_parser_completers.py b/tests/test_parser_completers.py index 90fac2ac..11521643 100644 --- a/tests/test_parser_completers.py +++ b/tests/test_parser_completers.py @@ -5,6 +5,7 @@ from decimal import Decimal from io import StringIO from pathlib import Path +from datetime import date from django.apps import apps from django.core.management import CommandError, call_command @@ -97,6 +98,19 @@ class TestShellCompletersAndParsers(TestCase): "dir2/file3.txt", "file4.txt", ], + "date_field": [ + date(1984, 8, 7), + date(1989, 7, 27), + date(2021, 1, 6), + date(2021, 1, 7), + date(2021, 1, 8), + date(2021, 1, 31), + date(2021, 2, 9), + date(2021, 2, 10), + date(2024, 2, 29), + date(2024, 9, 20), + date(2025, 2, 28), + ], } def setUp(self): @@ -138,6 +152,9 @@ def test_model_object_parser_metavar(self): self.assertTrue(re.search(r"--ip\s+\[IPV4\|IPV6\]", stdout)) self.assertTrue(re.search(r"--email\s+EMAIL", stdout)) self.assertTrue(re.search(r"--url\s+URL", stdout)) + self.assertTrue(re.search(r"--file\s+PATH", stdout)) + self.assertTrue(re.search(r"--file-path\s+PATH", stdout)) + self.assertTrue(re.search(r"--date\s+YYYY-MM-DD", stdout)) except AssertionError: self.fail(stdout) @@ -400,6 +417,165 @@ def test_file_path_field(self): ) self.assertEqual(completions, ["dir2/file3.txt"]) + def test_date_field(self): + completions = get_values( + self.shellcompletion.complete("model_fields test --date ") + ) + self.assertEqual( + completions, + [ + "1984-08-07", + "1989-07-27", + "2021-01-06", + "2021-01-07", + "2021-01-08", + "2021-01-31", + "2021-02-09", + "2021-02-10", + "2024-02-29", + "2024-09-20", + "2025-02-28", + ], + ) + + self.assertEqual( + get_values(self.shellcompletion.complete("model_fields test --date 1")), + ["1984-08-07", "1989-07-27"], + ) + + self.assertEqual( + get_values(self.shellcompletion.complete("model_fields test --date 19")), + ["1984-08-07", "1989-07-27"], + ) + + self.assertEqual( + get_values(self.shellcompletion.complete("model_fields test --date 198")), + ["1984-08-07", "1989-07-27"], + ) + + self.assertEqual( + get_values(self.shellcompletion.complete("model_fields test --date 1984-")), + ["1984-08-07"], + ) + + self.assertEqual( + get_values(self.shellcompletion.complete("model_fields test --date 2-")), + [], + ) + + self.assertEqual( + get_values(self.shellcompletion.complete("model_fields test --date 20")), + [ + "2021-01-06", + "2021-01-07", + "2021-01-08", + "2021-01-31", + "2021-02-09", + "2021-02-10", + "2024-02-29", + "2024-09-20", + "2025-02-28", + ], + ) + + self.assertEqual( + get_values(self.shellcompletion.complete("model_fields test --date 2021")), + [ + "2021-01-06", + "2021-01-07", + "2021-01-08", + "2021-01-31", + "2021-02-09", + "2021-02-10", + ], + ) + + self.assertEqual( + get_values( + self.shellcompletion.complete("model_fields test --date 2021-0") + ), + [ + "2021-01-06", + "2021-01-07", + "2021-01-08", + "2021-01-31", + "2021-02-09", + "2021-02-10", + ], + ) + + self.assertEqual( + get_values( + self.shellcompletion.complete("model_fields test --date 2021-01-") + ), + [ + "2021-01-06", + "2021-01-07", + "2021-01-08", + "2021-01-31", + ], + ) + + self.assertEqual( + get_values( + self.shellcompletion.complete("model_fields test --date 2021-01-3") + ), + [ + "2021-01-31", + ], + ) + + self.assertEqual( + get_values( + self.shellcompletion.complete("model_fields test --date 2021-01-0") + ), + ["2021-01-06", "2021-01-07", "2021-01-08"], + ) + + self.assertEqual( + get_values( + self.shellcompletion.complete("model_fields test --date 2021-01-06") + ), + ["2021-01-06"], + ) + + self.assertEqual( + get_values( + self.shellcompletion.complete("model_fields test --date 2021-01-09") + ), + [], + ) + + self.assertEqual( + get_values(self.shellcompletion.complete("model_fields test --date 2024")), + ["2024-02-29", "2024-09-20"], + ) + + self.assertEqual( + get_values( + self.shellcompletion.complete("model_fields test --date 2024-02-2") + ), + ["2024-02-29"], + ) + + self.assertEqual( + get_values( + self.shellcompletion.complete("model_fields test --date 2025-02-") + ), + ["2025-02-28"], + ) + + self.assertEqual( + json.loads(call_command("model_fields", "test", "--date", "2024-02-29")), + { + "date": { + str( + ShellCompleteTester.objects.get(date_field=date(2024, 2, 29)).pk + ): "2024-02-29" + } + }, + ) + def test_ip_field(self): result = StringIO() with contextlib.redirect_stdout(result): @@ -967,13 +1143,17 @@ def test_uuid_field(self): def test_id_field(self): result = StringIO() - ids = ShellCompleteTester.objects.values_list("id", flat=True) + ids = ShellCompleteTester.objects.filter(id__isnull=False).values_list( + "id", flat=True + ) starts = {} for id in ids: starts.setdefault(str(id)[0], []).append(str(id)) start_chars = set(starts.keys()) + ids = ids[0:50] + with contextlib.redirect_stdout(result): call_command( "shellcompletion",