diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e944136..b11c4b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index d4ef89c..067a867 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/connect/client/fluent.py b/connect/client/fluent.py index 76c0e65..7b9ccd0 100644 --- a/connect/client/fluent.py +++ b/connect/client/fluent.py @@ -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 @@ -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(): """ @@ -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() } diff --git a/connect/client/testing/fluent.py b/connect/client/testing/fluent.py index 9b82b3b..77c18f8 100644 --- a/connect/client/testing/fluent.py +++ b/connect/client/testing/fluent.py @@ -10,6 +10,7 @@ import responses from pytest import MonkeyPatch from pytest_httpx import HTTPXMock +from pytest_httpx._options import _HTTPXMockOptions from responses import matchers from connect.client.fluent import _ConnectClientBase @@ -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): @@ -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() + finally: + _async_mocker.reset() + _monkeypatch.undo() def mock( self, @@ -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( + match_body, + separators=(',', ':'), + ensure_ascii=False, + allow_nan=False, + ).encode('utf-8') else: kwargs['match_content'] = match_body diff --git a/poetry.lock b/poetry.lock index 5454194..cb88c58 100644 --- a/poetry.lock +++ b/poetry.lock @@ -61,6 +61,19 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["test"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + [[package]] name = "black" version = "23.12.1" @@ -381,7 +394,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "test"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -628,14 +641,14 @@ trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httpx" -version = "0.26.0" +version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, - {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, ] [package.dependencies] @@ -643,13 +656,13 @@ anyio = "*" certifi = "*" httpcore = "==1.*" idna = "*" -sniffio = "*" [package.extras] brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" @@ -666,27 +679,6 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "importlib-metadata" -version = "6.11.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "importlib_metadata-6.11.0-py3-none-any.whl", hash = "sha256:f0afba6205ad8f8947c7d338b5342d5db2afbfd82f9cbef7879a9539cc12eb9b"}, - {file = "importlib_metadata-6.11.0.tar.gz", hash = "sha256:1231cf92d825c9e03cfc4da076a16de6422c863558229ea0b22b675657463443"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)", "pytest-ruff"] - [[package]] name = "inflect" version = "7.4.0" @@ -768,9 +760,6 @@ files = [ {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, ] -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - [package.extras] docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] @@ -923,7 +912,6 @@ files = [ click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} jinja2 = ">=2.11.1" markdown = ">=3.3.6" markupsafe = ">=2.0.1" @@ -969,7 +957,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" @@ -1159,7 +1146,7 @@ version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main", "docs"] +groups = ["main", "docs", "test"] files = [ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, @@ -1189,44 +1176,48 @@ extra = ["pygments (>=2.12)"] [[package]] name = "pytest" -version = "7.4.4" +version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.15.1" -description = "Pytest support for asyncio." +version = "1.4.0" +description = "Pytest support for asyncio" optional = false -python-versions = ">= 3.6" +python-versions = ">=3.10" groups = ["test"] files = [ - {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, - {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, + {file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"}, + {file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"}, ] [package.dependencies] -pytest = ">=5.4.0" +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.4,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] -testing = ["coverage", "hypothesis (>=5.7.1)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" @@ -1249,22 +1240,22 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale [[package]] name = "pytest-httpx" -version = "0.29.0" +version = "0.35.0" description = "Send responses to httpx." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pytest_httpx-0.29.0-py3-none-any.whl", hash = "sha256:7d6fd29042e7b98ed98199ded120bc8100c8078ca306952666e89bf8807b95ff"}, - {file = "pytest_httpx-0.29.0.tar.gz", hash = "sha256:ed08ed802e2b315b83cdd16f0b26cbb2b836c29e0fde5c18bc3105f1073e0332"}, + {file = "pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744"}, + {file = "pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f"}, ] [package.dependencies] -httpx = "==0.26.*" -pytest = ">=7,<9" +httpx = "==0.28.*" +pytest = "==8.*" [package.extras] -testing = ["pytest-asyncio (==0.23.*)", "pytest-cov (==4.*)"] +testing = ["pytest-asyncio (==0.24.*)", "pytest-cov (==6.*)"] [[package]] name = "pytest-mock" @@ -1494,7 +1485,7 @@ files = [ {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, ] -markers = {main = "python_version < \"3.11\"", test = "python_full_version <= \"3.11.0a6\""} +markers = {main = "python_version == \"3.10\"", test = "python_full_version <= \"3.11.0a6\""} [[package]] name = "typeguard" @@ -1509,7 +1500,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""} typing-extensions = ">=4.10.0" [package.extras] @@ -1527,7 +1517,7 @@ files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -markers = {test = "python_version < \"3.11\""} +markers = {test = "python_version < \"3.13\""} [[package]] name = "urllib3" @@ -1590,28 +1580,7 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] -[[package]] -name = "zipp" -version = "3.20.2" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -groups = ["main", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] lock-version = "2.1" -python-versions = ">=3.9,<4" -content-hash = "39eb3448bab4e49f2f4c6d34a5c07a132a3ea2c0558482d5061c87a49b58e1ad" +python-versions = ">=3.10,<4" +content-hash = "0515cf09a59d1d7bf5d8c7094b3ace2a6fbd6dfec49ee92c4c7adce3893b64e2" diff --git a/pyproject.toml b/pyproject.toml index 10436fe..7b61874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ classifiers = [ "Environment :: Console", "Operating System :: OS Independent", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -35,20 +34,20 @@ keywords = [ ] [tool.poetry.dependencies] -python = ">=3.9,<4" +python = ">=3.10,<4" connect-markdown-renderer = "^3" PyYAML = ">=5.3.1" requests = ">=2.23" -inflect = ">=4.1" -httpx = ">=0.23" +inflect = ">=7" +httpx = ">=0.28" asgiref = "^3.3.4" responses = ">=0.14.0,<1" -pytest-httpx = ">=0.27,<0.30" +pytest-httpx = ">=0.35,<1" [tool.poetry.group.test.dependencies] black = "23.*" -pytest = ">=6.1.2,<8" -pytest-cov = ">=2.10.1,<5" +pytest = ">=8,<9" +pytest-cov = ">=2.10.1,<7" pytest-mock = "^3.10" coverage = {extras = ["toml"], version = ">=5.3,<7"} flake8 = ">=6" @@ -62,7 +61,7 @@ flake8-isort = "^6.0" flake8-broken-line = ">=1.0" flake8-pyproject = "^1.2.3" isort = "^5.10" -pytest-asyncio = "^0.15.1" +pytest-asyncio = ">=0.24,<2" [tool.poetry.group.docs.dependencies] mkdocs = "^1.3.1" diff --git a/tests/async_client/test_fluent.py b/tests/async_client/test_fluent.py index 98158b0..dcba09d 100644 --- a/tests/async_client/test_fluent.py +++ b/tests/async_client/test_fluent.py @@ -348,6 +348,10 @@ async def test_execute_uparseable_connect_error(httpx_mock): @pytest.mark.asyncio +@pytest.mark.httpx_mock( + assert_all_requests_were_expected=False, + can_send_already_matched_responses=True, +) @pytest.mark.parametrize('encoding', ('utf-8', 'iso-8859-1')) async def test_execute_error_with_reason(httpx_mock, encoding): httpx_mock.add_response( diff --git a/tests/async_client/test_testing.py b/tests/async_client/test_testing.py index ad16e95..488bd15 100644 --- a/tests/async_client/test_testing.py +++ b/tests/async_client/test_testing.py @@ -49,6 +49,21 @@ async def test_create(): assert await client.products['product_id'].items.create(payload={}) == {'test': 'data'} +@pytest.mark.asyncio +async def test_create_match_non_ascii_body(): + with AsyncConnectClientMocker('http://localhost') as mocker: + mocker.products.create(return_value={'test': 'data'}, match_body={'name': 'Peña'}) + client = AsyncConnectClient('api_key', endpoint='http://localhost') + assert await client.products.create(payload={'name': 'Peña'}) == {'test': 'data'} + + +@pytest.mark.asyncio +async def test_unrequested_mock_fails(): + with pytest.raises(AssertionError): + with AsyncConnectClientMocker('http://localhost') as mocker: + mocker.products.create(return_value={'test': 'data'}) + + @pytest.mark.asyncio async def test_bulk_create(): with AsyncConnectClientMocker('http://localhost') as mocker: diff --git a/tests/client/test_fluent.py b/tests/client/test_fluent.py index d1c5332..f71e3e4 100644 --- a/tests/client/test_fluent.py +++ b/tests/client/test_fluent.py @@ -6,7 +6,7 @@ from requests import RequestException, Timeout from connect.client.exceptions import ClientError -from connect.client.fluent import ConnectClient +from connect.client.fluent import ConnectClient, _get_environment_proxies from connect.client.logger import RequestLogger from connect.client.models import NS, Collection @@ -605,3 +605,69 @@ def test_getattr(): assert c.__getattr__('session') == 'mysession' assert c.__getattr__('response') == 'myresponse' assert isinstance(c.__getattr__('anything'), Collection) + + +def test_get_environment_proxies_schemes(mocker): + """ + Verify proxy env vars are turned into the httpx mount patterns the async + client expects, normalizing bare host:port values to a http:// URL. + + Confidence: our stdlib reimplementation of httpx's proxy discovery keeps the + same behaviour after dropping the private ``httpx._utils`` import. + """ + mocker.patch( + 'connect.client.fluent.getproxies', + return_value={ + 'http': 'proxy.example.org:8080', + 'https': 'http://secure.example.org:8080', + }, + ) + + mounts = _get_environment_proxies() + + assert mounts['http://'] == 'http://proxy.example.org:8080' + assert mounts['https://'] == 'http://secure.example.org:8080' + + +@pytest.mark.parametrize( + ('no_proxy_host', 'expected_key'), + ( + ('localhost', 'all://localhost'), + ('.internal', 'all://*.internal'), + ('example.com', 'all://*example.com'), + ('127.0.0.1', 'all://127.0.0.1'), + ('::1', 'all://[::1]'), + ('http://direct.example.org', 'http://direct.example.org'), + ), +) +def test_get_environment_proxies_no_proxy(mocker, no_proxy_host, expected_key): + """ + Verify each NO_PROXY host form (domain, IPv4, IPv6, localhost, explicit + scheme) maps to the mount pattern that disables proxying for it. + + Confidence: hosts meant to bypass the proxy actually do, matching curl/httpx + NO_PROXY semantics. + """ + mocker.patch( + 'connect.client.fluent.getproxies', + return_value={'all': 'http://proxy.example.org:8080', 'no': no_proxy_host}, + ) + + mounts = _get_environment_proxies() + + assert mounts['all://'] == 'http://proxy.example.org:8080' + assert mounts[expected_key] is None + + +def test_get_environment_proxies_wildcard_disables_all(mocker): + """ + Verify NO_PROXY=* discards every configured proxy. + + Confidence: the global bypass wins over any HTTP_PROXY/ALL_PROXY setting. + """ + mocker.patch( + 'connect.client.fluent.getproxies', + return_value={'all': 'http://proxy.example.org:8080', 'no': '*'}, + ) + + assert _get_environment_proxies() == {}