diff --git a/pyproject.toml b/pyproject.toml index 411c6b9..561b31a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "suz-sdk" -version = "1.2.0" +version = "1.3.0" description = "Python SDK for СУЗ API 3.0 (СУЗ-Облако 4.0)" readme = "README.md" requires-python = ">=3.11" diff --git a/src/suz_sdk/__init__.py b/src/suz_sdk/__init__.py index 77efc72..8a954c7 100644 --- a/src/suz_sdk/__init__.py +++ b/src/suz_sdk/__init__.py @@ -191,4 +191,4 @@ "RetryConfig", ] -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/src/suz_sdk/api/async_integration.py b/src/suz_sdk/api/async_integration.py index 79d2c59..17f7e3d 100644 --- a/src/suz_sdk/api/async_integration.py +++ b/src/suz_sdk/api/async_integration.py @@ -1,10 +1,11 @@ """Async IntegrationApi — registration endpoint (§9.2).""" import json -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable from typing import Any from suz_sdk.api.integration import ( + ConnectionInfo, DeleteConnectionResponse, IntegrationApi, ListConnectionsResponse, @@ -113,6 +114,29 @@ async def list_connections( total=body["total"], ) + async def aiter_connections(self, *, page_size: int = 100) -> AsyncIterator[ConnectionInfo]: + """Iterate over all registered integration connections across pages.""" + if page_size <= 0: + raise ValueError("page_size must be greater than 0") + + offset = 0 + yielded = 0 + + while True: + resp = await self.list_connections(limit=page_size, offset=offset) + batch = resp.oms_connection_infos + if not batch: + return + + for item in batch: + yield item + yielded += len(batch) + + if yielded >= resp.total or len(batch) < page_size: + return + + offset += page_size + async def delete_connection(self, oms_connection: str) -> DeleteConnectionResponse: """Delete a registered integration connection. diff --git a/src/suz_sdk/api/async_orders.py b/src/suz_sdk/api/async_orders.py index 750c813..ea2ff11 100644 --- a/src/suz_sdk/api/async_orders.py +++ b/src/suz_sdk/api/async_orders.py @@ -1,7 +1,7 @@ """Async OrdersApi — KM emission orders (§4.4).""" import json -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable from typing import Any, cast from suz_sdk.api.orders import ( @@ -134,6 +134,11 @@ async def list_orders(self) -> ListOrdersResponse: ], ) + async def aiter_orders(self, *, page_size: int = 100) -> AsyncIterator[OrderSummaryInfo]: + """Iterate over all orders using paginated search requests.""" + async for item in self.aiter_search_orders(filter=None, page_size=page_size): + yield item + async def get_codes( self, order_id: str, @@ -290,6 +295,34 @@ async def search_orders( ], ) + async def aiter_search_orders( + self, + filter: OrderFilter | None = None, + *, + page_size: int = 100, + ) -> AsyncIterator[OrderSummaryInfo]: + """Iterate over all search results across pages.""" + if page_size <= 0: + raise ValueError("page_size must be greater than 0") + + page = 0 + yielded = 0 + + while True: + resp = await self.search_orders(filter=filter, limit=page_size, page=page) + batch = resp.results + if not batch: + return + + for item in batch: + yield item + yielded += len(batch) + + if yielded >= resp.total_count or len(batch) < page_size: + return + + page += 1 + async def close( self, order_id: str, diff --git a/src/suz_sdk/api/documents.py b/src/suz_sdk/api/documents.py index a967d37..5fc87a3 100644 --- a/src/suz_sdk/api/documents.py +++ b/src/suz_sdk/api/documents.py @@ -8,7 +8,7 @@ sign_document() POST /api/v3/documents/sign """ -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator from dataclasses import dataclass from typing import Any @@ -166,6 +166,33 @@ def search_documents( results=body.get("results", []), ) + def iter_documents( + self, + document_type: str, + *, + page_size: int = 100, + ) -> Iterator[dict[str, Any]]: + """Iterate over all matching documents across pages.""" + if page_size <= 0: + raise ValueError("page_size must be greater than 0") + + skip = 0 + yielded = 0 + + while True: + resp = self.search_documents(document_type=document_type, limit=page_size, skip=skip) + batch = resp.results + if not batch: + return + + yield from batch + yielded += len(batch) + + if yielded >= resp.total_count or len(batch) < page_size: + return + + skip += page_size + def get_document_content(self, doc_id: str) -> dict[str, Any]: """Retrieve the content of a document by its doc ID. @@ -360,6 +387,38 @@ async def search_documents( results=body.get("results", []), ) + async def aiter_documents( + self, + document_type: str, + *, + page_size: int = 100, + ) -> AsyncIterator[dict[str, Any]]: + """Iterate over all matching documents across pages.""" + if page_size <= 0: + raise ValueError("page_size must be greater than 0") + + skip = 0 + yielded = 0 + + while True: + resp = await self.search_documents( + document_type=document_type, + limit=page_size, + skip=skip, + ) + batch = resp.results + if not batch: + return + + for item in batch: + yield item + yielded += len(batch) + + if yielded >= resp.total_count or len(batch) < page_size: + return + + skip += page_size + async def get_document_content(self, doc_id: str) -> dict[str, Any]: """Retrieve the content of a document by its doc ID. diff --git a/src/suz_sdk/api/integration.py b/src/suz_sdk/api/integration.py index aa3d2f0..a2d0eb4 100644 --- a/src/suz_sdk/api/integration.py +++ b/src/suz_sdk/api/integration.py @@ -1,8 +1,8 @@ """Integration registration API client (§9.2). -Implements "Регистрация установки экземпляра интеграционного решения" — -the method for registering an integration installation with СУЗ and -obtaining an ``omsConnection`` UUID. +Implements "Регистрация установки экземпляра интеграционного +решения" — the method for registering an integration installation with СУЗ +and obtaining an ``omsConnection`` UUID. API specification (§9.2.1, Table 360–364): Method: POST @@ -34,7 +34,7 @@ """ import json -from collections.abc import Callable +from collections.abc import Callable, Iterator from dataclasses import dataclass, field from typing import Any @@ -240,6 +240,28 @@ def list_connections( total=body["total"], ) + def iter_connections(self, *, page_size: int = 100) -> Iterator[ConnectionInfo]: + """Iterate over all registered integration connections across pages.""" + if page_size <= 0: + raise ValueError("page_size must be greater than 0") + + offset = 0 + yielded = 0 + + while True: + resp = self.list_connections(limit=page_size, offset=offset) + batch = resp.oms_connection_infos + if not batch: + return + + yield from batch + yielded += len(batch) + + if yielded >= resp.total or len(batch) < page_size: + return + + offset += page_size + def delete_connection(self, oms_connection: str) -> DeleteConnectionResponse: """Delete a registered integration connection. diff --git a/src/suz_sdk/api/orders.py b/src/suz_sdk/api/orders.py index 4204d1b..d4eceb0 100644 --- a/src/suz_sdk/api/orders.py +++ b/src/suz_sdk/api/orders.py @@ -20,7 +20,7 @@ """ import json -from collections.abc import Callable +from collections.abc import Callable, Iterator from dataclasses import dataclass, field from typing import Any, cast @@ -311,6 +311,19 @@ def list_orders(self) -> ListOrdersResponse: ], ) + def iter_orders(self, *, page_size: int = 100) -> Iterator[OrderSummaryInfo]: + """Iterate over all orders using paginated search requests. + + This helper provides memory-efficient iteration over order summaries. + + Args: + page_size: Number of records requested per page. + + Yields: + OrderSummaryInfo items across all available pages. + """ + yield from self.iter_search_orders(filter=None, page_size=page_size) + def get_codes( self, order_id: str, @@ -504,6 +517,41 @@ def search_orders( results=[self._parse_order_summary_info(item) for item in body.get("results", [])], ) + def iter_search_orders( + self, + filter: OrderFilter | None = None, + *, + page_size: int = 100, + ) -> Iterator[OrderSummaryInfo]: + """Iterate over all search results across pages. + + Args: + filter: Optional OrderFilter criteria. + page_size: Number of records requested per page. + + Yields: + OrderSummaryInfo items from all result pages. + """ + if page_size <= 0: + raise ValueError("page_size must be greater than 0") + + page = 0 + yielded = 0 + + while True: + resp = self.search_orders(filter=filter, limit=page_size, page=page) + batch = resp.results + if not batch: + return + + yield from batch + yielded += len(batch) + + if yielded >= resp.total_count or len(batch) < page_size: + return + + page += 1 + def close( self, order_id: str, diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 5bb9141..cb207b9 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -59,6 +59,25 @@ async def aclose(self) -> None: pass +class AsyncSequenceTransport: + """Returns a predefined response sequence and captures all requests.""" + + def __init__(self, response_bodies: list[object]) -> None: + self.response_bodies = response_bodies + self.requests: list[Request] = [] + self.last_request: Request | None = None + + async def request(self, req: Request) -> Response: + self.requests.append(req) + self.last_request = req + index = len(self.requests) - 1 + assert index < len(self.response_bodies) + return Response(status_code=200, headers={}, body=self.response_bodies[index]) + + async def aclose(self) -> None: + pass + + def make_client(transport, **kwargs) -> AsyncSuzClient: defaults = {"oms_id": OMS_ID, "client_token": "tok"} defaults.update(kwargs) @@ -450,6 +469,94 @@ async def test_search_orders_filter_in_body(self): assert body["filter"]["orderStatuses"] == ["ACTIVE"] assert body["filter"]["orderIds"] == [ORDER_ID] + @pytest.mark.anyio + async def test_aiter_search_orders_paginates(self): + t = AsyncSequenceTransport( + [ + { + "totalCount": 3, + "results": [ + { + "orderId": "order-1", + "orderStatus": "ACTIVE", + "createdTimestamp": 1700000000000, + }, + { + "orderId": "order-2", + "orderStatus": "ACTIVE", + "createdTimestamp": 1700000000001, + }, + ], + }, + { + "totalCount": 3, + "results": [ + { + "orderId": "order-3", + "orderStatus": "ACTIVE", + "createdTimestamp": 1700000000002, + } + ], + }, + ] + ) + client = make_client(t) + + result = [item async for item in client.orders.aiter_search_orders(page_size=2)] + + assert [item.order_id for item in result] == ["order-1", "order-2", "order-3"] + assert len(t.requests) == 2 + first_body = t.requests[0].json_body + second_body = t.requests[1].json_body + assert first_body is not None + assert second_body is not None + assert first_body["limit"] == 2 + assert first_body["page"] == 0 + assert second_body["page"] == 1 + + @pytest.mark.anyio + async def test_aiter_search_orders_empty_result(self): + t = AsyncSequenceTransport([{"totalCount": 0, "results": []}]) + client = make_client(t) + + result = [item async for item in client.orders.aiter_search_orders(page_size=100)] + + assert result == [] + assert len(t.requests) == 1 + + @pytest.mark.anyio + async def test_aiter_orders_uses_paginated_search(self): + t = AsyncSequenceTransport( + [ + { + "totalCount": 2, + "results": [ + { + "orderId": "order-a", + "orderStatus": "ACTIVE", + "createdTimestamp": 1700000000000, + } + ], + }, + { + "totalCount": 2, + "results": [ + { + "orderId": "order-b", + "orderStatus": "ACTIVE", + "createdTimestamp": 1700000000001, + } + ], + }, + ] + ) + client = make_client(t) + + result = [item async for item in client.orders.aiter_orders(page_size=1)] + + assert [item.order_id for item in result] == ["order-a", "order-b"] + assert all(req.path == "/api/v3/orders/search" for req in t.requests) + # --------------------------------------------------------------------------- # AsyncReportsApi @@ -607,6 +714,45 @@ async def test_list_connections_sends_auth_header(self): await client.integration.list_connections() assert t.last_request.headers["clientToken"] == "mytoken" + @pytest.mark.anyio + async def test_aiter_connections_paginates(self): + t = AsyncSequenceTransport( + [ + { + "omsConnectionInfos": [ + {**_CONN_INFO, "omsConnection": "conn-1"}, + {**_CONN_INFO, "omsConnection": "conn-2"}, + ], + "total": 3, + }, + { + "omsConnectionInfos": [ + {**_CONN_INFO, "omsConnection": "conn-3"}, + ], + "total": 3, + }, + ] + ) + client = make_client(t) + + result = [item async for item in client.integration.aiter_connections(page_size=2)] + + assert [item.oms_connection for item in result] == ["conn-1", "conn-2", "conn-3"] + assert len(t.requests) == 2 + assert t.requests[0].params["limit"] == "2" + assert t.requests[0].params["offset"] == "0" + assert t.requests[1].params["offset"] == "2" + + @pytest.mark.anyio + async def test_aiter_connections_empty_result(self): + t = AsyncSequenceTransport([{"omsConnectionInfos": [], "total": 0}]) + client = make_client(t) + + result = [item async for item in client.integration.aiter_connections(page_size=10)] + + assert result == [] + assert len(t.requests) == 1 + # --------------------------------------------------------------------------- # AsyncAuthApi diff --git a/tests/test_documents.py b/tests/test_documents.py index c994bcc..c299b44 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -79,6 +79,37 @@ async def aclose(self) -> None: pass +class SequenceStubTransport: + """Returns a predefined response sequence and captures all requests.""" + + def __init__(self, responses: list[Response]) -> None: + self._responses = responses + self.requests: list[Request] = [] + + def request(self, req: Request) -> Response: + self.requests.append(req) + index = len(self.requests) - 1 + assert index < len(self._responses) + return self._responses[index] + + +class AsyncSequenceStubTransport: + """Async version of SequenceStubTransport.""" + + def __init__(self, responses: list[Response]) -> None: + self._responses = responses + self.requests: list[Request] = [] + + async def request(self, req: Request) -> Response: + self.requests.append(req) + index = len(self.requests) - 1 + assert index < len(self._responses) + return self._responses[index] + + async def aclose(self) -> None: + pass + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -248,6 +279,36 @@ def test_results_missing_defaults_to_empty_list(self) -> None: assert result.results == [] +class TestIterDocuments: + def test_iterates_pages_and_advances_skip(self) -> None: + transport = SequenceStubTransport( + responses=[ + _ok({"totalCount": 3, "results": [{"docId": "d1"}, {"docId": "d2"}]}), + _ok({"totalCount": 3, "results": [{"docId": "d3"}]}), + ] + ) + api = _make_api(transport) # type: ignore[arg-type] + + result = list(api.iter_documents(_DOC_TYPE, page_size=2)) + + assert [item["docId"] for item in result] == ["d1", "d2", "d3"] + assert len(transport.requests) == 2 + assert transport.requests[0].params.get("limit") == "2" + assert transport.requests[0].params.get("skip") == "0" + assert transport.requests[1].params.get("skip") == "2" + + def test_empty_result_stops_immediately(self) -> None: + transport = SequenceStubTransport( + responses=[_ok({"totalCount": 0, "results": []})] + ) + api = _make_api(transport) # type: ignore[arg-type] + + result = list(api.iter_documents(_DOC_TYPE, page_size=50)) + + assert result == [] + assert len(transport.requests) == 1 + + # --------------------------------------------------------------------------- # get_document_content — sync # --------------------------------------------------------------------------- @@ -558,6 +619,40 @@ async def test_sends_auth_header(self) -> None: assert req.headers.get("clientToken") == _TOKEN +class TestAsyncIterDocuments: + @pytest.mark.anyio + async def test_iterates_pages_and_advances_skip(self) -> None: + transport = AsyncSequenceStubTransport( + responses=[ + _ok({"totalCount": 3, "results": [{"docId": "d1"}, {"docId": "d2"}]}), + _ok({"totalCount": 3, "results": [{"docId": "d3"}]}), + ] + ) + api = _make_async_api(transport) # type: ignore[arg-type] + + result: list[dict[str, str]] = [] + async for item in api.aiter_documents(_DOC_TYPE, page_size=2): + result.append(item) + + assert [item["docId"] for item in result] == ["d1", "d2", "d3"] + assert len(transport.requests) == 2 + assert transport.requests[0].params.get("limit") == "2" + assert transport.requests[0].params.get("skip") == "0" + assert transport.requests[1].params.get("skip") == "2" + + @pytest.mark.anyio + async def test_empty_result_stops_immediately(self) -> None: + transport = AsyncSequenceStubTransport( + responses=[_ok({"totalCount": 0, "results": []})] + ) + api = _make_async_api(transport) # type: ignore[arg-type] + + result = [item async for item in api.aiter_documents(_DOC_TYPE, page_size=25)] + + assert result == [] + assert len(transport.requests) == 1 + + # --------------------------------------------------------------------------- # Async get_document_content # --------------------------------------------------------------------------- diff --git a/tests/test_integration_new.py b/tests/test_integration_new.py index 501eb9d..e2e3c45 100644 --- a/tests/test_integration_new.py +++ b/tests/test_integration_new.py @@ -41,6 +41,18 @@ def request(self, req: Request) -> Response: return self._response +class SequenceStubTransport: + def __init__(self, responses: list[Response]) -> None: + self._responses = responses + self.requests: list[Request] = [] + + def request(self, req: Request) -> Response: + self.requests.append(req) + index = len(self.requests) - 1 + assert index < len(self._responses) + return self._responses[index] + + def _make_api(transport: StubTransport) -> IntegrationApi: return IntegrationApi( transport=transport, @@ -240,3 +252,58 @@ def test_delete_connection_without_auth_headers(self) -> None: result = api.delete_connection(_OMS_CONNECTION) assert result.success is True + + +class TestIterConnections: + def test_iterates_pages_and_advances_offset(self) -> None: + transport = SequenceStubTransport( + responses=[ + Response( + status_code=200, + headers={}, + body={ + "omsConnectionInfos": [ + {**_CONNECTION_INFO, "omsConnection": "conn-1"}, + {**_CONNECTION_INFO, "omsConnection": "conn-2"}, + ], + "total": 3, + }, + ), + Response( + status_code=200, + headers={}, + body={ + "omsConnectionInfos": [ + {**_CONNECTION_INFO, "omsConnection": "conn-3"}, + ], + "total": 3, + }, + ), + ] + ) + api = _make_api(transport) # type: ignore[arg-type] + + result = list(api.iter_connections(page_size=2)) + + assert [item.oms_connection for item in result] == ["conn-1", "conn-2", "conn-3"] + assert len(transport.requests) == 2 + assert transport.requests[0].params.get("limit") == "2" + assert transport.requests[0].params.get("offset") == "0" + assert transport.requests[1].params.get("offset") == "2" + + def test_empty_result_stops_immediately(self) -> None: + transport = SequenceStubTransport( + responses=[ + Response( + status_code=200, + headers={}, + body={"omsConnectionInfos": [], "total": 0}, + ) + ] + ) + api = _make_api(transport) # type: ignore[arg-type] + + result = list(api.iter_connections(page_size=10)) + + assert result == [] + assert len(transport.requests) == 1 diff --git a/tests/test_orders_new.py b/tests/test_orders_new.py index 78a9787..bb0606f 100644 --- a/tests/test_orders_new.py +++ b/tests/test_orders_new.py @@ -37,6 +37,18 @@ def request(self, req: Request) -> Response: return self._response +class SequenceStubTransport: + def __init__(self, responses: list[Response]) -> None: + self._responses = responses + self.requests: list[Request] = [] + + def request(self, req: Request) -> Response: + self.requests.append(req) + index = len(self.requests) - 1 + assert index < len(self._responses) + return self._responses[index] + + def _make_api(transport: StubTransport) -> OrdersApi: return OrdersApi( transport=transport, @@ -455,3 +467,76 @@ def test_missing_template_id_defaults_to_zero(self) -> None: data = {"gtin": _GTIN, "bufferStatus": "ACTIVE", "totalCodes": 10} buf = OrdersApi._parse_buffer_info(data) assert buf.template_id == 0 + + +# --------------------------------------------------------------------------- +# iter_*() +# --------------------------------------------------------------------------- + +class TestIterSearchOrders: + def test_iterates_all_pages_and_advances_page(self) -> None: + page_1 = { + "totalCount": 3, + "results": [ + {**_SUMMARY_INFO, "orderId": "order-1"}, + {**_SUMMARY_INFO, "orderId": "order-2"}, + ], + } + page_2 = { + "totalCount": 3, + "results": [{**_SUMMARY_INFO, "orderId": "order-3"}], + } + transport = SequenceStubTransport( + responses=[ + Response(status_code=200, headers={}, body=page_1), + Response(status_code=200, headers={}, body=page_2), + ] + ) + api = _make_api(transport) # type: ignore[arg-type] + + result = list(api.iter_search_orders(page_size=2)) + + assert [item.order_id for item in result] == ["order-1", "order-2", "order-3"] + assert len(transport.requests) == 2 + first_body = transport.requests[0].json_body + second_body = transport.requests[1].json_body + assert first_body is not None + assert second_body is not None + assert first_body["limit"] == 2 + assert first_body["page"] == 0 + assert second_body["page"] == 1 + + def test_empty_result_stops_after_first_request(self) -> None: + transport = SequenceStubTransport( + responses=[Response(status_code=200, headers={}, body={"totalCount": 0, "results": []})] + ) + api = _make_api(transport) # type: ignore[arg-type] + + result = list(api.iter_search_orders(page_size=10)) + + assert result == [] + assert len(transport.requests) == 1 + + +class TestIterOrders: + def test_iter_orders_uses_paginated_search(self) -> None: + page_1 = { + "totalCount": 2, + "results": [{**_SUMMARY_INFO, "orderId": "order-a"}], + } + page_2 = { + "totalCount": 2, + "results": [{**_SUMMARY_INFO, "orderId": "order-b"}], + } + transport = SequenceStubTransport( + responses=[ + Response(status_code=200, headers={}, body=page_1), + Response(status_code=200, headers={}, body=page_2), + ] + ) + api = _make_api(transport) # type: ignore[arg-type] + + result = list(api.iter_orders(page_size=1)) + + assert [item.order_id for item in result] == ["order-a", "order-b"] + assert all(req.path == "/api/v3/orders/search" for req in transport.requests)