Skip to content

Commit

Permalink
fix #14 fix #160
Browse files Browse the repository at this point in the history
  • Loading branch information
bckohan committed Jan 17, 2025
1 parent a3cfdd3 commit bad591f
Show file tree
Hide file tree
Showing 7 changed files with 265 additions and 9 deletions.
60 changes: 55 additions & 5 deletions django_typer/completers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +31,7 @@
from django.core.management import get_commands
from django.db.models import (
CharField,
DateField,
DecimalField,
Field,
FileField,
Expand Down Expand Up @@ -78,6 +76,7 @@ class ModelObjectCompleter:
- `ImageField <https://docs.djangoproject.com/en/stable/ref/models/fields/#imagefield>`_
- `FilePathField <https://docs.djangoproject.com/en/stable/ref/models/fields/#filepathfield>`_
- `TextField <https://docs.djangoproject.com/en/stable/ref/models/fields/#textfield>`_
- `DateField <https://docs.djangoproject.com/en/stable/ref/models/fields/#datefield>`_ **(Must use ISO 8601 format (YYYY-MM-DD)**
- `UUIDField <https://docs.djangoproject.com/en/stable/ref/models/fields/#uuidfield>`_
- `FloatField <https://docs.djangoproject.com/en/stable/ref/models/fields/#floatfield>`_
- `DecimalField <https://docs.djangoproject.com/en/stable/ref/models/fields/#decimalfield>`_
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand All @@ -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):
Expand Down Expand Up @@ -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(
Expand All @@ -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]] = []
Expand Down Expand Up @@ -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]
Expand Down
9 changes: 8 additions & 1 deletion django_typer/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"""

import typing as t
from datetime import date
from uuid import UUID

from click import Context, Parameter, ParamType
Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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}
)
Expand Down
3 changes: 3 additions & 0 deletions doc/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/django-commons/django-typer/issues/160>`_
* Implemented `Add security scans to CI. <https://github.com/django-commons/django-typer/issues/158>`_
* Implemented `Add a performance regression. <https://github.com/django-commons/django-typer/issues/157>`_
* Implemented `Use in-house shell completer classes. <https://github.com/django-commons/django-typer/issues/156>`_
Expand All @@ -25,6 +26,8 @@ v3.0.0 (202X-XX-XX)
* Fixed `Installed shellcompletion scripts do not pass values of --settings or --pythonpath <https://github.com/django-commons/django-typer/issues/68>`_
* Fixed `shellcompletion complete should print to the command's stdout. <https://github.com/django-commons/django-typer/issues/19>`_
* Implemented `Add completer/parser for FileField and FilePathField <https://github.com/django-commons/django-typer/issues/17>`_
* Implemented `Add completer/parser for DateField <https://github.com/django-commons/django-typer/issues/14>`_


Migrating from 2.x to 3.x
-------------------------
Expand Down
12 changes: 11 additions & 1 deletion tests/apps/test_app/management/commands/model_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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)
6 changes: 5 additions & 1 deletion tests/apps/test_app/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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),
),
],
),
]
2 changes: 2 additions & 0 deletions tests/apps/test_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading

0 comments on commit bad591f

Please sign in to comment.