From 60121939f6b225c7a719dd561e372e1d8e5e2c4a Mon Sep 17 00:00:00 2001 From: Francesco Bruni Date: Mon, 18 May 2026 12:09:07 -0700 Subject: [PATCH] Fixed #36626 -- Preserved milliseconds in DataSource time fields. --- django/contrib/gis/gdal/field.py | 23 +++++++++++++++---- django/contrib/gis/gdal/prototypes/ds.py | 8 ++++++- docs/ref/contrib/gis/gdal.txt | 10 ++++++-- docs/releases/6.2.txt | 10 +++++++- .../data/has_nulls/has_nulls.geojson | 5 +++- tests/gis_tests/gdal_tests/test_ds.py | 21 +++++++++++++---- 6 files changed, 63 insertions(+), 14 deletions(-) diff --git a/django/contrib/gis/gdal/field.py b/django/contrib/gis/gdal/field.py index 967ff9835a58..1a2218821a45 100644 --- a/django/contrib/gis/gdal/field.py +++ b/django/contrib/gis/gdal/field.py @@ -1,4 +1,4 @@ -from ctypes import byref, c_int +from ctypes import byref, c_float, c_int from datetime import date, datetime, time from django.contrib.gis.gdal.base import GDALBase @@ -73,8 +73,9 @@ def as_datetime(self): "Retrieve the Field's value as a tuple of date & time components." if not self.is_set: return None - yy, mm, dd, hh, mn, ss, tz = [c_int() for i in range(7)] - status = capi.get_field_as_datetime( + yy, mm, dd, hh, mn, tz = [c_int() for _ in range(6)] + ss = c_float() + status = capi.get_field_as_datetime_x( self._feat.ptr, self._index, byref(yy), @@ -192,7 +193,17 @@ def value(self): # 100=GMT, 104=GMT+1, 80=GMT-5, etc. try: yy, mm, dd, hh, mn, ss, tz = self.as_datetime() - return datetime(yy.value, mm.value, dd.value, hh.value, mn.value, ss.value) + seconds = int(ss.value) + milliseconds = int(round((ss.value - seconds) * 1000)) + return datetime( + yy.value, + mm.value, + dd.value, + hh.value, + mn.value, + seconds, + milliseconds * 1000, + ) except (TypeError, ValueError, GDALException): return None @@ -203,7 +214,9 @@ def value(self): "Return a Python `time` object for this OFTTime field." try: yy, mm, dd, hh, mn, ss, tz = self.as_datetime() - return time(hh.value, mn.value, ss.value) + seconds = int(ss.value) + milliseconds = int(round((ss.value - seconds) * 1000)) + return time(hh.value, mn.value, seconds, milliseconds * 1000) except (ValueError, GDALException): return None diff --git a/django/contrib/gis/gdal/prototypes/ds.py b/django/contrib/gis/gdal/prototypes/ds.py index 0434bc5125a0..ad132fae9db2 100644 --- a/django/contrib/gis/gdal/prototypes/ds.py +++ b/django/contrib/gis/gdal/prototypes/ds.py @@ -4,7 +4,7 @@ OGR_Fld_* routines are relevant here. """ -from ctypes import POINTER, c_char_p, c_double, c_int, c_long, c_uint, c_void_p +from ctypes import POINTER, c_char_p, c_double, c_float, c_int, c_long, c_uint, c_void_p from django.contrib.gis.gdal.envelope import OGREnvelope from django.contrib.gis.gdal.libgdal import lgdal @@ -21,6 +21,7 @@ ) c_int_p = POINTER(c_int) # shortcut type +c_float_p = POINTER(c_float) GDAL_OF_READONLY = 0x00 GDAL_OF_UPDATE = 0x01 @@ -91,6 +92,11 @@ lgdal.OGR_F_GetFieldAsDateTime, [c_void_p, c_int, c_int_p, c_int_p, c_int_p, c_int_p, c_int_p, c_int_p], ) +get_field_as_datetime_x = int_output( + lgdal.OGR_F_GetFieldAsDateTimeEx, + [c_void_p, c_int, c_int_p, c_int_p, c_int_p, c_int_p, c_int_p, c_float_p, c_int_p], +) + get_field_as_double = double_output(lgdal.OGR_F_GetFieldAsDouble, [c_void_p, c_int]) get_field_as_integer = int_output(lgdal.OGR_F_GetFieldAsInteger, [c_void_p, c_int]) get_field_as_integer64 = int64_output( diff --git a/docs/ref/contrib/gis/gdal.txt b/docs/ref/contrib/gis/gdal.txt index 6e42f7a9e08c..83cd8ae586d1 100644 --- a/docs/ref/contrib/gis/gdal.txt +++ b/docs/ref/contrib/gis/gdal.txt @@ -470,12 +470,18 @@ __ https://gdal.org/drivers/vector/ .. method:: as_datetime() - Returns the value of the field as a tuple of date and time components: + Returns the value of the field as a tuple of date and time components with + millisecond precision: .. code-block:: pycon >>> city["Created"].as_datetime() - (c_long(1999), c_long(5), c_long(23), c_long(0), c_long(0), c_long(0), c_long(0)) + (c_long(1999), c_long(5), c_long(23), c_long(0), c_long(0), c_float(0.0), c_long(0)) + + .. versionchanged:: 6.2 + + The seconds component is now returned as a ``c_float`` with millisecond + precision instead of a ``c_int``. ``Driver`` ---------- diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index d04c8a3be458..d0c963f10806 100644 --- a/docs/releases/6.2.txt +++ b/docs/releases/6.2.txt @@ -58,7 +58,8 @@ Minor features :mod:`django.contrib.gis` ~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* :class:`~django.contrib.gis.gdal.DataSource` now preserves millisecond + precision when reading time and datetime fields. :mod:`django.contrib.messages` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -276,6 +277,13 @@ backends. consistently returning an HTTP 403 response for staff users without the view or change permission regardless of whether the object exists. +:mod:`django.contrib.gis` +------------------------- + +* The seconds value returned by + :meth:`~django.contrib.gis.gdal.Field.as_datetime` is now a ``c_float`` + rather than a ``c_int``. + Miscellaneous ------------- diff --git a/tests/gis_tests/data/has_nulls/has_nulls.geojson b/tests/gis_tests/data/has_nulls/has_nulls.geojson index 0fccf1968439..8ae52a187782 100644 --- a/tests/gis_tests/data/has_nulls/has_nulls.geojson +++ b/tests/gis_tests/data/has_nulls/has_nulls.geojson @@ -13,7 +13,8 @@ "properties": { "uuid": "1378c26f-cbe6-44b0-929f-eb330d4991f5", "boolean": true, - "datetime": "1994-08-14T11:32:14+0000", + "datetime": "1994-08-14T11:32:14.123+0000", + "time": "11:32:14.123", "name": "Philadelphia", "integer": 5, "num": 1.001 @@ -58,6 +59,7 @@ "type": "Feature", "properties": { "uuid": "fa2ba67c-a135-4338-b924-a9622b5d869f", + "time": "00:00:00", "integer": null, "num": null }, @@ -98,6 +100,7 @@ "properties": { "uuid": "4494c1f3-55ab-4256-b365-12115cb388d5", "datetime": "2018-11-29T03:02:52+0000", + "time": "03:02:52", "name": "north", "integer": 8, "num": 0.0, diff --git a/tests/gis_tests/gdal_tests/test_ds.py b/tests/gis_tests/gdal_tests/test_ds.py index 5bc71acf3e86..ea16c06f4a25 100644 --- a/tests/gis_tests/gdal_tests/test_ds.py +++ b/tests/gis_tests/gdal_tests/test_ds.py @@ -1,10 +1,16 @@ import os import re -from datetime import datetime +from datetime import datetime, time from pathlib import Path from django.contrib.gis.gdal import DataSource, Envelope, GDALException, OGRGeometry -from django.contrib.gis.gdal.field import OFTDateTime, OFTInteger, OFTReal, OFTString +from django.contrib.gis.gdal.field import ( + OFTDateTime, + OFTInteger, + OFTReal, + OFTString, + OFTTime, +) from django.contrib.gis.geos import GEOSGeometry from django.test import SimpleTestCase @@ -74,7 +80,7 @@ TestDS( "has_nulls", nfeat=3, - nfld=6, + nfld=7, geom="POLYGON", gtype=3, driver="GeoJSON", @@ -85,6 +91,7 @@ "num": OFTReal, "integer": OFTInteger, "datetime": OFTDateTime, + "time": OFTTime, "boolean": OFTInteger, }, extent=(-75.274200, 39.846504, -74.959717, 40.119040), # Got extent from QGIS @@ -99,10 +106,11 @@ "integer": [5, None, 8], "boolean": [True, None, False], "datetime": [ - datetime.strptime("1994-08-14T11:32:14", datetime_format), + datetime(1994, 8, 14, 11, 32, 14, 123000), None, datetime.strptime("2018-11-29T03:02:52", datetime_format), ], + "time": [time(11, 32, 14, 123000), time(0), time(3, 2, 52)], }, fids=range(3), ), @@ -353,3 +361,8 @@ def test_nonexistent_field(self): msg = "invalid field name: nonexistent" with self.assertRaisesMessage(GDALException, msg): ds[0].get_fields("nonexistent") + + def test_datetime_with_milliseconds(self): + feature = DataSource(get_ds_file("has_nulls", "geojson"))[0][0] + for field_name in "datetime", "time": + self.assertEqual(feature.get(field_name).microsecond, 123000)