From f831ddc24632590aa0bcbe35beda7ae2020965d1 Mon Sep 17 00:00:00 2001
From: Sergey Vasilyev <nolar@nolar.info>
Date: Mon, 9 Oct 2023 02:31:57 +0200
Subject: [PATCH] Switch to Python 3.12's recommended way of getting UTC time
 (TZ-aware)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The old TZ-naive way is deprecated and soon will be removed — this warning causes exceptions in strict-mode tests.

Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
---
 docs/errors.rst                           |  2 +-
 docs/probing.rst                          |  4 ++--
 examples/13-hooks/example.py              |  2 +-
 kopf/_cogs/clients/events.py              |  8 ++++----
 kopf/_cogs/structs/credentials.py         |  6 ++++--
 kopf/_core/actions/application.py         |  2 +-
 kopf/_core/actions/execution.py           |  6 +++---
 kopf/_core/actions/progression.py         | 13 ++++++++-----
 kopf/_core/engines/peering.py             |  8 ++++----
 kopf/_core/engines/probing.py             |  6 +++---
 tests/conftest.py                         |  4 ++++
 tests/handling/test_timing_consistency.py |  4 ++--
 12 files changed, 37 insertions(+), 28 deletions(-)

diff --git a/docs/errors.rst b/docs/errors.rst
index 784e30e3..1139cc4f 100644
--- a/docs/errors.rst
+++ b/docs/errors.rst
@@ -56,7 +56,7 @@ is no need to retry over time, as it will not become better::
     @kopf.on.create('kopfexamples')
     def create_fn(spec, **_):
         valid_until = datetime.datetime.fromisoformat(spec['validUntil'])
-        if valid_until <= datetime.datetime.utcnow():
+        if valid_until <= datetime.datetime.now(datetime.timezone.utc):
             raise kopf.PermanentError("The object is not valid anymore.")
 
 See also: :ref:`never-again-filters` to prevent handlers from being invoked
diff --git a/docs/probing.rst b/docs/probing.rst
index 601ad40a..c7a92819 100644
--- a/docs/probing.rst
+++ b/docs/probing.rst
@@ -76,7 +76,7 @@ probing handlers:
 
     @kopf.on.probe(id='now')
     def get_current_timestamp(**kwargs):
-        return datetime.datetime.utcnow().isoformat()
+        return datetime.datetime.now(datetime.timezone.utc).isoformat()
 
     @kopf.on.probe(id='random')
     def get_random_value(**kwargs):
@@ -91,7 +91,7 @@ The handler results will be reported as the content of the liveness response:
 .. code-block:: console
 
     $ curl http://localhost:8080/healthz
-    {"now": "2019-11-07T18:03:52.513803", "random": 765846}
+    {"now": "2019-11-07T18:03:52.513803+00:00", "random": 765846}
 
 .. note::
     The liveness status report is simplistic and minimalistic at the moment.
diff --git a/examples/13-hooks/example.py b/examples/13-hooks/example.py
index 3bd66466..2f44c194 100644
--- a/examples/13-hooks/example.py
+++ b/examples/13-hooks/example.py
@@ -55,7 +55,7 @@ async def login_fn(**kwargs):
         certificate_path=cert.filename() if cert else None,  # can be a temporary file
         private_key_path=pkey.filename() if pkey else None,  # can be a temporary file
         default_namespace=config.namespace,
-        expiration=datetime.datetime.utcnow() + datetime.timedelta(seconds=30),
+        expiration=datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=30),
     )
 
 
diff --git a/kopf/_cogs/clients/events.py b/kopf/_cogs/clients/events.py
index 3bca4bb6..2a7793e6 100644
--- a/kopf/_cogs/clients/events.py
+++ b/kopf/_cogs/clients/events.py
@@ -49,7 +49,7 @@ async def post_event(
         suffix = message[-MAX_MESSAGE_LENGTH // 2 + (len(infix) - len(infix) // 2):]
         message = f'{prefix}{infix}{suffix}'
 
-    now = datetime.datetime.utcnow()
+    now = datetime.datetime.now(datetime.timezone.utc)
     body = {
         'metadata': {
             'namespace': namespace,
@@ -67,9 +67,9 @@ async def post_event(
 
         'involvedObject': full_ref,
 
-        'firstTimestamp': now.isoformat() + 'Z',  # '2019-01-28T18:25:03.000000Z' -- seen in `kubectl describe ...`
-        'lastTimestamp': now.isoformat() + 'Z',  # '2019-01-28T18:25:03.000000Z' - seen in `kubectl get events`
-        'eventTime': now.isoformat() + 'Z',  # '2019-01-28T18:25:03.000000Z'
+        'firstTimestamp': now.isoformat(),  # '2019-01-28T18:25:03.000000+00:00' -- seen in `kubectl describe ...`
+        'lastTimestamp': now.isoformat(),  # '2019-01-28T18:25:03.000000+00:00' - seen in `kubectl get events`
+        'eventTime': now.isoformat(),  # '2019-01-28T18:25:03.000000+00:00'
     }
 
     try:
diff --git a/kopf/_cogs/structs/credentials.py b/kopf/_cogs/structs/credentials.py
index 9a40bb3f..040ee9a3 100644
--- a/kopf/_cogs/structs/credentials.py
+++ b/kopf/_cogs/structs/credentials.py
@@ -229,7 +229,9 @@ async def expire(self) -> None:
         Unlike invalidation, the expired credentials are not remembered
         and not blocked from reappearing.
         """
-        now = datetime.datetime.utcnow()
+        now = datetime.datetime.now(datetime.timezone.utc)
+        if self._next_expiration.tzinfo is None:
+            now = now.replace(tzinfo=None)  # for comparability
         if now >= self._next_expiration:  # quick & lockless for speed: it is done on every API call
             async with self._lock:
                 for key, item in list(self._current.items()):
@@ -315,7 +317,7 @@ async def populate(
         await self._ready.turn_to(True)
 
     def is_empty(self) -> bool:
-        now = datetime.datetime.utcnow()
+        now = datetime.datetime.now(datetime.timezone.utc)
         return all(
             item.info.expiration is not None and now >= item.info.expiration  # i.e. expired
             for key, item in self._current.items()
diff --git a/kopf/_core/actions/application.py b/kopf/_core/actions/application.py
index 805dda23..22f8e99b 100644
--- a/kopf/_core/actions/application.py
+++ b/kopf/_core/actions/application.py
@@ -89,7 +89,7 @@ async def apply(
             logger.debug(f"Sleeping was interrupted by new changes, {unslept_delay} seconds left.")
         else:
             # Any unique always-changing value will work; not necessary a timestamp.
-            value = datetime.datetime.utcnow().isoformat()
+            value = datetime.datetime.now(datetime.timezone.utc).isoformat()
             touch = patches.Patch()
             settings.persistence.progress_storage.touch(body=body, patch=touch, value=value)
             await patch_and_check(
diff --git a/kopf/_core/actions/execution.py b/kopf/_core/actions/execution.py
index 37573f69..89cb095c 100644
--- a/kopf/_core/actions/execution.py
+++ b/kopf/_core/actions/execution.py
@@ -113,7 +113,7 @@ def finished(self) -> bool:
     @property
     def sleeping(self) -> bool:
         ts = self.delayed
-        now = datetime.datetime.utcnow()
+        now = datetime.datetime.now(datetime.timezone.utc)
         return not self.finished and ts is not None and ts > now
 
     @property
@@ -122,7 +122,7 @@ def awakened(self) -> bool:
 
     @property
     def runtime(self) -> datetime.timedelta:
-        now = datetime.datetime.utcnow()
+        now = datetime.datetime.now(datetime.timezone.utc)
         return now - (self.started if self.started else now)
 
 
@@ -277,7 +277,7 @@ async def execute_handler_once(
             handler=handler,
             cause=cause,
             retry=state.retries,
-            started=state.started or datetime.datetime.utcnow(),  # "or" is for type-checking.
+            started=state.started or datetime.datetime.now(datetime.timezone.utc),  # "or" is for type-checking.
             runtime=state.runtime,
             settings=settings,
             lifecycle=lifecycle,  # just a default for the sub-handlers, not used directly.
diff --git a/kopf/_core/actions/progression.py b/kopf/_core/actions/progression.py
index 65367ee5..780d8cf0 100644
--- a/kopf/_core/actions/progression.py
+++ b/kopf/_core/actions/progression.py
@@ -54,7 +54,7 @@ class HandlerState(execution.HandlerState):
     def from_scratch(cls, *, purpose: Optional[str] = None) -> "HandlerState":
         return cls(
             active=True,
-            started=datetime.datetime.utcnow(),
+            started=datetime.datetime.now(datetime.timezone.utc),
             purpose=purpose,
         )
 
@@ -62,7 +62,7 @@ def from_scratch(cls, *, purpose: Optional[str] = None) -> "HandlerState":
     def from_storage(cls, __d: progress.ProgressRecord) -> "HandlerState":
         return cls(
             active=False,
-            started=_datetime_fromisoformat(__d.get('started')) or datetime.datetime.utcnow(),
+            started=_datetime_fromisoformat(__d.get('started')) or datetime.datetime.now(datetime.timezone.utc),
             stopped=_datetime_fromisoformat(__d.get('stopped')),
             delayed=_datetime_fromisoformat(__d.get('delayed')),
             purpose=__d.get('purpose') if __d.get('purpose') else None,
@@ -104,7 +104,7 @@ def with_outcome(
             self,
             outcome: execution.Outcome,
     ) -> "HandlerState":
-        now = datetime.datetime.utcnow()
+        now = datetime.datetime.now(datetime.timezone.utc)
         cls = type(self)
         return cls(
             active=self.active,
@@ -313,7 +313,7 @@ def delays(self) -> Collection[float]:
         processing routine, based on all delays of different origin:
         e.g. postponed daemons, stopping daemons, temporarily failed handlers.
         """
-        now = datetime.datetime.utcnow()
+        now = datetime.datetime.now(datetime.timezone.utc)
         return [
             max(0, (handler_state.delayed - now).total_seconds()) if handler_state.delayed else 0
             for handler_state in self._states.values()
@@ -381,4 +381,7 @@ def _datetime_fromisoformat(val: Optional[str]) -> Optional[datetime.datetime]:
     if val is None:
         return None
     else:
-        return datetime.datetime.fromisoformat(val)
+        dt = datetime.datetime.fromisoformat(val)
+        if dt.tzinfo is None:
+            dt = dt.replace(tzinfo=datetime.timezone.utc)
+        return dt
diff --git a/kopf/_core/engines/peering.py b/kopf/_core/engines/peering.py
index 6c511f8e..8eafccf0 100644
--- a/kopf/_core/engines/peering.py
+++ b/kopf/_core/engines/peering.py
@@ -68,10 +68,10 @@ def __init__(
         self.priority = priority
         self.lifetime = datetime.timedelta(seconds=int(lifetime))
         self.lastseen = (iso8601.parse_date(lastseen) if lastseen is not None else
-                         datetime.datetime.utcnow())
+                         datetime.datetime.now(datetime.timezone.utc))
         self.lastseen = self.lastseen.replace(tzinfo=None)  # only the naive utc -- for comparison
         self.deadline = self.lastseen + self.lifetime
-        self.is_dead = self.deadline <= datetime.datetime.utcnow()
+        self.is_dead = self.deadline <= datetime.datetime.now(datetime.timezone.utc)
 
     def __repr__(self) -> str:
         clsname = self.__class__.__name__
@@ -149,7 +149,7 @@ async def process_peering_event(
     # are expected to expire, and force the immediate re-evaluation by a certain change of self.
     # This incurs an extra PATCH request besides usual keepalives, but in the complete silence
     # from other peers that existed a moment earlier, this should not be a problem.
-    now = datetime.datetime.utcnow()
+    now = datetime.datetime.now(datetime.timezone.utc)
     delays = [(peer.deadline - now).total_seconds() for peer in same_peers + prio_peers]
     unslept = await aiotime.sleep(delays, wakeup=stream_pressure)
     if unslept is None and delays:
@@ -279,7 +279,7 @@ def detect_own_id(*, manual: bool) -> Identity:
 
     user = getpass.getuser()
     host = hostnames.get_descriptive_hostname()
-    now = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
+    now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S")
     rnd = ''.join(random.choices('abcdefhijklmnopqrstuvwxyz0123456789', k=3))
     return Identity(f'{user}@{host}' if manual else f'{user}@{host}/{now}/{rnd}')
 
diff --git a/kopf/_core/engines/probing.py b/kopf/_core/engines/probing.py
index 51675a46..0c1bbb99 100644
--- a/kopf/_core/engines/probing.py
+++ b/kopf/_core/engines/probing.py
@@ -48,10 +48,10 @@ async def get_health(
 
         # Recollect the data on-demand, and only if is is older that a reasonable caching period.
         # Protect against multiple parallel requests performing the same heavy activity.
-        now = datetime.datetime.utcnow()
+        now = datetime.datetime.now(datetime.timezone.utc)
         if probing_timestamp is None or now - probing_timestamp >= probing_max_age:
             async with probing_lock:
-                now = datetime.datetime.utcnow()
+                now = datetime.datetime.now(datetime.timezone.utc)
                 if probing_timestamp is None or now - probing_timestamp >= probing_max_age:
 
                     activity_results = await activities.run_activity(
@@ -64,7 +64,7 @@ async def get_health(
                     )
                     probing_container.clear()
                     probing_container.update(activity_results)
-                    probing_timestamp = datetime.datetime.utcnow()
+                    probing_timestamp = datetime.datetime.now(datetime.timezone.utc)
 
         return aiohttp.web.json_response(probing_container)
 
diff --git a/tests/conftest.py b/tests/conftest.py
index 43bae928..bbafa261 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -38,6 +38,10 @@ def pytest_configure(config):
     # TODO: Remove when fixed in https://github.com/pytest-dev/pytest-asyncio/issues/460:
     config.addinivalue_line('filterwarnings', 'ignore:There is no current event loop:DeprecationWarning:pytest_asyncio')
 
+    # Python 3.12 transitional period:
+    config.addinivalue_line('filterwarnings', 'ignore:datetime.datetime.utcfromtimestamp.*:DeprecationWarning:dateutil')
+    config.addinivalue_line('filterwarnings', 'ignore:datetime.datetime.utcfromtimestamp.*:DeprecationWarning:freezegun')
+
 
 def pytest_addoption(parser):
     parser.addoption("--only-e2e", action="store_true", help="Execute end-to-end tests only.")
diff --git a/tests/handling/test_timing_consistency.py b/tests/handling/test_timing_consistency.py
index 97e485d1..15650c3b 100644
--- a/tests/handling/test_timing_consistency.py
+++ b/tests/handling/test_timing_consistency.py
@@ -56,7 +56,7 @@ def move_to_tsB(*_, **__):
     # Simulate the call as if the event has just arrived on the watch-stream.
     # Another way (the same effect): process_changing_cause() and its result.
     with freezegun.freeze_time(tsA_triggered) as frozen_dt:
-        assert datetime.datetime.utcnow() < ts0  # extra precaution
+        assert datetime.datetime.now(datetime.timezone.utc) < ts0  # extra precaution
         await process_resource_event(
             lifecycle=kopf.lifecycles.all_at_once,
             registry=registry,
@@ -68,7 +68,7 @@ def move_to_tsB(*_, **__):
             raw_event={'type': 'ADDED', 'object': body},
             event_queue=asyncio.Queue(),
         )
-        assert datetime.datetime.utcnow() > ts0  # extra precaution
+        assert datetime.datetime.now(datetime.timezone.utc) > ts0  # extra precaution
 
     assert state_store.called