Skip to content

Commit e167136

Browse files
authored
Merge pull request #86 from cloudblue/cr/LITE-deps-bump-py310
LITE-33583: Runtime floor to Python 3.10 and modernize http toolchain
2 parents 62d9035 + 6a26429 commit e167136

9 files changed

Lines changed: 233 additions & 101 deletions

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
runs-on: ubuntu-latest
1515
strategy:
1616
matrix:
17-
python-version: ['3.9', '3.10', '3.11', '3.12']
17+
python-version: ['3.10', '3.11', '3.12']
1818
steps:
1919
- name: Checkout
2020
uses: actions/checkout@v3

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ play with the CloudBlue Connect API using a python REPL like [jupyter](https://j
1717

1818
## Install
1919

20-
`Connect Python OpenAPI Client` requires python 3.9 or later.
20+
`Connect Python OpenAPI Client` requires python 3.10 or later.
2121

2222

2323
`Connect Python OpenAPI Client` can be installed from [pypi.org](https://pypi.org/project/connect-openapi-client/) using pip:

connect/client/fluent.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
# Copyright (c) 2025 CloudBlue. All Rights Reserved.
55
#
66
import contextvars
7+
import ipaddress
78
import threading
89
from functools import cache
910
from json.decoder import JSONDecodeError
1011
from typing import Union
12+
from urllib.request import getproxies
1113

1214
import httpx
1315
import requests
14-
from httpx._config import Proxy
15-
from httpx._utils import get_environment_proxies
1616
from requests.adapters import HTTPAdapter
1717

1818
from connect.client.constants import CONNECT_ENDPOINT_URL, CONNECT_SPECS_URL
@@ -240,6 +240,64 @@ def _get_namespace_class(self):
240240
_SSL_CONTEXT = httpx.create_ssl_context()
241241

242242

243+
def _is_ipv4_hostname(hostname):
244+
try:
245+
ipaddress.IPv4Address(hostname.split('/')[0])
246+
except ValueError:
247+
return False
248+
return True
249+
250+
251+
def _is_ipv6_hostname(hostname):
252+
try:
253+
ipaddress.IPv6Address(hostname.split('/')[0])
254+
except ValueError:
255+
return False
256+
return True
257+
258+
259+
def _get_environment_proxies():
260+
"""
261+
Build httpx mount patterns from the standard proxy environment variables
262+
(HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY and lowercase variants).
263+
264+
Ported from httpx's internal ``get_environment_proxies`` so we don't depend
265+
on the private ``httpx._utils`` module.
266+
"""
267+
proxy_info = getproxies()
268+
mounts = {}
269+
270+
for scheme in ('http', 'https', 'all'):
271+
if proxy_info.get(scheme):
272+
hostname = proxy_info[scheme]
273+
# Default scheme for a scheme-less proxy env var, mirroring httpx's
274+
# get_environment_proxies; not an app-level insecure connection.
275+
mounts[f'{scheme}://'] = (
276+
hostname if '://' in hostname else f'http://{hostname}' # NOSONAR
277+
)
278+
279+
no_proxy_hosts = [host.strip() for host in proxy_info.get('no', '').split(',')]
280+
# NO_PROXY=* bypasses every proxy, so ignore all proxy configuration.
281+
if '*' in no_proxy_hosts:
282+
return {}
283+
for hostname in no_proxy_hosts:
284+
if hostname:
285+
mounts[_no_proxy_mount(hostname)] = None
286+
287+
return mounts
288+
289+
290+
def _no_proxy_mount(hostname):
291+
"""Map a single NO_PROXY entry to its httpx mount pattern."""
292+
if '://' in hostname:
293+
return hostname
294+
if _is_ipv6_hostname(hostname):
295+
return f'all://[{hostname}]'
296+
if _is_ipv4_hostname(hostname) or hostname.lower() == 'localhost':
297+
return f'all://{hostname}'
298+
return f'all://*{hostname}'
299+
300+
243301
@cache
244302
def _get_async_mounts():
245303
"""
@@ -249,8 +307,8 @@ def _get_async_mounts():
249307
return {
250308
key: None
251309
if url is None
252-
else httpx.AsyncHTTPTransport(verify=_SSL_CONTEXT, proxy=Proxy(url=url))
253-
for key, url in get_environment_proxies().items()
310+
else httpx.AsyncHTTPTransport(verify=_SSL_CONTEXT, proxy=httpx.Proxy(url=url))
311+
for key, url in _get_environment_proxies().items()
254312
}
255313

256314

connect/client/testing/fluent.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import responses
1111
from pytest import MonkeyPatch
1212
from pytest_httpx import HTTPXMock
13+
from pytest_httpx._options import _HTTPXMockOptions
1314
from responses import matchers
1415

1516
from connect.client.fluent import _ConnectClientBase
@@ -165,7 +166,13 @@ def _get_namespace_class(self):
165166

166167

167168
_monkeypatch = MonkeyPatch()
168-
_async_mocker = HTTPXMock()
169+
# The ConnectClient retries requests, so a single registered response must be
170+
# able to answer repeated (retried) requests.
171+
_async_mocker = HTTPXMock(
172+
_HTTPXMockOptions(
173+
can_send_already_matched_responses=True,
174+
),
175+
)
169176

170177

171178
class AsyncConnectClientMocker(ConnectClientMocker):
@@ -194,8 +201,14 @@ async def mocked_handle_async_request(
194201
)
195202

196203
def reset(self, success=True):
197-
_async_mocker.reset(success)
198-
_monkeypatch.undo()
204+
try:
205+
if success:
206+
# pytest-httpx>=0.31 no longer asserts on reset(); do it explicitly
207+
# so unrequested mocks / unexpected requests still fail the test.
208+
_async_mocker._assert_options()
209+
finally:
210+
_async_mocker.reset()
211+
_monkeypatch.undo()
199212

200213
def mock(
201214
self,
@@ -222,7 +235,15 @@ def mock(
222235

223236
if match_body:
224237
if isinstance(match_body, (dict, list, tuple)):
225-
kwargs['match_content'] = json.dumps(match_body).encode('utf-8')
238+
# Mirror httpx>=0.28's request-body serialization exactly, or
239+
# match_content won't compare equal (compact separators, raw
240+
# UTF-8 rather than \uXXXX escapes, and no NaN/Infinity).
241+
kwargs['match_content'] = json.dumps(
242+
match_body,
243+
separators=(',', ':'),
244+
ensure_ascii=False,
245+
allow_nan=False,
246+
).encode('utf-8')
226247
else:
227248
kwargs['match_content'] = match_body
228249

0 commit comments

Comments
 (0)