From df08ed3ef38b785c2d53889be58fc580c8a27c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=87=BA=F0=9F=87=A6=20Sviatoslav=20Sydorenko=20=28?= =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D1=82=D0=BE=D1=81=D0=BB=D0=B0=D0=B2=20=D0=A1?= =?UTF-8?q?=D0=B8=D0=B4=D0=BE=D1=80=D0=B5=D0=BD=D0=BA=D0=BE=29?= Date: Tue, 11 Mar 2025 17:19:40 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20Remove=20Python=202=20datetime?= =?UTF-8?q?=20compat=20fallbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch marks the `ansible.module_utils.compat.datetime` module as deprecated, including `UTC`, `utcfromtimestamp()` and `utcnow` shims that it provides, scheduling its removal for v2.21. It also replaces any uses of the compatibility helpers with non-deprecated calls to CPython stdlib. PR #81874 Co-authored-by: Matt Clay --- .../81874-deprecate-datetime-compat.yml | 11 ++++ lib/ansible/module_utils/compat/datetime.py | 54 +++++++++++-------- .../module_utils/facts/system/date_time.py | 6 ++- lib/ansible/modules/get_url.py | 12 ++--- lib/ansible/modules/uri.py | 11 ++-- lib/ansible/modules/wait_for.py | 39 ++++++++------ .../module_utils/compat/test_datetime.py | 20 ++++--- .../module_utils/facts/test_date_time.py | 17 +++--- 8 files changed, 106 insertions(+), 64 deletions(-) create mode 100644 changelogs/fragments/81874-deprecate-datetime-compat.yml diff --git a/changelogs/fragments/81874-deprecate-datetime-compat.yml b/changelogs/fragments/81874-deprecate-datetime-compat.yml new file mode 100644 index 00000000000..63f1b259632 --- /dev/null +++ b/changelogs/fragments/81874-deprecate-datetime-compat.yml @@ -0,0 +1,11 @@ +--- + +deprecated_features: +- >- + ``ansible.module_utils.compat.datetime`` - The datetime compatibility + shims are now deprecated. They are scheduled to be removed in + ``ansible-core`` v2.21. This includes ``UTC``, ``utcfromtimestamp()`` + and ``utcnow`` importable from said module + (https://github.com/ansible/ansible/pull/81874). + +... diff --git a/lib/ansible/module_utils/compat/datetime.py b/lib/ansible/module_utils/compat/datetime.py index d3cdc0d3d38..7392a753340 100644 --- a/lib/ansible/module_utils/compat/datetime.py +++ b/lib/ansible/module_utils/compat/datetime.py @@ -3,36 +3,48 @@ from __future__ import annotations -from ansible.module_utils.six import PY3 +import datetime as _datetime +import typing as t -import datetime +from ansible.module_utils.common.warnings import deprecate -if PY3: - UTC = datetime.timezone.utc -else: - _ZERO = datetime.timedelta(0) +_UTC = _datetime.timezone.utc - class _UTC(datetime.tzinfo): - __slots__ = () - def utcoffset(self, dt): - return _ZERO +def _utcfromtimestamp(timestamp: float) -> _datetime.datetime: + """Construct an aware UTC datetime from a POSIX timestamp.""" + return _datetime.datetime.fromtimestamp(timestamp, _UTC) - def dst(self, dt): - return _ZERO - def tzname(self, dt): - return "UTC" +def _utcnow() -> _datetime.datetime: + """Construct an aware UTC datetime from time.time().""" + return _datetime.datetime.now(_UTC) - UTC = _UTC() +_deprecated_shims_map: dict[str, t.Callable[..., object] | _datetime.timezone] = { + 'UTC': _UTC, + 'utcfromtimestamp': _utcfromtimestamp, + 'utcnow': _utcnow, +} -def utcfromtimestamp(timestamp): # type: (float) -> datetime.datetime - """Construct an aware UTC datetime from a POSIX timestamp.""" - return datetime.datetime.fromtimestamp(timestamp, UTC) +__all__ = tuple(_deprecated_shims_map) -def utcnow(): # type: () -> datetime.datetime - """Construct an aware UTC datetime from time.time().""" - return datetime.datetime.now(UTC) +def __getattr__(importable_name: str) -> t.Callable[..., object] | _datetime.timezone: + """Inject import-time deprecation warnings. + + Specifically, for ``UTC``, ``utcfromtimestamp()`` and ``utcnow()``. + """ + try: + importable = _deprecated_shims_map[importable_name] + except KeyError as key_err: + raise AttributeError(f"module {__name__!r} has no attribute {key_err}") from None + + deprecate( + msg=f'The `ansible.module_utils.compat.datetime.{importable_name}` ' + 'function is deprecated.', + version='2.21', + ) + + return importable diff --git a/lib/ansible/module_utils/facts/system/date_time.py b/lib/ansible/module_utils/facts/system/date_time.py index 908d00aa163..1cef95077be 100644 --- a/lib/ansible/module_utils/facts/system/date_time.py +++ b/lib/ansible/module_utils/facts/system/date_time.py @@ -22,7 +22,6 @@ import time import ansible.module_utils.compat.typing as t from ansible.module_utils.facts.collector import BaseFactCollector -from ansible.module_utils.compat.datetime import utcfromtimestamp class DateTimeFactCollector(BaseFactCollector): @@ -36,7 +35,10 @@ class DateTimeFactCollector(BaseFactCollector): # Store the timestamp once, then get local and UTC versions from that epoch_ts = time.time() now = datetime.datetime.fromtimestamp(epoch_ts) - utcnow = utcfromtimestamp(epoch_ts).replace(tzinfo=None) + utcnow = datetime.datetime.fromtimestamp( + epoch_ts, + tz=datetime.timezone.utc, + ) date_time_facts['year'] = now.strftime('%Y') date_time_facts['month'] = now.strftime('%m') diff --git a/lib/ansible/modules/get_url.py b/lib/ansible/modules/get_url.py index 563ae5a61ea..a794a609346 100644 --- a/lib/ansible/modules/get_url.py +++ b/lib/ansible/modules/get_url.py @@ -373,10 +373,10 @@ import re import shutil import tempfile import traceback +from datetime import datetime, timezone from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six.moves.urllib.parse import urlsplit -from ansible.module_utils.compat.datetime import utcnow, utcfromtimestamp from ansible.module_utils.common.text.converters import to_native from ansible.module_utils.urls import fetch_url, url_argument_spec @@ -399,10 +399,10 @@ def url_get(module, url, dest, use_proxy, last_mod_time, force, timeout=10, head Return (tempfile, info about the request) """ - start = utcnow() + start = datetime.now(timezone.utc) rsp, info = fetch_url(module, url, use_proxy=use_proxy, force=force, last_mod_time=last_mod_time, timeout=timeout, headers=headers, method=method, unredirected_headers=unredirected_headers, decompress=decompress, ciphers=ciphers, use_netrc=use_netrc) - elapsed = (utcnow() - start).seconds + elapsed = (datetime.now(timezone.utc) - start).seconds if info['status'] == 304: module.exit_json(url=url, dest=dest, changed=False, msg=info.get('msg', ''), status_code=info['status'], elapsed=elapsed) @@ -608,7 +608,7 @@ def main(): # If the file already exists, prepare the last modified time for the # request. mtime = os.path.getmtime(dest) - last_mod_time = utcfromtimestamp(mtime) + last_mod_time = datetime.fromtimestamp(mtime, timezone.utc) # If the checksum does not match we have to force the download # because last_mod_time may be newer than on remote @@ -616,11 +616,11 @@ def main(): force = True # download to tmpsrc - start = utcnow() + start = datetime.now(timezone.utc) method = 'HEAD' if module.check_mode else 'GET' tmpsrc, info = url_get(module, url, dest, use_proxy, last_mod_time, force, timeout, headers, tmp_dest, method, unredirected_headers=unredirected_headers, decompress=decompress, ciphers=ciphers, use_netrc=use_netrc) - result['elapsed'] = (utcnow() - start).seconds + result['elapsed'] = (datetime.now(timezone.utc) - start).seconds result['src'] = tmpsrc # Now the request has completed, we can finally generate the final diff --git a/lib/ansible/modules/uri.py b/lib/ansible/modules/uri.py index df0b1c99ba6..448b8f98ac9 100644 --- a/lib/ansible/modules/uri.py +++ b/lib/ansible/modules/uri.py @@ -438,12 +438,12 @@ import os import re import shutil import tempfile +from datetime import datetime, timezone from ansible.module_utils.basic import AnsibleModule, sanitize_keys from ansible.module_utils.six import binary_type, iteritems, string_types from ansible.module_utils.six.moves.urllib.parse import urlencode, urlsplit from ansible.module_utils.common.text.converters import to_native, to_text -from ansible.module_utils.compat.datetime import utcnow, utcfromtimestamp from ansible.module_utils.six.moves.collections_abc import Mapping, Sequence from ansible.module_utils.urls import ( fetch_url, @@ -579,7 +579,10 @@ def uri(module, url, dest, body, body_format, method, headers, socket_timeout, c kwargs = {} if dest is not None and os.path.isfile(dest): # if destination file already exist, only download if file newer - kwargs['last_mod_time'] = utcfromtimestamp(os.path.getmtime(dest)) + kwargs['last_mod_time'] = datetime.fromtimestamp( + os.path.getmtime(dest), + tz=timezone.utc, + ) if module.params.get('follow_redirects') in ('no', 'yes'): module.deprecate( @@ -693,12 +696,12 @@ def main(): module.exit_json(stdout="skipped, since '%s' does not exist" % removes, changed=False) # Make the request - start = utcnow() + start = datetime.now(timezone.utc) r, info = uri(module, url, dest, body, body_format, method, dict_headers, socket_timeout, ca_path, unredirected_headers, decompress, ciphers, use_netrc) - elapsed = (utcnow() - start).seconds + elapsed = (datetime.now(timezone.utc) - start).seconds if r and dest is not None and os.path.isdir(dest): filename = get_response_filename(r) or 'index.html' diff --git a/lib/ansible/modules/wait_for.py b/lib/ansible/modules/wait_for.py index 3b64142379e..468b6c0b4d9 100644 --- a/lib/ansible/modules/wait_for.py +++ b/lib/ansible/modules/wait_for.py @@ -224,7 +224,6 @@ match_groupdict: import binascii import contextlib -import datetime import errno import math import mmap @@ -234,11 +233,11 @@ import select import socket import time import traceback +from datetime import datetime, timedelta, timezone from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.common.sys_info import get_platform_subclass from ansible.module_utils.common.text.converters import to_bytes, to_native -from ansible.module_utils.compat.datetime import utcnow HAS_PSUTIL = False @@ -532,7 +531,7 @@ def main(): except Exception: module.fail_json(msg="unknown active_connection_state (%s) defined" % _connection_state, elapsed=0) - start = utcnow() + start = datetime.now(timezone.utc) if delay: time.sleep(delay) @@ -541,9 +540,9 @@ def main(): time.sleep(timeout) elif state in ['absent', 'stopped']: # first wait for the stop condition - end = start + datetime.timedelta(seconds=timeout) + end = start + timedelta(seconds=timeout) - while utcnow() < end: + while datetime.now(timezone.utc) < end: if path: try: if not os.access(b_path, os.F_OK): @@ -560,7 +559,7 @@ def main(): # Conditions not yet met, wait and try again time.sleep(module.params['sleep']) else: - elapsed = utcnow() - start + elapsed = datetime.now(timezone.utc) - start if port: module.fail_json(msg=msg or "Timeout when waiting for %s:%s to stop." % (host, port), elapsed=elapsed.seconds) elif path: @@ -568,15 +567,15 @@ def main(): elif state in ['started', 'present']: # wait for start condition - end = start + datetime.timedelta(seconds=timeout) - while utcnow() < end: + end = start + timedelta(seconds=timeout) + while datetime.now(timezone.utc) < end: if path: try: os.stat(b_path) except OSError as e: # If anything except file not present, throw an error if e.errno != 2: - elapsed = utcnow() - start + elapsed = datetime.now(timezone.utc) - start module.fail_json(msg=msg or "Failed to stat %s, %s" % (path, e.strerror), elapsed=elapsed.seconds) # file doesn't exist yet, so continue else: @@ -611,7 +610,9 @@ def main(): except IOError: pass elif port: - alt_connect_timeout = math.ceil(_timedelta_total_seconds(end - utcnow())) + alt_connect_timeout = math.ceil( + _timedelta_total_seconds(end - datetime.now(timezone.utc)), + ) try: s = socket.create_connection((host, port), min(connect_timeout, alt_connect_timeout)) except Exception: @@ -622,8 +623,12 @@ def main(): if b_compiled_search_re: b_data = b'' matched = False - while utcnow() < end: - max_timeout = math.ceil(_timedelta_total_seconds(end - utcnow())) + while datetime.now(timezone.utc) < end: + max_timeout = math.ceil( + _timedelta_total_seconds( + end - datetime.now(timezone.utc), + ), + ) readable = select.select([s], [], [], max_timeout)[0] if not readable: # No new data. Probably means our timeout @@ -667,7 +672,7 @@ def main(): else: # while-else # Timeout expired - elapsed = utcnow() - start + elapsed = datetime.now(timezone.utc) - start if port: if search_regex: module.fail_json(msg=msg or "Timeout when waiting for search string %s in %s:%s" % (search_regex, host, port), elapsed=elapsed.seconds) @@ -681,19 +686,19 @@ def main(): elif state == 'drained': # wait until all active connections are gone - end = start + datetime.timedelta(seconds=timeout) + end = start + timedelta(seconds=timeout) tcpconns = TCPConnectionInfo(module) - while utcnow() < end: + while datetime.now(timezone.utc) < end: if tcpconns.get_active_connections_count() == 0: break # Conditions not yet met, wait and try again time.sleep(module.params['sleep']) else: - elapsed = utcnow() - start + elapsed = datetime.now(timezone.utc) - start module.fail_json(msg=msg or "Timeout when waiting for %s:%s to drain" % (host, port), elapsed=elapsed.seconds) - elapsed = utcnow() - start + elapsed = datetime.now(timezone.utc) - start module.exit_json(state=state, port=port, search_regex=search_regex, match_groups=match_groups, match_groupdict=match_groupdict, path=path, elapsed=elapsed.seconds) diff --git a/test/units/module_utils/compat/test_datetime.py b/test/units/module_utils/compat/test_datetime.py index 5bcb8f710b1..a8dfd8ed12b 100644 --- a/test/units/module_utils/compat/test_datetime.py +++ b/test/units/module_utils/compat/test_datetime.py @@ -2,23 +2,29 @@ from __future__ import annotations import datetime -from ansible.module_utils.compat.datetime import utcnow, utcfromtimestamp, UTC +import pytest + +from ansible.module_utils.compat import datetime as compat_datetime + + +pytestmark = pytest.mark.usefixtures('capfd') # capture deprecation warnings def test_utc(): - assert UTC.tzname(None) == 'UTC' - assert UTC.utcoffset(None) == datetime.timedelta(0) - assert UTC.dst(None) is None + assert compat_datetime.UTC.tzname(None) == 'UTC' + assert compat_datetime.UTC.utcoffset(None) == datetime.timedelta(0) + + assert compat_datetime.UTC.dst(None) is None def test_utcnow(): - assert utcnow().tzinfo is UTC + assert compat_datetime.utcnow().tzinfo is compat_datetime.UTC def test_utcfometimestamp_zero(): - dt = utcfromtimestamp(0) + dt = compat_datetime.utcfromtimestamp(0) - assert dt.tzinfo is UTC + assert dt.tzinfo is compat_datetime.UTC assert dt.year == 1970 assert dt.month == 1 assert dt.day == 1 diff --git a/test/units/module_utils/facts/test_date_time.py b/test/units/module_utils/facts/test_date_time.py index 0a17d47df21..595be46b7a3 100644 --- a/test/units/module_utils/facts/test_date_time.py +++ b/test/units/module_utils/facts/test_date_time.py @@ -5,16 +5,15 @@ from __future__ import annotations import pytest -import datetime import string import time +from datetime import datetime, timezone -from ansible.module_utils.compat.datetime import UTC from ansible.module_utils.facts.system import date_time EPOCH_TS = 1594449296.123456 -DT = datetime.datetime(2020, 7, 11, 12, 34, 56, 124356) -UTC_DT = datetime.datetime(2020, 7, 11, 2, 34, 56, 124356) +DT = datetime(2020, 7, 11, 12, 34, 56, 124356) +UTC_DT = datetime(2020, 7, 11, 2, 34, 56, 124356) @pytest.fixture @@ -26,9 +25,13 @@ def fake_now(monkeypatch): class FakeNow: @classmethod - def fromtimestamp(cls, timestamp, tz=None): - if tz == UTC: - return UTC_DT.replace(tzinfo=tz) + def fromtimestamp( + cls: type[FakeNow], + timestamp: float, + tz: timezone | None = None, + ) -> datetime: + if tz == timezone.utc: + return UTC_DT.replace(tzinfo=None) return DT.replace(tzinfo=tz) def _time():