Skip to content

Commit d1b4191

Browse files
chayimgerzse
andauthored
Add support for Python 3.12 (redis#2979)
Add support for Python 3.12. This required a bunch of changes all over the place, listed below in a random order. Fix tests, especially around SSL connections. Stop requiring typing-extensions. Enable tracemalloc for tests, and add the possibility to run the tests with profiling enabled. Fix some issues identified by tracemalloc, like sockets not being closed in case of SSL handshake failures. Remove the CI test reporting plugin, it does not work easily with forked repos anyway. Fix checking of module versions, make the comparison accurate. Not sure how it worked before, but it looks like it did not match exactly the format in the server INFO response, i.e. MMmmPP. Remove loggers from tests, it's just noise in the output. If we don't use asserts, nobody will check the log output from CI. Speed up the computation for slots when initializing a cluster. After profiling, this turned out to be very slow, when it does not have to be. It does not make sense to recompute the same thing over and over in a loop. Run uvloop tests in matrix, i.e. don't bundle two tests executions (without uvloop and with it) in the same job. Easier to spot failures like this, and arguably the jobs can be scheduled in parallel so the overall execution is faster. Unlock urllib version, to be able to use more recent pytest versions. --------- Co-authored-by: Gabriel Erzse <[email protected]>
1 parent 1bb8eab commit d1b4191

19 files changed

+194
-165
lines changed

.flake8

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ exclude =
1616
ignore =
1717
E126
1818
E203
19+
E231
1920
E701
2021
E704
2122
F405

.github/workflows/integration.yaml

+42-47
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ permissions:
2525

2626
env:
2727
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
28+
# this speeds up coverage with Python 3.12: https://github.com/nedbat/coveragepy/issues/1665
29+
COVERAGE_CORE: sysmon
2830
REDIS_IMAGE: redis:7.4-rc2
2931
REDIS_STACK_IMAGE: redis/redis-stack-server:7.4.0-rc2
3032

@@ -54,26 +56,28 @@ jobs:
5456
pip install -r dev_requirements.txt
5557
invoke linters
5658
57-
run-tests:
59+
resp2-tests:
5860
runs-on: ubuntu-latest
5961
timeout-minutes: 60
6062
strategy:
6163
max-parallel: 15
6264
fail-fast: false
6365
matrix:
64-
python-version: ['3.8', '3.9', '3.10', '3.11', 'pypy-3.8', 'pypy-3.9']
66+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.8', 'pypy-3.9']
6567
test-type: ['standalone', 'cluster']
6668
connection-type: ['hiredis', 'plain']
6769
env:
6870
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
69-
name: Python ${{ matrix.python-version }} ${{matrix.test-type}}-${{matrix.connection-type}} tests
71+
name: RESP2 ${{ matrix.python-version }} ${{matrix.test-type}}-${{matrix.connection-type}}
7072
steps:
7173
- uses: actions/checkout@v4
74+
7275
- uses: actions/setup-python@v5
7376
with:
7477
python-version: ${{ matrix.python-version }}
7578
cache: 'pip'
76-
- name: run tests
79+
80+
- name: Run tests
7781
run: |
7882
pip install -U setuptools wheel
7983
pip install -r requirements.txt
@@ -84,52 +88,48 @@ jobs:
8488
invoke devenv
8589
sleep 10 # time to settle
8690
invoke ${{matrix.test-type}}-tests
91+
ls -1
8792
88-
- uses: actions/upload-artifact@v4
89-
if: success() || failure()
93+
- name: Upload test results and profiling data
94+
uses: actions/upload-artifact@v4
9095
with:
9196
name: pytest-results-${{matrix.test-type}}-${{matrix.connection-type}}-${{matrix.python-version}}
92-
path: '${{matrix.test-type}}*results.xml'
97+
path: |
98+
${{matrix.test-type}}*-results.xml
99+
prof/**
100+
profile_output*
101+
if-no-files-found: error
102+
retention-days: 10
93103

94104
- name: Upload codecov coverage
95105
uses: codecov/codecov-action@v4
96106
with:
97107
fail_ci_if_error: false
98108

99-
- name: View Test Results
100-
uses: dorny/test-reporter@v1
101-
if: success() || failure()
102-
continue-on-error: true
103-
with:
104-
name: Test Results ${{matrix.python-version}} ${{matrix.test-type}}-${{matrix.connection-type}}
105-
path: '*.xml'
106-
reporter: java-junit
107-
list-suites: all
108-
list-tests: all
109-
max-annotations: 10
110-
fail-on-error: 'false'
111-
112-
resp3_tests:
109+
resp3-tests:
113110
runs-on: ubuntu-latest
114111
strategy:
115112
fail-fast: false
116113
matrix:
117-
python-version: ['3.8', '3.11']
114+
python-version: ['3.8', '3.12']
118115
test-type: ['standalone', 'cluster']
119116
connection-type: ['hiredis', 'plain']
117+
event-loop: ['asyncio', 'uvloop']
120118
exclude:
121119
- test-type: 'cluster'
122120
connection-type: 'hiredis'
123121
env:
124122
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
125-
name: RESP3 [${{ matrix.python-version }} ${{matrix.test-type}}-${{matrix.connection-type}}]
123+
name: RESP3 ${{ matrix.python-version }} ${{matrix.test-type}}-${{matrix.connection-type}}-${{matrix.event-loop}}
126124
steps:
127125
- uses: actions/checkout@v4
126+
128127
- uses: actions/setup-python@v5
129128
with:
130129
python-version: ${{ matrix.python-version }}
131130
cache: 'pip'
132-
- name: run tests
131+
132+
- name: Run tests
133133
run: |
134134
pip install -U setuptools wheel
135135
pip install -r requirements.txt
@@ -139,37 +139,32 @@ jobs:
139139
fi
140140
invoke devenv
141141
sleep 10 # time to settle
142-
invoke ${{matrix.test-type}}-tests --protocol=3
143-
invoke ${{matrix.test-type}}-tests --uvloop --protocol=3
142+
if [ "${{matrix.event-loop}}" == "uvloop" ]; then
143+
invoke ${{matrix.test-type}}-tests --uvloop --protocol=3
144+
else
145+
invoke ${{matrix.test-type}}-tests --protocol=3
146+
fi
144147
145-
- uses: actions/upload-artifact@v4
146-
if: success() || failure()
148+
- name: Upload test results and profiling data
149+
uses: actions/upload-artifact@v4
147150
with:
148-
name: pytest-results-${{matrix.test-type}}-${{matrix.connection-type}}-${{matrix.python-version}}-resp3
149-
path: '${{matrix.test-type}}*results.xml'
151+
name: pytest-results-${{matrix.test-type}}-${{matrix.connection-type}}-${{matrix.python-version}}-${{matrix.event-loop}}-resp3
152+
path: |
153+
${{matrix.test-type}}*-results.xml
154+
prof/**
155+
profile_output*
156+
if-no-files-found: error
157+
retention-days: 10
150158

151159
- name: Upload codecov coverage
152160
uses: codecov/codecov-action@v4
153161
with:
154162
fail_ci_if_error: false
155163

156-
- name: View Test Results
157-
uses: dorny/test-reporter@v1
158-
if: success() || failure()
159-
continue-on-error: true
160-
with:
161-
name: Test Results ${{matrix.python-version}} ${{matrix.test-type}}-${{matrix.connection-type}}-resp3
162-
path: '*.xml'
163-
reporter: java-junit
164-
list-suites: all
165-
list-tests: all
166-
max-annotations: 10
167-
fail-on-error: 'false'
168-
169-
build_and_test_package:
164+
build-and-test-package:
170165
name: Validate building and installing the package
171166
runs-on: ubuntu-latest
172-
needs: [run-tests]
167+
needs: [resp2-tests, resp3-tests]
173168
strategy:
174169
fail-fast: false
175170
matrix:
@@ -183,13 +178,13 @@ jobs:
183178
run: |
184179
bash .github/workflows/install_and_test.sh ${{ matrix.extension }}
185180
186-
install_package_from_commit:
181+
install-package-from-commit:
187182
name: Install package from commit hash
188183
runs-on: ubuntu-latest
189184
strategy:
190185
fail-fast: false
191186
matrix:
192-
python-version: ['3.8', '3.9', '3.10', '3.11', 'pypy-3.8', 'pypy-3.9']
187+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.8', 'pypy-3.9']
193188
steps:
194189
- uses: actions/checkout@v4
195190
- uses: actions/setup-python@v5

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,6 @@ coverage.xml
1616
.venv*
1717
*.xml
1818
.coverage*
19+
prof
20+
profile_output*
1921
docker/stunnel/keys

dev_requirements.txt

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ click==8.0.4
44
flake8-isort==6.0.0
55
flake8==5.0.4
66
flynt~=0.69.0
7-
invoke==1.7.3
8-
mock==4.0.3
7+
invoke==2.2.0
8+
mock
99
packaging>=20.4
1010
pytest
1111
pytest-asyncio
1212
pytest-cov
13+
pytest-profiling
1314
pytest-timeout
1415
ujson>=4.2.0
15-
urllib3<2
1616
uvloop
1717
vulture>=2.3.0
1818
wheel>=0.30.0

redis/asyncio/cluster.py

+19-21
Original file line numberDiff line numberDiff line change
@@ -1317,37 +1317,35 @@ async def initialize(self) -> None:
13171317
port = int(primary_node[1])
13181318
host, port = self.remap_host_port(host, port)
13191319

1320+
nodes_for_slot = []
1321+
13201322
target_node = tmp_nodes_cache.get(get_node_name(host, port))
13211323
if not target_node:
13221324
target_node = ClusterNode(
13231325
host, port, PRIMARY, **self.connection_kwargs
13241326
)
13251327
# add this node to the nodes cache
13261328
tmp_nodes_cache[target_node.name] = target_node
1329+
nodes_for_slot.append(target_node)
1330+
1331+
replica_nodes = slot[3:]
1332+
for replica_node in replica_nodes:
1333+
host = replica_node[0]
1334+
port = replica_node[1]
1335+
host, port = self.remap_host_port(host, port)
1336+
1337+
target_replica_node = tmp_nodes_cache.get(get_node_name(host, port))
1338+
if not target_replica_node:
1339+
target_replica_node = ClusterNode(
1340+
host, port, REPLICA, **self.connection_kwargs
1341+
)
1342+
# add this node to the nodes cache
1343+
tmp_nodes_cache[target_replica_node.name] = target_replica_node
1344+
nodes_for_slot.append(target_replica_node)
13271345

13281346
for i in range(int(slot[0]), int(slot[1]) + 1):
13291347
if i not in tmp_slots:
1330-
tmp_slots[i] = []
1331-
tmp_slots[i].append(target_node)
1332-
replica_nodes = [slot[j] for j in range(3, len(slot))]
1333-
1334-
for replica_node in replica_nodes:
1335-
host = replica_node[0]
1336-
port = replica_node[1]
1337-
host, port = self.remap_host_port(host, port)
1338-
1339-
target_replica_node = tmp_nodes_cache.get(
1340-
get_node_name(host, port)
1341-
)
1342-
if not target_replica_node:
1343-
target_replica_node = ClusterNode(
1344-
host, port, REPLICA, **self.connection_kwargs
1345-
)
1346-
tmp_slots[i].append(target_replica_node)
1347-
# add this node to the nodes cache
1348-
tmp_nodes_cache[target_replica_node.name] = (
1349-
target_replica_node
1350-
)
1348+
tmp_slots[i] = nodes_for_slot
13511349
else:
13521350
# Validate that 2 nodes want to use the same slot cache
13531351
# setup

redis/asyncio/connection.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1000,7 +1000,7 @@ def parse_url(url: str) -> ConnectKwargs:
10001000
try:
10011001
kwargs[name] = parser(value)
10021002
except (TypeError, ValueError):
1003-
raise ValueError(f"Invalid value for `{name}` in connection URL.")
1003+
raise ValueError(f"Invalid value for '{name}' in connection URL.")
10041004
else:
10051005
kwargs[name] = value
10061006

redis/cluster.py

+16-19
Original file line numberDiff line numberDiff line change
@@ -1522,6 +1522,8 @@ def _get_or_create_cluster_node(self, host, port, role, tmp_nodes_cache):
15221522
target_node = ClusterNode(host, port, role)
15231523
if target_node.server_type != role:
15241524
target_node.server_type = role
1525+
# add this node to the nodes cache
1526+
tmp_nodes_cache[target_node.name] = target_node
15251527

15261528
return target_node
15271529

@@ -1585,31 +1587,26 @@ def initialize(self):
15851587
port = int(primary_node[1])
15861588
host, port = self.remap_host_port(host, port)
15871589

1590+
nodes_for_slot = []
1591+
15881592
target_node = self._get_or_create_cluster_node(
15891593
host, port, PRIMARY, tmp_nodes_cache
15901594
)
1591-
# add this node to the nodes cache
1592-
tmp_nodes_cache[target_node.name] = target_node
1595+
nodes_for_slot.append(target_node)
1596+
1597+
replica_nodes = slot[3:]
1598+
for replica_node in replica_nodes:
1599+
host = str_if_bytes(replica_node[0])
1600+
port = int(replica_node[1])
1601+
host, port = self.remap_host_port(host, port)
1602+
target_replica_node = self._get_or_create_cluster_node(
1603+
host, port, REPLICA, tmp_nodes_cache
1604+
)
1605+
nodes_for_slot.append(target_replica_node)
15931606

15941607
for i in range(int(slot[0]), int(slot[1]) + 1):
15951608
if i not in tmp_slots:
1596-
tmp_slots[i] = []
1597-
tmp_slots[i].append(target_node)
1598-
replica_nodes = [slot[j] for j in range(3, len(slot))]
1599-
1600-
for replica_node in replica_nodes:
1601-
host = str_if_bytes(replica_node[0])
1602-
port = replica_node[1]
1603-
host, port = self.remap_host_port(host, port)
1604-
1605-
target_replica_node = self._get_or_create_cluster_node(
1606-
host, port, REPLICA, tmp_nodes_cache
1607-
)
1608-
tmp_slots[i].append(target_replica_node)
1609-
# add this node to the nodes cache
1610-
tmp_nodes_cache[target_replica_node.name] = (
1611-
target_replica_node
1612-
)
1609+
tmp_slots[i] = nodes_for_slot
16131610
else:
16141611
# Validate that 2 nodes want to use the same slot cache
16151612
# setup

redis/commands/graph/query_result.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import sys
22
from collections import OrderedDict
3-
from distutils.util import strtobool
43

54
# from prettytable import PrettyTable
65
from redis import ResponseError
@@ -571,3 +570,19 @@ async def parse_array(self, value):
571570
"""
572571
scalar = [await self.parse_scalar(value[i]) for i in range(len(value))]
573572
return scalar
573+
574+
575+
def strtobool(val):
576+
"""
577+
Convert a string representation of truth to true (1) or false (0).
578+
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
579+
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
580+
'val' is anything else.
581+
"""
582+
val = val.lower()
583+
if val in ("y", "yes", "t", "true", "on", "1"):
584+
return True
585+
elif val in ("n", "no", "f", "false", "off", "0"):
586+
return False
587+
else:
588+
raise ValueError(f"invalid truth value {val!r}")

redis/connection.py

+20-2
Original file line numberDiff line numberDiff line change
@@ -813,8 +813,26 @@ def __init__(
813813
super().__init__(**kwargs)
814814

815815
def _connect(self):
816-
"Wrap the socket with SSL support"
816+
"""
817+
Wrap the socket with SSL support, handling potential errors.
818+
"""
817819
sock = super()._connect()
820+
try:
821+
return self._wrap_socket_with_ssl(sock)
822+
except OSError:
823+
sock.close()
824+
raise
825+
826+
def _wrap_socket_with_ssl(self, sock):
827+
"""
828+
Wraps the socket with SSL support.
829+
830+
Args:
831+
sock: The plain socket to wrap with SSL.
832+
833+
Returns:
834+
An SSL wrapped socket.
835+
"""
818836
context = ssl.create_default_context()
819837
context.check_hostname = self.check_hostname
820838
context.verify_mode = self.cert_reqs
@@ -957,7 +975,7 @@ def parse_url(url):
957975
try:
958976
kwargs[name] = parser(value)
959977
except (TypeError, ValueError):
960-
raise ValueError(f"Invalid value for `{name}` in connection URL.")
978+
raise ValueError(f"Invalid value for '{name}' in connection URL.")
961979
else:
962980
kwargs[name] = value
963981

0 commit comments

Comments
 (0)