Repository: googleapis/python-bigquery
Pull request #2236 (2025-07-12 01:28:08)

feat: adds datetime_format as an option
This commit introduces new configuration options for BigQuery load jobs and external table definitions, aligning with recent updates to the underlying protos.

New options added:

`datetime_format`: Datetime format used for parsing DATETIME values. (Applies to `LoadJobConfig`, `LoadJob`, and `ExternalConfig`)

Changes include:

Added corresponding properties (getters/setters) to `LoadJobConfig`, `LoadJob`, and `ExternalConfig`.
Updated docstrings and type hints for all new attributes.
Updated unit tests to cover the new options, ensuring they are correctly handled during object initialization, serialization to API representation, and deserialization from API responses.


Patch:
diff --git a/google/cloud/bigquery/external_config.py b/google/cloud/bigquery/external_config.py
index 370f62c0..82c6a9e7 100644
--- a/google/cloud/bigquery/external_config.py
+++ b/google/cloud/bigquery/external_config.py
@@ -862,6 +862,21 @@ class ExternalConfig(object):
     def date_format(self, value: Optional[str]):
         self._properties["dateFormat"] = value
 
+    @property
+    def datetime_format(self) -> Optional[str]:
+        """Optional[str]: Format used to parse DATETIME values. Supports C-style
+        and SQL-style values.
+
+        See:
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/tables#ExternalDataConfiguration.FIELDS.datetime_format
+        """
+        result = self._properties.get("datetimeFormat")
+        return typing.cast(str, result)
+
+    @datetime_format.setter
+    def datetime_format(self, value: Optional[str]):
+        self._properties["datetimeFormat"] = value
+
     @property
     def time_zone(self) -> Optional[str]:
         """Optional[str]: Time zone used when parsing timestamp values that do not
diff --git a/google/cloud/bigquery/job/load.py b/google/cloud/bigquery/job/load.py
index 2e5a9a9b..3be914f4 100644
--- a/google/cloud/bigquery/job/load.py
+++ b/google/cloud/bigquery/job/load.py
@@ -561,6 +561,19 @@ class LoadJobConfig(_JobConfig):
     def date_format(self, value: Optional[str]):
         self._set_sub_prop("dateFormat", value)
 
+    @property
+    def datetime_format(self) -> Optional[str]:
+        """Optional[str]: Date format used for parsing DATETIME values.
+
+        See:
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/Job#JobConfigurationLoad.FIELDS.datetime_format
+        """
+        return self._get_sub_prop("datetimeFormat")
+
+    @datetime_format.setter
+    def datetime_format(self, value: Optional[str]):
+        self._set_sub_prop("datetimeFormat", value)
+
     @property
     def time_zone(self) -> Optional[str]:
         """Optional[str]: Default time zone that will apply when parsing timestamp
@@ -949,6 +962,13 @@ class LoadJob(_AsyncJob):
         """
         return self.configuration.date_format
 
+    @property
+    def datetime_format(self):
+        """See
+        :attr:`google.cloud.bigquery.job.LoadJobConfig.datetime_format`.
+        """
+        return self.configuration.datetime_format
+
     @property
     def time_zone(self):
         """See


Test patch:
diff --git a/tests/unit/job/test_load.py b/tests/unit/job/test_load.py
index 77adf0cc..7afe9cba 100644
--- a/tests/unit/job/test_load.py
+++ b/tests/unit/job/test_load.py
@@ -38,6 +38,7 @@ class TestLoadJob(_Base):
         self.OUTPUT_ROWS = 345
         self.REFERENCE_FILE_SCHEMA_URI = "gs://path/to/reference"
         self.DATE_FORMAT = "%Y-%m-%d"
+        self.DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S"
         self.TIME_ZONE = "UTC"
         self.TIME_FORMAT = "%H:%M:%S"
         self.TIMESTAMP_FORMAT = "YYYY-MM-DD HH:MM:SS.SSSSSSZ"
@@ -47,6 +48,7 @@ class TestLoadJob(_Base):
         config = resource["configuration"]["load"]
         config["sourceUris"] = [self.SOURCE1]
         config["dateFormat"] = self.DATE_FORMAT
+        config["datetimeFormat"] = self.DATETIME_FORMAT
         config["timeZone"] = self.TIME_ZONE
         config["timeFormat"] = self.TIME_FORMAT
         config["timestampFormat"] = self.TIMESTAMP_FORMAT
@@ -164,6 +166,10 @@ class TestLoadJob(_Base):
             self.assertEqual(job.date_format, config["dateFormat"])
         else:
             self.assertIsNone(job.date_format)
+        if "datetimeFormat" in config:
+            self.assertEqual(job.datetime_format, config["datetimeFormat"])
+        else:
+            self.assertIsNone(job.datetime_format)
         if "timeZone" in config:
             self.assertEqual(job.time_zone, config["timeZone"])
         else:
@@ -219,6 +225,7 @@ class TestLoadJob(_Base):
         self.assertIsNone(job.schema_update_options)
         self.assertIsNone(job.reference_file_schema_uri)
         self.assertIsNone(job.date_format)
+        self.assertIsNone(job.datetime_format)
         self.assertIsNone(job.time_zone)
         self.assertIsNone(job.time_format)
         self.assertIsNone(job.timestamp_format)
@@ -618,6 +625,7 @@ class TestLoadJob(_Base):
             },
             "schemaUpdateOptions": [SchemaUpdateOption.ALLOW_FIELD_ADDITION],
             "dateFormat": self.DATE_FORMAT,
+            "datetimeFormat": self.DATETIME_FORMAT,
             "timeZone": self.TIME_ZONE,
             "timeFormat": self.TIME_FORMAT,
             "timestampFormat": self.TIMESTAMP_FORMAT,
@@ -651,6 +659,7 @@ class TestLoadJob(_Base):
         config.schema_update_options = [SchemaUpdateOption.ALLOW_FIELD_ADDITION]
         config.reference_file_schema_uri = "gs://path/to/reference"
         config.date_format = self.DATE_FORMAT
+        config.datetime_format = self.DATETIME_FORMAT
         config.time_zone = self.TIME_ZONE
         config.time_format = self.TIME_FORMAT
         config.timestamp_format = self.TIMESTAMP_FORMAT
diff --git a/tests/unit/job/test_load_config.py b/tests/unit/job/test_load_config.py
index b733bdda..dbb06248 100644
--- a/tests/unit/job/test_load_config.py
+++ b/tests/unit/job/test_load_config.py
@@ -844,6 +844,22 @@ class TestLoadJobConfig(_Base):
         config.date_format = date_format
         self.assertEqual(config._properties["load"]["dateFormat"], date_format)
 
+    def test_datetime_format_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.datetime_format)
+
+    def test_datetime_format_hit(self):
+        datetime_format = "%Y-%m-%dT%H:%M:%S"
+        config = self._get_target_class()()
+        config._properties["load"]["datetimeFormat"] = datetime_format
+        self.assertEqual(config.datetime_format, datetime_format)
+
+    def test_datetime_format_setter(self):
+        datetime_format = "YYYY/MM/DD HH24:MI:SS"
+        config = self._get_target_class()()
+        config.datetime_format = datetime_format
+        self.assertEqual(config._properties["load"]["datetimeFormat"], datetime_format)
+
     def test_time_zone_missing(self):
         config = self._get_target_class()()
         self.assertIsNone(config.time_zone)
diff --git a/tests/unit/test_external_config.py b/tests/unit/test_external_config.py
index 8b41cd8e..3a441d1f 100644
--- a/tests/unit/test_external_config.py
+++ b/tests/unit/test_external_config.py
@@ -26,6 +26,7 @@ import pytest
 class TestExternalConfig(unittest.TestCase):
     SOURCE_URIS = ["gs://foo", "gs://bar"]
     DATE_FORMAT = "MM/DD/YYYY"
+    DATETIME_FORMAT = "MM/DD/YYYY HH24:MI:SS"
     TIME_ZONE = "America/Los_Angeles"
     TIME_FORMAT = "HH24:MI:SS"
     TIMESTAMP_FORMAT = "MM/DD/YYYY HH24:MI:SS.FF6 TZR"
@@ -38,6 +39,7 @@ class TestExternalConfig(unittest.TestCase):
         "ignoreUnknownValues": False,
         "compression": "compression",
         "dateFormat": DATE_FORMAT,
+        "datetimeFormat": DATETIME_FORMAT,
         "timeZone": TIME_ZONE,
         "timeFormat": TIME_FORMAT,
         "timestampFormat": TIMESTAMP_FORMAT,
@@ -88,6 +90,7 @@ class TestExternalConfig(unittest.TestCase):
         ec.schema = [schema.SchemaField("full_name", "STRING", mode="REQUIRED")]
 
         ec.date_format = self.DATE_FORMAT
+        ec.datetime_format = self.DATETIME_FORMAT
         ec.time_zone = self.TIME_ZONE
         ec.time_format = self.TIME_FORMAT
         ec.timestamp_format = self.TIMESTAMP_FORMAT
@@ -106,6 +109,7 @@ class TestExternalConfig(unittest.TestCase):
             "connectionId": "path/to/connection",
             "schema": exp_schema,
             "dateFormat": self.DATE_FORMAT,
+            "datetimeFormat": self.DATETIME_FORMAT,
             "timeZone": self.TIME_ZONE,
             "timeFormat": self.TIME_FORMAT,
             "timestampFormat": self.TIMESTAMP_FORMAT,
@@ -145,6 +149,7 @@ class TestExternalConfig(unittest.TestCase):
         self.assertEqual(ec.max_bad_records, 17)
         self.assertEqual(ec.source_uris, self.SOURCE_URIS)
         self.assertEqual(ec.date_format, self.DATE_FORMAT)
+        self.assertEqual(ec.datetime_format, self.DATETIME_FORMAT)
         self.assertEqual(ec.time_zone, self.TIME_ZONE)
         self.assertEqual(ec.time_format, self.TIME_FORMAT)
         self.assertEqual(ec.timestamp_format, self.TIMESTAMP_FORMAT)