Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']
python-version: ['3.10', '3.11', '3.12']
steps:
- name: Checkout
uses: actions/checkout@v3
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ play with the CloudBlue Connect API using a python REPL like [jupyter](https://j

## Install

`Connect Python OpenAPI Client` requires python 3.9 or later.
`Connect Python OpenAPI Client` requires python 3.10 or later.


`Connect Python OpenAPI Client` can be installed from [pypi.org](https://pypi.org/project/connect-openapi-client/) using pip:
Expand Down
66 changes: 62 additions & 4 deletions connect/client/fluent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
# Copyright (c) 2025 CloudBlue. All Rights Reserved.
#
import contextvars
import ipaddress
import threading
from functools import cache
from json.decoder import JSONDecodeError
from typing import Union
from urllib.request import getproxies

import httpx
import requests
from httpx._config import Proxy
from httpx._utils import get_environment_proxies
from requests.adapters import HTTPAdapter

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


def _is_ipv4_hostname(hostname):
try:
ipaddress.IPv4Address(hostname.split('/')[0])
except ValueError:
return False
return True


def _is_ipv6_hostname(hostname):
try:
ipaddress.IPv6Address(hostname.split('/')[0])
except ValueError:
return False
return True


def _get_environment_proxies():
"""
Build httpx mount patterns from the standard proxy environment variables
(HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY and lowercase variants).

Ported from httpx's internal ``get_environment_proxies`` so we don't depend
on the private ``httpx._utils`` module.
"""
proxy_info = getproxies()
mounts = {}

for scheme in ('http', 'https', 'all'):
if proxy_info.get(scheme):
hostname = proxy_info[scheme]
# Default scheme for a scheme-less proxy env var, mirroring httpx's
# get_environment_proxies; not an app-level insecure connection.
mounts[f'{scheme}://'] = (
hostname if '://' in hostname else f'http://{hostname}' # NOSONAR
)

no_proxy_hosts = [host.strip() for host in proxy_info.get('no', '').split(',')]
# NO_PROXY=* bypasses every proxy, so ignore all proxy configuration.
if '*' in no_proxy_hosts:
return {}
for hostname in no_proxy_hosts:
if hostname:
mounts[_no_proxy_mount(hostname)] = None

return mounts


def _no_proxy_mount(hostname):
"""Map a single NO_PROXY entry to its httpx mount pattern."""
if '://' in hostname:
return hostname
if _is_ipv6_hostname(hostname):
return f'all://[{hostname}]'
if _is_ipv4_hostname(hostname) or hostname.lower() == 'localhost':
return f'all://{hostname}'
return f'all://*{hostname}'


@cache
def _get_async_mounts():
"""
Expand All @@ -249,8 +307,8 @@ def _get_async_mounts():
return {
key: None
if url is None
else httpx.AsyncHTTPTransport(verify=_SSL_CONTEXT, proxy=Proxy(url=url))
for key, url in get_environment_proxies().items()
else httpx.AsyncHTTPTransport(verify=_SSL_CONTEXT, proxy=httpx.Proxy(url=url))
for key, url in _get_environment_proxies().items()
}


Expand Down
29 changes: 25 additions & 4 deletions connect/client/testing/fluent.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import responses
from pytest import MonkeyPatch
from pytest_httpx import HTTPXMock
from pytest_httpx._options import _HTTPXMockOptions
Comment thread
qarlosh marked this conversation as resolved.
from responses import matchers

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


_monkeypatch = MonkeyPatch()
_async_mocker = HTTPXMock()
# The ConnectClient retries requests, so a single registered response must be
# able to answer repeated (retried) requests.
_async_mocker = HTTPXMock(
_HTTPXMockOptions(
can_send_already_matched_responses=True,
),
)


class AsyncConnectClientMocker(ConnectClientMocker):
Expand Down Expand Up @@ -194,8 +201,14 @@ async def mocked_handle_async_request(
)

def reset(self, success=True):
_async_mocker.reset(success)
_monkeypatch.undo()
try:
if success:
# pytest-httpx>=0.31 no longer asserts on reset(); do it explicitly
# so unrequested mocks / unexpected requests still fail the test.
_async_mocker._assert_options()
Comment thread
qarlosh marked this conversation as resolved.
finally:
_async_mocker.reset()
_monkeypatch.undo()

def mock(
self,
Expand All @@ -222,7 +235,15 @@ def mock(

if match_body:
if isinstance(match_body, (dict, list, tuple)):
kwargs['match_content'] = json.dumps(match_body).encode('utf-8')
# Mirror httpx>=0.28's request-body serialization exactly, or
# match_content won't compare equal (compact separators, raw
# UTF-8 rather than \uXXXX escapes, and no NaN/Infinity).
kwargs['match_content'] = json.dumps(
Comment thread
qarlosh marked this conversation as resolved.
match_body,
separators=(',', ':'),
ensure_ascii=False,
allow_nan=False,
).encode('utf-8')
else:
kwargs['match_content'] = match_body

Expand Down
Loading
Loading