Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
26 changes: 22 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,12 @@ def mock(

if match_body:
if isinstance(match_body, (dict, list, tuple)):
kwargs['match_content'] = json.dumps(match_body).encode('utf-8')
# httpx>=0.28 serializes JSON request bodies compactly, so match
# the same separators or match_content won't compare equal.
kwargs['match_content'] = json.dumps(
Comment thread
qarlosh marked this conversation as resolved.
match_body,
separators=(',', ':'),
).encode('utf-8')
else:
kwargs['match_content'] = match_body

Expand Down
Loading
Loading