Repository: pypi/warehouse

Issue:
JSON API omits timezone and microseconds from upload_time
When a request is made to `https://pypi.org/pypi/$PROJECT/json` or `https://pypi.org/pypi/$PROJECT/$VERSION/json`, the `"upload_time"` fields in the response contain no timezone information and has no sub-second precision (e.g., `"2018-09-22T19:16:05"`).  Compare this to the XML-RPC API, in which the `"upload_time"` fields in the data returned by the `release_urls` method all end with `Z` and have microsecond precision (e.g., `"2018-09-22T19:16:05.067954Z"`).
Align JSON API and XML-RPC timestamps
Fixes #4751 (I think).


Discussion:
@di Adding micro seconds might break existing API users, If so how do we handle that?

 How bad is adding a new field in the JSON with `upload_time_with_tz`? 
@gsb-eng Yes, see #4752. I think we'll need to add a new field, perhaps we can come up with a better name than `upload_time_with_tz`.
The change looks generally OK to me, though I am concerned that this is going to break people who had manually written parse strings for the JSON date.

Resolving patch:
diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py
--- a/warehouse/legacy/api/json.py
+++ b/warehouse/legacy/api/json.py
@@ -144,6 +144,7 @@ def json_release(release, request):
                 #       here to consider it no longer in use.
                 "downloads": -1,
                 "upload_time": f.upload_time.strftime("%Y-%m-%dT%H:%M:%S"),
+                "upload_time_iso_8601": f.upload_time.isoformat() + "Z",
                 "url": request.route_url("packaging.file", path=f.path),
                 "requires_python": r.requires_python if r.requires_python else None,
             }
diff --git a/warehouse/legacy/api/xmlrpc/views.py b/warehouse/legacy/api/xmlrpc/views.py
--- a/warehouse/legacy/api/xmlrpc/views.py
+++ b/warehouse/legacy/api/xmlrpc/views.py
@@ -450,6 +450,7 @@ def release_urls(request, package_name: str, version: str):
             "digests": {"md5": f.md5_digest, "sha256": f.sha256_digest},
             "has_sig": f.has_signature,
             "upload_time": f.upload_time.isoformat() + "Z",
+            "upload_time_iso_8601": f.upload_time.isoformat() + "Z",
             "comment_text": f.comment_text,
             # TODO: Remove this once we've had a long enough time with it
             #       here to consider it no longer in use.


Test patch:
diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py
--- a/tests/unit/legacy/api/test_json.py
+++ b/tests/unit/legacy/api/test_json.py
@@ -281,6 +281,7 @@ def test_detail_renders(self, pyramid_config, db_request, db_session):
                         "upload_time": files[0].upload_time.strftime(
                             "%Y-%m-%dT%H:%M:%S"
                         ),
+                        "upload_time_iso_8601": files[0].upload_time.isoformat() + "Z",
                         "url": "/the/fake/url/",
                         "requires_python": None,
                     }
@@ -302,6 +303,7 @@ def test_detail_renders(self, pyramid_config, db_request, db_session):
                         "upload_time": files[1].upload_time.strftime(
                             "%Y-%m-%dT%H:%M:%S"
                         ),
+                        "upload_time_iso_8601": files[1].upload_time.isoformat() + "Z",
                         "url": "/the/fake/url/",
                         "requires_python": None,
                     }
@@ -323,6 +325,7 @@ def test_detail_renders(self, pyramid_config, db_request, db_session):
                         "upload_time": files[2].upload_time.strftime(
                             "%Y-%m-%dT%H:%M:%S"
                         ),
+                        "upload_time_iso_8601": files[2].upload_time.isoformat() + "Z",
                         "url": "/the/fake/url/",
                         "requires_python": None,
                     }
@@ -343,6 +346,7 @@ def test_detail_renders(self, pyramid_config, db_request, db_session):
                     "python_version": "source",
                     "size": 200,
                     "upload_time": files[2].upload_time.strftime("%Y-%m-%dT%H:%M:%S"),
+                    "upload_time_iso_8601": files[2].upload_time.isoformat() + "Z",
                     "url": "/the/fake/url/",
                     "requires_python": None,
                 }
@@ -424,6 +428,7 @@ def test_minimal_renders(self, pyramid_config, db_request):
                         "python_version": "source",
                         "size": 200,
                         "upload_time": file.upload_time.strftime("%Y-%m-%dT%H:%M:%S"),
+                        "upload_time_iso_8601": file.upload_time.isoformat() + "Z",
                         "url": "/the/fake/url/",
                         "requires_python": None,
                     }
@@ -441,6 +446,7 @@ def test_minimal_renders(self, pyramid_config, db_request):
                     "python_version": "source",
                     "size": 200,
                     "upload_time": file.upload_time.strftime("%Y-%m-%dT%H:%M:%S"),
+                    "upload_time_iso_8601": file.upload_time.isoformat() + "Z",
                     "url": "/the/fake/url/",
                     "requires_python": None,
                 }
diff --git a/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py b/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py
--- a/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py
+++ b/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py
@@ -654,6 +654,7 @@ def test_release_urls(db_request):
             "digests": {"md5": file_.md5_digest, "sha256": file_.sha256_digest},
             "has_sig": file_.has_signature,
             "upload_time": file_.upload_time.isoformat() + "Z",
+            "upload_time_iso_8601": file_.upload_time.isoformat() + "Z",
             "comment_text": file_.comment_text,
             "downloads": -1,
             "path": file_.path,