From 9fa7232abe448a6c65fb1609dc296a60632ceee8 Mon Sep 17 00:00:00 2001 From: Pablo Caro Revuelta Date: Wed, 1 Jul 2026 15:06:25 +0200 Subject: [PATCH 1/5] ci(python): add Python 3.13 and 3.14 to the test matrix Extend the CI build matrix and PyPI trove classifiers to cover Python 3.13 and 3.14. Two runtime issues surfaced on the new versions and are fixed here: - Python 3.13+ enforces that an object returned by __iter__ must itself be a proper iterator (i.e. define __iter__). AbstractIterator and AbstractAsyncIterator only defined __next__/__anext__, so iterating a ResourceSet raised 'ResourceIterator object is not iterable'. Add __iter__/__aiter__ returning self. - httpcore 1.0.6 crashed at import on Python 3.14 (setting __module__ on typing.Union). Bump httpcore 1.0.6 -> 1.0.9 (and h11 0.14 -> 0.16) in the lock file; both are transitive deps, so no pyproject change needed. All 407 tests pass on 3.12, 3.13 and 3.14. (cherry picked from commit 6353e585b0b7b05ea457f0203c5bc5a7e08de486) --- .github/workflows/build.yml | 2 +- connect/client/models/iterators.py | 6 ++++++ poetry.lock | 16 ++++++++-------- pyproject.toml | 2 ++ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a0b92fc..d9aa6d8 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.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout uses: actions/checkout@v7 diff --git a/connect/client/models/iterators.py b/connect/client/models/iterators.py index 20be4bb..c5b6287 100644 --- a/connect/client/models/iterators.py +++ b/connect/client/models/iterators.py @@ -33,6 +33,9 @@ def get_item(self, item): class AbstractIterator(AbstractBaseIterator): + def __iter__(self): + return self + def _load(self): if not self._loaded: self._rs._results, self._rs._content_range = self._execute_request() @@ -83,6 +86,9 @@ def _execute_request(self): class AbstractAsyncIterator(AbstractBaseIterator): + def __aiter__(self): + return self + async def _load(self): if not self._loaded: self._rs._results, self._rs._content_range = await self._execute_request() diff --git a/poetry.lock b/poetry.lock index cb88c58..e6eca5c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -607,31 +607,31 @@ colorama = ">=0.4" [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] name = "httpcore" -version = "1.0.6" +version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"}, - {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [package.dependencies] certifi = "*" -h11 = ">=0.13,<0.15" +h11 = ">=0.16" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] diff --git a/pyproject.toml b/pyproject.toml index 7b61874..2c8f9bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Utilities", "Topic :: Software Development :: Libraries", ] From 6c4e208eb073778acbfb0af9e04761740938ae14 Mon Sep 17 00:00:00 2001 From: Maxim Kolyubyakin Date: Sun, 1 Nov 2026 11:35:16 +0100 Subject: [PATCH 2/5] perf(client): import inflect only when a resource's help needs it help_formatter imported inflect at module level, and inflect takes most of a second to import. So every `import connect.client` paid it, and with it every extension, report and CLI command, although only the help of a resource uses it: on Python 3.13, `import connect.client` took 1,165 ms. It now takes 196 ms. A test checks that importing the client leaves inflect unimported. --- connect/client/help_formatter.py | 8 ++++++-- tests/client/test_help_formatter.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/connect/client/help_formatter.py b/connect/client/help_formatter.py index 6a11ed6..a29adea 100644 --- a/connect/client/help_formatter.py +++ b/connect/client/help_formatter.py @@ -3,7 +3,6 @@ # # Copyright (c) 2025 CloudBlue. All Rights Reserved. # -import inflect from connect.utils.terminal.markdown import render from connect.client.models import ( @@ -29,7 +28,7 @@ class DefaultFormatter: def __init__(self, specs): self._specs = specs - self._p = inflect.engine() + self._p = None def format_client(self): lines = [ @@ -107,6 +106,11 @@ def format_resource(self, resource): return render(f'~~{resource.path}~~ **does not exists.**') resource_name = resource.path.split('/')[-2] + if self._p is None: + # inflect takes most of a second to import, so only the help of a resource loads it. + import inflect + + self._p = inflect.engine() resource_name = self._p.singular_noun(resource_name) lines = [ f'# {resource_name.title()} resource', diff --git a/tests/client/test_help_formatter.py b/tests/client/test_help_formatter.py index 924cf6d..5ce7023 100644 --- a/tests/client/test_help_formatter.py +++ b/tests/client/test_help_formatter.py @@ -1,3 +1,6 @@ +import subprocess +import sys + import pytest from connect.client.help_formatter import DefaultFormatter @@ -107,3 +110,14 @@ def test_format_rs(openapi_specs, rs_factory): rs = rs_factory(path='does-not-exists') formatted = formatter.format(rs) assert 'does not exist' in formatted + + +def test_importing_the_client_does_not_import_inflect(): + result = subprocess.run( + [sys.executable, '-c', 'import sys, connect.client; print("inflect" in sys.modules)'], + capture_output=True, + text=True, + check=True, + ) + + assert result.stdout.strip() == 'False' From 5cef67e7eeea7b7fcbdc414f2c991407cd027d4a Mon Sep 17 00:00:00 2001 From: Maxim Kolyubyakin Date: Sun, 1 Nov 2026 11:35:16 +0100 Subject: [PATCH 3/5] build(deps): make the linters run on Python 3.14 The test matrix now includes Python 3.14, where flake8 could not run: - flake8-future-import uses ast.Str, which Python 3.14 removed. It goes: its last release is from 2022, its FI1 codes are on the ignore list, and code that requires Python 3.10 needs no __future__ imports. - flake8-bugbear was capped below 23, and those releases crash on Python 3.14's syntax tree. It now requires 24 or newer, as in connect-cli. The newer bugbear flags one test that expected any Exception (B017). It now matches the error the test raises. No other locked version changes. --- poetry.lock | 35 ++++++++++------------------------- pyproject.toml | 3 +-- tests/client/test_testing.py | 2 +- 3 files changed, 12 insertions(+), 28 deletions(-) diff --git a/poetry.lock b/poetry.lock index e6eca5c..348bf80 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "anyio" @@ -457,22 +457,22 @@ flake8 = ">5" [[package]] name = "flake8-bugbear" -version = "22.12.6" +version = "24.12.12" description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8.1" groups = ["test"] files = [ - {file = "flake8-bugbear-22.12.6.tar.gz", hash = "sha256:4cdb2c06e229971104443ae293e75e64c6107798229202fbe4f4091427a30ac0"}, - {file = "flake8_bugbear-22.12.6-py3-none-any.whl", hash = "sha256:b69a510634f8a9c298dfda2b18a8036455e6b19ecac4fe582e4d7a0abfa50a30"}, + {file = "flake8_bugbear-24.12.12-py3-none-any.whl", hash = "sha256:1b6967436f65ca22a42e5373aaa6f2d87966ade9aa38d4baf2a1be550767545e"}, + {file = "flake8_bugbear-24.12.12.tar.gz", hash = "sha256:46273cef0a6b6ff48ca2d69e472f41420a42a46e24b2a8972e4f0d6733d12a64"}, ] [package.dependencies] -attrs = ">=19.2.0" -flake8 = ">=3.0.0" +attrs = ">=22.2.0" +flake8 = ">=6.0.0" [package.extras] -dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "tox"] +dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] [[package]] name = "flake8-cognitive-complexity" @@ -504,21 +504,6 @@ files = [ [package.dependencies] flake8 = ">=5" -[[package]] -name = "flake8-future-import" -version = "0.4.7" -description = "__future__ import checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["test"] -files = [ - {file = "flake8-future-import-0.4.7.tar.gz", hash = "sha256:ed826cca28fd4b55c486d69c4bfb32e9a052efc66714bf22bf471f50ecf8e0de"}, - {file = "flake8_future_import-0.4.7-py2.py3-none-any.whl", hash = "sha256:7ecb1b89f5e026afb0cfdb8642d150bf56efbb6fb86a713ea4568398142f47ae"}, -] - -[package.dependencies] -flake8 = "*" - [[package]] name = "flake8-import-order" version = "0.18.2" @@ -1481,11 +1466,11 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["main", "test"] +markers = "python_version == \"3.10\"" 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.10\"", test = "python_full_version <= \"3.11.0a6\""} [[package]] name = "typeguard" @@ -1583,4 +1568,4 @@ watchmedo = ["PyYAML (>=3.10)"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "0515cf09a59d1d7bf5d8c7094b3ace2a6fbd6dfec49ee92c4c7adce3893b64e2" +content-hash = "f5db5beb44d6c9e5c269c98412f604d2bd6382d568ec512048a5ab3d2e731ce8" diff --git a/pyproject.toml b/pyproject.toml index 2c8f9bb..2dc9528 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,10 +54,9 @@ pytest-mock = "^3.10" coverage = {extras = ["toml"], version = ">=5.3,<7"} flake8 = ">=6" flake8-black = "0.*" -flake8-bugbear = ">=20,<23" +flake8-bugbear = ">=24" flake8-cognitive-complexity = "^0.1" flake8-commas = "~4" -flake8-future-import = "~0.4" flake8-import-order = ">=0.18.2" flake8-isort = "^6.0" flake8-broken-line = ">=1.0" diff --git a/tests/client/test_testing.py b/tests/client/test_testing.py index 4a65004..2894738 100644 --- a/tests/client/test_testing.py +++ b/tests/client/test_testing.py @@ -411,6 +411,6 @@ def test_mocker_reset(mocker): ) mocked_reset = mocker.patch('connect.client.testing.fluent._mocker.reset') mocker = ConnectClientMocker('http://localhost') - with pytest.raises(Exception): + with pytest.raises(Exception, match='error'): mocker.reset() mocked_reset.assert_called_once() From 2b8000c7b976f804c4579e4e6c8867534f71b78b Mon Sep 17 00:00:00 2001 From: Maxim Kolyubyakin Date: Mon, 2 Nov 2026 09:32:19 +0100 Subject: [PATCH 4/5] refactor(client): keep format_resource within the complexity limit Loading inflect lazily added a branch to format_resource and took its cognitive complexity to 16, one over Sonar's limit of 15. The lazy load moves into a small _singular helper. Behaviour is unchanged. --- connect/client/help_formatter.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/connect/client/help_formatter.py b/connect/client/help_formatter.py index a29adea..674030c 100644 --- a/connect/client/help_formatter.py +++ b/connect/client/help_formatter.py @@ -100,18 +100,20 @@ def format_collection(self, collection): return render('\n'.join(lines)) - def format_resource(self, resource): - res_info = self._specs.get_resource(resource.path) - if not res_info: - return render(f'~~{resource.path}~~ **does not exists.**') - - resource_name = resource.path.split('/')[-2] + def _singular(self, noun): if self._p is None: # inflect takes most of a second to import, so only the help of a resource loads it. import inflect self._p = inflect.engine() - resource_name = self._p.singular_noun(resource_name) + return self._p.singular_noun(noun) + + def format_resource(self, resource): + res_info = self._specs.get_resource(resource.path) + if not res_info: + return render(f'~~{resource.path}~~ **does not exists.**') + + resource_name = self._singular(resource.path.split('/')[-2]) lines = [ f'# {resource_name.title()} resource', f'**path: /{resource.path}**', From 9a4d457ba6b0244dbadaee676d6e55d84bd04271 Mon Sep 17 00:00:00 2001 From: Maxim Kolyubyakin Date: Mon, 2 Nov 2026 09:32:19 +0100 Subject: [PATCH 5/5] test(testing): keep one call that can raise inside each pytest.raises Sonar (python:S5778) flagged four exception tests that also ran their setup inside pytest.raises, so a setup error of the same type would have passed them. The setup now runs first. The test whose mocked request is meant not to match starts and resets the mocker itself, so the unused mock isn't reported. --- tests/client/test_testing.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/client/test_testing.py b/tests/client/test_testing.py index 2894738..88c4bc0 100644 --- a/tests/client/test_testing.py +++ b/tests/client/test_testing.py @@ -23,11 +23,16 @@ def test_create(): client = ConnectClient('api_key', endpoint='http://localhost') assert client.products.create(data=b'binary content') == {'test': 'data'} - with pytest.raises(ClientError): - with ConnectClientMocker('http://localhost') as mocker: - mocker.products.create(return_value={'test': 'data'}, match_body=b'another content') - client = ConnectClient('api_key', endpoint='http://localhost') - assert client.products.create(data=b'binary content') == {'test': 'data'} + mocker = ConnectClientMocker('http://localhost') + mocker.start() + try: + mocker.products.create(return_value={'test': 'data'}, match_body=b'another content') + products = ConnectClient('api_key', endpoint='http://localhost').products + with pytest.raises(ClientError): + products.create(data=b'binary content') + finally: + # The mocked request is meant not to match, so don't check that it was used. + mocker.reset(success=False) with ConnectClientMocker('http://localhost') as mocker: mocker('my_namespace').products.create(return_value={'test': 'data'}) @@ -350,21 +355,23 @@ def test_count_error(): with ConnectClientMocker('http://localhost') as mocker: mocker.products.all().count(status_code=500) - client = ConnectClient('api_key', endpoint='http://localhost') + products = ConnectClient('api_key', endpoint='http://localhost').products.all() with pytest.raises(ClientError) as cv: - client.products.all().count() + products.count() assert cv.value.status_code == 500 def test_invalid_return_value(): - with pytest.raises(TypeError): - with ConnectClientMocker('http://localhost') as mocker: - mocker.products.all().mock(return_value='hello') + with ConnectClientMocker('http://localhost') as mocker: + products = mocker.products.all() + with pytest.raises(TypeError): + products.mock(return_value='hello') - with pytest.raises(TypeError): - with ConnectClientMocker('http://localhost') as mocker: - mocker.products.all().count(return_value='hello') + with ConnectClientMocker('http://localhost') as mocker: + products = mocker.products.all() + with pytest.raises(TypeError): + products.count(return_value='hello') def test_inner_mockers():