Repository: hetznercloud/hcloud-python

Issue:
Incompatible ISO8601 format returned by the API 
## Bug Report

**Current Behavior**

The [floating IP API documentation](https://docs.hetzner.cloud/#floating-ips-get-all-floating-ips) is using the following ISO8601 format: `2016-01-30T23:55:00+00:00`, but the API currently returns a different ISO8601 format: `2023-06-29T15:37:22Z`.

We recently removed the `python-dateutil` package because we assumed the API will return the format in the documentation (without the `Z`), and use [`datetime.datetime.fromisoformat`](https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat) to parse the datetime.

Python <3.11 does not support parsing the `2023-06-29T15:37:22Z` ISO8601 format, and this breaks our client for all version of Python <3.11.

**Input Code**

- REPL or Repo link if applicable:

On python 3.9
```python
>>> from datetime import datetime
>>> datetime.fromisoformat("2023-06-29T15:37:22Z")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: Invalid isoformat string: '2023-06-29T15:37:22Z'
>>> datetime.fromisoformat("2016-01-30T23:55:00+00:00")
datetime.datetime(2016, 1, 30, 23, 55, tzinfo=datetime.timezone.utc)
```

**Expected behavior/code**

The client should be able to parse the ISO8601 format.

**Environment**

- Python Version: Python 3.9
- Hcloud-Python Version: c821dbe3e2bec59211154dd52bafe3b930945907

**Possible Solution**

- Change the API to return a ISO8601 format parsable by python <3.11. 
- Implement a small wrapper to replace the `Z` with `+00:00`.
- Revert #221, but we probably don't want that.


**Additional context/Screenshots**

- https://github.com/hetznercloud/hcloud-python/pull/221
- https://dev.azure.com/ansible/hetzner.hcloud/_build/results?buildId=82132&view=logs&j=fdb818b8-39d8-529a-d9da-324e304604bc&t=2d52cce2-88ce-5057-da88-145e92cee00f&l=1529


Discussion:

Patch:
diff --git a/hcloud/_compat.py b/hcloud/_compat.py
new file mode 100644
index 0000000..6eb552d
--- /dev/null
+++ b/hcloud/_compat.py
@@ -0,0 +1,11 @@
+from datetime import datetime
+
+
+def isoparse(value: str) -> datetime:
+    # Python <3.11 doesn't fully support parsing ISO8601 datetime strings. This
+    # workaround replaces the ending `Z` or `z` with `+00:00` and allows
+    # `datetime.fromisoformat` to parse the datetime string.
+    if value[-1] in "Zz":
+        value = value[:-1] + "+00:00"
+
+    return datetime.fromisoformat(value)
diff --git a/hcloud/actions/domain.py b/hcloud/actions/domain.py
index ef77fc7..865a405 100644
--- a/hcloud/actions/domain.py
+++ b/hcloud/actions/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from .._exceptions import HCloudException
 from ..core.domain import BaseDomain
 
@@ -51,8 +50,8 @@ class Action(BaseDomain):
 
         self.status = status
         self.progress = progress
-        self.started = datetime.fromisoformat(started) if started else None
-        self.finished = datetime.fromisoformat(finished) if finished else None
+        self.started = isoparse(started) if started else None
+        self.finished = isoparse(finished) if finished else None
         self.resources = resources
         self.error = error
 
diff --git a/hcloud/certificates/domain.py b/hcloud/certificates/domain.py
index 0ee92e6..afe44ed 100644
--- a/hcloud/certificates/domain.py
+++ b/hcloud/certificates/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain, DomainIdentityMixin
 
 
@@ -59,13 +58,9 @@ class Certificate(BaseDomain, DomainIdentityMixin):
         self.certificate = certificate
         self.domain_names = domain_names
         self.fingerprint = fingerprint
-        self.not_valid_before = (
-            datetime.fromisoformat(not_valid_before) if not_valid_before else None
-        )
-        self.not_valid_after = (
-            datetime.fromisoformat(not_valid_after) if not_valid_after else None
-        )
-        self.created = datetime.fromisoformat(created) if created else None
+        self.not_valid_before = isoparse(not_valid_before) if not_valid_before else None
+        self.not_valid_after = isoparse(not_valid_after) if not_valid_after else None
+        self.created = isoparse(created) if created else None
         self.labels = labels
         self.status = status
 
diff --git a/hcloud/deprecation/domain.py b/hcloud/deprecation/domain.py
index 1679feb..705016d 100644
--- a/hcloud/deprecation/domain.py
+++ b/hcloud/deprecation/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain
 
 
@@ -25,7 +24,7 @@ class DeprecationInfo(BaseDomain):
         announced=None,
         unavailable_after=None,
     ):
-        self.announced = datetime.fromisoformat(announced) if announced else None
+        self.announced = isoparse(announced) if announced else None
         self.unavailable_after = (
-            datetime.fromisoformat(unavailable_after) if unavailable_after else None
+            isoparse(unavailable_after) if unavailable_after else None
         )
diff --git a/hcloud/firewalls/domain.py b/hcloud/firewalls/domain.py
index 7a43923..ea431b5 100644
--- a/hcloud/firewalls/domain.py
+++ b/hcloud/firewalls/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain
 
 
@@ -30,7 +29,7 @@ class Firewall(BaseDomain):
         self.rules = rules
         self.applied_to = applied_to
         self.labels = labels
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
 
 
 class FirewallRule:
diff --git a/hcloud/floating_ips/domain.py b/hcloud/floating_ips/domain.py
index a268a43..133cc11 100644
--- a/hcloud/floating_ips/domain.py
+++ b/hcloud/floating_ips/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain
 
 
@@ -72,7 +71,7 @@ class FloatingIP(BaseDomain):
         self.blocked = blocked
         self.protection = protection
         self.labels = labels
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
         self.name = name
 
 
diff --git a/hcloud/images/domain.py b/hcloud/images/domain.py
index 2950d9d..8b1c63a 100644
--- a/hcloud/images/domain.py
+++ b/hcloud/images/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain, DomainIdentityMixin
 
 
@@ -85,11 +84,11 @@ class Image(BaseDomain, DomainIdentityMixin):
         self.id = id
         self.name = name
         self.type = type
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
         self.description = description
         self.image_size = image_size
         self.disk_size = disk_size
-        self.deprecated = datetime.fromisoformat(deprecated) if deprecated else None
+        self.deprecated = isoparse(deprecated) if deprecated else None
         self.bound_to = bound_to
         self.os_flavor = os_flavor
         self.os_version = os_version
diff --git a/hcloud/isos/domain.py b/hcloud/isos/domain.py
index 63ca5f9..6d4572e 100644
--- a/hcloud/isos/domain.py
+++ b/hcloud/isos/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain, DomainIdentityMixin
 
 
@@ -36,4 +35,4 @@ class Iso(BaseDomain, DomainIdentityMixin):
         self.type = type
         self.architecture = architecture
         self.description = description
-        self.deprecated = datetime.fromisoformat(deprecated) if deprecated else None
+        self.deprecated = isoparse(deprecated) if deprecated else None
diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py
index 27e2a35..9c77432 100644
--- a/hcloud/load_balancers/domain.py
+++ b/hcloud/load_balancers/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain
 
 
@@ -76,7 +75,7 @@ class LoadBalancer(BaseDomain):
     ):
         self.id = id
         self.name = name
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
         self.public_net = public_net
         self.private_net = private_net
         self.location = location
diff --git a/hcloud/networks/domain.py b/hcloud/networks/domain.py
index 0101280..709a3d7 100644
--- a/hcloud/networks/domain.py
+++ b/hcloud/networks/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain
 
 
@@ -54,7 +53,7 @@ class Network(BaseDomain):
     ):
         self.id = id
         self.name = name
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
         self.ip_range = ip_range
         self.subnets = subnets
         self.routes = routes
diff --git a/hcloud/placement_groups/domain.py b/hcloud/placement_groups/domain.py
index d013032..41d0680 100644
--- a/hcloud/placement_groups/domain.py
+++ b/hcloud/placement_groups/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain
 
 
@@ -35,7 +34,7 @@ class PlacementGroup(BaseDomain):
         self.labels = labels
         self.servers = servers
         self.type = type
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
 
 
 class CreatePlacementGroupResponse(BaseDomain):
diff --git a/hcloud/primary_ips/domain.py b/hcloud/primary_ips/domain.py
index cdf1f23..66e73c0 100644
--- a/hcloud/primary_ips/domain.py
+++ b/hcloud/primary_ips/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain
 
 
@@ -74,7 +73,7 @@ class PrimaryIP(BaseDomain):
         self.blocked = blocked
         self.protection = protection
         self.labels = labels
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
         self.name = name
         self.assignee_id = assignee_id
         self.assignee_type = assignee_type
diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py
index 15b81cd..286ba86 100644
--- a/hcloud/servers/domain.py
+++ b/hcloud/servers/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain
 
 
@@ -113,7 +112,7 @@ class Server(BaseDomain):
         self.id = id
         self.name = name
         self.status = status
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
         self.public_net = public_net
         self.server_type = server_type
         self.datacenter = datacenter
diff --git a/hcloud/ssh_keys/domain.py b/hcloud/ssh_keys/domain.py
index 530b7f8..25096f4 100644
--- a/hcloud/ssh_keys/domain.py
+++ b/hcloud/ssh_keys/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain, DomainIdentityMixin
 
 
@@ -36,4 +35,4 @@ class SSHKey(BaseDomain, DomainIdentityMixin):
         self.fingerprint = fingerprint
         self.public_key = public_key
         self.labels = labels
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
diff --git a/hcloud/volumes/domain.py b/hcloud/volumes/domain.py
index 953b4be..0366cd5 100644
--- a/hcloud/volumes/domain.py
+++ b/hcloud/volumes/domain.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from .._compat import isoparse
 from ..core.domain import BaseDomain, DomainIdentityMixin
 
 
@@ -66,7 +65,7 @@ class Volume(BaseDomain, DomainIdentityMixin):
         self.id = id
         self.name = name
         self.server = server
-        self.created = datetime.fromisoformat(created) if created else None
+        self.created = isoparse(created) if created else None
         self.location = location
         self.size = size
         self.linux_device = linux_device


Test patch:
diff --git a/tests/unit/core/test_domain.py b/tests/unit/core/test_domain.py
index 09aa6ba..b1116df 100644
--- a/tests/unit/core/test_domain.py
+++ b/tests/unit/core/test_domain.py
@@ -1,7 +1,6 @@
-from datetime import datetime
-
 import pytest
 
+from hcloud._compat import isoparse
 from hcloud.core.domain import (
     BaseDomain,
     DomainIdentityMixin,
@@ -102,7 +101,7 @@ class ActionDomain(BaseDomain, DomainIdentityMixin):
     def __init__(self, id, name="name1", started=None):
         self.id = id
         self.name = name
-        self.started = datetime.fromisoformat(started) if started else None
+        self.started = isoparse(started) if started else None
 
 
 class TestBaseDomain:
@@ -126,7 +125,7 @@ class TestBaseDomain:
                 {
                     "id": 4,
                     "name": "name-name3",
-                    "started": datetime.fromisoformat("2016-01-30T23:50+00:00"),
+                    "started": isoparse("2016-01-30T23:50+00:00"),
                 },
             ),
         ],
diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py
index 649094f..9442919 100644
--- a/tests/unit/networks/test_client.py
+++ b/tests/unit/networks/test_client.py
@@ -1,8 +1,8 @@
-from datetime import datetime
 from unittest import mock
 
 import pytest
 
+from hcloud._compat import isoparse
 from hcloud.actions.client import BoundAction
 from hcloud.networks.client import BoundNetwork, NetworksClient
 from hcloud.networks.domain import Network, NetworkRoute, NetworkSubnet
@@ -20,9 +20,7 @@ class TestBoundNetwork:
         )
 
         assert bound_network.id == 1
-        assert bound_network.created == datetime.fromisoformat(
-            "2016-01-30T23:50:11+00:00"
-        )
+        assert bound_network.created == isoparse("2016-01-30T23:50:11+00:00")
         assert bound_network.name == "mynet"
         assert bound_network.ip_range == "10.0.0.0/16"
         assert bound_network.protection["delete"] is False
diff --git a/tests/unit/test_compat.py b/tests/unit/test_compat.py
new file mode 100644
index 0000000..095d0f3
--- /dev/null
+++ b/tests/unit/test_compat.py
@@ -0,0 +1,36 @@
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from hcloud._compat import isoparse
+
+
+@pytest.mark.parametrize(
+    ["value", "expected"],
+    [
+        (
+            "2023-06-29T15:37:22",
+            datetime(2023, 6, 29, 15, 37, 22),
+        ),
+        (
+            "2023-06-29T15:37:22+00:00",
+            datetime(2023, 6, 29, 15, 37, 22, tzinfo=timezone.utc),
+        ),
+        (
+            "2023-06-29T15:37:22+02:12",
+            datetime(
+                2023, 6, 29, 15, 37, 22, tzinfo=timezone(timedelta(hours=2, minutes=12))
+            ),
+        ),
+        (
+            "2023-06-29T15:37:22Z",
+            datetime(2023, 6, 29, 15, 37, 22, tzinfo=timezone.utc),
+        ),
+        (
+            "2023-06-29T15:37:22z",
+            datetime(2023, 6, 29, 15, 37, 22, tzinfo=timezone.utc),
+        ),
+    ],
+)
+def test_isoparse(value: str, expected: datetime):
+    assert isoparse(value) == expected
diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py
index 8065fe2..16fea07 100644
--- a/tests/unit/volumes/test_client.py
+++ b/tests/unit/volumes/test_client.py
@@ -1,8 +1,8 @@
-from datetime import datetime
 from unittest import mock
 
 import pytest
 
+from hcloud._compat import isoparse
 from hcloud.actions.client import BoundAction
 from hcloud.locations.client import BoundLocation
 from hcloud.locations.domain import Location
@@ -23,9 +23,7 @@ class TestBoundVolume:
         )
 
         assert bound_volume.id == 1
-        assert bound_volume.created == datetime.fromisoformat(
-            "2016-01-30T23:50:11+00:00"
-        )
+        assert bound_volume.created == isoparse("2016-01-30T23:50:11+00:00")
         assert bound_volume.name == "database-storage"
         assert isinstance(bound_volume.server, BoundServer)
         assert bound_volume.server.id == 12