AAP-59874: Update to Python 3.12 (#16208)

* update to Python 3.12

* remove use of utcnow

* switch to timezone.utc

datetime.UTC is an alias of datetime.timezone.utc. if we're doing the double import for datetime it's more straightforward to just import timezone as well and get it directly

* debug python env version issue

* change python version

* pin to SHA and remove debug portion
This commit is contained in:
jessicamack 2026-01-07 11:57:24 -05:00 committed by GitHub
parent 48c7534b57
commit de86b93690
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 124 additions and 85 deletions

View File

@ -183,14 +183,19 @@ jobs:
path: awx-operator
- name: Setup python, referencing action at awx relative path
uses: ./awx/.github/actions/setup-python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13'
python-version: '3.12'
- name: Install playbook dependencies
run: |
python -m pip install docker
- name: Check Python version
working-directory: awx
run: |
make print-PYTHON
- name: Build AWX image
working-directory: awx
run: |

View File

@ -7,7 +7,7 @@ build:
os: ubuntu-22.04
tools:
python: >-
3.11
3.12
commands:
- pip install --user tox
- python3 -m tox -e docs --notest -v

View File

@ -1,6 +1,6 @@
-include awx/ui/Makefile
PYTHON := $(notdir $(shell for i in python3.11 python3; do command -v $$i; done|sed 1q))
PYTHON := $(notdir $(shell for i in python3.12 python3; do command -v $$i; done|sed 1q))
SHELL := bash
DOCKER_COMPOSE ?= docker compose
OFFICIAL ?= no
@ -79,7 +79,7 @@ RECEPTOR_IMAGE ?= quay.io/ansible/receptor:devel
SRC_ONLY_PKGS ?= cffi,pycparser,psycopg,twilio
# These should be upgraded in the AWX and Ansible venv before attempting
# to install the actual requirements
VENV_BOOTSTRAP ?= pip==21.2.4 setuptools==80.9.0 setuptools_scm[toml]==8.0.4 wheel==0.42.0 cython==3.1.3
VENV_BOOTSTRAP ?= pip==25.3 setuptools==80.9.0 setuptools_scm[toml]==9.2.2 wheel==0.45.1 cython==3.1.3
NAME ?= awx

View File

@ -198,8 +198,8 @@ def generate_receptor_tls(instance_obj):
.issuer_name(ca_cert.issuer)
.public_key(csr.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650))
.not_valid_before(datetime.datetime.now(datetime.UTC))
.not_valid_after(datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=3650))
.add_extension(
csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value,
critical=csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical,

View File

@ -5,7 +5,7 @@ import signal
import sys
import time
import traceback
from datetime import datetime
from datetime import datetime, timezone
from uuid import uuid4
import json
@ -301,7 +301,7 @@ class WorkerPool(object):
'\n'
'{% endfor %}'
)
now = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
return tmpl.render(pool=self, workers=self.workers, meta=self.debug_meta, dt=now)
def write(self, preferred_queue, body):

View File

@ -1,7 +1,6 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
import sys
from distutils.util import strtobool
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand
@ -9,6 +8,24 @@ from django.conf import settings
from awx.main.models import CredentialType, Credential, ExecutionEnvironment
def strtobool(val):
"""Convert a string representation of truth to true (1) or false (0).
True values are 'y', 'yes', 't', 'true', 'on', and '1'.
False values are 'n', 'no', 'f', 'false', 'off', and '0'.
Raises ValueError if 'val' is anything else.
This replaces the deprecated distutils.util.strtobool removed in Python 3.12.
"""
val = val.lower()
if val in ('y', 'yes', 't', 'true', 'on', '1'):
return 1
elif val in ('n', 'no', 'f', 'false', 'off', '0'):
return 0
else:
raise ValueError(f"invalid truth value {val!r}")
class Command(BaseCommand):
"""Create default execution environments, intended for new installs"""

View File

@ -422,7 +422,7 @@ class BaseTask(object):
except IOError as e:
if e.errno not in (errno.EAGAIN, errno.EACCES):
os.close(self.lock_fd)
logger.error("I/O error({0}) while trying to aquire lock on file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
logger.error("I/O error({0}) while trying to acquire lock on file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
raise
else:
if not emitted_lockfile_log:

View File

@ -10,7 +10,7 @@ import time
from collections import namedtuple
from contextlib import redirect_stdout
from datetime import datetime
from distutils.version import LooseVersion as Version
from packaging.version import Version
from io import StringIO
# dispatcherd

View File

@ -88,7 +88,7 @@ class TestSwaggerGeneration:
JSON['info']['version'] = release
if not request.config.getoption('--genschema'):
JSON['modified'] = datetime.datetime.utcnow().isoformat()
JSON['modified'] = datetime.datetime.now(datetime.UTC).isoformat()
# Make some basic assertions about the rendered JSON so we can
# be sure it doesn't break across DRF upgrades and view/serializer

View File

@ -139,7 +139,7 @@ def test_send_notifications_job_id(mocker):
mocker.patch('awx.main.models.UnifiedJob.objects.get')
system.send_notifications([], job_id=1)
assert UnifiedJob.objects.get.called
assert UnifiedJob.objects.get.called_with(id=1)
UnifiedJob.objects.get.assert_called_with(id=1)
@mock.patch('awx.main.models.UnifiedJob.objects.get')
@ -156,7 +156,7 @@ def test_send_notifications_list(mock_notifications_filter, mock_job_get, mocker
assert mock_notifications[0].save.called
assert mock_job.notifications.add.called
assert mock_job.notifications.add.called_with(*mock_notifications)
mock_job.notifications.add.assert_called_with(*mock_notifications)
@pytest.mark.parametrize(
@ -1508,8 +1508,8 @@ def test_fcntl_ioerror():
@mock.patch('os.open')
@mock.patch('logging.getLogger')
def test_acquire_lock_open_fail_logged(logging_getLogger, os_open, mock_me):
@mock.patch('awx.main.tasks.jobs.logger')
def test_acquire_lock_open_fail_logged(logger_mock, os_open, mock_me):
err = OSError()
err.errno = 3
err.strerror = 'dummy message'
@ -1519,21 +1519,18 @@ def test_acquire_lock_open_fail_logged(logging_getLogger, os_open, mock_me):
os_open.side_effect = err
logger = mock.Mock()
logging_getLogger.return_value = logger
ProjectUpdate = jobs.RunProjectUpdate()
with pytest.raises(OSError):
ProjectUpdate.acquire_lock(instance)
assert logger.err.called_with("I/O error({0}) while trying to open lock file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message'))
logger_mock.error.assert_called_with("I/O error({0}) while trying to open lock file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message'))
@mock.patch('os.open')
@mock.patch('os.close')
@mock.patch('logging.getLogger')
@mock.patch('awx.main.tasks.jobs.logger')
@mock.patch('fcntl.lockf')
def test_acquire_lock_acquisition_fail_logged(fcntl_lockf, logging_getLogger, os_close, os_open, mock_me):
def test_acquire_lock_acquisition_fail_logged(fcntl_lockf, logger_mock, os_close, os_open, mock_me):
err = IOError()
err.errno = 3
err.strerror = 'dummy message'
@ -1544,16 +1541,15 @@ def test_acquire_lock_acquisition_fail_logged(fcntl_lockf, logging_getLogger, os
os_open.return_value = 3
logger = mock.Mock()
logging_getLogger.return_value = logger
fcntl_lockf.side_effect = err
ProjectUpdate = jobs.RunProjectUpdate()
with pytest.raises(IOError):
ProjectUpdate.acquire_lock(instance)
os_close.assert_called_with(3)
assert logger.err.called_with("I/O error({0}) while trying to acquire lock on file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message'))
logger_mock.error.assert_called_with(
"I/O error({0}) while trying to acquire lock on file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message')
)
@pytest.mark.parametrize('injector_cls', [cls for cls in ManagedCredentialType.registry.values() if cls.injectors])

View File

@ -8,7 +8,7 @@ import logging.handlers
import sys
import traceback
import os
from datetime import datetime
from datetime import datetime, timezone
# Django
from django.conf import settings
@ -50,7 +50,7 @@ class RSysLogHandler(logging.handlers.SysLogHandler):
# because the alternative is blocking the
# socket.send() in the Python process, which we definitely don't
# want to do)
dt = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
dt = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
msg = f'{dt} ERROR rsyslogd was unresponsive: '
exc = traceback.format_exc()
try:

View File

@ -393,7 +393,7 @@ class Licenser(object):
end_date = parse(sub['subscriptions']['endDate'])
except Exception:
continue
now = datetime.utcnow()
now = datetime.now(timezone.utc)
now = now.replace(tzinfo=end_date.tzinfo)
if end_date < now:
# If the sub has a past end date, skip it

View File

@ -24,9 +24,9 @@ try:
from ansible.module_utils.compat.version import LooseVersion as Version
except ImportError:
try:
from distutils.version import LooseVersion as Version
from packaging.version import Version
except ImportError:
raise AssertionError('To use this plugin or module with ansible-core 2.11, you need to use Python < 3.12 with distutils.version present')
raise AssertionError('To use this plugin or module you need to use Python >= 3.12')
try:
import yaml

View File

@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
import json
from awxkit.utils import poll_until
@ -35,10 +35,10 @@ class HasStatus(object):
return self
def wait_until_completed(self, interval=5, timeout=60, **kwargs):
start_time = datetime.utcnow()
start_time = datetime.now(timezone.utc)
HasStatus.wait_until_status(self, self.completed_statuses, interval=interval, timeout=timeout, **kwargs)
if not getattr(self, 'event_processing_finished', True):
elapsed = datetime.utcnow() - start_time
elapsed = datetime.now(timezone.utc) - start_time
time_left = timeout - elapsed.total_seconds()
poll_until(lambda: getattr(self.get(), 'event_processing_finished', True), interval=interval, timeout=time_left, **kwargs)
return self
@ -92,7 +92,7 @@ class HasStatus(object):
except Exception as e:
msg += '\nFailed to obtain dependency stdout: {}'.format(e)
msg += '\nTIME WHEN STATUS WAS FOUND: {} (UTC)\n'.format(datetime.utcnow())
msg += '\nTIME WHEN STATUS WAS FOUND: {} (UTC)\n'.format(datetime.now(timezone.utc))
raise AssertionError(msg)

View File

@ -1,5 +1,15 @@
from distutils.version import LooseVersion
from packaging.version import Version
def version_cmp(x, y):
return LooseVersion(x)._cmp(y)
"""Compare two version strings.
Returns -1 if x < y, 0 if x == y, 1 if x > y
"""
vx = Version(x)
vy = Version(y)
if vx < vy:
return -1
elif vx > vy:
return 1
else:
return 0

View File

@ -1,13 +1,29 @@
import locale
import json
from distutils.util import strtobool
import yaml
from awxkit.cli.utils import colored
from awxkit import config
def strtobool(val):
"""Convert a string representation of truth to true (1) or false (0).
True values are 'y', 'yes', 't', 'true', 'on', and '1'.
False values are 'n', 'no', 'f', 'false', 'off', and '0'.
Raises ValueError if 'val' is anything else.
This replaces the deprecated distutils.util.strtobool removed in Python 3.12.
"""
val = val.lower()
if val in ('y', 'yes', 't', 'true', 'on', '1'):
return 1
elif val in ('n', 'no', 'f', 'false', 'off', '0'):
return 0
else:
raise ValueError(f"invalid truth value {val!r}")
def get_config_credentials():
"""Load username and password from config.credentials.default.

View File

@ -6,10 +6,8 @@ import re
import sys
import yaml
from distutils.util import strtobool
from .custom import CustomAction
from .format import add_output_formatting_arguments
from .format import add_output_formatting_arguments, strtobool
from .resource import DEPRECATED_RESOURCES_REVERSE

View File

@ -1,4 +1,4 @@
from datetime import datetime, timedelta, tzinfo
from datetime import datetime, timedelta, tzinfo, timezone
import inspect
import logging
import random
@ -364,7 +364,7 @@ def are_same_endpoint(first, second):
def utcnow():
"""Provide a wrapped copy of the built-in utcnow that can be easily mocked."""
return datetime.utcnow()
return datetime.now(timezone.utc)
class UTC(tzinfo):

View File

@ -205,7 +205,7 @@ class WSClient(object):
message = json.loads(message)
log.debug('received message: {}'.format(message))
if self._add_received_time:
message['received_time'] = datetime.datetime.utcnow()
message['received_time'] = datetime.datetime.now(datetime.UTC)
if all([message.get('group_name') == 'jobs', message.get('status') == 'pending', message.get('unified_job_id'), self._should_subscribe_to_pending_job]):
if bool(message.get('project_id')) == (self._should_subscribe_to_pending_job['events'] == 'project_update_events'):

View File

@ -92,7 +92,7 @@ setup(
'requests',
'setuptools',
],
python_requires=">=3.8",
python_requires=">=3.12",
extras_require={'formatting': ['jq'], 'websockets': ['websocket-client==0.57.0'], 'crypto': ['cryptography']},
license='Apache 2.0',
classifiers=[
@ -104,7 +104,7 @@ setup(
'Operating System :: MacOS :: MacOS X',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.12',
'Topic :: System :: Software Distribution',
'Topic :: System :: Systems Administration',
],

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from datetime import datetime, timezone
import sys
from unittest import mock
@ -379,7 +379,7 @@ class TestUpdatePayload(object):
def test_to_ical():
now = datetime.utcnow()
now = datetime.now(timezone.utc)
ical_datetime = utils.to_ical(now)
date = str(now.date()).replace('-', '')
time = str(now.time()).split('.')[0].replace(':', '')

View File

@ -8,7 +8,7 @@ skip_missing_interpreters = true
# skipsdist = true
[testenv]
basepython = python3.11
basepython = python3.12
setenv =
PYTHONPATH = {toxinidir}:{env:PYTHONPATH:}:.
deps =

View File

@ -129,14 +129,14 @@ the following dates are saved in the database:
- `main_schedule.rrule` - the original `RRULE` string provided by the user
- `main_schedule.dtstart` - the _first_ datetime in the list of all occurrences (coerced to UTC)
- `main_schedule.dtend` - the _last_ datetime in the list of all occurrences (coerced to UTC)
- `main_schedule.next_run` - the _next_ datetime in list after `utcnow()` (coerced to UTC)
- `main_schedule.next_run` - the _next_ datetime in list after `now(datetime.UTC)` (coerced to UTC)
AWX makes use of [Celery Periodic Tasks
(celerybeat)](http://docs.celeryproject.org/en/latest/userguide/periodic-tasks.html)
to run a periodic task that discovers new jobs that need to run at a regular
interval (by default, every 30 seconds). When this task starts, it queries the
database for Schedules where `Schedule.next_run` is between
`scheduler_last_runtime()` and `utcnow()`. For each of these, a new job is
`scheduler_last_runtime()` and `now(datetime.UTC)`. For each of these, a new job is
launched, and `Schedule.next_run` is changed to the next chronological datetime
in the list of all occurrences.

View File

@ -35,9 +35,6 @@ filterwarnings =
# FIXME: Delete this entry once `zope` is updated.
once:Deprecated call to `pkg_resources.declare_namespace.'zope'.`.\nImplementing implicit namespace packages .as specified in PEP 420. is preferred to `pkg_resources.declare_namespace`. See https.//setuptools.pypa.io/en/latest/references/keywords.html#keyword-namespace-packages:DeprecationWarning:
# FIXME: Delete this entry once the use of `distutils` is exterminated from the repo.
once:The distutils package is deprecated and slated for removal in Python 3.12. Use setuptools or check PEP 632 for potential alternatives:DeprecationWarning:_pytest.assertion.rewrite
# FIXME: Delete this entry once `coreapi` is deleted from the dependencies
# FIXME: and is no longer imported at runtime.
once:CoreAPI compatibility is deprecated and will be removed in DRF 3.17:rest_framework.RemovedInDRF317Warning:rest_framework.schemas.coreapi

View File

@ -1,4 +1,4 @@
aiohttp>=3.9.4 # CVE-2024-30251
aiohttp>=3.12.14 # CVE-2024-30251
ansi2html # Used to format the stdout from jobs into html for display
jq # used for indirect host counting feature
asciichartpy
@ -65,7 +65,7 @@ urllib3<2.4.0, >=1.26.19 # CVE-2024-37891. capped by kubernetes 34.1.0 reqs
uWSGI>=2.0.28
uwsgitop
wheel>=0.38.1 # CVE-2022-40898
pip==21.2.4 # see UPGRADE BLOCKERs
pip==25.3 # see UPGRADE BLOCKERs
setuptools==80.9.0 # see UPGRADE BLOCKERs
setuptools_scm[toml]
setuptools-rust>=0.11.4 # cryptography build dep

View File

@ -475,7 +475,7 @@ service-identity==24.2.0
# via twisted
setuptools-rust==1.10.2
# via -r /awx_devel/requirements/requirements.in
setuptools-scm[toml]==8.1.0
setuptools-scm[toml]==9.2.2
# via -r /awx_devel/requirements/requirements.in
six==1.17.0
# via
@ -542,7 +542,7 @@ uwsgitop==0.12
# via -r /awx_devel/requirements/requirements.in
websocket-client==1.8.0
# via kubernetes
wheel==0.42.0
wheel==0.45.1
# via -r /awx_devel/requirements/requirements.in
wrapt==1.17.3
# via opentelemetry-instrumentation
@ -556,7 +556,7 @@ zstandard==0.25.0
# via aiohttp
# The following packages are considered to be unsafe in a requirements file:
pip==21.2.4
pip==25.3
# via -r /awx_devel/requirements/requirements.in
setuptools==80.9.0
# via

View File

@ -16,7 +16,7 @@ _cleanup() {
generate_requirements() {
venv="`pwd`/venv"
echo $venv
/usr/bin/python3.11 -m venv "${venv}"
/usr/bin/python3.12 -m venv "${venv}"
# shellcheck disable=SC1090
source ${venv}/bin/activate

View File

@ -48,12 +48,12 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \
patch \
postgresql \
postgresql-devel \
python3.11 \
"python3.11-devel" \
"python3.11-pip" \
"python3.11-setuptools" \
"python3.11-packaging" \
"python3.11-psycopg2" \
python3.12 \
"python3.12-devel" \
"python3.12-pip" \
"python3.12-setuptools" \
"python3.12-packaging" \
"python3.12-psycopg2" \
swig \
unzip \
xmlsec1-devel \
@ -63,7 +63,7 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \
RUN mkdir -p ~/.ssh && chmod 0700 ~/.ssh
RUN ssh-keyscan github.com > ~/.ssh/known_hosts
RUN pip3.11 install -vv build
RUN pip3.12 install -vv build
{% if image_architecture == 'ppc64le' %}
RUN dnf -y update && dnf install -y wget && \
@ -133,12 +133,12 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \
krb5-workstation \
nginx \
postgresql \
python3.11 \
"python3.11-devel" \
"python3.11-pip*" \
"python3.11-setuptools" \
"python3.11-packaging" \
"python3.11-psycopg2" \
python3.12 \
"python3.12-devel" \
"python3.12-pip*" \
"python3.12-setuptools" \
"python3.12-packaging" \
"python3.12-psycopg2" \
rsync \
rsyslog \
subversion \
@ -149,7 +149,7 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \
xmlsec1-openssl && \
dnf -y clean all
RUN pip3.11 install -vv virtualenv supervisor dumb-init build
RUN pip3.12 install -vv virtualenv supervisor dumb-init build
RUN rm -rf /root/.cache && rm -rf /tmp/*
@ -181,8 +181,8 @@ RUN dnf -y install \
unzip && \
npm install -g n && n 16.13.1 && npm install -g npm@8.5.0 && dnf remove -y nodejs
RUN pip3.11 install -vv git+https://github.com/coderanger/supervisor-stdout.git@973ba19967cdaf46d9c1634d1675fc65b9574f6e
RUN pip3.11 install -vv black setuptools-scm build
RUN pip3.12 install -vv git+https://github.com/coderanger/supervisor-stdout.git@973ba19967cdaf46d9c1634d1675fc65b9574f6e
RUN pip3.12 install -vv black setuptools-scm build
# This package randomly fails to download.
# It is nice to have in the dev env, but not necessary.
@ -251,8 +251,8 @@ ADD tools/scripts/awx-python /usr/bin/awx-python
{% endif %}
{% if (build_dev|bool) or (kube_dev|bool) %}
RUN echo /awx_devel > /var/lib/awx/venv/awx/lib/python3.11/site-packages/awx.egg-link
RUN echo /awx_devel > /var/lib/awx/venv/awx/lib/python3.11/site-packages/awx.pth
RUN echo /awx_devel > /var/lib/awx/venv/awx/lib/python3.12/site-packages/awx.egg-link
RUN echo /awx_devel > /var/lib/awx/venv/awx/lib/python3.12/site-packages/awx.pth
RUN ln -sf /awx_devel/tools/docker-compose/awx-manage /usr/local/bin/awx-manage
RUN ln -sf /awx_devel/tools/scripts/awx-python /usr/bin/awx-python
RUN ln -sf /awx_devel/tools/scripts/rsyslog-4xx-recovery /usr/bin/rsyslog-4xx-recovery
@ -284,8 +284,8 @@ RUN for dir in \
/var/lib/awx/.local \
/var/lib/awx/venv \
/var/lib/awx/venv/awx/bin \
/var/lib/awx/venv/awx/lib/python3.11 \
/var/lib/awx/venv/awx/lib/python3.11/site-packages \
/var/lib/awx/venv/awx/lib/python3.12 \
/var/lib/awx/venv/awx/lib/python3.12/site-packages \
/var/lib/awx/projects \
/var/lib/awx/rsyslog \
/var/run/awx-rsyslog \

View File

@ -310,12 +310,12 @@ if __name__ == '__main__':
if events > 0:
for k_id in created_job_ids:
generate_events(events, str(k_id), time_delta)
print(datetime.datetime.utcnow().isoformat())
print(datetime.datetime.now(datetime.UTC).isoformat())
conn.close()
finally:
# restore all indexes
print(datetime.datetime.utcnow().isoformat())
print(datetime.datetime.now(datetime.UTC).isoformat())
print('restoring indexes and constraints (this may take awhile)')
workers = []
@ -343,4 +343,4 @@ if __name__ == '__main__':
sql = f'ALTER TABLE main_jobevent ADD CONSTRAINT {conname} {condef}'
cleanup(sql)
print(datetime.datetime.utcnow().isoformat())
print(datetime.datetime.now(datetime.UTC).isoformat())