Repository: huggingface/huggingface_hub (python)

Issue:
list_inference_endpoints fails with Date parsing error
### Describe the bug

Pretty simple, the `list_inference_endpoints` is completely broken. It fails with some parse date error, see stacktrace. 

### Reproduction

list_inference_endpoints(namespace=NAMESPACE, token=HF_API_KEY)

### Logs

```shell
File "/Users/jd/code/prompt-js/prompt-worker-py/activities/llama_guard.py", line 98, in check_endpoint
    x = list_inference_endpoints(namespace=NAMESPACE, token=HF_API_KEY)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jd/code/prompt-js/prompt-worker-py/.venv/lib/python3.11/site-packages/huggingface_hub/hf_api.py", line 7716, in list_inference_endpoints
    return [
           ^
  File "/Users/jd/code/prompt-js/prompt-worker-py/.venv/lib/python3.11/site-packages/huggingface_hub/hf_api.py", line 7717, in <listcomp>
    InferenceEndpoint.from_raw(endpoint, namespace=namespace, token=token)
  File "/Users/jd/code/prompt-js/prompt-worker-py/.venv/lib/python3.11/site-packages/huggingface_hub/_inference_endpoints.py", line 134, in from_raw
    return cls(raw=raw, namespace=namespace, _token=token, _api=api)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 7, in __init__
  File "/Users/jd/code/prompt-js/prompt-worker-py/.venv/lib/python3.11/site-packages/huggingface_hub/_inference_endpoints.py", line 138, in __post_init__
    self._populate_from_raw()
  File "/Users/jd/code/prompt-js/prompt-worker-py/.venv/lib/python3.11/site-packages/huggingface_hub/_inference_endpoints.py", line 394, in _populate_from_raw
    self.created_at = parse_datetime(self.raw["status"]["createdAt"])
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jd/code/prompt-js/prompt-worker-py/.venv/lib/python3.11/site-packages/huggingface_hub/utils/_datetime.py", line 59, in parse_datetime
    raise ValueError(
ValueError: Cannot parse '2024-11-20T20:01:58Z' as a datetime. Date string is expected to follow '%Y-%m-%dT%H:%M:%S.%fZ' pattern.
```


### System info

```shell
- huggingface_hub version: 0.26.2
- Platform: macOS-14.4.1-arm64-arm-64bit
- Python version: 3.11.7
- Running in iPython ?: No
- Running in notebook ?: No
- Running in Google Colab ?: No
- Running in Google Colab Enterprise ?: No
- Token path ?: /Users/jd/.cache/huggingface/token
- Has saved token ?: True
- Who am I ?: jamie-de
- Configured git credential helpers: osxkeychain
- FastAI: N/A
- Tensorflow: N/A
- Torch: 2.2.0
- Jinja2: 3.1.4
- Graphviz: N/A
- keras: N/A
- Pydot: N/A
- Pillow: 11.0.0
- hf_transfer: N/A
- gradio: N/A
- tensorboard: N/A
- numpy: 1.25.1
- pydantic: 2.9.2
- aiohttp: 3.10.11
- ENDPOINT: https://huggingface.co
- HF_HUB_CACHE: /Users/jd/.cache/huggingface/hub
- HF_ASSETS_CACHE: /Users/jd/.cache/huggingface/assets
- HF_TOKEN_PATH: /Users/jd/.cache/huggingface/token
- HF_STORED_TOKENS_PATH: /Users/jd/.cache/huggingface/stored_tokens
- HF_HUB_OFFLINE: False
- HF_HUB_DISABLE_TELEMETRY: False
- HF_HUB_DISABLE_PROGRESS_BARS: None
- HF_HUB_DISABLE_SYMLINKS_WARNING: False
- HF_HUB_DISABLE_EXPERIMENTAL_WARNING: False
- HF_HUB_DISABLE_IMPLICIT_TOKEN: False
- HF_HUB_ENABLE_HF_TRANSFER: False
- HF_HUB_ETAG_TIMEOUT: 10
- HF_HUB_DOWNLOAD_TIMEOUT: 10
```


Pull request:
Fix timestamp parsing to always include milliseconds
fixes #2671.

See the original [slack thread](https://huggingface.slack.com/archives/C03E4DQ9LAJ/p1729806188701489) (internal) for more context.

The client expects timestamps to follow the pattern `%Y-%m-%dT%H:%M:%S.%fZ`, but the server was inconsistently returning timestamps without milliseconds when they were zero (e.g. "2024-11-20T20:01:58Z"). This causes the parsing error reported in the issue mentioned above. 
we're handling this client-side as it's intentional behavior in the API to reduce precision, see[ this comment](https://github.com/huggingface-internal/workloads/pull/2524#discussion_r1860758864) (private).

this is fixed by a small refactoring of `parse_datetime()` function and adding a normalization to ensure it has six digits of microseconds by padding with zeros or truncating. This approach handles inputs with or without milliseconds and nanoseconds.

cc @ErikKaum 

Patch:
diff --git a/src/huggingface_hub/utils/_datetime.py b/src/huggingface_hub/utils/_datetime.py
index e544884b..1a7f4428 100644
--- a/src/huggingface_hub/utils/_datetime.py
+++ b/src/huggingface_hub/utils/_datetime.py
@@ -46,15 +46,20 @@ def parse_datetime(date_string: str) -> datetime:
             If `date_string` cannot be parsed.
     """
     try:
-        # Datetime ending with a Z means "UTC". We parse the date and then explicitly
-        # set the timezone to UTC.
-        # See https://en.wikipedia.org/wiki/ISO_8601#Coordinated_Universal_Time_(UTC)
-        # Taken from https://stackoverflow.com/a/3168394.
-        if len(date_string) == 30:
-            # Means timezoned-timestamp with nanoseconds precision. We need to truncate the last 3 digits.
-            date_string = date_string[:-4] + "Z"
-        dt = datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S.%fZ")
-        return dt.replace(tzinfo=timezone.utc)  # Set explicit timezone
+        # Normalize the string to always have 6 digits of fractional seconds
+        if date_string.endswith("Z"):
+            # Case 1: No decimal point (e.g., "2024-11-16T00:27:02Z")
+            if "." not in date_string:
+                # No fractional seconds - insert .000000
+                date_string = date_string[:-1] + ".000000Z"
+            # Case 2: Has decimal point (e.g., "2022-08-19T07:19:38.123456789Z")
+            else:
+                # Get the fractional and base parts
+                base, fraction = date_string[:-1].split(".")
+                # fraction[:6] takes first 6 digits and :0<6 pads with zeros if less than 6 digits
+                date_string = f"{base}.{fraction[:6]:0<6}Z"
+
+        return datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
     except ValueError as e:
         raise ValueError(
             f"Cannot parse '{date_string}' as a datetime. Date string is expected to"


Test patch:
diff --git a/tests/test_utils_datetime.py b/tests/test_utils_datetime.py
index 3cb6c048..091fbc1f 100644
--- a/tests/test_utils_datetime.py
+++ b/tests/test_utils_datetime.py
@@ -20,6 +20,12 @@ class TestDatetimeUtils(unittest.TestCase):
             datetime(2022, 8, 19, 7, 19, 38, 123456, tzinfo=timezone.utc),
         )
 
+        # Test without milliseconds (should add .000)
+        self.assertEqual(
+            parse_datetime("2024-11-16T00:27:02Z"),
+            datetime(2024, 11, 16, 0, 27, 2, 0, tzinfo=timezone.utc),
+        )
+
         with pytest.raises(ValueError, match=r".*Cannot parse '2022-08-19T07:19:38' as a datetime.*"):
             parse_datetime("2022-08-19T07:19:38")