From fbe5db5e5ba96de0cc8097fc321d5b3bb0bf6002 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 15:56:53 -0500 Subject: [PATCH 1/6] chore: bump version to 2.25.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 896c6d8..9c2f271 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ofsc" -version = "2.24.3" +version = "2.25.0" description = "Python wrapper for Oracle Field Service API" authors = [{ name = "Borja Toron", email = "borja.toron@gmail.com" }] requires-python = ">=3.13" diff --git a/uv.lock b/uv.lock index 056600e..4b7f3fc 100644 --- a/uv.lock +++ b/uv.lock @@ -424,7 +424,7 @@ wheels = [ [[package]] name = "ofsc" -version = "2.24.3" +version = "2.25.0" source = { editable = "." } dependencies = [ { name = "cachetools" }, From 92149cbd299a4dec701272622704519529ecffd7 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 16:06:10 -0500 Subject: [PATCH 2/6] feat: add async generator get_all_workzones to AsyncOFSMetadata (#112) Add get_all_workzones(limit=100) async generator that yields individual Workzone objects one by one, fetching pages on demand via get_workzones(). Implements lazy pagination using a true Python async generator with yield, reusing existing get_workzones() for consistent error handling. - Add AsyncGenerator import from collections.abc - Add 4 mocked tests in TestAsyncGetAllWorkzones - Regenerate docs/ENDPOINTS.md - Update README with async generator feature note Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + docs/ENDPOINTS.md | 2 +- ofsc/async_client/metadata.py | 25 +++++++ tests/async/test_async_workzones.py | 106 ++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7dc8081..027c31f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ async with AsyncOFSC(clientID="...", secret="...", companyName="...") as client: - **Context Manager**: Must be used as an async context manager to properly manage HTTP client lifecycle - **Simplified API**: Async methods always return Pydantic models (no `response_type` parameter) - **Request/Response Logging**: Optional httpx event hooks for automatic API call tracing +- **Async Generators**: Lazy pagination helpers that yield individual items across all pages (e.g. `get_all_workzones`) `[Async]` ### Enabling Request/Response Logging diff --git a/docs/ENDPOINTS.md b/docs/ENDPOINTS.md index e6888ba..36ae357 100644 --- a/docs/ENDPOINTS.md +++ b/docs/ENDPOINTS.md @@ -1,6 +1,6 @@ # OFSC API Endpoints Reference -**Version:** 2.24.3 +**Version:** 2.25.0 **Last Updated:** 2026-03-05 This document provides a comprehensive reference of all Oracle Field Service Cloud (OFSC) API endpoints and their implementation status in pyOFSC. diff --git a/ofsc/async_client/metadata.py b/ofsc/async_client/metadata.py index f0f591a..503a4ba 100644 --- a/ofsc/async_client/metadata.py +++ b/ofsc/async_client/metadata.py @@ -1,5 +1,6 @@ """Async version of OFSMetadata API module.""" +from collections.abc import AsyncGenerator from pathlib import Path from typing import Tuple from urllib.parse import quote_plus, urljoin @@ -2148,6 +2149,30 @@ async def get_workzone(self, label: str) -> Workzone: f"Failed to get workzone '{label}'", ) + async def get_all_workzones(self, limit: int = 100) -> AsyncGenerator[Workzone, None]: + """Async generator that yields all workzones one by one, fetching pages on demand. + + :param limit: Maximum number of workzones to fetch per page (default 100) + :type limit: int + :return: Async generator yielding individual Workzone objects + :rtype: AsyncGenerator[Workzone, None] + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + offset = 0 + has_more = True + + while has_more: + response = await self.get_workzones(offset=offset, limit=limit) + for workzone in response.items: + yield workzone + has_more = response.hasMore or False + offset += len(response.items) + if len(response.items) == 0: + break + async def create_workzone(self, workzone: Workzone) -> Workzone: """Create a new workzone. diff --git a/tests/async/test_async_workzones.py b/tests/async/test_async_workzones.py index 0690748..5059f31 100644 --- a/tests/async/test_async_workzones.py +++ b/tests/async/test_async_workzones.py @@ -1,5 +1,6 @@ """Async tests for workzone operations.""" +import inspect import time from unittest.mock import AsyncMock, Mock @@ -409,3 +410,108 @@ async def test_optional_fields(self, mock_instance: AsyncOFSC): assert elem.function is None assert elem.order is None assert elem.apiParameterName is None + + +class TestAsyncGetAllWorkzones: + """Test async get_all_workzones generator method.""" + + def _make_workzones_response(self, labels: list[str], has_more: bool = False) -> dict: + """Build a mock workzones list response dict.""" + return { + "totalResults": len(labels), + "hasMore": has_more, + "items": [ + { + "workZoneLabel": label, + "workZoneName": f"Zone {label}", + "status": "active", + "travelArea": "test_area", + } + for label in labels + ], + } + + @pytest.mark.asyncio + async def test_get_all_workzones_returns_async_generator(self, mock_instance: AsyncOFSC): + """Verify that get_all_workzones returns an async generator.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = self._make_workzones_response(["WZ_A", "WZ_B"]) + mock_response.raise_for_status = Mock() + + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + result = mock_instance.metadata.get_all_workzones() + assert inspect.isasyncgen(result) + + # Exhaust the generator to avoid resource warning + async for _ in result: + pass + + @pytest.mark.asyncio + async def test_get_all_workzones_yields_workzone_instances(self, mock_instance: AsyncOFSC): + """Verify that each yielded item is a Workzone instance.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = self._make_workzones_response(["WZ_A", "WZ_B", "WZ_C"]) + mock_response.raise_for_status = Mock() + + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + items = [] + async for item in mock_instance.metadata.get_all_workzones(): + items.append(item) + + assert len(items) == 3 + for item in items: + assert isinstance(item, Workzone) + assert hasattr(item, "workZoneLabel") + assert hasattr(item, "workZoneName") + + @pytest.mark.asyncio + async def test_get_all_workzones_fetches_all(self, mock_instance: AsyncOFSC): + """Verify that pagination works: multiple pages are fetched until hasMore is False.""" + page1_response = Mock() + page1_response.status_code = 200 + page1_response.json.return_value = self._make_workzones_response(["WZ_1", "WZ_2"], has_more=True) + page1_response.raise_for_status = Mock() + + page2_response = Mock() + page2_response.status_code = 200 + page2_response.json.return_value = self._make_workzones_response(["WZ_3", "WZ_4"], has_more=False) + page2_response.raise_for_status = Mock() + + mock_instance.metadata._client.get = AsyncMock(side_effect=[page1_response, page2_response]) + + collected = [] + async for item in mock_instance.metadata.get_all_workzones(limit=2): + collected.append(item) + + assert len(collected) == 4 + labels = [wz.workZoneLabel for wz in collected] + assert labels == ["WZ_1", "WZ_2", "WZ_3", "WZ_4"] + # Verify the API was called twice (two pages) + assert mock_instance.metadata._client.get.call_count == 2 + + @pytest.mark.asyncio + async def test_get_all_workzones_unique_labels(self, mock_instance: AsyncOFSC): + """Verify no duplicate labels are yielded across pages.""" + page1_response = Mock() + page1_response.status_code = 200 + page1_response.json.return_value = self._make_workzones_response(["ZONE_A", "ZONE_B", "ZONE_C"], has_more=True) + page1_response.raise_for_status = Mock() + + page2_response = Mock() + page2_response.status_code = 200 + page2_response.json.return_value = self._make_workzones_response(["ZONE_D", "ZONE_E"], has_more=False) + page2_response.raise_for_status = Mock() + + mock_instance.metadata._client.get = AsyncMock(side_effect=[page1_response, page2_response]) + + collected = [] + async for item in mock_instance.metadata.get_all_workzones(limit=3): + collected.append(item) + + labels = [wz.workZoneLabel for wz in collected] + assert len(labels) == len(set(labels)), "Duplicate labels found across pages" + assert set(labels) == {"ZONE_A", "ZONE_B", "ZONE_C", "ZONE_D", "ZONE_E"} From d0273cf687270045aa7d66707b86ff1dd49260e6 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 16:13:54 -0500 Subject: [PATCH 3/6] feat: add example for lazy iteration of workzones using async generator --- examples/async_get_all_workzones.py | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 examples/async_get_all_workzones.py diff --git a/examples/async_get_all_workzones.py b/examples/async_get_all_workzones.py new file mode 100644 index 0000000..5f10c13 --- /dev/null +++ b/examples/async_get_all_workzones.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Example: iterate all workzones lazily with get_all_workzones. + +This example demonstrates: +- Using the async generator get_all_workzones() +- Lazy pagination — pages are fetched on demand as you iterate +- Collecting results vs. processing one-by-one +""" + +import asyncio +from pathlib import Path + +from dotenv import load_dotenv + +# Load environment variables from .env file +env_path = Path(__file__).parent.parent / ".env" +load_dotenv(dotenv_path=env_path) + +from config import Config + +from ofsc.async_client import AsyncOFSC + + +async def main(): + async with AsyncOFSC( + clientID=Config.OFSC_CLIENT_ID, + secret=Config.OFSC_CLIENT_SECRET, + companyName=Config.OFSC_COMPANY, + root=Config.OFSC_ROOT, + ) as client: + print("Iterating workzones one by one (lazy):") + count = 0 + async for workzone in client.metadata.get_all_workzones(limit=10): + print(f" [{count + 1}] {workzone.workZoneLabel}: {workzone.workZoneName}") + count += 1 + + print(f"\nTotal workzones yielded: {count}") + + # You can also collect into a list with an async comprehension + all_labels = [ + wz.workZoneLabel + async for wz in client.metadata.get_all_workzones(limit=10) + ] + print(f"Labels collected: {all_labels}") + + +if __name__ == "__main__": + asyncio.run(main()) From eb79e38f64451b26773fa01a26cf9a33b6251512 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 18:44:04 -0500 Subject: [PATCH 4/6] refactor: reduce code duplication in async capacity.py (#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `_to_csv_param()` static helper to AsyncClientBase supporting CsvList, list[str], and str - Remove cross-module import of `_convert_model_to_api_params` from sync capacity.py - Replace 8+ inline isinstance(x, list) CSV conversions with `self._to_csv_param()` - Migrate update_quota, update_booking_closing_schedule, update_booking_statuses to `_patch_item()` - Migrate show_booking_grid to `_post_item()` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- ofsc/async_client/_base.py | 19 +++++- ofsc/async_client/capacity.py | 121 ++++++++++++++++++---------------- 2 files changed, 80 insertions(+), 60 deletions(-) diff --git a/ofsc/async_client/_base.py b/ofsc/async_client/_base.py index 8a03f07..3a37824 100644 --- a/ofsc/async_client/_base.py +++ b/ofsc/async_client/_base.py @@ -1,6 +1,6 @@ """Shared base class for all async OFSC API modules.""" -from typing import Type, TypeVar +from typing import Type, TypeVar, Union from urllib.parse import quote_plus, urljoin import httpx @@ -17,7 +17,7 @@ OFSCServerError, OFSCValidationError, ) -from ..models import OFSConfig +from ..models import CsvList, OFSConfig T = TypeVar("T") @@ -152,6 +152,21 @@ def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> Non # region Generic HTTP helpers + @staticmethod + def _to_csv_param(value: Union[CsvList, list[str], str]) -> str: + """Convert a list, CsvList, or plain string to a CSV string for API query params. + + :param value: A CsvList model, a list of strings, or a plain string + :type value: Union[CsvList, list[str], str] + :return: Comma-separated string suitable for an API query parameter + :rtype: str + """ + if isinstance(value, CsvList): + return value.value + if isinstance(value, list): + return ",".join(value) + return value + def _clean_response(self, data: dict) -> dict: """Remove the 'links' key from an API response dict. diff --git a/ofsc/async_client/capacity.py b/ofsc/async_client/capacity.py index 168f1ff..e2d65f5 100644 --- a/ofsc/async_client/capacity.py +++ b/ofsc/async_client/capacity.py @@ -24,7 +24,6 @@ ShowBookingGridRequest, ShowBookingGridResponse, ) -from ..capacity import _convert_model_to_api_params class AsyncOFSCapacity(AsyncClientBase): @@ -71,7 +70,20 @@ async def get_available_capacity( calendarTimeIntervals=calendarTimeIntervals, fields=fields, ) - params = _convert_model_to_api_params(capacity_request) + params: dict = { + "dates": self._to_csv_param(capacity_request.dates), + "availableTimeIntervals": capacity_request.availableTimeIntervals, + "calendarTimeIntervals": capacity_request.calendarTimeIntervals, + } + if capacity_request.areas is not None: + params["areas"] = self._to_csv_param(capacity_request.areas) + if capacity_request.categories is not None: + params["categories"] = self._to_csv_param(capacity_request.categories) + if capacity_request.aggregateResults is not None: + params["aggregateResults"] = str(capacity_request.aggregateResults).lower() + if capacity_request.fields is not None: + params["fields"] = self._to_csv_param(capacity_request.fields) + url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/capacity") try: response = await self._client.get(url, headers=self.headers, params=params) @@ -132,7 +144,22 @@ async def get_quota( returnStatuses=returnStatuses, timeSlotLevel=timeSlotLevel, ) - params = _convert_model_to_api_params(quota_request) + params: dict = {"dates": self._to_csv_param(quota_request.dates)} + if quota_request.areas is not None: + params["areas"] = self._to_csv_param(quota_request.areas) + if quota_request.categories is not None: + params["categories"] = self._to_csv_param(quota_request.categories) + if quota_request.aggregateResults is not None: + params["aggregateResults"] = str(quota_request.aggregateResults).lower() + if quota_request.categoryLevel is not None: + params["categoryLevel"] = str(quota_request.categoryLevel).lower() + if quota_request.intervalLevel is not None: + params["intervalLevel"] = str(quota_request.intervalLevel).lower() + if quota_request.returnStatuses is not None: + params["returnStatuses"] = str(quota_request.returnStatuses).lower() + if quota_request.timeSlotLevel is not None: + params["timeSlotLevel"] = str(quota_request.timeSlotLevel).lower() + url = urljoin(self.baseUrl, "/rest/ofscCapacity/v2/quota") try: response = await self._client.get(url, headers=self.headers, params=params) @@ -165,16 +192,12 @@ async def update_quota( """ if isinstance(data, dict): data = QuotaUpdateRequest.model_validate(data) - url = urljoin(self.baseUrl, "/rest/ofscCapacity/v2/quota") - try: - response = await self._client.patch(url, headers=self.headers, json=data.model_dump(exclude_none=True)) - response.raise_for_status() - return QuotaUpdateResponse.model_validate(response.json()) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to update quota") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._patch_item( + "/rest/ofscCapacity/v2/quota", + data, + QuotaUpdateResponse, + "Failed to update quota", + ) # Deprecated camelCase alias getQuota = get_quota @@ -232,23 +255,19 @@ async def get_activity_booking_options( OFSCApiError: For other API errors OFSCNetworkError: For network/transport errors """ - params: dict = {} - if isinstance(dates, list): - params["dates"] = ",".join(dates) - else: - params["dates"] = dates + params: dict = {"dates": self._to_csv_param(dates)} if areas is not None: - params["areas"] = ",".join(areas) if isinstance(areas, list) else areas + params["areas"] = self._to_csv_param(areas) if activityType is not None: params["activityType"] = activityType if duration is not None: params["duration"] = duration if workSkills is not None: - params["workSkills"] = ",".join(workSkills) if isinstance(workSkills, list) else workSkills + params["workSkills"] = self._to_csv_param(workSkills) if timeSlots is not None: - params["timeSlots"] = ",".join(timeSlots) if isinstance(timeSlots, list) else timeSlots + params["timeSlots"] = self._to_csv_param(timeSlots) if categories is not None: - params["categories"] = ",".join(categories) if isinstance(categories, list) else categories + params["categories"] = self._to_csv_param(categories) if languageCode is not None: params["languageCode"] = languageCode if timeZone is not None: @@ -302,8 +321,7 @@ async def get_booking_closing_schedule( OFSCApiError: For other API errors OFSCNetworkError: For network/transport errors """ - params: dict = {} - params["areas"] = ",".join(areas) if isinstance(areas, list) else areas + params: dict = {"areas": self._to_csv_param(areas)} url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingClosingSchedule") try: @@ -337,16 +355,12 @@ async def update_booking_closing_schedule( """ if isinstance(data, dict): data = BookingClosingScheduleUpdateRequest.model_validate(data) - url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingClosingSchedule") - try: - response = await self._client.patch(url, headers=self.headers, json=data.model_dump(exclude_none=True)) - response.raise_for_status() - return BookingClosingScheduleResponse.model_validate(response.json()) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to update booking closing schedule") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._patch_item( + "/rest/ofscCapacity/v1/bookingClosingSchedule", + data, + BookingClosingScheduleResponse, + "Failed to update booking closing schedule", + ) # endregion @@ -372,10 +386,9 @@ async def get_booking_statuses( OFSCApiError: For other API errors OFSCNetworkError: For network/transport errors """ - params: dict = {} - params["dates"] = ",".join(dates) if isinstance(dates, list) else dates + params: dict = {"dates": self._to_csv_param(dates)} if areas is not None: - params["areas"] = ",".join(areas) if isinstance(areas, list) else areas + params["areas"] = self._to_csv_param(areas) url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingStatuses") try: @@ -409,16 +422,12 @@ async def update_booking_statuses( """ if isinstance(data, dict): data = BookingStatusesUpdateRequest.model_validate(data) - url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingStatuses") - try: - response = await self._client.patch(url, headers=self.headers, json=data.model_dump(exclude_none=True)) - response.raise_for_status() - return BookingStatusesResponse.model_validate(response.json()) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to update booking statuses") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._patch_item( + "/rest/ofscCapacity/v1/bookingStatuses", + data, + BookingStatusesResponse, + "Failed to update booking statuses", + ) # endregion @@ -445,16 +454,12 @@ async def show_booking_grid( """ if isinstance(data, dict): data = ShowBookingGridRequest.model_validate(data) - url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/showBookingGrid") - try: - response = await self._client.post(url, headers=self.headers, json=data.model_dump(exclude_none=True)) - response.raise_for_status() - return ShowBookingGridResponse.model_validate(response.json()) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to show booking grid") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._post_item( + "/rest/ofscCapacity/v1/showBookingGrid", + data, + ShowBookingGridResponse, + "Failed to show booking grid", + ) # endregion @@ -480,7 +485,7 @@ async def get_booking_fields_dependencies( """ params: dict = {} if areas is not None: - params["areas"] = ",".join(areas) if isinstance(areas, list) else areas + params["areas"] = self._to_csv_param(areas) url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingFieldsDependencies") try: From 5fa966b9dc450c80ec1f350b46753d05155517c6 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 16:31:53 -0500 Subject: [PATCH 5/6] feat: add async generator get_all_resources to AsyncOFSCore (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements lazy pagination of all resources using an async generator, following the same pattern as get_all_workzones (#112). - Add get_all_resources() to AsyncOFSCoreResourcesMixin in ofsc/async_client/core/resources.py, delegating to get_resources() with hasMore + offset loop, yielding individual Resource objects - Pass through all get_resources() params: fields, expand_inventories, expand_workskills, expand_workzones, expand_workschedules - Add TestAsyncGetAllResources (4 mocked tests) to tests/async/test_async_resources_get.py - Update README to mention get_all_resources in async generators section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- ofsc/async_client/core/resources.py | 51 +++++++++++ tests/async/test_async_resources_get.py | 116 +++++++++++++++++++++++- 3 files changed, 167 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 027c31f..fd72aac 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ async with AsyncOFSC(clientID="...", secret="...", companyName="...") as client: - **Context Manager**: Must be used as an async context manager to properly manage HTTP client lifecycle - **Simplified API**: Async methods always return Pydantic models (no `response_type` parameter) - **Request/Response Logging**: Optional httpx event hooks for automatic API call tracing -- **Async Generators**: Lazy pagination helpers that yield individual items across all pages (e.g. `get_all_workzones`) `[Async]` +- **Async Generators**: Lazy pagination helpers that yield individual items across all pages (e.g. `get_all_workzones`, `get_all_resources`) `[Async]` ### Enabling Request/Response Logging diff --git a/ofsc/async_client/core/resources.py b/ofsc/async_client/core/resources.py index 4f03b23..5a5cf68 100644 --- a/ofsc/async_client/core/resources.py +++ b/ofsc/async_client/core/resources.py @@ -1,5 +1,6 @@ """Async resource methods mixin for OFSCore API.""" +from collections.abc import AsyncGenerator from datetime import date from typing import Any, Protocol from urllib.parse import quote_plus, urljoin @@ -553,6 +554,56 @@ async def get_resources( except httpx.TransportError as e: raise OFSCNetworkError(f"Network error: {str(e)}") from e + async def get_all_resources( + self: _CoreBaseProtocol, + limit: int = 100, + fields: list[str] | None = None, + expand_inventories: bool = False, + expand_workskills: bool = False, + expand_workzones: bool = False, + expand_workschedules: bool = False, + ) -> AsyncGenerator[Resource, None]: + """Async generator that yields all resources one by one, fetching pages on demand. + + :param limit: Maximum number of resources to fetch per page (default 100) + :type limit: int + :param fields: List of fields to return + :type fields: list[str] | None + :param expand_inventories: Include resource inventories + :type expand_inventories: bool + :param expand_workskills: Include resource workskills + :type expand_workskills: bool + :param expand_workzones: Include resource workzones + :type expand_workzones: bool + :param expand_workschedules: Include resource workschedules + :type expand_workschedules: bool + :return: Async generator yielding individual Resource objects + :rtype: AsyncGenerator[Resource, None] + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + offset = 0 + has_more = True + + while has_more: + response = await self.get_resources( + offset=offset, + limit=limit, + fields=fields, + expand_inventories=expand_inventories, + expand_workskills=expand_workskills, + expand_workzones=expand_workzones, + expand_workschedules=expand_workschedules, + ) + for resource in response.items: + yield resource + has_more = response.hasMore or False + offset += len(response.items) + if len(response.items) == 0: + break + # region Write / Delete Operations async def create_resource( diff --git a/tests/async/test_async_resources_get.py b/tests/async/test_async_resources_get.py index b679663..00348b9 100644 --- a/tests/async/test_async_resources_get.py +++ b/tests/async/test_async_resources_get.py @@ -1,8 +1,10 @@ """Tests for async resource GET operations.""" +import inspect import json from datetime import date from pathlib import Path +from unittest.mock import AsyncMock, Mock import pytest @@ -423,4 +425,116 @@ def test_calendars_validation(self): response = CalendarsListResponse.model_validate(saved_data["response_data"]) assert isinstance(response, CalendarsListResponse) - assert hasattr(response, "items") + + +# =================================================================== +# GET ALL RESOURCES (ASYNC GENERATOR) +# =================================================================== + + +class TestAsyncGetAllResources: + """Test async get_all_resources generator method.""" + + def _make_resources_response(self, resource_ids: list[str], has_more: bool = False) -> dict: + """Build a mock resource list response dict.""" + return { + "totalResults": len(resource_ids), + "hasMore": has_more, + "items": [ + { + "resourceId": rid, + "name": f"Resource {rid}", + "resourceType": "technician", + "language": "en", + "timeZone": "UTC", + } + for rid in resource_ids + ], + } + + @pytest.mark.asyncio + async def test_get_all_resources_returns_async_generator(self, mock_instance: AsyncOFSC): + """Verify that get_all_resources returns an async generator.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = self._make_resources_response(["R1", "R2"]) + mock_response.raise_for_status = Mock() + + mock_instance.core._client.get = AsyncMock(return_value=mock_response) + + result = mock_instance.core.get_all_resources() + assert inspect.isasyncgen(result) + + # Exhaust the generator to avoid resource warning + async for _ in result: + pass + + @pytest.mark.asyncio + async def test_get_all_resources_yields_resource_instances(self, mock_instance: AsyncOFSC): + """Verify that each yielded item is a Resource instance.""" + from ofsc.models import Resource + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = self._make_resources_response(["R1", "R2", "R3"]) + mock_response.raise_for_status = Mock() + + mock_instance.core._client.get = AsyncMock(return_value=mock_response) + + items = [] + async for item in mock_instance.core.get_all_resources(): + items.append(item) + + assert len(items) == 3 + for item in items: + assert isinstance(item, Resource) + assert hasattr(item, "resourceId") + assert hasattr(item, "name") + + @pytest.mark.asyncio + async def test_get_all_resources_fetches_all_pages(self, mock_instance: AsyncOFSC): + """Verify that pagination works: multiple pages are fetched until hasMore is False.""" + page1_response = Mock() + page1_response.status_code = 200 + page1_response.json.return_value = self._make_resources_response(["R1", "R2"], has_more=True) + page1_response.raise_for_status = Mock() + + page2_response = Mock() + page2_response.status_code = 200 + page2_response.json.return_value = self._make_resources_response(["R3", "R4"], has_more=False) + page2_response.raise_for_status = Mock() + + mock_instance.core._client.get = AsyncMock(side_effect=[page1_response, page2_response]) + + collected = [] + async for item in mock_instance.core.get_all_resources(limit=2): + collected.append(item) + + assert len(collected) == 4 + resource_ids = [r.resourceId for r in collected] + assert resource_ids == ["R1", "R2", "R3", "R4"] + # Verify the API was called twice (two pages) + assert mock_instance.core._client.get.call_count == 2 + + @pytest.mark.asyncio + async def test_get_all_resources_unique_resource_ids(self, mock_instance: AsyncOFSC): + """Verify no duplicate resourceIds are yielded across pages.""" + page1_response = Mock() + page1_response.status_code = 200 + page1_response.json.return_value = self._make_resources_response(["RA", "RB", "RC"], has_more=True) + page1_response.raise_for_status = Mock() + + page2_response = Mock() + page2_response.status_code = 200 + page2_response.json.return_value = self._make_resources_response(["RD", "RE"], has_more=False) + page2_response.raise_for_status = Mock() + + mock_instance.core._client.get = AsyncMock(side_effect=[page1_response, page2_response]) + + collected = [] + async for item in mock_instance.core.get_all_resources(limit=3): + collected.append(item) + + resource_ids = [r.resourceId for r in collected] + assert len(resource_ids) == len(set(resource_ids)), "Duplicate resourceIds found across pages" + assert set(resource_ids) == {"RA", "RB", "RC", "RD", "RE"} From de45e83e9a80921bc37ceace98b28fe702260a96 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 18:21:30 -0500 Subject: [PATCH 6/6] feat: add test for get_all_resources to verify unique resource IDs against manual pagination --- tests/async/test_async_resources_get.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/async/test_async_resources_get.py b/tests/async/test_async_resources_get.py index 00348b9..51920b8 100644 --- a/tests/async/test_async_resources_get.py +++ b/tests/async/test_async_resources_get.py @@ -516,6 +516,29 @@ async def test_get_all_resources_fetches_all_pages(self, mock_instance: AsyncOFS # Verify the API was called twice (two pages) assert mock_instance.core._client.get.call_count == 2 + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_all_resources_matches_manual_pagination(self, async_instance: AsyncOFSC): + """Verify get_all_resources yields same unique resources as manual get_resources pagination.""" + # Manual pagination + manual_ids = set() + offset = 0 + while True: + page = await async_instance.core.get_resources(offset=offset, limit=10) + for r in page.items: + manual_ids.add(r.resourceId) + if not page.hasMore: + break + offset += 10 + + # Generator + generator_ids = set() + async for resource in async_instance.core.get_all_resources(limit=10): + generator_ids.add(resource.resourceId) + + assert len(manual_ids) > 0, "No resources found in test environment" + assert manual_ids == generator_ids + @pytest.mark.asyncio async def test_get_all_resources_unique_resource_ids(self, mock_instance: AsyncOFSC): """Verify no duplicate resourceIds are yielded across pages."""