import typing as t
from datetime import date, time, timedelta
from functools import partial
from click import Context, Parameter
from click.core import ParameterSource
from click.shell_completion import CompletionItem
from django.conf import settings
from django.db import models
from django.db.models.query import QuerySet
def int_ranges(incomplete: str, max_val: int) -> t.List[t.Tuple[int, int]]:
lower = int(incomplete)
neg = lower < 0
lower = abs(lower)
upper = abs(lower + 1)
ranges = [(-upper, -lower)] if neg else [(lower, upper)]
while (lower := lower * 10) <= max_val:
upper *= 10
ranges.append((-upper, -lower) if neg else (lower, upper))
return ranges
[docs]
def int_query(
incomplete: str,
lookup_field: str,
queryset: QuerySet,
**_,
) -> models.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.
**This query will utilize the database index if one exists.**
:param incomplete: The incomplete string.
:param lookup_field: The name of the model field to use for lookup.
:param queryset: The starting queryset to use to determine integer ranges.
: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.
"""
qry = models.Q()
neg = incomplete.startswith("-")
for lower, upper in int_ranges(
incomplete,
queryset.aggregate(models.Max(lookup_field))[f"{lookup_field}__max"],
):
qry |= models.Q(
**{f"{lookup_field}__gt{'' if neg else 'e'}": lower}
) & models.Q(**{f"{lookup_field}__lt{'e' if neg else ''}": upper})
return qry
[docs]
def float_query(
incomplete: str, lookup_field: str, queryset: QuerySet, **_
) -> models.Q:
"""
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
**This query will utilize the database index if one exists.**
:param incomplete: The incomplete string.
:param lookup_field: The name of the model field to use for lookup.
:param queryset: The starting queryset to use to determine float ranges.
: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.
"""
incomplete = incomplete.rstrip("0").rstrip(".")
lower = float(incomplete)
if "." in incomplete:
upper = lower + float(
f"0.{'0' * (len(incomplete) - incomplete.index('.') - 2)}1"
)
else:
return int_query(
incomplete=incomplete, lookup_field=lookup_field, queryset=queryset
)
return models.Q(**{f"{lookup_field}__gte": lower}) & models.Q(
**{f"{lookup_field}__lt": upper}
)
[docs]
def text_query(
incomplete: str, lookup_field: str, case_insensitive: bool = False, **_
) -> models.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.
**This query will utilize the database index if one exists.**
:param incomplete: The incomplete string.
:param lookup_field: The name of the model field to use for lookup.
:param case_insensitive: If the lookup should be case insensitive or not.
:return: A Q object to use for filtering the queryset.
"""
if case_insensitive:
return models.Q(**{f"{lookup_field}__istartswith": incomplete})
return models.Q(**{f"{lookup_field}__startswith": incomplete})
[docs]
def uuid_query(incomplete: str, lookup_field: str, **_) -> t.Tuple[models.Q, int]:
"""
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.
**This query will utilize the database index if one exists.**
:param incomplete: The incomplete string.
:param lookup_field: The name of the model field to use for lookup.
:return: A 2-tuple where the first element is the Q object to use for filtering the
queryset and the second is the integer offset into the incomplete string where
the completion characters should be concatenated.
: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
# will be ignored, and the completion suggestions and parsing will still work
uuid = ""
offset = 0
for char in incomplete:
if char.isalnum():
uuid += char
else:
offset -= 1
if len(incomplete) >= 9:
offset += 1
if len(incomplete) >= 14:
offset += 1
if len(incomplete) >= 19:
offset += 1
if len(incomplete) >= 24:
offset += 1
if len(uuid) > 32:
raise ValueError(f"Too many UUID characters: {incomplete}")
min_uuid = UUID(uuid + "0" * (32 - len(uuid)))
max_uuid = UUID(uuid + "f" * (32 - len(uuid)))
return (
models.Q(**{f"{lookup_field}__gte": min_uuid})
& models.Q(**{f"{lookup_field}__lte": max_uuid}),
offset,
)
[docs]
def date_query(incomplete: str, lookup_field: str, **_) -> models.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).
**This query will utilize the database index if one exists.**
:param incomplete: The incomplete string.
:param lookup_field: The name of the model field to use for lookup.
: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.
"""
lower_bound, upper_bound = get_date_bounds(incomplete)
return models.Q(**{f"{lookup_field}__gte": lower_bound}) & models.Q(
**{f"{lookup_field}__lte": upper_bound}
)
[docs]
def time_query(incomplete: str, lookup_field: str, **_) -> models.Q:
"""
Default completion query builder for time fields. This method will return a Q
object that will match any value that starts with the incomplete time string.
All times must be in ISO 8601 format (HH:MM:SS.ssssss).
**This query will utilize the database index if one exists.**
:param incomplete: The incomplete string.
:param lookup_field: The name of the model field to use for lookup.
:return: A Q object to use for filtering the queryset.
:raises ValueError: If the incomplete string is not a valid partial time.
:raises AssertionError: If the incomplete string is not a valid partial time.
"""
lower_bound, upper_bound = get_time_bounds(incomplete)
return models.Q(**{f"{lookup_field}__gte": lower_bound}) & models.Q(
**{f"{lookup_field}__lte": upper_bound}
)
[docs]
def datetime_query(incomplete: str, lookup_field: str, **_) -> models.Q:
"""
Default completion query builder for datetime fields. This method will return a
Q object that will match any value that starts with the incomplete datetime
string. All dates must be in ISO8601 format (YYYY-MM-DDTHH:MM:SS.ssssss±HH:MM).
**This query will utilize the database index if one exists.**
:param incomplete: The incomplete string.
:param lookup_field: The name of the model field to use for lookup.
:return: A Q object to use for filtering the queryset.
:raises ValueError: If the incomplete string is not a valid partial datetime.
:raises AssertionError: If the incomplete string is not a valid partial
datetime.
"""
import re
from datetime import datetime
from django.utils.timezone import get_default_timezone, make_aware
parts = incomplete.split("T")
lower_bound, upper_bound = get_date_bounds(parts[0])
def get_tz_part(dt_str: str) -> str:
return dt_str[dt_str.rindex("+") if "+" in dt_str else dt_str.rindex("-") :]
time_lower = datetime.min.time()
time_upper = datetime.max.time()
tz_part = ""
if len(parts) > 1:
time_parts = re.split(r"[+-]", parts[1])
time_lower, time_upper = get_time_bounds(time_parts[0])
# we punt on the timezones - if the user supplies a partial timezone
# different than the default django timezone, its just too complicated to be
# worth trying to complete, we ensure it aligns as a prefix to the
# configured default timezone instead
if len(time_parts) > 1 and parts[1]:
tz_part = get_tz_part(parts[1])
lower_bound = datetime.combine(lower_bound, time_lower)
upper_bound = datetime.combine(upper_bound, time_upper)
if settings.USE_TZ:
lower_bound = make_aware(lower_bound, get_default_timezone())
upper_bound = make_aware(upper_bound, get_default_timezone())
db_tz_part = get_tz_part(lower_bound.isoformat())
assert db_tz_part.startswith(tz_part)
else:
assert not tz_part
return models.Q(**{f"{lookup_field}__gte": lower_bound}) & models.Q(
**{f"{lookup_field}__lte": upper_bound}
)
[docs]
def duration_query(
incomplete: str, lookup_field: str, queryset: QuerySet, **_
) -> models.Q:
"""
Default completion query builder for duration fields. This method will return a
Q object that will match any value that is greater than the incomplete duration
string (or less if negative). Duration strings are formatted in a subset of the
ISO8601 standard. Only day, hours, minutes and fractional seconds are supported.
Year, week and month specifiers are not.
**This query will utilize the database index if one exists.**
:param incomplete: The incomplete string.
:param lookup_field: The name of the model field to use for lookup.
:param queryset: The queryset to use to determine integer ranges.
:return: A Q object to use for filtering the queryset.
:raises ValueError: If the incomplete string is not a valid partial duration.
:raises AssertionError: If the incomplete string is not a valid partial
duration.
"""
from django_typer.utils import parse_iso_duration
duration, ambiguity = parse_iso_duration(incomplete)
if incomplete.endswith("S") or (duration.microseconds and not ambiguity):
return models.Q(**{f"{lookup_field}": duration})
neg = incomplete.startswith("-")
qry = models.Q()
horizon = None # time horizon is exclusive!
if ambiguity and "T" not in incomplete and "D" not in incomplete:
# days is unbounded
# if days == 5, we want to match 5-<6, 50-<60, 500-<600, etc
max_val = (
queryset.filter(qry).aggregate(models.Min(lookup_field))[
f"{lookup_field}__min"
]
if neg
else queryset.filter(qry).aggregate(models.Max(lookup_field))[
f"{lookup_field}__max"
]
)
for lower, upper in int_ranges(
ambiguity,
max_val.days,
):
qry |= models.Q(
**{f"{lookup_field}__gt{'' if neg else 'e'}": timedelta(days=lower)}
) & models.Q(
**{f"{lookup_field}__lt{'e' if neg else ''}": timedelta(days=upper)}
)
elif duration.days or "D" in incomplete or "T" in incomplete:
horizon = timedelta(days=1)
if "T" in incomplete:
hours, seconds = divmod(duration.seconds, 3600)
minutes, seconds = divmod(seconds, 60)
# if we are here, we may or may not have an ambiguous time component
# or (exclusively) we may be missing time components
if ambiguity is None:
# handle no ambiguity first
# there's no way to be here and have any ambiguity or non-ambiguous
# microseconds ex: PT PT1. PT1M PT1H PT1H1M
if incomplete.endswith("M"):
horizon = timedelta(minutes=1)
elif incomplete.endswith("H"):
horizon = timedelta(hours=1)
elif not incomplete.endswith("T"):
horizon = timedelta(seconds=1)
else:
# we have a trailing ambiguity
if "." in incomplete or seconds:
# microsecond ambiguity
# 5.000 -> the most this could be is 5.000999
floor = int(f"{ambiguity:0<6}")
duration = timedelta(
days=abs(duration.days),
seconds=abs(duration.seconds),
microseconds=floor,
)
duration = -duration if neg else duration
horizon = timedelta(
microseconds=int(ambiguity + "9" * (6 - len(ambiguity))) + 1 - floor
)
else:
# ambiguity is at least a seconds ambiguity
int_amb = int(ambiguity)
compound_horizon: t.List[
t.Tuple[int, int]
] = [] # seconds horizons small -> large
compound_horizon.append(
(
int_amb,
int_amb + 1,
)
)
compound_horizon.append(
(int(f"{ambiguity}0"), min(int(f"{ambiguity}9") + 1, 60))
)
if "M" not in incomplete:
# ambiguity is minutes or seconds
compound_horizon.append(
(
int_amb * 60,
int_amb * 60 + 60,
)
)
if len(ambiguity) == 1:
compound_horizon.append(
(
int(f"{ambiguity}0") * 60,
min(int(f"{ambiguity}9") + 1, 60) * 60,
)
)
if "H" not in incomplete:
# ambiguity is hours or minutes or seconds
# bug here T1 could be T1H or T10H-T19H
compound_horizon.append(
(
int_amb * 3600,
int_amb * 3600 + 3600,
)
)
if len(ambiguity) == 1:
compound_horizon.append(
(
int(f"{ambiguity}0") * 3600,
min(int(f"{ambiguity}9") + 1, 24) * 3600,
)
)
c_qry = models.Q()
for lower, upper in compound_horizon:
lwr, upr = (
(
duration - timedelta(seconds=upper),
duration - timedelta(seconds=lower),
)
if neg
else (
duration + timedelta(seconds=lower),
duration + timedelta(seconds=upper),
)
)
h_qry = models.Q(
**{f"{lookup_field}__gt{'' if neg else 'e'}": lwr}
) & models.Q(**{f"{lookup_field}__lt{'e' if neg else ''}": upr})
c_qry |= h_qry
qry &= c_qry
inclusive = "" if incomplete.endswith("T") and duration.days else "e"
qry &= (
models.Q(**{f"{lookup_field}__lt{inclusive}": duration})
if neg
else models.Q(**{f"{lookup_field}__gt{inclusive}": duration})
)
if horizon:
qry &= (
models.Q(**{f"{lookup_field}__gt": duration - horizon})
if neg
else models.Q(**{f"{lookup_field}__lt": duration + horizon})
)
return qry
[docs]
def get_date_bounds(incomplete: str) -> t.Tuple[date, date]:
"""
Turn an incomplete YYYY-MM-DD date string into upper and lower bound date
objects.
:param incomplete: The incomplete time string.
:return: A 2-tuple of (lower, upper) date object boundaries.
"""
import calendar
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 lower_bound, upper_bound
[docs]
def get_time_bounds(incomplete: str) -> t.Tuple[time, time]:
"""
Turn an incomplete HH::MM::SS.ssssss time string into upper and lower bound time
objects.
:param incomplete: The incomplete time string.
:return: A 2-tuple of (lower, upper) time object boundaries.
"""
time_parts = incomplete.split(":")
if time_parts and time_parts[0]:
hours_low = int(time_parts[0] + "0" * (2 - len(time_parts[0])))
hours_high = min(int(time_parts[0] + "9" * (2 - len(time_parts[0]))), 23)
minutes_low = 0
minutes_high = 59
seconds_low = 0
seconds_high = 59
microseconds_low = 0
microseconds_high = 999999
if len(time_parts) > 1:
assert len(time_parts[0]) > 1 # Hours must be 2 digits
minutes_low = int(time_parts[1] + "0" * (2 - len(time_parts[1])))
minutes_high = min(int(time_parts[1] + "9" * (2 - len(time_parts[1]))), 59)
if len(time_parts) > 2:
seconds_parts = time_parts[2].split(".")
int_seconds = seconds_parts[0]
assert len(time_parts[1]) > 1 # Minutes must be 2 digits
seconds_low = int(int_seconds + "0" * (2 - len(int_seconds)))
seconds_high = min(int(int_seconds + "9" * (2 - len(int_seconds))), 59)
if len(seconds_parts) > 1:
microseconds = seconds_parts[1]
microseconds_low = int(microseconds + "0" * (6 - len(microseconds)))
microseconds_high = int(
microseconds + "9" * (6 - len(microseconds))
)
return time(
hour=hours_low,
minute=minutes_low,
second=seconds_low,
microsecond=microseconds_low,
), time(
hour=hours_high,
minute=minutes_high,
second=seconds_high,
microsecond=microseconds_high,
)
return time.min, time.max
[docs]
class ModelObjectCompleter:
"""
A completer for generic Django model objects. This completer will work
for most Django core model field types where completion makes sense.
This completer currently supports the following field types and their subclasses:
- :class:`~django.db.models.IntegerField`
- :class:`~django.db.models.AutoField`
- :class:`~django.db.models.BigAutoField`
- :class:`~django.db.models.BigIntegerField`
- :class:`~django.db.models.SmallIntegerField`
- :class:`~django.db.models.PositiveIntegerField`
- :class:`~django.db.models.PositiveSmallIntegerField`
- :class:`~django.db.models.SmallAutoField`
- :class:`~django.db.models.CharField`
- :class:`~django.db.models.SlugField`
- :class:`~django.db.models.URLField`
- :class:`~django.db.models.EmailField`
- :class:`~django.db.models.FileField`
- :class:`~django.db.models.ImageField`
- :class:`~django.db.models.FilePathField`
- :class:`~django.db.models.TextField`
- :class:`~django.db.models.DateField` **(Must use ISO 8601: YYYY-MM-DD)**
- :class:`~django.db.models.TimeField` **(Must use ISO 8601: HH:MM:SS.ssssss)**
- :class:`~django.db.models.DateTimeField` **(Must use ISO 8601: YYYY-MM-DDTHH:MM:SS.ssssss±HH:MM)**
- :class:`~django.db.models.DurationField` **(Must use ISO 8601: YYYY-MM-DDTHH:MM:SS.ssssss±HH:MM)**
- :class:`~django.db.models.UUIDField`
- :class:`~django.db.models.FloatField`
- :class:`~django.db.models.DecimalField`
- :class:`~django.db.models.GenericIPAddressField`
.. note::
The queries used by this completer will make use of column indexes. Completions
should be fast even for large data.
The completer query logic is pluggable, but the defaults cover most use cases. The
limit field is important. It defaults to 50 meaning if more than 50 potential
completions are found only the first 50 will be returned and there will be no
indication to the user that there are more. This is to prevent the shell from
becoming unresponsive when offering completion for large tables.
To use this completer, pass an instance of this class to the `shell_complete`
argument of a typer.Option or typer.Argument:
.. code-block:: python
from django_typer.completers import ModelObjectCompleter
class Command(TyperCommand):
def handle(
self,
model_obj: Annotated[
MyModel,
typer.Argument(
shell_complete=ModelObjectCompleter(MyModel, lookup_field="name"),
help=_("The model object to use.")
)
]
):
...
.. note::
See also :func:`~django_typer.utils.model_parser_completer` for a convenience
function that returns a configured parser and completer for a model object
and helps reduce boilerplate.
:param model_or_qry: The Django model class or a queryset to filter against.
: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 any named arguments and returns a Q object.
It will be passed:
- **incomplete** the incomplete string
- **lookup_field** the name of the model field to use for lookup
- **queryset** the base queryset to use for completions
- **context** the click context
- **parameter** the click parameter
- **completer** an instance of this ModelObjectCompleter class
It is not required to use those arguments, but they are available.
It must return a Q object to use for filtering the queryset. The default query
will use the relevant query builder depending on the lookup field class.
..note::
The query builder function may also return a second integer offset value.
This value will be used to adjust the index into the completion strings when
we concatenate the incomplete string with the lookup field value - see UUID
which has to allow for - to be missing
: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 False.
: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.
:param order_by: The order_by parameter to prioritize completions in. By default
the default queryset ordering will be used for the model.
:param use_choices: Whether or not to use the field choices for completion. If True,
matches to choice values coerced to strings will be returned. If False, the
field's default query builder will be used instead.
"""
QueryBuilder = t.Callable[
["ModelObjectCompleter", Context, Parameter, str], models.Q
]
model_cls: t.Type[models.Model]
queryset: QuerySet
lookup_field: str
help_field: t.Optional[str] = None
query: t.Callable[..., t.Union[models.Q, t.Tuple[models.Q, int]]]
limit: t.Optional[int] = 50
case_insensitive: bool = False
distinct: bool = True
order_by: t.List[str] = []
use_choices: bool = True
_field: models.Field
[docs]
@staticmethod
def to_str(obj: t.Any) -> str:
"""
Convert the given object into a string suitable for use in completions.
"""
from datetime import datetime
from django.utils.timezone import get_default_timezone
if isinstance(obj, datetime):
if settings.USE_TZ and get_default_timezone():
obj = obj.astimezone(get_default_timezone())
return obj.isoformat()
elif isinstance(obj, time):
return obj.isoformat()
elif isinstance(obj, date):
return obj.isoformat()
elif isinstance(obj, timedelta):
from django_typer.utils import duration_iso_string
return duration_iso_string(obj)
return str(obj)
[docs]
def __init__(
self,
model_or_qry: t.Union[t.Type[models.Model], QuerySet],
lookup_field: t.Optional[str] = None,
help_field: t.Optional[str] = help_field,
query: t.Optional[QueryBuilder] = None,
limit: t.Optional[int] = limit,
case_insensitive: bool = case_insensitive,
distinct: bool = distinct,
order_by: t.Optional[t.Union[str, t.Sequence[str]]] = order_by,
use_choices: bool = use_choices,
):
import inspect
if inspect.isclass(model_or_qry) and issubclass(model_or_qry, models.Model):
self.model_cls = model_or_qry
self.queryset = model_or_qry.objects.all()
elif isinstance(model_or_qry, QuerySet):
self.model_cls = model_or_qry.model
self.queryset = model_or_qry
else:
raise ValueError(
"ModelObjectCompleter requires a Django model class or queryset."
)
self.lookup_field = str(
lookup_field or getattr(self.model_cls._meta.pk, "name", "id")
)
self.help_field = help_field
self.limit = limit
self.case_insensitive = case_insensitive
self.distinct = distinct
if order_by:
self.order_by = [order_by] if isinstance(order_by, str) else list(order_by)
self.use_choices = use_choices
self._field = self.model_cls._meta.get_field(self.lookup_field)
if query:
self.query = query
else:
if isinstance(self._field, models.IntegerField):
self.query = int_query
elif isinstance(
self._field,
(
models.CharField,
models.TextField,
models.GenericIPAddressField,
models.FileField,
models.FilePathField,
),
):
self.query = partial(
text_query,
case_insensitive=self.case_insensitive,
)
elif isinstance(self._field, models.UUIDField):
self.query = uuid_query
elif isinstance(self._field, (models.FloatField, models.DecimalField)):
self.query = float_query
elif isinstance(self._field, models.DateTimeField):
self.query = datetime_query
elif isinstance(self._field, models.DateField):
self.query = date_query
elif isinstance(self._field, models.TimeField):
self.query = time_query
elif isinstance(self._field, models.DurationField):
self.query = duration_query
else:
raise ValueError(
f"Unsupported lookup field class: {self._field.__class__.__name__}"
)
def __call__(
self, context: Context, parameter: Parameter, incomplete: str
) -> t.List[CompletionItem]:
"""
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 = models.Q(**{self.lookup_field + "__isnull": False})
offset = 0
if incomplete:
try:
result = self.query(
incomplete=incomplete,
lookup_field=self.lookup_field,
queryset=self.queryset,
context=context,
parameter=parameter,
completer=self,
)
if isinstance(result, tuple):
completion_qry &= result[0]
offset = result[1] if len(result) > 1 else 0
else:
completion_qry &= result
except (ValueError, TypeError, AssertionError):
return []
columns = [self.lookup_field]
if self.help_field:
columns.append(self.help_field)
excluded: t.List[models.Model] = []
if (
self.distinct
and parameter.name
and context.get_parameter_source(parameter.name)
is not ParameterSource.DEFAULT
):
excluded = context.params.get(parameter.name, []) or []
qryset = self.queryset.filter(completion_qry).exclude(
pk__in=[ex.pk for ex in excluded]
)
if self.order_by:
qryset = qryset.order_by(*self.order_by)
completions = []
for values in qryset.distinct().values_list(*columns)[0 : self.limit]:
str_value = self.to_str(values[0])
if str_value:
completions.append(
CompletionItem(
# use the incomplete string prefix incase this was a case
# insensitive match
value=incomplete + str_value[len(incomplete) + offset :],
help=values[1] if len(values) > 1 else None,
)
)
return completions