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/help_formatter.py b/connect/client/help_formatter.py index 6a11ed6..674030c 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 = [ @@ -101,13 +100,20 @@ def format_collection(self, collection): return render('\n'.join(lines)) + 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() + 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 = resource.path.split('/')[-2] - resource_name = self._p.singular_noun(resource_name) + resource_name = self._singular(resource.path.split('/')[-2]) lines = [ f'# {resource_name.title()} resource', f'**path: /{resource.path}**', 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..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" @@ -607,31 +592,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)"] @@ -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 7b61874..2dc9528 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", ] @@ -52,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_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' diff --git a/tests/client/test_testing.py b/tests/client/test_testing.py index 4a65004..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(): @@ -411,6 +418,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()