Repository: marshmallow-code/apispec
Pull request #815 (2022-12-05 15:13:48)

Datetime support for multiple format
Adding a function `datetime2properties` to support the differents formats of `marshamllow.field.DateTime`. 
An exemple for custom DateTime format has been added to the doc in `Using Plugins / Marshmallow Plugin`

The support for `timestamp` and `timestamp_ms` mean that DateTime type can be either "string" or "integer". Format can also have multiple values.  Because of that 2 test where modified to account for that variability: 
- `test_field2property_type`
- `test_field2property_formats`

Then 6 tests were added to test the different output of `datetime2properties`.

TheBigRoomXXL: I am sorry but do you need something more before merging? I don't really know why this pull request stay open if there is no problem with it. 
lafrech: Simply lack of benevolent maintainer time. Please keep this open. I just can't promise when it will be reviewed. To anyone, feel free to chime in and help with the review.

PRs that are obviously wrong are closed, PRs that are trivially good are merged. Other PRs take time to review. Sorry about that. Thanks for contributing.
Bangertm: The one thing I'm thinking about with this code is whether it should be more closely integrated with `field2type_and_format`. The first condition is leaving the results of of `field2type_and_format` in place, which was a bit confusing when I first looked at it, because you have to scan up a few hundred lines to figure out what's going on. 

It might be better to remove `DateTime` from the field mapping and call this function from `field2type_and_format` for that special case. 
TheBigRoomXXL: @lafrech Thank you for your fast feeback, I just needed to know if there was something expected of me. 


@Bangertm  You are right, finding the result of the first condition might be a little confusing. I see two options to fix that:

1 - just add a comment explaning the expected result:
```python
if field.format == "iso" or field.format is None:
  # Will return { "type": "string", "format": "date-time" } as specified inside DEFAULT_FIELD_MAPPING
  pass
```

2 - Force the result here:

```python
if field.format == "iso" or field.format is None:
ret = {
  "type": "string",
  "format": "date-time",
}
```

The funny thing is that if you do that you suddenly break the tests for date format. It looks like `isinstance(field, marshmallow.fields.DateTime)` return true if `field`  of type marshmallow.fields.Date. I guess it kind of make sense but it is a little weird and **I don't know if it's expected behavior**.

To avoid any confusion in the future, it would probably be wise to `and not isinstance(field, marshmallow.fields.Date)` to the first if statement (line 527). 

Regarding the original problem I think the option 1 is best as it doesn't break the usual flow and is still quite clear. 
TheBigRoomXXL: So I made 3 changes to the original merge request: 

- I added `and not isinstance(field, marshmallow.fields.Date )` to avoid any false positive
- I added a comment describing the output of the iso format so that it doesn't override any existing logic. 
- I changed `{"type": "integer", "format": "timestamp",}` to `{"type": "number", "format": "float",}` because marshamllow return float, not integer.
TheBigRoomXXL: Sorry for the delay and thank you for your review. 
sloria: Made some minor fixups so this is mergeable. Thanks so much for the contribution @TheBigRoomXXL!

Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 17bea2b..539d3e9 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -78,3 +78,4 @@ Contributors (chronological)
 - Mounier Florian `@paradoxxxzero <https://github.com/paradoxxxzero>`_
 - Renato Damas `@codectl <https://github.com/codectl>`_
 - Tayler Sokalski `@tsokalski <https://github.com/tsokalski>`_
+- Sebastien Lovergne `@TheBigRoomXXL <https://github.com/TheBigRoomXXL>`_
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 05dc58a..c7b07a2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,6 +4,12 @@ Changelog
 6.4.0 (unreleased)
 ******************
 
+Features:
+
+- ``MarshmallowPlugin``: Support different datetime formats
+  for ``marshmallow.fields.DateTime`` fields (:issue:`814`).
+  Thanks :user:`TheBigRoomXXL` for the suggestion and PR.
+
 Other changes:
 
 - Support Python 3.12.
diff --git a/docs/using_plugins.rst b/docs/using_plugins.rst
index 369fa22..c00d3e6 100644
--- a/docs/using_plugins.rst
+++ b/docs/using_plugins.rst
@@ -236,6 +236,22 @@ Schema Modifiers
 
 apispec will respect schema modifiers such as ``exclude`` and ``partial`` in the generated schema definition. If a schema is initialized with modifiers, apispec will treat each combination of modifiers as a unique schema definition.
 
+Custom DateTime formats
+***********************
+
+apispec supports all four basic formats of `marshmallow.fields.DateTime`: ``"rfc"`` (for RFC822), ``"iso"`` (for ISO8601), 
+``"timestamp"``, ``"timestamp_ms"`` (for a POSIX timestamp).
+
+If you are using a custom DateTime format you should pass a regex string to the ``pattern`` parameter in your field ``metadata`` so that it is included as documentation. 
+
+.. code-block:: python
+
+    class SchemaWithCustomDate(Schema):
+        french_date = ma.DateTime(
+            format="%d-%m%Y %H:%M:%S",
+            metadata={"pattern": r"^\d{2}-\d{2}-\d{4} \d{2}:\d{2}:\d{2}$"},
+        )
+
 Custom Fields
 *************
 
diff --git a/src/apispec/ext/marshmallow/field_converter.py b/src/apispec/ext/marshmallow/field_converter.py
index 2bf8a9d..79fd368 100644
--- a/src/apispec/ext/marshmallow/field_converter.py
+++ b/src/apispec/ext/marshmallow/field_converter.py
@@ -110,6 +110,7 @@ class FieldConverterMixin:
             self.list2properties,
             self.dict2properties,
             self.timedelta2properties,
+            self.datetime2properties,
         ]
 
     def map_to_openapi_type(self, field_cls, *args):
@@ -518,6 +519,53 @@ class FieldConverterMixin:
             ret["enum"] = [field.field._serialize(v, None, None) for v in choices]
         return ret
 
+    def datetime2properties(self, field, **kwargs: typing.Any) -> dict:
+        """Return a dictionary of properties from :class:`DateTime <marshmallow.fields.DateTime` fields.
+
+        :param Field field: A marshmallow field.
+        :rtype: dict
+        """
+        ret = {}
+        if isinstance(field, marshmallow.fields.DateTime) and not isinstance(
+            field, marshmallow.fields.Date
+        ):
+            if field.format == "iso" or field.format is None:
+                # Will return { "type": "string", "format": "date-time" }
+                # as specified inside DEFAULT_FIELD_MAPPING
+                pass
+            elif field.format == "rfc":
+                ret = {
+                    "type": "string",
+                    "format": None,
+                    "example": "Wed, 02 Oct 2002 13:00:00 GMT",
+                    "pattern": r"((Mon|Tue|Wed|Thu|Fri|Sat|Sun), ){0,1}\d{2} "
+                    + r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} "
+                    + r"(UT|GMT|EST|EDT|CST|CDT|MST|MDT|PST|PDT|(Z|A|M|N)|(\+|-)\d{4})",
+                }
+            elif field.format == "timestamp":
+                ret = {
+                    "type": "number",
+                    "format": "float",
+                    "example": "1676451245.596",
+                    "min": "0",
+                }
+            elif field.format == "timestamp_ms":
+                ret = {
+                    "type": "number",
+                    "format": "float",
+                    "example": "1676451277514.654",
+                    "min": "0",
+                }
+            else:
+                ret = {
+                    "type": "string",
+                    "format": None,
+                    "pattern": field.metadata["pattern"]
+                    if field.metadata.get("pattern")
+                    else None,
+                }
+        return ret
+
 
 def make_type_list(types):
     """Return a list of types from a type attribute


Test patch:
diff --git a/tests/test_ext_marshmallow_field.py b/tests/test_ext_marshmallow_field.py
index 0fb04b6..142eb36 100644
--- a/tests/test_ext_marshmallow_field.py
+++ b/tests/test_ext_marshmallow_field.py
@@ -377,6 +377,75 @@ def test_nested_field_with_property(spec_fixture):
     }
 
 
+def test_datetime2property_iso(spec_fixture):
+    field = fields.DateTime(format="iso")
+    res = spec_fixture.openapi.field2property(field)
+    assert res == {
+        "type": "string",
+        "format": "date-time",
+    }
+
+
+def test_datetime2property_rfc(spec_fixture):
+    field = fields.DateTime(format="rfc")
+    res = spec_fixture.openapi.field2property(field)
+    assert res == {
+        "type": "string",
+        "format": None,
+        "example": "Wed, 02 Oct 2002 13:00:00 GMT",
+        "pattern": r"((Mon|Tue|Wed|Thu|Fri|Sat|Sun), ){0,1}\d{2} "
+        + r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} "
+        + r"(UT|GMT|EST|EDT|CST|CDT|MST|MDT|PST|PDT|(Z|A|M|N)|(\+|-)\d{4})",
+    }
+
+
+def test_datetime2property_timestamp(spec_fixture):
+    field = fields.DateTime(format="timestamp")
+    res = spec_fixture.openapi.field2property(field)
+    assert res == {
+        "type": "number",
+        "format": "float",
+        "min": "0",
+        "example": "1676451245.596",
+    }
+
+
+def test_datetime2property_timestamp_ms(spec_fixture):
+    field = fields.DateTime(format="timestamp_ms")
+    res = spec_fixture.openapi.field2property(field)
+    assert res == {
+        "type": "number",
+        "format": "float",
+        "min": "0",
+        "example": "1676451277514.654",
+    }
+
+
+def test_datetime2property_custom_format(spec_fixture):
+    field = fields.DateTime(
+        format="%d-%m%Y %H:%M:%S",
+        metadata={
+            "pattern": r"^((?:(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}(?:\.\d+)?))(Z|[\+-]\d{2}:\d{2})?)$"
+        },
+    )
+    res = spec_fixture.openapi.field2property(field)
+    assert res == {
+        "type": "string",
+        "format": None,
+        "pattern": r"^((?:(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}(?:\.\d+)?))(Z|[\+-]\d{2}:\d{2})?)$",
+    }
+
+
+def test_datetime2property_custom_format_missing_regex(spec_fixture):
+    field = fields.DateTime(format="%d-%m%Y %H:%M:%S")
+    res = spec_fixture.openapi.field2property(field)
+    assert res == {
+        "type": "string",
+        "format": None,
+        "pattern": None,
+    }
+
+
 class TestField2PropertyPluck:
     @pytest.fixture(autouse=True)
     def _setup(self, spec_fixture):