diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 0f44078..485833b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -59,4 +59,4 @@ jobs: run: uv sync --all-extras - name: Type check - run: uv run pyright + run: uv run basedpyright diff --git a/CHANGELOG.md b/CHANGELOG.md index ffed9cd..50c2994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.0] - 2026-01-09 + +### Changed + +- **BREAKING**: `HeadlineListItem` model removed and merged into `HeadlineDetails` (now a type alias for backward compatibility) +- Refactored internal operations structure to use mixins for better code organization +- Improved bulk operations with generic validation and processing helpers in base classes +- Standardized error handling across all operations to consistently use `raise_for_status()` + +### Added + +- New `mixins/` folder with transform mixins for each operation type (goals, meetings, issues, headlines, todos, users) +- Generic bulk operation helpers in `AbstractOperations` base class (`_validate_bulk_item`, `_process_bulk_sync`) +- Generic async bulk processing in `AsyncBaseOperations` with configurable semaphore for concurrency control +- Reusable `OptionalDatetime` and `OptionalFloat` annotated types using Pydantic's `BeforeValidator` + +### Fixed + +- Removed duplicate datetime and float validators across models (Todo, Issue, Goal) +- Removed redundant `__init__` methods from async operations + ## [0.20.1] - 2025-12-10 ### Fixed @@ -231,7 +252,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Configuration management with multiple API key sources - httpx-based HTTP client with bearer token authentication -[Unreleased]: https://github.com/franccesco/bloomy-python/compare/v0.20.1...HEAD +[Unreleased]: https://github.com/franccesco/bloomy-python/compare/v0.21.0...HEAD +[0.21.0]: https://github.com/franccesco/bloomy-python/compare/v0.20.1...v0.21.0 [0.20.1]: https://github.com/franccesco/bloomy-python/compare/v0.20.0...v0.20.1 [0.20.0]: https://github.com/franccesco/bloomy-python/compare/v0.19.0...v0.20.0 [0.19.0]: https://github.com/franccesco/bloomy-python/compare/v0.18.0...v0.19.0 diff --git a/CLAUDE.md b/CLAUDE.md index b3e8f26..611bbed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,12 +80,17 @@ The Bloomy Python SDK is organized as a client-based architecture where all API - Sync operations in `src/bloomy/operations/` inherit from `BaseOperations` - Async operations in `src/bloomy/operations/async_/` inherit from `AsyncBaseOperations` - Both inherit from `AbstractOperations` which provides shared logic + - Response transformation is handled by mixins in `src/bloomy/operations/mixins/` (e.g., `UserOperationsMixin`, `MeetingOperationsMixin`) + - Mixins use `_transform` suffix naming convention (e.g., `goals_transform.py`, `users_transform.py`) + - Generic bulk operations logic is provided in base classes (`_validate_bulk_item`, `_process_bulk_sync`, `_process_bulk_async`) - Operations are accessed via client attributes: `client.user`, `client.meeting`, etc. 4. **Models (`src/bloomy/models.py`)**: - Pydantic models for type-safe API responses - All models inherit from `BloomyBaseModel` with common config - Field aliases map PascalCase API responses to snake_case Python attributes + - Reusable annotated types: `OptionalDatetime` and `OptionalFloat` using Pydantic's `BeforeValidator` + - Some models are type aliases for backward compatibility (e.g., `HeadlineListItem = HeadlineDetails`) 5. **API Endpoints**: - Base URL: `https://app.bloomgrowth.com/api/v1` @@ -101,7 +106,7 @@ The Bloomy Python SDK is organized as a client-based architecture where all API 3. **Error Handling**: - Custom exception hierarchy rooted at `BloomyError` - `APIError` includes status code - - HTTP errors are raised via `response.raise_for_status()` + - All operations consistently use `response.raise_for_status()` for error handling 4. **Type Annotations**: Uses Python 3.12+ union syntax (`|`) and Pydantic models for structured return types. diff --git a/README.md b/README.md index cf4e9a2..67a7404 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Python SDK for interacting with the Bloom Growth API, providing easy access to users, meetings, todos, goals, scorecards, issues, and headlines. -✨ **New in v0.13.0**: Full async/await support with `AsyncClient` for better performance in async applications! +✨ **New in v0.21.0**: Improved internal architecture with reusable mixins and enhanced bulk operation performance! ## Installation @@ -59,13 +59,13 @@ asyncio.run(main()) user = client.user.details() # Get user with direct reports and positions -user = client.user.details(user_id=123, all=True) +user = client.user.details(user_id=123, include_all=True) # Search users results = client.user.search("john") -# Get all users -users = client.user.all() +# List all users +users = client.user.list() ``` ### Meetings diff --git a/docs/guide/async.md b/docs/guide/async.md index f038b92..ff00589 100644 --- a/docs/guide/async.md +++ b/docs/guide/async.md @@ -226,12 +226,12 @@ async def bulk_create_todos(client: AsyncClient, todos_data: list[dict]): async def sync_user_data(client: AsyncClient): """Sync all user data efficiently.""" # Get all users - all_users = await client.user.all() - + all_users = await client.user.list() + # Process users in batches to avoid overwhelming the API batch_size = 10 user_details = [] - + for i in range(0, len(all_users), batch_size): batch = all_users[i:i + batch_size] batch_tasks = [ @@ -240,7 +240,7 @@ async def sync_user_data(client: AsyncClient): ] batch_results = await asyncio.gather(*batch_tasks) user_details.extend(batch_results) - + return user_details ``` diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 4705be6..11c6db2 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -260,8 +260,8 @@ client_account1 = Client(api_key="api-key-for-account-1") client_account2 = Client(api_key="api-key-for-account-2") # Use different clients for different operations -users_account1 = client_account1.user.all() -users_account2 = client_account2.user.all() +users_account1 = client_account1.user.list() +users_account2 = client_account2.user.list() ``` ## Next Steps diff --git a/docs/guide/bulk-operations.md b/docs/guide/bulk-operations.md index 3ab9add..4c3b94c 100644 --- a/docs/guide/bulk-operations.md +++ b/docs/guide/bulk-operations.md @@ -23,6 +23,9 @@ Each bulk operation returns a `BulkCreateResult` containing: !!! note "Best-Effort Processing" Bulk operations are not transactional. If one item fails, the operation continues with the remaining items. Always check both successful and failed results. +!!! tip "Enhanced in v0.21.0" + Bulk operations now use generic validation and processing helpers in the base classes, providing consistent error handling and performance across all resource types. + ## BulkCreateResult Structure ```python diff --git a/pyproject.toml b/pyproject.toml index 6a79b4c..6f6cb90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bloomy-python" -version = "0.20.1" +version = "0.21.0" description = "Python SDK for Bloom Growth API" readme = "README.md" authors = [{ name = "Franccesco Orozco", email = "franccesco@codingdose.info" }] @@ -18,7 +18,7 @@ dev = [ "pytest-cov>=5.0.0", "pytest-asyncio>=0.23.0", "ruff>=0.5.0", - "pyright>=1.1.0", + "basedpyright>=1.1.0", ] [build-system] @@ -69,7 +69,7 @@ max-complexity = 10 quote-style = "double" indent-style = "space" -[tool.pyright] +[tool.basedpyright] include = ["src"] pythonVersion = "3.12" typeCheckingMode = "strict" diff --git a/src/bloomy/models.py b/src/bloomy/models.py index d344342..0c4fc7b 100644 --- a/src/bloomy/models.py +++ b/src/bloomy/models.py @@ -4,9 +4,38 @@ from datetime import datetime from enum import StrEnum -from typing import Any +from typing import Annotated, Any -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field + + +def _parse_optional_datetime(v: Any) -> datetime | None: + """Parse optional datetime fields, treating empty strings as None. + + Returns: + The datetime value or None if empty/None. + + """ + if v is None or v == "": + return None + return v + + +def _parse_optional_float(v: Any) -> float | None: + """Parse optional float fields, treating empty strings as None. + + Returns: + The float value or None if empty/None. + + """ + if v is None or v == "": + return None + return float(v) + + +# Reusable annotated types for optional fields that may come as empty strings +OptionalDatetime = Annotated[datetime | None, BeforeValidator(_parse_optional_datetime)] +OptionalFloat = Annotated[float | None, BeforeValidator(_parse_optional_float)] class GoalStatus(StrEnum): @@ -122,26 +151,13 @@ class Todo(BloomyBaseModel): id: int = Field(alias="Id") name: str = Field(alias="Name") details_url: str | None = Field(alias="DetailsUrl", default=None) - due_date: datetime | None = Field(alias="DueDate", default=None) - complete_date: datetime | None = Field(alias="CompleteTime", default=None) - create_date: datetime | None = Field(alias="CreateTime", default=None) + due_date: OptionalDatetime = Field(alias="DueDate", default=None) + complete_date: OptionalDatetime = Field(alias="CompleteTime", default=None) + create_date: OptionalDatetime = Field(alias="CreateTime", default=None) meeting_id: int | None = Field(alias="OriginId", default=None) meeting_name: str | None = Field(alias="Origin", default=None) complete: bool = Field(alias="Complete", default=False) - @field_validator("due_date", "complete_date", "create_date", mode="before") - @classmethod - def parse_optional_datetime(cls, v: Any) -> datetime | None: - """Parse optional datetime fields. - - Returns: - The parsed datetime or None if empty. - - """ - if v is None or v == "": - return None - return v - class Issue(BloomyBaseModel): """Model for issue.""" @@ -155,21 +171,8 @@ class Issue(BloomyBaseModel): owner_name: str = Field(alias="OwnerName") owner_id: int = Field(alias="OwnerId") owner_image_url: str = Field(alias="OwnerImageUrl") - closed_date: datetime | None = Field(alias="ClosedDate", default=None) - completion_date: datetime | None = Field(alias="CompletionDate", default=None) - - @field_validator("closed_date", "completion_date", mode="before") - @classmethod - def parse_optional_datetime(cls, v: Any) -> datetime | None: - """Parse optional datetime fields. - - Returns: - The parsed datetime or None if empty. - - """ - if v is None or v == "": - return None - return v + closed_date: OptionalDatetime = Field(alias="ClosedDate", default=None) + completion_date: OptionalDatetime = Field(alias="CompletionDate", default=None) class Headline(BloomyBaseModel): @@ -192,54 +195,28 @@ class Goal(BloomyBaseModel): id: int = Field(alias="Id") name: str = Field(alias="Name") due_date: datetime = Field(alias="DueDate") - complete_date: datetime | None = Field(alias="CompleteDate", default=None) + complete_date: OptionalDatetime = Field(alias="CompleteDate", default=None) create_date: datetime = Field(alias="CreateDate") is_archived: bool = Field(alias="IsArchived", default=False) percent_complete: float = Field(alias="PercentComplete", default=0.0) accountable_user_id: int = Field(alias="AccountableUserId") accountable_user_name: str | None = Field(alias="AccountableUserName", default=None) - @field_validator("complete_date", mode="before") - @classmethod - def parse_optional_datetime(cls, v: Any) -> datetime | None: - """Parse optional datetime fields. - - Returns: - The parsed datetime or None if empty. - - """ - if v is None or v == "": - return None - return v - class ScorecardMetric(BloomyBaseModel): """Model for scorecard metric.""" id: int = Field(alias="Id") title: str = Field(alias="Title") - target: float | None = Field(alias="Target", default=None) + target: OptionalFloat = Field(alias="Target", default=None) unit: str | None = Field(alias="Unit", default=None) week_number: int = Field(alias="WeekNumber") - value: float | None = Field(alias="Value", default=None) + value: OptionalFloat = Field(alias="Value", default=None) metric_type: str = Field(alias="MetricType") accountable_user_id: int = Field(alias="AccountableUserId") accountable_user_name: str | None = Field(alias="AccountableUserName", default=None) is_inverse: bool = Field(alias="IsInverse", default=False) - @field_validator("target", "value", mode="before") - @classmethod - def parse_optional_float(cls, v: Any) -> float | None: - """Parse optional float fields. - - Returns: - The parsed float or None if empty. - - """ - if v is None or v == "": - return None - return float(v) - class CurrentWeek(BloomyBaseModel): """Model for current week information.""" @@ -380,7 +357,7 @@ class HeadlineDetails(BloomyBaseModel): id: int title: str - notes_url: str + notes_url: str | None = None meeting_details: MeetingInfo owner_details: OwnerDetails archived: bool @@ -388,16 +365,8 @@ class HeadlineDetails(BloomyBaseModel): closed_at: str | None = None -class HeadlineListItem(BloomyBaseModel): - """Model for headline list items.""" - - id: int - title: str - meeting_details: MeetingInfo - owner_details: OwnerDetails - archived: bool - created_at: str - closed_at: str | None = None +# HeadlineListItem is identical to HeadlineDetails - use type alias +HeadlineListItem = HeadlineDetails class BulkCreateError(BloomyBaseModel): diff --git a/src/bloomy/operations/async_/goals.py b/src/bloomy/operations/async_/goals.py index a781f06..6b4a770 100644 --- a/src/bloomy/operations/async_/goals.py +++ b/src/bloomy/operations/async_/goals.py @@ -2,13 +2,11 @@ from __future__ import annotations -import asyncio import builtins from typing import TYPE_CHECKING from ...models import ( ArchivedGoalInfo, - BulkCreateError, BulkCreateResult, CreatedGoalInfo, GoalInfo, @@ -16,25 +14,15 @@ GoalStatus, ) from ...utils.async_base_operations import AsyncBaseOperations +from ..mixins.goals_transform import GoalOperationsMixin if TYPE_CHECKING: from typing import Any - import httpx - -class AsyncGoalOperations(AsyncBaseOperations): +class AsyncGoalOperations(AsyncBaseOperations, GoalOperationsMixin): """Async class to handle all operations related to goals (aka "rocks").""" - def __init__(self, client: httpx.AsyncClient) -> None: - """Initialize the async goal operations. - - Args: - client: The async HTTP client to use for API requests. - - """ - super().__init__(client) - async def list( self, user_id: int | None = None, archived: bool = False ) -> builtins.list[GoalInfo] | GoalListResponse: @@ -60,22 +48,7 @@ async def list( response.raise_for_status() data = response.json() - active_goals: list[GoalInfo] = [ - GoalInfo( - id=goal["Id"], - user_id=goal["Owner"]["Id"], - user_name=goal["Owner"]["Name"], - title=goal["Name"], - created_at=goal["CreateTime"], - due_date=goal["DueDate"], - status="Completed" if goal.get("Complete") else "Incomplete", - meeting_id=goal["Origins"][0]["Id"] if goal.get("Origins") else None, - meeting_title=( - goal["Origins"][0]["Name"] if goal.get("Origins") else None - ), - ) - for goal in data - ] + active_goals = self._transform_goal_list(data) if archived: archived_goals = await self._get_archived_goals(user_id) @@ -106,20 +79,7 @@ async def create( response.raise_for_status() data = response.json() - # Map completion status - completion_map = {2: "complete", 1: "on", 0: "off"} - status = completion_map.get(data.get("Completion", 0), "off") - - return CreatedGoalInfo( - id=data["Id"], - user_id=user_id, - user_name=data["Owner"]["Name"], - title=title, - meeting_id=meeting_id, - meeting_title=data["Origins"][0]["Name"], - status=status, - created_at=data["CreateTime"], - ) + return self._transform_created_goal(data, title, meeting_id, user_id) async def delete(self, goal_id: int) -> None: """Delete a goal. @@ -148,29 +108,13 @@ async def update( status: The status value. Can be a GoalStatus enum member or string ('on', 'off', or 'complete'). Use GoalStatus.ON_TRACK, GoalStatus.AT_RISK, or GoalStatus.COMPLETE for type safety. - - Raises: - ValueError: If an invalid status value is provided + Invalid values will raise ValueError via the update payload builder. """ if accountable_user is None: accountable_user = await self.get_user_id() - payload: dict[str, Any] = {"accountableUserId": accountable_user} - - if title is not None: - payload["title"] = title - - if status is not None: - valid_status = {"on": "OnTrack", "off": "AtRisk", "complete": "Complete"} - # Handle both GoalStatus enum and string - status_value = status.value if isinstance(status, GoalStatus) else status - status_key = status_value.lower() - if status_key not in valid_status: - raise ValueError( - "Invalid status value. Must be 'on', 'off', or 'complete'." - ) - payload["completion"] = valid_status[status_key] + payload = self._build_goal_update_payload(accountable_user, title, status) response = await self._client.put(f"rocks/{goal_id}", json=payload) response.raise_for_status() @@ -214,16 +158,7 @@ async def _get_archived_goals( response.raise_for_status() data = response.json() - return [ - ArchivedGoalInfo( - id=goal["Id"], - title=goal["Name"], - created_at=goal["CreateTime"], - due_date=goal["DueDate"], - status="Complete" if goal.get("Complete") else "Incomplete", - ) - for goal in data - ] + return self._transform_archived_goals(data) async def create_many( self, goals: builtins.list[dict[str, Any]], max_concurrent: int = 5 @@ -260,67 +195,17 @@ async def create_many( ``` """ - # Create a semaphore to limit concurrent requests - semaphore = asyncio.Semaphore(max_concurrent) - - async def create_single_goal( - index: int, goal_data: dict[str, Any] - ) -> tuple[int, CreatedGoalInfo | BulkCreateError]: - """Create a single goal with error handling. - - Returns: - Tuple of (index, result) where result is either CreatedGoalInfo - or BulkCreateError. - - Raises: - ValueError: When required parameters are missing. - - """ - async with semaphore: - try: - # Extract parameters from the goal data - title = goal_data.get("title") - meeting_id = goal_data.get("meeting_id") - user_id = goal_data.get("user_id") - - # Validate required parameters - if title is None: - raise ValueError("title is required") - if meeting_id is None: - raise ValueError("meeting_id is required") - - # Create the goal - created_goal = await self.create( - title=title, meeting_id=meeting_id, user_id=user_id - ) - return (index, created_goal) - - except Exception as e: - error = BulkCreateError( - index=index, input_data=goal_data, error=str(e) - ) - return (index, error) - - # Create tasks for all goals - tasks = [ - create_single_goal(index, goal_data) - for index, goal_data in enumerate(goals) - ] - - # Execute all tasks concurrently - results = await asyncio.gather(*tasks) - - # Sort results to maintain order - results.sort(key=lambda x: x[0]) - - # Separate successful and failed results - successful: builtins.list[CreatedGoalInfo] = [] - failed: builtins.list[BulkCreateError] = [] - - for _, result in results: - if isinstance(result, CreatedGoalInfo): - successful.append(result) - else: - failed.append(result) - - return BulkCreateResult(successful=successful, failed=failed) + + async def _create_single(data: dict[str, Any]) -> CreatedGoalInfo: + return await self.create( + title=data["title"], + meeting_id=data["meeting_id"], + user_id=data.get("user_id"), + ) + + return await self._process_bulk_async( + goals, + _create_single, + required_fields=["title", "meeting_id"], + max_concurrent=max_concurrent, + ) diff --git a/src/bloomy/operations/async_/headlines.py b/src/bloomy/operations/async_/headlines.py index d6dcdb1..cfaef2e 100644 --- a/src/bloomy/operations/async_/headlines.py +++ b/src/bloomy/operations/async_/headlines.py @@ -9,27 +9,18 @@ HeadlineDetails, HeadlineInfo, HeadlineListItem, - MeetingInfo, OwnerDetails, ) from ...utils.async_base_operations import AsyncBaseOperations +from ..mixins.headlines_transform import HeadlineOperationsMixin if TYPE_CHECKING: - import httpx + pass -class AsyncHeadlineOperations(AsyncBaseOperations): +class AsyncHeadlineOperations(AsyncBaseOperations, HeadlineOperationsMixin): """Async class to handle all operations related to headlines.""" - def __init__(self, client: httpx.AsyncClient) -> None: - """Initialize the async headline operations. - - Args: - client: The async HTTP client to use for API requests. - - """ - super().__init__(client) - async def create( self, meeting_id: int, @@ -97,22 +88,7 @@ async def details(self, headline_id: int) -> HeadlineDetails: response.raise_for_status() data = response.json() - return HeadlineDetails( - id=data["Id"], - title=data["Name"], - notes_url=data["DetailsUrl"], - meeting_details=MeetingInfo( - id=data["OriginId"], - title=data["Origin"], - ), - owner_details=OwnerDetails( - id=data["Owner"]["Id"], - name=data["Owner"]["Name"], - ), - archived=data["Archived"], - created_at=data["CreateTime"], - closed_at=data["CloseTime"], - ) + return self._transform_headline_details(data) async def list( self, user_id: int | None = None, meeting_id: int | None = None @@ -143,24 +119,7 @@ async def list( response.raise_for_status() data = response.json() - return [ - HeadlineListItem( - id=headline["Id"], - title=headline["Name"], - meeting_details=MeetingInfo( - id=headline["OriginId"], - title=headline["Origin"], - ), - owner_details=OwnerDetails( - id=headline["Owner"]["Id"], - name=headline["Owner"]["Name"], - ), - archived=headline["Archived"], - created_at=headline["CreateTime"], - closed_at=headline["CloseTime"], - ) - for headline in data - ] + return self._transform_headline_list(data) async def delete(self, headline_id: int) -> None: """Delete a headline. diff --git a/src/bloomy/operations/async_/issues.py b/src/bloomy/operations/async_/issues.py index 2336959..90c2117 100644 --- a/src/bloomy/operations/async_/issues.py +++ b/src/bloomy/operations/async_/issues.py @@ -2,37 +2,25 @@ from __future__ import annotations -import asyncio import builtins from typing import TYPE_CHECKING from ...models import ( - BulkCreateError, BulkCreateResult, CreatedIssue, IssueDetails, IssueListItem, ) from ...utils.async_base_operations import AsyncBaseOperations +from ..mixins.issues_transform import IssueOperationsMixin if TYPE_CHECKING: from typing import Any - import httpx - -class AsyncIssueOperations(AsyncBaseOperations): +class AsyncIssueOperations(AsyncBaseOperations, IssueOperationsMixin): """Async class to handle all operations related to issues.""" - def __init__(self, client: httpx.AsyncClient) -> None: - """Initialize the async issue operations. - - Args: - client: The async HTTP client to use for API requests. - - """ - super().__init__(client) - async def details(self, issue_id: int) -> IssueDetails: """Retrieve detailed information about a specific issue. @@ -48,17 +36,7 @@ async def details(self, issue_id: int) -> IssueDetails: response.raise_for_status() data = response.json() - return IssueDetails( - id=data["Id"], - title=data["Name"], - notes_url=data["DetailsUrl"], - created_at=data["CreateTime"], - completed_at=data["CloseTime"], - meeting_id=data["OriginId"], - meeting_title=data["Origin"], - user_id=data["Owner"]["Id"], - user_name=data["Owner"]["Name"], - ) + return self._transform_issue_details(data) async def list( self, user_id: int | None = None, meeting_id: int | None = None @@ -91,17 +69,7 @@ async def list( response.raise_for_status() data = response.json() - return [ - IssueListItem( - id=issue["Id"], - title=issue["Name"], - notes_url=issue["DetailsUrl"], - created_at=issue["CreateTime"], - meeting_id=issue["OriginId"], - meeting_title=issue["Origin"], - ) - for issue in data - ] + return self._transform_issue_list(data) async def complete(self, issue_id: int) -> IssueDetails: """Mark an issue as completed/solved. @@ -202,14 +170,7 @@ async def create( response.raise_for_status() data = response.json() - return CreatedIssue( - id=data["Id"], - meeting_id=data["OriginId"], - meeting_title=data["Origin"], - title=data["Name"], - user_id=data["Owner"]["Id"], - notes_url=data["DetailsUrl"], - ) + return self._transform_created_issue(data) async def create_many( self, issues: builtins.list[dict[str, Any]], max_concurrent: int = 5 @@ -247,68 +208,18 @@ async def create_many( ``` """ - # Create a semaphore to limit concurrent requests - semaphore = asyncio.Semaphore(max_concurrent) - - async def create_single_issue( - index: int, issue_data: dict[str, Any] - ) -> tuple[int, CreatedIssue | BulkCreateError]: - """Create a single issue with error handling. - - Returns: - Tuple of (index, result) where result is either CreatedIssue - or BulkCreateError. - - Raises: - ValueError: When required parameters are missing. - - """ - async with semaphore: - try: - # Extract parameters from the issue data - meeting_id = issue_data.get("meeting_id") - title = issue_data.get("title") - user_id = issue_data.get("user_id") - notes = issue_data.get("notes") - - # Validate required parameters - if meeting_id is None: - raise ValueError("meeting_id is required") - if title is None: - raise ValueError("title is required") - - # Create the issue - created_issue = await self.create( - meeting_id=meeting_id, title=title, user_id=user_id, notes=notes - ) - return (index, created_issue) - - except Exception as e: - error = BulkCreateError( - index=index, input_data=issue_data, error=str(e) - ) - return (index, error) - - # Create tasks for all issues - tasks = [ - create_single_issue(index, issue_data) - for index, issue_data in enumerate(issues) - ] - - # Execute all tasks concurrently - results = await asyncio.gather(*tasks) - - # Sort results to maintain order - results.sort(key=lambda x: x[0]) - - # Separate successful and failed results - successful: builtins.list[CreatedIssue] = [] - failed: builtins.list[BulkCreateError] = [] - - for _, result in results: - if isinstance(result, CreatedIssue): - successful.append(result) - else: - failed.append(result) - - return BulkCreateResult(successful=successful, failed=failed) + + async def _create_single(data: dict[str, Any]) -> CreatedIssue: + return await self.create( + meeting_id=data["meeting_id"], + title=data["title"], + user_id=data.get("user_id"), + notes=data.get("notes"), + ) + + return await self._process_bulk_async( + issues, + _create_single, + required_fields=["meeting_id", "title"], + max_concurrent=max_concurrent, + ) diff --git a/src/bloomy/operations/async_/meetings.py b/src/bloomy/operations/async_/meetings.py index d628c54..a7591fb 100644 --- a/src/bloomy/operations/async_/meetings.py +++ b/src/bloomy/operations/async_/meetings.py @@ -18,12 +18,13 @@ Todo, ) from ...utils.async_base_operations import AsyncBaseOperations +from ..mixins.meetings_transform import MeetingOperationsMixin if TYPE_CHECKING: - import httpx + pass -class AsyncMeetingOperations(AsyncBaseOperations): +class AsyncMeetingOperations(AsyncBaseOperations, MeetingOperationsMixin): """Async class to handle all operations related to meetings. Note: @@ -32,15 +33,6 @@ class AsyncMeetingOperations(AsyncBaseOperations): """ - def __init__(self, client: httpx.AsyncClient) -> None: - """Initialize the async meeting operations. - - Args: - client: The async HTTP client to use for API requests. - - """ - super().__init__(client) - async def list(self, user_id: int | None = None) -> builtins.list[MeetingListItem]: """List all meetings for a specific user. @@ -87,17 +79,7 @@ async def attendees(self, meeting_id: int) -> builtins.list[MeetingAttendee]: response.raise_for_status() data: Any = response.json() - # Map Id to UserId for compatibility - return [ - MeetingAttendee.model_validate( - { - "UserId": attendee["Id"], - "Name": attendee["Name"], - "ImageUrl": attendee["ImageUrl"], - } - ) - for attendee in data - ] + return self._transform_attendees(data) async def issues( self, meeting_id: int, include_closed: bool = False @@ -126,25 +108,7 @@ async def issues( response.raise_for_status() data: Any = response.json() - # Map meeting issue format to Issue model format - return [ - Issue.model_validate( - { - "Id": issue["Id"], - "Name": issue["Name"], - "DetailsUrl": issue.get("DetailsUrl"), - "CreateDate": issue["CreateTime"], - "MeetingId": issue["OriginId"], - "MeetingName": issue["Origin"], - "OwnerName": issue["Owner"]["Name"], - "OwnerId": issue["Owner"]["Id"], - "OwnerImageUrl": issue["Owner"]["ImageUrl"], - "ClosedDate": issue.get("CloseTime"), - "CompletionDate": issue.get("CompleteTime"), - } - ) - for issue in data - ] + return self._transform_meeting_issues(data, meeting_id) async def todos( self, meeting_id: int, include_closed: bool = False @@ -195,45 +159,7 @@ async def metrics(self, meeting_id: int) -> builtins.list[ScorecardMetric]: response.raise_for_status() raw_data = response.json() - if not isinstance(raw_data, list): - return [] - - metrics: list[ScorecardMetric] = [] - # Type the list explicitly - data_list: list[Any] = raw_data # type: ignore[assignment] - for item in data_list: - if not isinstance(item, dict): - continue - - # Cast to Any dict to satisfy type checker - item_dict: dict[str, Any] = item # type: ignore[assignment] - measurable_id = item_dict.get("Id") - measurable_name = item_dict.get("Name") - - if not measurable_id or not measurable_name: - continue - - owner_data = item_dict.get("Owner", {}) - if not isinstance(owner_data, dict): - owner_data = {} - owner_dict: dict[str, Any] = owner_data # type: ignore[assignment] - - metrics.append( - ScorecardMetric( - Id=int(measurable_id), - Title=str(measurable_name).strip(), - Target=float(item_dict.get("Target", 0)), - Unit=str(item_dict.get("Modifiers", "")), - WeekNumber=0, # Not provided in this endpoint - Value=None, - MetricType=str(item_dict.get("Direction", "")), - AccountableUserId=int(owner_dict.get("Id") or 0), - AccountableUserName=str(owner_dict.get("Name") or ""), - IsInverse=False, - ) - ) - - return metrics + return self._transform_metrics(raw_data) async def details( self, meeting_id: int, include_closed: bool = False @@ -396,68 +322,20 @@ async def create_many( ``` """ - # Create a semaphore to limit concurrent requests - semaphore = asyncio.Semaphore(max_concurrent) - - async def create_single_meeting( - index: int, meeting_data: dict[str, Any] - ) -> tuple[int, dict[str, Any] | BulkCreateError]: - """Create a single meeting with error handling. - - Returns: - Tuple of (index, result) where result is either dict or - BulkCreateError. - - Raises: - ValueError: When required parameters are missing. - - """ - async with semaphore: - try: - # Extract parameters from the meeting data - title = meeting_data.get("title") - add_self = meeting_data.get("add_self", True) - attendees = meeting_data.get("attendees") - - # Validate required parameters - if title is None: - raise ValueError("title is required") - - # Create the meeting - created_meeting = await self.create( - title=title, add_self=add_self, attendees=attendees - ) - return (index, created_meeting) - - except Exception as e: - error = BulkCreateError( - index=index, input_data=meeting_data, error=str(e) - ) - return (index, error) - - # Create tasks for all meetings - tasks = [ - create_single_meeting(index, meeting_data) - for index, meeting_data in enumerate(meetings) - ] - - # Execute all tasks concurrently - results = await asyncio.gather(*tasks) - # Sort results to maintain order - results.sort(key=lambda x: x[0]) - - # Separate successful and failed results - successful: builtins.list[dict[str, Any]] = [] - failed: builtins.list[BulkCreateError] = [] - - for _, result in results: - if isinstance(result, dict): - successful.append(result) - else: - failed.append(result) + async def _create_single(data: dict[str, Any]) -> dict[str, Any]: + return await self.create( + title=data["title"], + add_self=data.get("add_self", True), + attendees=data.get("attendees"), + ) - return BulkCreateResult(successful=successful, failed=failed) + return await self._process_bulk_async( + meetings, + _create_single, + required_fields=["title"], + max_concurrent=max_concurrent, + ) async def get_many( self, meeting_ids: list[int], max_concurrent: int = 5 diff --git a/src/bloomy/operations/async_/scorecard.py b/src/bloomy/operations/async_/scorecard.py index 69a10f0..71b8259 100644 --- a/src/bloomy/operations/async_/scorecard.py +++ b/src/bloomy/operations/async_/scorecard.py @@ -3,27 +3,14 @@ from __future__ import annotations import builtins -from typing import TYPE_CHECKING from ...models import ScorecardItem, ScorecardWeek from ...utils.async_base_operations import AsyncBaseOperations -if TYPE_CHECKING: - import httpx - class AsyncScorecardOperations(AsyncBaseOperations): """Async class to handle all operations related to scorecards.""" - def __init__(self, client: httpx.AsyncClient) -> None: - """Initialize the async scorecard operations. - - Args: - client: The async HTTP client to use for API requests. - - """ - super().__init__(client) - async def current_week(self) -> ScorecardWeek: """Retrieve the current week details. diff --git a/src/bloomy/operations/async_/todos.py b/src/bloomy/operations/async_/todos.py index 033116b..e350e2b 100644 --- a/src/bloomy/operations/async_/todos.py +++ b/src/bloomy/operations/async_/todos.py @@ -2,21 +2,19 @@ from __future__ import annotations -import asyncio import builtins from datetime import datetime from typing import TYPE_CHECKING -from ...models import BulkCreateError, BulkCreateResult, Todo +from ...models import BulkCreateResult, Todo from ...utils.async_base_operations import AsyncBaseOperations +from ..mixins.todos_transform import TodoOperationsMixin if TYPE_CHECKING: from typing import Any - import httpx - -class AsyncTodoOperations(AsyncBaseOperations): +class AsyncTodoOperations(AsyncBaseOperations, TodoOperationsMixin): """Async class to handle all operations related to todos. Note: @@ -25,15 +23,6 @@ class AsyncTodoOperations(AsyncBaseOperations): """ - def __init__(self, client: httpx.AsyncClient) -> None: - """Initialize the async todo operations. - - Args: - client: The async HTTP client to use for API requests. - - """ - super().__init__(client) - async def list( self, user_id: int | None = None, meeting_id: int | None = None ) -> builtins.list[Todo]: @@ -112,30 +101,13 @@ async def create( if user_id is None: user_id = await self.get_user_id() - payload: dict[str, Any] = { - "title": title, - "accountableUserId": user_id, - } - - if notes is not None: - payload["notes"] = notes - - if due_date is not None: - payload["dueDate"] = due_date - if meeting_id is not None: - # Meeting todo - use the correct endpoint with PascalCase keys - payload = { - "Title": title, - "ForId": user_id, - } - if notes is not None: - payload["Notes"] = notes - if due_date is not None: - payload["dueDate"] = due_date + # Meeting todo + payload = self._build_meeting_todo_payload(title, user_id, notes, due_date) response = await self._client.post(f"L10/{meeting_id}/todos", json=payload) else: # User todo + payload = self._build_user_todo_payload(title, user_id, notes, due_date) response = await self._client.post("todo/create", json=payload) response.raise_for_status() @@ -194,7 +166,6 @@ async def update( Raises: ValueError: If no update fields are provided - RuntimeError: If the update request fails Example: ```python @@ -217,9 +188,7 @@ async def update( raise ValueError("At least one field must be provided") response = await self._client.put(f"todo/{todo_id}", json=payload) - - if response.status_code != 200: - raise RuntimeError(f"Failed to update todo. Status: {response.status_code}") + response.raise_for_status() # Fetch the updated todo details return await self.details(todo_id) @@ -233,9 +202,6 @@ async def details(self, todo_id: int) -> Todo: Returns: A Todo model instance containing the todo details - Raises: - RuntimeError: If the request to retrieve the todo details fails - Example: ```python await client.todo.details(1) @@ -244,12 +210,6 @@ async def details(self, todo_id: int) -> Todo: """ response = await self._client.get(f"todo/{todo_id}") - - if not response.is_success: - raise RuntimeError( - f"Failed to get todo details. Status: {response.status_code}" - ) - response.raise_for_status() todo = response.json() @@ -292,73 +252,19 @@ async def create_many( ``` """ - # Create a semaphore to limit concurrent requests - semaphore = asyncio.Semaphore(max_concurrent) - - async def create_single_todo( - index: int, todo_data: dict[str, Any] - ) -> tuple[int, Todo | BulkCreateError]: - """Create a single todo with error handling. - - Returns: - Tuple of (index, result) where result is either Todo or - BulkCreateError. - - Raises: - ValueError: When required parameters are missing. - - """ - async with semaphore: - try: - # Extract parameters from the todo data - title = todo_data.get("title") - meeting_id = todo_data.get("meeting_id") - due_date = todo_data.get("due_date") - user_id = todo_data.get("user_id") - notes = todo_data.get("notes") - - # Validate required parameters - if title is None: - raise ValueError("title is required") - if meeting_id is None: - raise ValueError("meeting_id is required") - - # Create the todo - created_todo = await self.create( - title=title, - meeting_id=meeting_id, - due_date=due_date, - user_id=user_id, - notes=notes, - ) - return (index, created_todo) - - except Exception as e: - error = BulkCreateError( - index=index, input_data=todo_data, error=str(e) - ) - return (index, error) - - # Create tasks for all todos - tasks = [ - create_single_todo(index, todo_data) - for index, todo_data in enumerate(todos) - ] - - # Execute all tasks concurrently - results = await asyncio.gather(*tasks) - - # Sort results to maintain order - results.sort(key=lambda x: x[0]) - - # Separate successful and failed results - successful: builtins.list[Todo] = [] - failed: builtins.list[BulkCreateError] = [] - - for _, result in results: - if isinstance(result, Todo): - successful.append(result) - else: - failed.append(result) - - return BulkCreateResult(successful=successful, failed=failed) + + async def _create_single(data: dict[str, Any]) -> Todo: + return await self.create( + title=data["title"], + meeting_id=data["meeting_id"], + due_date=data.get("due_date"), + user_id=data.get("user_id"), + notes=data.get("notes"), + ) + + return await self._process_bulk_async( + todos, + _create_single, + required_fields=["title", "meeting_id"], + max_concurrent=max_concurrent, + ) diff --git a/src/bloomy/operations/async_/users.py b/src/bloomy/operations/async_/users.py index 9cd9dab..0c3caad 100644 --- a/src/bloomy/operations/async_/users.py +++ b/src/bloomy/operations/async_/users.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...models import ( DirectReport, Position, @@ -12,24 +10,12 @@ UserSearchResult, ) from ...utils.async_base_operations import AsyncBaseOperations -from ..mixins.users import UserOperationsMixin - -if TYPE_CHECKING: - import httpx +from ..mixins.users_transform import UserOperationsMixin class AsyncUserOperations(AsyncBaseOperations, UserOperationsMixin): """Async class to handle all operations related to users.""" - def __init__(self, client: httpx.AsyncClient) -> None: - """Initialize the async user operations. - - Args: - client: The async HTTP client to use for API requests. - - """ - super().__init__(client) - async def details( self, user_id: int | None = None, diff --git a/src/bloomy/operations/goals.py b/src/bloomy/operations/goals.py index 12d1bc2..be73c7c 100644 --- a/src/bloomy/operations/goals.py +++ b/src/bloomy/operations/goals.py @@ -7,7 +7,6 @@ from ..models import ( ArchivedGoalInfo, - BulkCreateError, BulkCreateResult, CreatedGoalInfo, GoalInfo, @@ -15,12 +14,13 @@ GoalStatus, ) from ..utils.base_operations import BaseOperations +from .mixins.goals_transform import GoalOperationsMixin if TYPE_CHECKING: from typing import Any -class GoalOperations(BaseOperations): +class GoalOperations(BaseOperations, GoalOperationsMixin): """Class to handle all the operations related to goals (also known as "rocks"). Note: @@ -70,22 +70,7 @@ def list( response.raise_for_status() data = response.json() - active_goals: list[GoalInfo] = [ - GoalInfo( - id=goal["Id"], - user_id=goal["Owner"]["Id"], - user_name=goal["Owner"]["Name"], - title=goal["Name"], - created_at=goal["CreateTime"], - due_date=goal["DueDate"], - status="Completed" if goal.get("Complete") else "Incomplete", - meeting_id=goal["Origins"][0]["Id"] if goal.get("Origins") else None, - meeting_title=( - goal["Origins"][0]["Name"] if goal.get("Origins") else None - ), - ) - for goal in data - ] + active_goals = self._transform_goal_list(data) if archived: archived_goals = self._get_archived_goals(user_id) @@ -122,20 +107,7 @@ def create( response.raise_for_status() data = response.json() - # Map completion status - completion_map = {2: "complete", 1: "on", 0: "off"} - status = completion_map.get(data.get("Completion", 0), "off") - - return CreatedGoalInfo( - id=data["Id"], - user_id=user_id, - user_name=data["Owner"]["Name"], - title=title, - meeting_id=meeting_id, - meeting_title=data["Origins"][0]["Name"], - status=status, - created_at=data["CreateTime"], - ) + return self._transform_created_goal(data, title, meeting_id, user_id) def delete(self, goal_id: int) -> None: """Delete a goal. @@ -169,9 +141,7 @@ def update( status: The status value. Can be a GoalStatus enum member or string ('on', 'off', or 'complete'). Use GoalStatus.ON_TRACK, GoalStatus.AT_RISK, or GoalStatus.COMPLETE for type safety. - - Raises: - ValueError: If an invalid status value is provided + Invalid values will raise ValueError via the update payload builder. Example: ```python @@ -188,21 +158,7 @@ def update( if accountable_user is None: accountable_user = self.user_id - payload: dict[str, Any] = {"accountableUserId": accountable_user} - - if title is not None: - payload["title"] = title - - if status is not None: - valid_status = {"on": "OnTrack", "off": "AtRisk", "complete": "Complete"} - # Handle both GoalStatus enum and string - status_value = status.value if isinstance(status, GoalStatus) else status - status_key = status_value.lower() - if status_key not in valid_status: - raise ValueError( - "Invalid status value. Must be 'on', 'off', or 'complete'." - ) - payload["completion"] = valid_status[status_key] + payload = self._build_goal_update_payload(accountable_user, title, status) response = self._client.put(f"rocks/{goal_id}", json=payload) response.raise_for_status() @@ -261,16 +217,7 @@ def _get_archived_goals(self, user_id: int | None = None) -> list[ArchivedGoalIn response.raise_for_status() data = response.json() - return [ - ArchivedGoalInfo( - id=goal["Id"], - title=goal["Name"], - created_at=goal["CreateTime"], - due_date=goal["DueDate"], - status="Complete" if goal.get("Complete") else "Incomplete", - ) - for goal in data - ] + return self._transform_archived_goals(data) def create_many( self, goals: builtins.list[dict[str, Any]] @@ -292,9 +239,6 @@ def create_many( - successful: List of CreatedGoalInfo instances for successful creations - failed: List of BulkCreateError instances for failed creations - Raises: - ValueError: When required parameters are missing in goal data - Example: ```python result = client.goal.create_many([ @@ -308,31 +252,14 @@ def create_many( ``` """ - successful: builtins.list[CreatedGoalInfo] = [] - failed: builtins.list[BulkCreateError] = [] - - for index, goal_data in enumerate(goals): - try: - # Extract parameters from the goal data - title = goal_data.get("title") - meeting_id = goal_data.get("meeting_id") - user_id = goal_data.get("user_id") - - # Validate required parameters - if title is None: - raise ValueError("title is required") - if meeting_id is None: - raise ValueError("meeting_id is required") - - # Create the goal - created_goal = self.create( - title=title, meeting_id=meeting_id, user_id=user_id - ) - successful.append(created_goal) - - except Exception as e: - failed.append( - BulkCreateError(index=index, input_data=goal_data, error=str(e)) - ) - - return BulkCreateResult(successful=successful, failed=failed) + + def _create_single(data: dict[str, Any]) -> CreatedGoalInfo: + return self.create( + title=data["title"], + meeting_id=data["meeting_id"], + user_id=data.get("user_id"), + ) + + return self._process_bulk_sync( + goals, _create_single, required_fields=["title", "meeting_id"] + ) diff --git a/src/bloomy/operations/headlines.py b/src/bloomy/operations/headlines.py index f0763fb..77f8bad 100644 --- a/src/bloomy/operations/headlines.py +++ b/src/bloomy/operations/headlines.py @@ -8,13 +8,13 @@ HeadlineDetails, HeadlineInfo, HeadlineListItem, - MeetingInfo, OwnerDetails, ) from ..utils.base_operations import BaseOperations +from .mixins.headlines_transform import HeadlineOperationsMixin -class HeadlineOperations(BaseOperations): +class HeadlineOperations(BaseOperations, HeadlineOperationsMixin): """Class to handle all operations related to headlines.""" def create( @@ -84,22 +84,7 @@ def details(self, headline_id: int) -> HeadlineDetails: response.raise_for_status() data = response.json() - return HeadlineDetails( - id=data["Id"], - title=data["Name"], - notes_url=data["DetailsUrl"], - meeting_details=MeetingInfo( - id=data["OriginId"], - title=data["Origin"], - ), - owner_details=OwnerDetails( - id=data["Owner"]["Id"], - name=data["Owner"]["Name"], - ), - archived=data["Archived"], - created_at=data["CreateTime"], - closed_at=data["CloseTime"], - ) + return self._transform_headline_details(data) def list( self, user_id: int | None = None, meeting_id: int | None = None @@ -153,24 +138,7 @@ def list( response.raise_for_status() data = response.json() - return [ - HeadlineListItem( - id=headline["Id"], - title=headline["Name"], - meeting_details=MeetingInfo( - id=headline["OriginId"], - title=headline["Origin"], - ), - owner_details=OwnerDetails( - id=headline["Owner"]["Id"], - name=headline["Owner"]["Name"], - ), - archived=headline["Archived"], - created_at=headline["CreateTime"], - closed_at=headline["CloseTime"], - ) - for headline in data - ] + return self._transform_headline_list(data) def delete(self, headline_id: int) -> None: """Delete a headline. diff --git a/src/bloomy/operations/issues.py b/src/bloomy/operations/issues.py index 742386e..a5f75c9 100644 --- a/src/bloomy/operations/issues.py +++ b/src/bloomy/operations/issues.py @@ -6,16 +6,16 @@ from typing import Any from ..models import ( - BulkCreateError, BulkCreateResult, CreatedIssue, IssueDetails, IssueListItem, ) from ..utils.base_operations import BaseOperations +from .mixins.issues_transform import IssueOperationsMixin -class IssueOperations(BaseOperations): +class IssueOperations(BaseOperations, IssueOperationsMixin): """Class to handle all operations related to issues. Provides functionality to create, retrieve, list, and solve issues @@ -44,17 +44,7 @@ def details(self, issue_id: int) -> IssueDetails: response.raise_for_status() data = response.json() - return IssueDetails( - id=data["Id"], - title=data["Name"], - notes_url=data["DetailsUrl"], - created_at=data["CreateTime"], - completed_at=data["CloseTime"], - meeting_id=data["OriginId"], - meeting_title=data["Origin"], - user_id=data["Owner"]["Id"], - user_name=data["Owner"]["Name"], - ) + return self._transform_issue_details(data) def list( self, user_id: int | None = None, meeting_id: int | None = None @@ -98,17 +88,7 @@ def list( response.raise_for_status() data = response.json() - return [ - IssueListItem( - id=issue["Id"], - title=issue["Name"], - notes_url=issue["DetailsUrl"], - created_at=issue["CreateTime"], - meeting_id=issue["OriginId"], - meeting_title=issue["Origin"], - ) - for issue in data - ] + return self._transform_issue_list(data) def complete(self, issue_id: int) -> IssueDetails: """Mark an issue as completed/solved. @@ -219,14 +199,7 @@ def create( response.raise_for_status() data = response.json() - return CreatedIssue( - id=data["Id"], - meeting_id=data["OriginId"], - meeting_title=data["Origin"], - title=data["Name"], - user_id=data["Owner"]["Id"], - notes_url=data["DetailsUrl"], - ) + return self._transform_created_issue(data) def create_many( self, issues: builtins.list[dict[str, Any]] @@ -248,9 +221,6 @@ def create_many( - successful: List of CreatedIssue instances for successful creations - failed: List of BulkCreateError instances for failed creations - Raises: - ValueError: When required parameters are missing in issue data - Example: ```python result = client.issue.create_many([ @@ -264,32 +234,15 @@ def create_many( ``` """ - successful: builtins.list[CreatedIssue] = [] - failed: builtins.list[BulkCreateError] = [] - - for index, issue_data in enumerate(issues): - try: - # Extract parameters from the issue data - meeting_id = issue_data.get("meeting_id") - title = issue_data.get("title") - user_id = issue_data.get("user_id") - notes = issue_data.get("notes") - - # Validate required parameters - if meeting_id is None: - raise ValueError("meeting_id is required") - if title is None: - raise ValueError("title is required") - - # Create the issue - created_issue = self.create( - meeting_id=meeting_id, title=title, user_id=user_id, notes=notes - ) - successful.append(created_issue) - - except Exception as e: - failed.append( - BulkCreateError(index=index, input_data=issue_data, error=str(e)) - ) - - return BulkCreateResult(successful=successful, failed=failed) + + def _create_single(data: dict[str, Any]) -> CreatedIssue: + return self.create( + meeting_id=data["meeting_id"], + title=data["title"], + user_id=data.get("user_id"), + notes=data.get("notes"), + ) + + return self._process_bulk_sync( + issues, _create_single, required_fields=["meeting_id", "title"] + ) diff --git a/src/bloomy/operations/meetings.py b/src/bloomy/operations/meetings.py index e11a188..fd93efc 100644 --- a/src/bloomy/operations/meetings.py +++ b/src/bloomy/operations/meetings.py @@ -17,9 +17,10 @@ Todo, ) from ..utils.base_operations import BaseOperations +from .mixins.meetings_transform import MeetingOperationsMixin -class MeetingOperations(BaseOperations): +class MeetingOperations(BaseOperations, MeetingOperationsMixin): """Class to handle all operations related to meetings. Note: @@ -74,14 +75,7 @@ def attendees(self, meeting_id: int) -> builtins.list[MeetingAttendee]: response.raise_for_status() data: Any = response.json() - return [ - MeetingAttendee( - UserId=attendee["Id"], - Name=attendee["Name"], - ImageUrl=attendee.get("ImageUrl", ""), - ) - for attendee in data - ] + return self._transform_attendees(data) def issues( self, meeting_id: int, include_closed: bool = False @@ -110,22 +104,7 @@ def issues( response.raise_for_status() data: Any = response.json() - return [ - Issue( - Id=issue["Id"], - Name=issue["Name"], - DetailsUrl=issue["DetailsUrl"], - CreateDate=issue["CreateTime"], - ClosedDate=issue["CloseTime"], - CompletionDate=issue.get("CompleteTime"), - OwnerId=issue.get("Owner", {}).get("Id", 0), - OwnerName=issue.get("Owner", {}).get("Name", ""), - OwnerImageUrl=issue.get("Owner", {}).get("ImageUrl", ""), - MeetingId=meeting_id, - MeetingName=issue["Origin"], - ) - for issue in data - ] + return self._transform_meeting_issues(data, meeting_id) def todos( self, meeting_id: int, include_closed: bool = False @@ -176,45 +155,7 @@ def metrics(self, meeting_id: int) -> builtins.list[ScorecardMetric]: response.raise_for_status() raw_data = response.json() - if not isinstance(raw_data, list): - return [] - - metrics: list[ScorecardMetric] = [] - # Type the list explicitly - data_list: list[Any] = raw_data # type: ignore[assignment] - for item in data_list: - if not isinstance(item, dict): - continue - - # Cast to Any dict to satisfy type checker - item_dict: dict[str, Any] = item # type: ignore[assignment] - measurable_id = item_dict.get("Id") - measurable_name = item_dict.get("Name") - - if not measurable_id or not measurable_name: - continue - - owner_data = item_dict.get("Owner", {}) - if not isinstance(owner_data, dict): - owner_data = {} - owner_dict: dict[str, Any] = owner_data # type: ignore[assignment] - - metrics.append( - ScorecardMetric( - Id=int(measurable_id), - Title=str(measurable_name).strip(), - Target=float(item_dict.get("Target", 0)), - Unit=str(item_dict.get("Modifiers", "")), - WeekNumber=0, # Not provided in this endpoint - Value=None, - MetricType=str(item_dict.get("Direction", "")), - AccountableUserId=int(owner_dict.get("Id") or 0), - AccountableUserName=str(owner_dict.get("Name") or ""), - IsInverse=False, - ) - ) - - return metrics + return self._transform_metrics(raw_data) def details(self, meeting_id: int, include_closed: bool = False) -> MeetingDetails: """Retrieve details of a specific meeting. @@ -338,9 +279,6 @@ def create_many( - successful: List of dicts with meeting_id, title, and attendees - failed: List of BulkCreateError instances for failed creations - Raises: - ValueError: When required parameters are missing in meeting data - Example: ```python result = client.meeting.create_many([ @@ -354,32 +292,17 @@ def create_many( ``` """ - successful: builtins.list[dict[str, Any]] = [] - failed: builtins.list[BulkCreateError] = [] - for index, meeting_data in enumerate(meetings): - try: - # Extract parameters from the meeting data - title = meeting_data.get("title") - add_self = meeting_data.get("add_self", True) - attendees = meeting_data.get("attendees") - - # Validate required parameters - if title is None: - raise ValueError("title is required") - - # Create the meeting - created_meeting = self.create( - title=title, add_self=add_self, attendees=attendees - ) - successful.append(created_meeting) - - except Exception as e: - failed.append( - BulkCreateError(index=index, input_data=meeting_data, error=str(e)) - ) + def _create_single(data: dict[str, Any]) -> dict[str, Any]: + return self.create( + title=data["title"], + add_self=data.get("add_self", True), + attendees=data.get("attendees"), + ) - return BulkCreateResult(successful=successful, failed=failed) + return self._process_bulk_sync( + meetings, _create_single, required_fields=["title"] + ) def get_many(self, meeting_ids: list[int]) -> BulkCreateResult[MeetingDetails]: """Retrieve details for multiple meetings in a best-effort manner. diff --git a/src/bloomy/operations/mixins/__init__.py b/src/bloomy/operations/mixins/__init__.py index 6fd6653..5034627 100644 --- a/src/bloomy/operations/mixins/__init__.py +++ b/src/bloomy/operations/mixins/__init__.py @@ -1,7 +1,17 @@ """Mixins for shared operation logic.""" -from .users import UserOperationsMixin +from .goals_transform import GoalOperationsMixin +from .headlines_transform import HeadlineOperationsMixin +from .issues_transform import IssueOperationsMixin +from .meetings_transform import MeetingOperationsMixin +from .todos_transform import TodoOperationsMixin +from .users_transform import UserOperationsMixin __all__ = [ + "GoalOperationsMixin", + "HeadlineOperationsMixin", + "IssueOperationsMixin", + "MeetingOperationsMixin", + "TodoOperationsMixin", "UserOperationsMixin", ] diff --git a/src/bloomy/operations/mixins/goals_transform.py b/src/bloomy/operations/mixins/goals_transform.py new file mode 100644 index 0000000..3720441 --- /dev/null +++ b/src/bloomy/operations/mixins/goals_transform.py @@ -0,0 +1,137 @@ +"""Mixin for shared goal operations logic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ...models import ( + ArchivedGoalInfo, + CreatedGoalInfo, + GoalInfo, + GoalStatus, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class GoalOperationsMixin: + """Shared logic for goal operations.""" + + def _transform_goal_list(self, data: Sequence[dict[str, Any]]) -> list[GoalInfo]: + """Transform API response to list of GoalInfo models. + + Args: + data: The raw API response data. + + Returns: + A list of GoalInfo models. + + """ + return [ + GoalInfo( + id=goal["Id"], + user_id=goal["Owner"]["Id"], + user_name=goal["Owner"]["Name"], + title=goal["Name"], + created_at=goal["CreateTime"], + due_date=goal["DueDate"], + status="Completed" if goal.get("Complete") else "Incomplete", + meeting_id=goal["Origins"][0]["Id"] if goal.get("Origins") else None, + meeting_title=( + goal["Origins"][0]["Name"] if goal.get("Origins") else None + ), + ) + for goal in data + ] + + def _transform_archived_goals( + self, data: Sequence[dict[str, Any]] + ) -> list[ArchivedGoalInfo]: + """Transform API response to list of ArchivedGoalInfo models. + + Args: + data: The raw API response data. + + Returns: + A list of ArchivedGoalInfo models. + + """ + return [ + ArchivedGoalInfo( + id=goal["Id"], + title=goal["Name"], + created_at=goal["CreateTime"], + due_date=goal["DueDate"], + status="Complete" if goal.get("Complete") else "Incomplete", + ) + for goal in data + ] + + def _transform_created_goal( + self, data: dict[str, Any], title: str, meeting_id: int, user_id: int + ) -> CreatedGoalInfo: + """Transform API response to CreatedGoalInfo model. + + Args: + data: The raw API response data. + title: The title of the goal. + meeting_id: The ID of the meeting associated with the goal. + user_id: The ID of the user responsible for the goal. + + Returns: + A CreatedGoalInfo model. + + """ + # Map completion status + completion_map = {2: "complete", 1: "on", 0: "off"} + status = completion_map.get(data.get("Completion", 0), "off") + + return CreatedGoalInfo( + id=data["Id"], + user_id=user_id, + user_name=data["Owner"]["Name"], + title=title, + meeting_id=meeting_id, + meeting_title=data["Origins"][0]["Name"], + status=status, + created_at=data["CreateTime"], + ) + + def _build_goal_update_payload( + self, + accountable_user: int, + title: str | None = None, + status: GoalStatus | str | None = None, + ) -> dict[str, Any]: + """Build payload for goal update operation. + + Args: + accountable_user: The ID of the user responsible for the goal. + title: The new title of the goal. + status: The status value (GoalStatus enum or string). + + Returns: + A dictionary containing the update payload. + + Raises: + ValueError: If an invalid status value is provided. + + """ + payload: dict[str, Any] = {"accountableUserId": accountable_user} + + if title is not None: + payload["title"] = title + + if status is not None: + valid_status = {"on": "OnTrack", "off": "AtRisk", "complete": "Complete"} + # Handle both GoalStatus enum and string + status_value = status.value if isinstance(status, GoalStatus) else status + status_key = status_value.lower() + if status_key not in valid_status: + raise ValueError( + "Invalid status value. Must be 'on', 'off', or 'complete'." + ) + payload["completion"] = valid_status[status_key] + + return payload diff --git a/src/bloomy/operations/mixins/headlines_transform.py b/src/bloomy/operations/mixins/headlines_transform.py new file mode 100644 index 0000000..d2bfffd --- /dev/null +++ b/src/bloomy/operations/mixins/headlines_transform.py @@ -0,0 +1,77 @@ +"""Mixin for shared headline operations logic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ...models import ( + HeadlineDetails, + HeadlineListItem, + MeetingInfo, + OwnerDetails, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class HeadlineOperationsMixin: + """Shared logic for headline operations.""" + + def _transform_headline_details(self, data: dict[str, Any]) -> HeadlineDetails: + """Transform API response to HeadlineDetails model. + + Args: + data: The raw API response data. + + Returns: + A HeadlineDetails model. + + """ + return HeadlineDetails( + id=data["Id"], + title=data["Name"], + notes_url=data["DetailsUrl"], + meeting_details=MeetingInfo( + id=data["OriginId"], + title=data["Origin"], + ), + owner_details=OwnerDetails( + id=data["Owner"]["Id"], + name=data["Owner"]["Name"], + ), + archived=data["Archived"], + created_at=data["CreateTime"], + closed_at=data["CloseTime"], + ) + + def _transform_headline_list( + self, data: Sequence[dict[str, Any]] + ) -> list[HeadlineListItem]: + """Transform API response to list of HeadlineListItem models. + + Args: + data: The raw API response data. + + Returns: + A list of HeadlineListItem models. + + """ + return [ + HeadlineListItem( + id=headline["Id"], + title=headline["Name"], + meeting_details=MeetingInfo( + id=headline["OriginId"], + title=headline["Origin"], + ), + owner_details=OwnerDetails( + id=headline["Owner"]["Id"], + name=headline["Owner"]["Name"], + ), + archived=headline["Archived"], + created_at=headline["CreateTime"], + closed_at=headline["CloseTime"], + ) + for headline in data + ] diff --git a/src/bloomy/operations/mixins/issues_transform.py b/src/bloomy/operations/mixins/issues_transform.py new file mode 100644 index 0000000..63c92e5 --- /dev/null +++ b/src/bloomy/operations/mixins/issues_transform.py @@ -0,0 +1,83 @@ +"""Mixin for shared issue operations logic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ...models import ( + CreatedIssue, + IssueDetails, + IssueListItem, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class IssueOperationsMixin: + """Shared logic for issue operations.""" + + def _transform_issue_details(self, data: dict[str, Any]) -> IssueDetails: + """Transform API response to IssueDetails model. + + Args: + data: The raw API response data. + + Returns: + An IssueDetails model. + + """ + return IssueDetails( + id=data["Id"], + title=data["Name"], + notes_url=data["DetailsUrl"], + created_at=data["CreateTime"], + completed_at=data["CloseTime"], + meeting_id=data["OriginId"], + meeting_title=data["Origin"], + user_id=data["Owner"]["Id"], + user_name=data["Owner"]["Name"], + ) + + def _transform_issue_list( + self, data: Sequence[dict[str, Any]] + ) -> list[IssueListItem]: + """Transform API response to list of IssueListItem models. + + Args: + data: The raw API response data. + + Returns: + A list of IssueListItem models. + + """ + return [ + IssueListItem( + id=issue["Id"], + title=issue["Name"], + notes_url=issue["DetailsUrl"], + created_at=issue["CreateTime"], + meeting_id=issue["OriginId"], + meeting_title=issue["Origin"], + ) + for issue in data + ] + + def _transform_created_issue(self, data: dict[str, Any]) -> CreatedIssue: + """Transform API response to CreatedIssue model. + + Args: + data: The raw API response data. + + Returns: + A CreatedIssue model. + + """ + return CreatedIssue( + id=data["Id"], + meeting_id=data["OriginId"], + meeting_title=data["Origin"], + title=data["Name"], + user_id=data["Owner"]["Id"], + notes_url=data["DetailsUrl"], + ) diff --git a/src/bloomy/operations/mixins/meetings_transform.py b/src/bloomy/operations/mixins/meetings_transform.py new file mode 100644 index 0000000..c457879 --- /dev/null +++ b/src/bloomy/operations/mixins/meetings_transform.py @@ -0,0 +1,119 @@ +"""Mixin for shared meeting operations logic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ...models import ( + Issue, + MeetingAttendee, + ScorecardMetric, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class MeetingOperationsMixin: + """Shared logic for meeting operations.""" + + def _transform_attendees( + self, data: Sequence[dict[str, Any]] + ) -> list[MeetingAttendee]: + """Transform API response to list of MeetingAttendee models. + + Args: + data: The raw API response data. + + Returns: + A list of MeetingAttendee models. + + """ + return [ + MeetingAttendee( + UserId=attendee["Id"], + Name=attendee["Name"], + ImageUrl=attendee.get("ImageUrl", ""), + ) + for attendee in data + ] + + def _transform_metrics(self, raw_data: Any) -> list[ScorecardMetric]: + """Transform API response to list of ScorecardMetric models. + + Args: + raw_data: The raw API response data. + + Returns: + A list of ScorecardMetric models. + + """ + if not isinstance(raw_data, list): + return [] + + metrics: list[ScorecardMetric] = [] + # Type the list explicitly + data_list: list[Any] = raw_data # type: ignore[assignment] + for item in data_list: + if not isinstance(item, dict): + continue + + # Cast to Any dict to satisfy type checker + item_dict: dict[str, Any] = item # type: ignore[assignment] + measurable_id = item_dict.get("Id") + measurable_name = item_dict.get("Name") + + if not measurable_id or not measurable_name: + continue + + owner_data = item_dict.get("Owner", {}) + if not isinstance(owner_data, dict): + owner_data = {} + owner_dict: dict[str, Any] = owner_data # type: ignore[assignment] + + metrics.append( + ScorecardMetric( + Id=int(measurable_id), + Title=str(measurable_name).strip(), + Target=float(item_dict.get("Target", 0)), + Unit=str(item_dict.get("Modifiers", "")), + WeekNumber=0, # Not provided in this endpoint + Value=None, + MetricType=str(item_dict.get("Direction", "")), + AccountableUserId=int(owner_dict.get("Id") or 0), + AccountableUserName=str(owner_dict.get("Name") or ""), + IsInverse=False, + ) + ) + + return metrics + + def _transform_meeting_issues( + self, data: Sequence[dict[str, Any]], meeting_id: int + ) -> list[Issue]: + """Transform API response to list of Issue models. + + Args: + data: The raw API response data. + meeting_id: The ID of the meeting. + + Returns: + A list of Issue models. + + """ + return [ + Issue( + Id=issue["Id"], + Name=issue["Name"], + DetailsUrl=issue["DetailsUrl"], + CreateDate=issue["CreateTime"], + ClosedDate=issue["CloseTime"], + CompletionDate=issue.get("CompleteTime"), + OwnerId=issue.get("Owner", {}).get("Id", 0), + OwnerName=issue.get("Owner", {}).get("Name", ""), + OwnerImageUrl=issue.get("Owner", {}).get("ImageUrl", ""), + MeetingId=meeting_id, + MeetingName=issue["Origin"], + ) + for issue in data + ] diff --git a/src/bloomy/operations/mixins/todos_transform.py b/src/bloomy/operations/mixins/todos_transform.py new file mode 100644 index 0000000..cf6107c --- /dev/null +++ b/src/bloomy/operations/mixins/todos_transform.py @@ -0,0 +1,71 @@ +"""Mixin for shared todo operations logic.""" + +from __future__ import annotations + +from typing import Any + + +class TodoOperationsMixin: + """Shared logic for todo operations.""" + + def _build_meeting_todo_payload( + self, + title: str, + user_id: int, + notes: str | None = None, + due_date: str | None = None, + ) -> dict[str, Any]: + """Build payload for creating a meeting todo. + + Args: + title: The title of the todo. + user_id: The ID of the user responsible for the todo. + notes: Additional notes for the todo. + due_date: The due date of the todo. + + Returns: + A dictionary containing the meeting todo payload. + + """ + payload: dict[str, Any] = { + "Title": title, + "ForId": user_id, + } + if notes is not None: + payload["Notes"] = notes + if due_date is not None: + payload["dueDate"] = due_date + + return payload + + def _build_user_todo_payload( + self, + title: str, + user_id: int, + notes: str | None = None, + due_date: str | None = None, + ) -> dict[str, Any]: + """Build payload for creating a user todo. + + Args: + title: The title of the todo. + user_id: The ID of the user responsible for the todo. + notes: Additional notes for the todo. + due_date: The due date of the todo. + + Returns: + A dictionary containing the user todo payload. + + """ + payload: dict[str, Any] = { + "title": title, + "accountableUserId": user_id, + } + + if notes is not None: + payload["notes"] = notes + + if due_date is not None: + payload["dueDate"] = due_date + + return payload diff --git a/src/bloomy/operations/mixins/users.py b/src/bloomy/operations/mixins/users_transform.py similarity index 100% rename from src/bloomy/operations/mixins/users.py rename to src/bloomy/operations/mixins/users_transform.py diff --git a/src/bloomy/operations/todos.py b/src/bloomy/operations/todos.py index 4c03fb4..bcaaf71 100644 --- a/src/bloomy/operations/todos.py +++ b/src/bloomy/operations/todos.py @@ -6,14 +6,15 @@ from datetime import datetime from typing import TYPE_CHECKING -from ..models import BulkCreateError, BulkCreateResult, Todo +from ..models import BulkCreateResult, Todo from ..utils.base_operations import BaseOperations +from .mixins.todos_transform import TodoOperationsMixin if TYPE_CHECKING: from typing import Any -class TodoOperations(BaseOperations): +class TodoOperations(BaseOperations, TodoOperationsMixin): """Class to handle all operations related to todos. Note: @@ -100,30 +101,13 @@ def create( if user_id is None: user_id = self.user_id - payload: dict[str, Any] = { - "title": title, - "accountableUserId": user_id, - } - - if notes is not None: - payload["notes"] = notes - - if due_date is not None: - payload["dueDate"] = due_date - if meeting_id is not None: - # Meeting todo - use the correct endpoint - payload = { - "Title": title, - "ForId": user_id, - } - if notes is not None: - payload["Notes"] = notes - if due_date is not None: - payload["dueDate"] = due_date + # Meeting todo + payload = self._build_meeting_todo_payload(title, user_id, notes, due_date) response = self._client.post(f"L10/{meeting_id}/todos", json=payload) else: # User todo + payload = self._build_user_todo_payload(title, user_id, notes, due_date) response = self._client.post("todo/create", json=payload) response.raise_for_status() @@ -182,7 +166,6 @@ def update( Raises: ValueError: If no update fields are provided - RuntimeError: If the update request fails Example: ```python @@ -205,9 +188,7 @@ def update( raise ValueError("At least one field must be provided") response = self._client.put(f"todo/{todo_id}", json=payload) - - if response.status_code != 200: - raise RuntimeError(f"Failed to update todo. Status: {response.status_code}") + response.raise_for_status() # Fetch the updated todo details return self.details(todo_id) @@ -221,9 +202,6 @@ def details(self, todo_id: int) -> Todo: Returns: A Todo model instance containing the todo details - Raises: - RuntimeError: If the request to retrieve the todo details fails - Example: ```python client.todo.details(1) @@ -232,12 +210,6 @@ def details(self, todo_id: int) -> Todo: """ response = self._client.get(f"todo/{todo_id}") - - if not response.is_success: - raise RuntimeError( - f"Failed to get todo details. Status: {response.status_code}" - ) - response.raise_for_status() todo = response.json() @@ -265,9 +237,6 @@ def create_many( - successful: List of Todo instances for successful creations - failed: List of BulkCreateError instances for failed creations - Raises: - ValueError: When required parameters are missing in todo data - Example: ```python result = client.todo.create_many([ @@ -281,37 +250,16 @@ def create_many( ``` """ - successful: builtins.list[Todo] = [] - failed: builtins.list[BulkCreateError] = [] - - for index, todo_data in enumerate(todos): - try: - # Extract parameters from the todo data - title = todo_data.get("title") - meeting_id = todo_data.get("meeting_id") - due_date = todo_data.get("due_date") - user_id = todo_data.get("user_id") - notes = todo_data.get("notes") - - # Validate required parameters - if title is None: - raise ValueError("title is required") - if meeting_id is None: - raise ValueError("meeting_id is required") - - # Create the todo - created_todo = self.create( - title=title, - meeting_id=meeting_id, - due_date=due_date, - user_id=user_id, - notes=notes, - ) - successful.append(created_todo) - - except Exception as e: - failed.append( - BulkCreateError(index=index, input_data=todo_data, error=str(e)) - ) - - return BulkCreateResult(successful=successful, failed=failed) + + def _create_single(data: dict[str, Any]) -> Todo: + return self.create( + title=data["title"], + meeting_id=data["meeting_id"], + due_date=data.get("due_date"), + user_id=data.get("user_id"), + notes=data.get("notes"), + ) + + return self._process_bulk_sync( + todos, _create_single, required_fields=["title", "meeting_id"] + ) diff --git a/src/bloomy/operations/users.py b/src/bloomy/operations/users.py index 640ad57..bd092b6 100644 --- a/src/bloomy/operations/users.py +++ b/src/bloomy/operations/users.py @@ -4,7 +4,7 @@ from ..models import DirectReport, Position, UserDetails, UserListItem, UserSearchResult from ..utils.base_operations import BaseOperations -from .mixins.users import UserOperationsMixin +from .mixins.users_transform import UserOperationsMixin class UserOperations(BaseOperations, UserOperationsMixin): diff --git a/src/bloomy/utils/abstract_operations.py b/src/bloomy/utils/abstract_operations.py index 2edf6b1..c0e5dcf 100644 --- a/src/bloomy/utils/abstract_operations.py +++ b/src/bloomy/utils/abstract_operations.py @@ -4,6 +4,8 @@ from typing import Any +from ..models import BulkCreateError, BulkCreateResult + class AbstractOperations: """Abstract base class for shared logic between sync and async operations.""" @@ -47,3 +49,52 @@ def _validate_mutual_exclusion( """ if param1 is not None and param2 is not None: raise ValueError(f"Cannot specify both {param1_name} and {param2_name}") + + def _validate_bulk_item( + self, item_data: dict[str, Any], required_fields: list[str] + ) -> None: + """Validate that required fields are present in bulk item data. + + Args: + item_data: The item data dictionary to validate. + required_fields: List of required field names. + + Raises: + ValueError: If any required field is missing. + + """ + for field in required_fields: + if item_data.get(field) is None: + raise ValueError(f"{field} is required") + + def _process_bulk_sync[T]( + self, + items: list[dict[str, Any]], + create_func: Any, + required_fields: list[str], + ) -> BulkCreateResult[T]: + """Process bulk creation synchronously. + + Args: + items: List of item data dictionaries. + create_func: Function to create a single item from data dict. + required_fields: List of required field names. + + Returns: + BulkCreateResult with successful and failed items. + + """ + successful: list[T] = [] + failed: list[BulkCreateError] = [] + + for index, item_data in enumerate(items): + try: + self._validate_bulk_item(item_data, required_fields) + created = create_func(item_data) + successful.append(created) + except Exception as e: + failed.append( + BulkCreateError(index=index, input_data=item_data, error=str(e)) + ) + + return BulkCreateResult(successful=successful, failed=failed) diff --git a/src/bloomy/utils/async_base_operations.py b/src/bloomy/utils/async_base_operations.py index 4cf7a8b..fb2f10d 100644 --- a/src/bloomy/utils/async_base_operations.py +++ b/src/bloomy/utils/async_base_operations.py @@ -2,11 +2,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import asyncio +from typing import TYPE_CHECKING, Any +from ..models import BulkCreateError, BulkCreateResult from .abstract_operations import AbstractOperations if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + import httpx @@ -65,3 +69,56 @@ async def _get_default_user_id(self) -> int: response.raise_for_status() data = response.json() return data["Id"] + + async def _process_bulk_async[T]( + self, + items: list[dict[str, Any]], + create_func: Callable[[dict[str, Any]], Awaitable[T]], + required_fields: list[str], + max_concurrent: int = 5, + ) -> BulkCreateResult[T]: + """Process bulk creation asynchronously with concurrency control. + + Args: + items: List of item data dictionaries. + create_func: Async function to create a single item from data dict. + required_fields: List of required field names. + max_concurrent: Maximum number of concurrent API requests. + + Returns: + BulkCreateResult with successful and failed items. + + """ + semaphore = asyncio.Semaphore(max_concurrent) + + async def create_single( + index: int, item_data: dict[str, Any] + ) -> tuple[int, T | BulkCreateError]: + async with semaphore: + try: + self._validate_bulk_item(item_data, required_fields) + created = await create_func(item_data) + return (index, created) + except Exception as e: + error = BulkCreateError( + index=index, input_data=item_data, error=str(e) + ) + return (index, error) + + tasks = [ + create_single(index, item_data) for index, item_data in enumerate(items) + ] + results = await asyncio.gather(*tasks) + results_list = list(results) + results_list.sort(key=lambda x: x[0]) + + successful: list[T] = [] + failed: list[BulkCreateError] = [] + + for _, result in results_list: + if isinstance(result, BulkCreateError): + failed.append(result) + else: + successful.append(result) + + return BulkCreateResult(successful=successful, failed=failed) diff --git a/tests/test_async_todos_extra.py b/tests/test_async_todos_extra.py index 5078bba..764f7ec 100644 --- a/tests/test_async_todos_extra.py +++ b/tests/test_async_todos_extra.py @@ -83,14 +83,20 @@ async def test_update_raises_on_failure( self, async_client: AsyncClient, mock_async_client: AsyncMock ) -> None: """Test that update raises error on failure.""" + from httpx import HTTPStatusError, Request, Response + mock_response = MagicMock() mock_response.status_code = 400 - mock_response.raise_for_status = MagicMock() + mock_response.raise_for_status.side_effect = HTTPStatusError( + "Bad Request", + request=MagicMock(spec=Request), + response=MagicMock(spec=Response, status_code=400), + ) mock_async_client.put.return_value = mock_response - # Call the method and expect error - with pytest.raises(RuntimeError, match="Failed to update todo"): + # Call the method and expect HTTPStatusError from raise_for_status() + with pytest.raises(HTTPStatusError): await async_client.todo.update( todo_id=1, title="Updated Task", diff --git a/tests/test_misc_coverage.py b/tests/test_misc_coverage.py index c24caea..eade2af 100644 --- a/tests/test_misc_coverage.py +++ b/tests/test_misc_coverage.py @@ -97,7 +97,7 @@ def test_configuration_type_checking(self) -> None: def test_mixins_type_checking(self) -> None: """Test mixins TYPE_CHECKING imports.""" - from bloomy.operations.mixins import users + from bloomy.operations.mixins import users_transform # Mixins should have TYPE_CHECKING - assert hasattr(users, "TYPE_CHECKING") + assert hasattr(users_transform, "TYPE_CHECKING") diff --git a/uv.lock b/uv.lock index 4595cc4..7133b30 100644 --- a/uv.lock +++ b/uv.lock @@ -47,9 +47,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/37/fb6973edeb700f6e3d6ff222400602ab1830446c25c7b4676d8de93e65b8/backrefs-5.8-py39-none-any.whl", hash = "sha256:a66851e4533fb5b371aa0628e1fee1af05135616b86140c9d787a2ffdf4b8fdc", size = 380336, upload-time = "2025-02-25T16:53:29.858Z" }, ] +[[package]] +name = "basedpyright" +version = "1.37.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/b0/fbba81ea29eed1274e965cd0445f0d6020b467ff4d3393791e4d6ae02e64/basedpyright-1.37.1.tar.gz", hash = "sha256:1f47bc6f45cbcc5d6f8619d60aa42128e4b38942f5118dcd4bc20c3466c5e02f", size = 25235384, upload-time = "2026-01-08T14:42:46.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/d6/6b33bb49f08d761d7c958a1e3cecfb3ffbdcf4ba6bbed65b23ab47516b75/basedpyright-1.37.1-py3-none-any.whl", hash = "sha256:caf3adfe54f51623241712f8b4367adb51ef8a8c2288e3e1ec4118319661340d", size = 12297397, upload-time = "2026-01-08T14:42:50.306Z" }, +] + [[package]] name = "bloomy-python" -version = "0.19.0" +version = "0.21.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -60,7 +72,7 @@ dependencies = [ [package.optional-dependencies] dev = [ - { name = "pyright" }, + { name = "basedpyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -76,9 +88,9 @@ dev = [ [package.metadata] requires-dist = [ + { name = "basedpyright", marker = "extra == 'dev'", specifier = ">=1.1.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, @@ -469,12 +481,19 @@ wheels = [ ] [[package]] -name = "nodeenv" -version = "1.9.1" +name = "nodejs-wheel-binaries" +version = "24.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/35/d806c2ca66072e36dc340ccdbeb2af7e4f1b5bcc33f1481f00ceed476708/nodejs_wheel_binaries-24.12.0.tar.gz", hash = "sha256:f1b50aa25375e264697dec04b232474906b997c2630c8f499f4caf3692938435", size = 8058, upload-time = "2025-12-11T21:12:26.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3b/9d6f044319cd5b1e98f07c41e2465b58cadc1c9c04a74c891578f3be6cb5/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:7564ddea0a87eff34e9b3ef71764cc2a476a8f09a5cccfddc4691148b0a47338", size = 55125859, upload-time = "2025-12-11T21:11:58.132Z" }, + { url = "https://files.pythonhosted.org/packages/48/a5/f5722bf15c014e2f476d7c76bce3d55c341d19122d8a5d86454db32a61a4/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:8ff929c4669e64613ceb07f5bbd758d528c3563820c75d5de3249eb452c0c0ab", size = 55309035, upload-time = "2025-12-11T21:12:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/a9/61/68d39a6f1b5df67805969fd2829ba7e80696c9af19537856ec912050a2be/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6ebacefa8891bc456ad3655e6bce0af7e20ba08662f79d9109986faeb703fd6f", size = 59661017, upload-time = "2025-12-11T21:12:05.268Z" }, + { url = "https://files.pythonhosted.org/packages/16/a1/31aad16f55a5e44ca7ea62d1367fc69f4b6e1dba67f58a0a41d0ed854540/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3292649a03682ccbfa47f7b04d3e4240e8c46ef04dc941b708f20e4e6a764f75", size = 60159770, upload-time = "2025-12-11T21:12:08.696Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5e/b7c569aa1862690ca4d4daf3a64cafa1ea6ce667a9e3ae3918c56e127d9b/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7fb83df312955ea355ba7f8cbd7055c477249a131d3cb43b60e4aeb8f8c730b1", size = 61653561, upload-time = "2025-12-11T21:12:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/71/87/567f58d7ba69ff0208be849b37be0f2c2e99c69e49334edd45ff44f00043/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2473c819448fedd7b036dde236b09f3c8bbf39fbbd0c1068790a0498800f498b", size = 62238331, upload-time = "2025-12-11T21:12:16.143Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9d/c6492188ce8de90093c6755a4a63bb6b2b4efb17094cb4f9a9a49c73ed3b/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_amd64.whl", hash = "sha256:2090d59f75a68079fabc9b86b14df8238b9aecb9577966dc142ce2a23a32e9bb", size = 41342076, upload-time = "2025-12-11T21:12:20.618Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/cd3290a647df567645353feed451ef4feaf5844496ced69c4dcb84295ff4/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_arm64.whl", hash = "sha256:d0c2273b667dd7e3f55e369c0085957b702144b1b04bfceb7ce2411e58333757", size = 39048104, upload-time = "2025-12-11T21:12:23.495Z" }, ] [[package]] @@ -601,19 +620,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/d1/c54e608505776ce4e7966d03358ae635cfd51dff1da6ee421c090dbc797b/pymdown_extensions-10.15-py3-none-any.whl", hash = "sha256:46e99bb272612b0de3b7e7caf6da8dd5f4ca5212c0b273feb9304e236c484e5f", size = 265845, upload-time = "2025-04-27T23:48:27.359Z" }, ] -[[package]] -name = "pyright" -version = "1.1.402" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207, upload-time = "2025-06-11T08:48:35.759Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004, upload-time = "2025-06-11T08:48:33.998Z" }, -] - [[package]] name = "pytest" version = "8.4.0"