Skip to content

Commit

Permalink
correct datetime timezone behavior, add order_by parameter on complet…
Browse files Browse the repository at this point in the history
…ers and add postgres tests
  • Loading branch information
bckohan committed Jan 21, 2025
1 parent c16ea21 commit 08b2806
Show file tree
Hide file tree
Showing 8 changed files with 899 additions and 855 deletions.
68 changes: 68 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,74 @@ jobs:
name: ${{ env.COVERAGE_FILE }}
path: ${{ env.COVERAGE_FILE }}

postgres:
runs-on: ubuntu-latest
# Service containers to run with `container-job`
env:
RDBMS: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
COVERAGE_FILE: postgres-py${{ matrix.python-version }}-dj${{ matrix.django-version }}.coverage
strategy:
matrix:
python-version: ['3.13']
postgres-version: ['latest']
django-version:
- '5.1' # December 2025

# Service containers to run with `runner-job`
services:
# Label used to access the service container
postgres:
# Docker Hub image
image: postgres:${{ matrix.postgres-version }}
# Provide the password for postgres
env:
POSTGRES_PASSWORD: postgres
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
# Maps tcp port 5432 on service container to the host
- 5432:5432

steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: install-emacs-macos
if: ${{ github.event.inputs.debug == 'true' }}
run: |
brew install emacs
- name: setup-ssh-debug
if: ${{ github.event.inputs.debug == 'true' }}
uses: mxschmitt/action-tmate@v3
with:
detached: true
- name: Setup Just
run: |
brew install just
- name: Install
run: |
just init
just install --with psycopg3
just pin-dependency Django~=${{ matrix.django-version }}
- name: Run Unit Tests
run: |
just test-all
- name: Store coverage files
uses: actions/upload-artifact@v4
with:
name: ${{ env.COVERAGE_FILE }}
path: ${{ env.COVERAGE_FILE }}

linux-bash-complete:
runs-on: ubuntu-latest
strategy:
Expand Down
73 changes: 53 additions & 20 deletions django_typer/completers.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ def handle(
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.
"""

QueryBuilder = t.Callable[["ModelObjectCompleter", Context, Parameter, str], Q]
Expand All @@ -150,6 +152,7 @@ def handle(
limit: t.Optional[int] = 50
case_insensitive: bool = False
distinct: bool = True
order_by: t.List[str] = []

# used to adjust the index into the completion strings when we concatenate
# the incomplete string with the lookup field value - see UUID which has
Expand All @@ -165,7 +168,11 @@ def queryset(self) -> t.Union[QuerySet, Manager[Model]]:
def to_str(self, obj: t.Any) -> str:
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()
Expand Down Expand Up @@ -435,18 +442,35 @@ def datetime_query(
import re
from datetime import datetime

from django.utils.timezone import get_default_timezone, make_aware

parts = incomplete.split("T")
lower_bound, upper_bound = self._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 = self._get_time_bounds(time_parts[0])
# if len(time_parts) > 1:
# TODO - handle timezone??
# 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 Q(**{f"{self.lookup_field}__gte": lower_bound}) & Q(
**{f"{self.lookup_field}__lte": upper_bound}
)
Expand All @@ -460,6 +484,7 @@ def __init__(
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,
):
import inspect

Expand All @@ -479,6 +504,8 @@ def __init__(
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._field = self.model_cls._meta.get_field( # pylint: disable=protected-access
self.lookup_field
Expand Down Expand Up @@ -512,7 +539,7 @@ def __init__(

def __call__(
self, context: Context, parameter: Parameter, incomplete: str
) -> t.Union[t.List[CompletionItem], t.List[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
Expand All @@ -537,7 +564,11 @@ def __call__(
except (ValueError, TypeError, AssertionError):
return []

excluded: t.List[t.Type[Model]] = []
columns = [self.lookup_field]
if self.help_field:
columns.append(self.help_field)

excluded: t.List[Model] = []
if (
self.distinct
and parameter.name
Expand All @@ -546,22 +577,24 @@ def __call__(
):
excluded = context.params.get(parameter.name, []) or []

return [
CompletionItem(
# use the incomplete string prefix incase this was a case insensitive match
value=incomplete
+ self.to_str(getattr(obj, self.lookup_field))[
len(incomplete) + self._offset :
],
help=getattr(obj, self.help_field, None) if self.help_field else "",
)
for obj in self.queryset.filter(completion_qry).distinct()[0 : self.limit]
if (
getattr(obj, self.lookup_field) is not None
and self.to_str(getattr(obj, self.lookup_field))
and obj not in excluded
)
]
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) + self._offset :],
help=values[1] if len(values) > 1 else None,
)
)
return completions


def complete_app_label(
Expand Down
2 changes: 2 additions & 0 deletions django_typer/management/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def model_parser_completer(
limit: t.Optional[int] = ModelObjectCompleter.limit,
distinct: bool = ModelObjectCompleter.distinct,
on_error: t.Optional[ModelObjectParser.error_handler] = ModelObjectParser.on_error,
order_by: t.Optional[t.Union[str, t.Sequence[str]]] = None,
) -> t.Dict[str, t.Any]:
"""
A factory function that returns a dictionary that can be used to specify
Expand Down Expand Up @@ -176,6 +177,7 @@ def handle(
query=query,
limit=limit,
distinct=distinct,
order_by=order_by,
),
}

Expand Down
4 changes: 2 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ init:
install-precommit:
poetry run pre-commit install

install:
install *OPTS:
poetry env use python
poetry lock
poetry install -E rich
poetry install -E rich {{ OPTS }}
poetry run pre-commit install

install-docs:
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ Sphinx = [
docutils = ">=0.21"
sphinx-autobuild = ">=2024.10.3"

[tool.poetry.group.psycopg3]
optional = true

[tool.poetry.group.psycopg3.dependencies]
psycopg = "^3.1.8"

[tool.mypy]
# The mypy configurations: http://bit.ly/2zEl9WI
allow_redefinition = false
Expand Down
26 changes: 21 additions & 5 deletions tests/apps/test_app/management/commands/model_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import typer
from django.utils.translation import gettext_lazy as _
from django.db.models import Q
from django.conf import settings
from django.utils.timezone import get_default_timezone

from django_typer.management import (
TyperCommand,
Expand Down Expand Up @@ -88,7 +90,7 @@ def test(
id: Annotated[
t.Optional[ShellCompleteTester],
typer.Option(
**model_parser_completer(ShellCompleteTester),
**model_parser_completer(ShellCompleteTester, order_by="id"),
help=t.cast(str, _("Fetch objects by their int (pk) fields.")),
),
] = None,
Expand Down Expand Up @@ -166,21 +168,29 @@ def test(
date: Annotated[
t.Optional[ShellCompleteTester],
typer.Option(
**model_parser_completer(ShellCompleteTester, "date_field"),
**model_parser_completer(
ShellCompleteTester, "date_field", order_by=["-date_field"]
),
help=t.cast(str, _("Fetch objects by their date fields.")),
),
] = None,
datetime: Annotated[
t.Optional[ShellCompleteTester],
typer.Option(
**model_parser_completer(ShellCompleteTester, "datetime_field"),
**model_parser_completer(
ShellCompleteTester,
"datetime_field",
order_by=("datetime_field", "id"),
),
help=t.cast(str, _("Fetch objects by their datetime fields.")),
),
] = None,
time: Annotated[
t.Optional[ShellCompleteTester],
typer.Option(
**model_parser_completer(ShellCompleteTester, "time_field"),
**model_parser_completer(
ShellCompleteTester, "time_field", order_by="-time_field"
),
help=t.cast(str, _("Fetch objects by their time fields.")),
),
] = None,
Expand Down Expand Up @@ -232,7 +242,13 @@ def test(
objects["date"] = {date.id: str(date.date_field)}
if datetime is not None:
assert isinstance(datetime, ShellCompleteTester)
objects["datetime"] = {datetime.id: str(datetime.datetime_field)}
objects["datetime"] = {
datetime.id: str(
datetime.datetime_field.astimezone(get_default_timezone())
if settings.USE_TZ
else datetime.datetime_field
)
}
if time is not None:
assert isinstance(time, ShellCompleteTester)
objects["time"] = {time.id: str(time.time_field)}
Expand Down
28 changes: 22 additions & 6 deletions tests/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,29 @@
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases

DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"TEST": {"NAME": BASE_DIR / "db.sqlite3"},
rdbms = os.environ.get("RDBMS", "sqlite")
if rdbms == "sqlite":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "test.db",
"USER": "",
"PASSWORD": "",
"HOST": "",
"PORT": "",
}
}
elif rdbms == "postgres":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "postgres"),
"USER": os.environ.get("POSTGRES_USER", "postgres"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
"HOST": os.environ.get("POSTGRES_HOST", ""),
"PORT": os.environ.get("POSTGRES_PORT", ""),
}
}
}


# Password validation
Expand Down
Loading

0 comments on commit 08b2806

Please sign in to comment.