Skip to content

Commit 6573caa

Browse files
committed
fix #13 and fix #14
1 parent 5c89f27 commit 6573caa

File tree

8 files changed

+574
-17
lines changed

8 files changed

+574
-17
lines changed

django_typer/completers.py

Lines changed: 142 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import os
2020
import sys
2121
import typing as t
22+
from datetime import date, time
2223
from functools import partial
2324
from pathlib import Path
2425
from types import MethodType
@@ -32,6 +33,7 @@
3233
from django.db.models import (
3334
CharField,
3435
DateField,
36+
DateTimeField,
3537
DecimalField,
3638
Field,
3739
FileField,
@@ -44,6 +46,7 @@
4446
Model,
4547
Q,
4648
TextField,
49+
TimeField,
4750
UUIDField,
4851
)
4952
from django.db.models.query import QuerySet
@@ -77,6 +80,8 @@ class ModelObjectCompleter:
7780
- `FilePathField <https://docs.djangoproject.com/en/stable/ref/models/fields/#filepathfield>`_
7881
- `TextField <https://docs.djangoproject.com/en/stable/ref/models/fields/#textfield>`_
7982
- `DateField <https://docs.djangoproject.com/en/stable/ref/models/fields/#datefield>`_ **(Must use ISO 8601: YYYY-MM-DD)**
83+
- `TimeField <https://docs.djangoproject.com/en/stable/ref/models/fields/#timefield>`_ **(Must use ISO 8601: HH:MM:SS.ssssss)**
84+
- `DateTimeField <https://docs.djangoproject.com/en/stable/ref/models/fields/#datetimefield>`_ **(Must use ISO 8601: YYYY-MM-DDTHH:MM:SS.ssssss±HH:MM)**
8085
- `UUIDField <https://docs.djangoproject.com/en/stable/ref/models/fields/#uuidfield>`_
8186
- `FloatField <https://docs.djangoproject.com/en/stable/ref/models/fields/#floatfield>`_
8287
- `DecimalField <https://docs.djangoproject.com/en/stable/ref/models/fields/#decimalfield>`_
@@ -158,6 +163,14 @@ def queryset(self) -> t.Union[QuerySet, Manager[Model]]:
158163
return self._queryset or self.model_cls.objects
159164

160165
def to_str(self, obj: t.Any) -> str:
166+
from datetime import datetime
167+
168+
if isinstance(obj, datetime):
169+
return obj.isoformat()
170+
elif isinstance(obj, time):
171+
return obj.isoformat()
172+
elif isinstance(obj, date):
173+
return obj.isoformat()
161174
return str(obj)
162175

163176
def int_query(self, context: Context, parameter: Parameter, incomplete: str) -> Q:
@@ -280,21 +293,14 @@ def uuid_query(self, context: Context, parameter: Parameter, incomplete: str) ->
280293
**{f"{self.lookup_field}__lte": max_uuid}
281294
)
282295

283-
def date_query(self, context: Context, parameter: Parameter, incomplete: str) -> Q:
296+
def _get_date_bounds(self, incomplete: str) -> t.Tuple[date, date]:
284297
"""
285-
Default completion query builder for date fields. This method will return a Q object that
286-
will match any value that starts with the incomplete date string. All dates must be in
287-
ISO8601 format (YYYY-MM-DD).
298+
Turn an incomplete YYYY-MM-DD date string into upper and lower bound date objects.
288299
289-
:param context: The click context.
290-
:param parameter: The click parameter.
291-
:param incomplete: The incomplete string.
292-
:return: A Q object to use for filtering the queryset.
293-
:raises ValueError: If the incomplete string is not a valid partial date.
294-
:raises AssertionError: If the incomplete string is not a valid partial date.
300+
:param incomplete: The incomplete time string.
301+
:return: A 2-tuple of (lower, upper) date object boundaries.
295302
"""
296303
import calendar
297-
from datetime import date
298304

299305
parts = incomplete.split("-")
300306
year_low = max(int(parts[0] + "0" * (4 - len(parts[0]))), 1)
@@ -320,6 +326,127 @@ def date_query(self, context: Context, parameter: Parameter, incomplete: str) ->
320326
month=month_high,
321327
day=day_high or calendar.monthrange(year_high, month_high)[1],
322328
)
329+
return lower_bound, upper_bound
330+
331+
def _get_time_bounds(self, incomplete: str) -> t.Tuple[time, time]:
332+
"""
333+
Turn an incomplete HH::MM::SS.ssssss time string into upper and lower bound time
334+
objects.
335+
336+
:param incomplete: The incomplete time string.
337+
:return: A 2-tuple of (lower, upper) time object boundaries.
338+
"""
339+
time_parts = incomplete.split(":")
340+
if len(time_parts) > 0:
341+
hours_low = int(time_parts[0] + "0" * (2 - len(time_parts[0])))
342+
hours_high = min(int(time_parts[0] + "9" * (2 - len(time_parts[0]))), 23)
343+
minutes_low = 0
344+
minutes_high = 59
345+
seconds_low = 0
346+
seconds_high = 59
347+
microseconds_low = 0
348+
microseconds_high = 999999
349+
if len(time_parts) > 1:
350+
assert len(time_parts[0]) > 1 # Hours must be 2 digits
351+
minutes_low = int(time_parts[1] + "0" * (2 - len(time_parts[1])))
352+
minutes_high = min(
353+
int(time_parts[1] + "9" * (2 - len(time_parts[1]))), 59
354+
)
355+
if len(time_parts) > 2:
356+
seconds_parts = time_parts[2].split(".")
357+
int_seconds = seconds_parts[0]
358+
assert len(time_parts[1]) > 1 # Minutes must be 2 digits
359+
seconds_low = int(int_seconds + "0" * (2 - len(int_seconds)))
360+
seconds_high = min(
361+
int(int_seconds + "9" * (2 - len(int_seconds))), 59
362+
)
363+
if len(seconds_parts) > 1:
364+
microseconds = seconds_parts[1]
365+
microseconds_low = int(
366+
microseconds + "0" * (6 - len(microseconds))
367+
)
368+
microseconds_high = int(
369+
microseconds + "9" * (6 - len(microseconds))
370+
)
371+
return time(
372+
hour=hours_low,
373+
minute=minutes_low,
374+
second=seconds_low,
375+
microsecond=microseconds_low,
376+
), time(
377+
hour=hours_high,
378+
minute=minutes_high,
379+
second=seconds_high,
380+
microsecond=microseconds_high,
381+
)
382+
return time.min, time.max
383+
384+
def date_query(self, context: Context, parameter: Parameter, incomplete: str) -> Q:
385+
"""
386+
Default completion query builder for date fields. This method will return a Q object that
387+
will match any value that starts with the incomplete date string. All dates must be in
388+
ISO8601 format (YYYY-MM-DD).
389+
390+
:param context: The click context.
391+
:param parameter: The click parameter.
392+
:param incomplete: The incomplete string.
393+
:return: A Q object to use for filtering the queryset.
394+
:raises ValueError: If the incomplete string is not a valid partial date.
395+
:raises AssertionError: If the incomplete string is not a valid partial date.
396+
"""
397+
lower_bound, upper_bound = self._get_date_bounds(incomplete)
398+
return Q(**{f"{self.lookup_field}__gte": lower_bound}) & Q(
399+
**{f"{self.lookup_field}__lte": upper_bound}
400+
)
401+
402+
def time_query(self, context: Context, parameter: Parameter, incomplete: str) -> Q:
403+
"""
404+
Default completion query builder for time fields. This method will return a Q object that
405+
will match any value that starts with the incomplete time string. All times must be in
406+
ISO 8601 format (HH:MM:SS.ssssss).
407+
408+
:param context: The click context.
409+
:param parameter: The click parameter.
410+
:param incomplete: The incomplete string.
411+
:return: A Q object to use for filtering the queryset.
412+
:raises ValueError: If the incomplete string is not a valid partial time.
413+
:raises AssertionError: If the incomplete string is not a valid partial time.
414+
"""
415+
lower_bound, upper_bound = self._get_time_bounds(incomplete)
416+
return Q(**{f"{self.lookup_field}__gte": lower_bound}) & Q(
417+
**{f"{self.lookup_field}__lte": upper_bound}
418+
)
419+
420+
def datetime_query(
421+
self, context: Context, parameter: Parameter, incomplete: str
422+
) -> Q:
423+
"""
424+
Default completion query builder for datetime fields. This method will return a Q object that
425+
will match any value that starts with the incomplete datetime string. All dates must be in
426+
ISO8601 format (YYYY-MM-DDTHH:MM:SS.ssssss±HH:MM).
427+
428+
:param context: The click context.
429+
:param parameter: The click parameter.
430+
:param incomplete: The incomplete string.
431+
:return: A Q object to use for filtering the queryset.
432+
:raises ValueError: If the incomplete string is not a valid partial datetime.
433+
:raises AssertionError: If the incomplete string is not a valid partial datetime.
434+
"""
435+
import re
436+
from datetime import datetime
437+
438+
parts = incomplete.split("T")
439+
lower_bound, upper_bound = self._get_date_bounds(parts[0])
440+
441+
time_lower = datetime.min.time()
442+
time_upper = datetime.max.time()
443+
if len(parts) > 1:
444+
time_parts = re.split(r"[+-]", parts[1])
445+
time_lower, time_upper = self._get_time_bounds(time_parts[0])
446+
# if len(time_parts) > 1:
447+
# TODO - handle timezone??
448+
lower_bound = datetime.combine(lower_bound, time_lower)
449+
upper_bound = datetime.combine(upper_bound, time_upper)
323450
return Q(**{f"{self.lookup_field}__gte": lower_bound}) & Q(
324451
**{f"{self.lookup_field}__lte": upper_bound}
325452
)
@@ -370,8 +497,12 @@ def __init__(
370497
self.query = self.uuid_query
371498
elif isinstance(self._field, (FloatField, DecimalField)):
372499
self.query = self.float_query
500+
elif isinstance(self._field, DateTimeField):
501+
self.query = self.datetime_query
373502
elif isinstance(self._field, DateField):
374503
self.query = self.date_query
504+
elif isinstance(self._field, TimeField):
505+
self.query = self.time_query
375506
else:
376507
raise ValueError(
377508
_("Unsupported lookup field class: {cls}").format(

django_typer/parsers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"""
2424

2525
import typing as t
26-
from datetime import date
26+
from datetime import date, datetime, time
2727
from uuid import UUID
2828

2929
from click import Context, Parameter, ParamType
@@ -99,8 +99,12 @@ def _get_metavar(self) -> str:
9999
return "FLOAT"
100100
elif isinstance(self._field, (models.FileField, models.FilePathField)):
101101
return "PATH"
102+
elif isinstance(self._field, models.DateTimeField):
103+
return "ISO 8601"
102104
elif isinstance(self._field, models.DateField):
103105
return "YYYY-MM-DD"
106+
elif isinstance(self._field, models.TimeField):
107+
return "HH:MM:SS.sss"
104108
return "TXT"
105109

106110
def __init__(
@@ -152,8 +156,12 @@ def convert(
152156
if char.isalnum():
153157
uuid += char
154158
value = UUID(uuid)
159+
elif isinstance(self._field, models.DateTimeField):
160+
value = datetime.fromisoformat(value)
155161
elif isinstance(self._field, models.DateField):
156162
value = date.fromisoformat(value)
163+
elif isinstance(self._field, models.TimeField):
164+
value = time.fromisoformat(value)
157165
return self.model_cls.objects.get(
158166
**{f"{self.lookup_field}{self._lookup}": value}
159167
)

django_typer/shells/zsh.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@ def install_dir(self) -> Path:
5454

5555
def format_completion(self, item: CompletionItem) -> str:
5656
def escape(s: str) -> str:
57+
# TODO is any of this necessary?
5758
return (
5859
s.replace('"', '""')
5960
.replace("'", "''")
6061
.replace("$", "\\$")
6162
.replace("`", "\\`")
62-
.replace(":", r"\\:")
63+
# .replace(":", r"\\:")
6364
)
6465

6566
return f"{item.type}\n{escape(self.process_rich_text(item.value))}\n{escape(self.process_rich_text(item.help)) if item.help else '_'}"

doc/source/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ v3.0.0 (202X-XX-XX)
2828
* Implemented `Add completer/parser for FileField and FilePathField <https://github.com/django-commons/django-typer/issues/17>`_
2929
* Implemented `Add completer/parser for DateTimeField <https://github.com/django-commons/django-typer/issues/15>`_
3030
* Implemented `Add completer/parser for DateField <https://github.com/django-commons/django-typer/issues/14>`_
31+
* Implemented `Add completer/parser for TimeField <https://github.com/django-commons/django-typer/issues/13>`_
3132

3233

3334
Migrating from 2.x to 3.x

tests/apps/test_app/management/commands/model_fields.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,20 @@ def test(
170170
help=t.cast(str, _("Fetch objects by their date fields.")),
171171
),
172172
] = None,
173+
datetime: Annotated[
174+
t.Optional[ShellCompleteTester],
175+
typer.Option(
176+
**model_parser_completer(ShellCompleteTester, "datetime_field"),
177+
help=t.cast(str, _("Fetch objects by their datetime fields.")),
178+
),
179+
] = None,
180+
time: Annotated[
181+
t.Optional[ShellCompleteTester],
182+
typer.Option(
183+
**model_parser_completer(ShellCompleteTester, "time_field"),
184+
help=t.cast(str, _("Fetch objects by their time fields.")),
185+
),
186+
] = None,
173187
):
174188
assert self.__class__ is Command
175189
objects = {}
@@ -216,4 +230,10 @@ def test(
216230
if date is not None:
217231
assert isinstance(date, ShellCompleteTester)
218232
objects["date"] = {date.id: str(date.date_field)}
233+
if datetime is not None:
234+
assert isinstance(datetime, ShellCompleteTester)
235+
objects["datetime"] = {datetime.id: str(datetime.datetime_field)}
236+
if time is not None:
237+
assert isinstance(time, ShellCompleteTester)
238+
objects["time"] = {time.id: str(time.time_field)}
219239
return json.dumps(objects)

tests/apps/test_app/migrations/0001_initial.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 4.2.18 on 2025-01-17 05:46
1+
# Generated by Django 4.2.18 on 2025-01-21 04:09
22

33
from django.db import migrations, models
44

@@ -69,6 +69,14 @@ class Migration(migrations.Migration):
6969
"date_field",
7070
models.DateField(db_index=True, default=None, null=True),
7171
),
72+
(
73+
"datetime_field",
74+
models.DateTimeField(db_index=True, default=None, null=True),
75+
),
76+
(
77+
"time_field",
78+
models.TimeField(db_index=True, default=None, null=True),
79+
),
7280
],
7381
),
7482
]

tests/apps/test_app/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ class ShellCompleteTester(models.Model):
2525
file_path_field = models.FilePathField(null=True, default=None, db_index=True)
2626

2727
date_field = models.DateField(null=True, default=None, db_index=True)
28+
29+
datetime_field = models.DateTimeField(null=True, default=None, db_index=True)
30+
31+
time_field = models.TimeField(null=True, default=None, db_index=True)

0 commit comments

Comments
 (0)