Skip to content

Commit 19beff8

Browse files
authored
Merge pull request #14 from franccesco/refactor/code-simplification-v0.21.0
refactor: code simplification and v0.21.0 release
2 parents 949d16f + b08f43f commit 19beff8

34 files changed

Lines changed: 894 additions & 1047 deletions

.github/workflows/quality.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,4 @@ jobs:
5959
run: uv sync --all-extras
6060

6161
- name: Type check
62-
run: uv run pyright
62+
run: uv run basedpyright

CHANGELOG.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.21.0] - 2026-01-09
11+
12+
### Changed
13+
14+
- **BREAKING**: `HeadlineListItem` model removed and merged into `HeadlineDetails` (now a type alias for backward compatibility)
15+
- Refactored internal operations structure to use mixins for better code organization
16+
- Improved bulk operations with generic validation and processing helpers in base classes
17+
- Standardized error handling across all operations to consistently use `raise_for_status()`
18+
19+
### Added
20+
21+
- New `mixins/` folder with transform mixins for each operation type (goals, meetings, issues, headlines, todos, users)
22+
- Generic bulk operation helpers in `AbstractOperations` base class (`_validate_bulk_item`, `_process_bulk_sync`)
23+
- Generic async bulk processing in `AsyncBaseOperations` with configurable semaphore for concurrency control
24+
- Reusable `OptionalDatetime` and `OptionalFloat` annotated types using Pydantic's `BeforeValidator`
25+
26+
### Fixed
27+
28+
- Removed duplicate datetime and float validators across models (Todo, Issue, Goal)
29+
- Removed redundant `__init__` methods from async operations
30+
1031
## [0.20.1] - 2025-12-10
1132

1233
### Fixed
@@ -231,7 +252,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
231252
- Configuration management with multiple API key sources
232253
- httpx-based HTTP client with bearer token authentication
233254

234-
[Unreleased]: https://github.com/franccesco/bloomy-python/compare/v0.20.1...HEAD
255+
[Unreleased]: https://github.com/franccesco/bloomy-python/compare/v0.21.0...HEAD
256+
[0.21.0]: https://github.com/franccesco/bloomy-python/compare/v0.20.1...v0.21.0
235257
[0.20.1]: https://github.com/franccesco/bloomy-python/compare/v0.20.0...v0.20.1
236258
[0.20.0]: https://github.com/franccesco/bloomy-python/compare/v0.19.0...v0.20.0
237259
[0.19.0]: https://github.com/franccesco/bloomy-python/compare/v0.18.0...v0.19.0

CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,17 @@ The Bloomy Python SDK is organized as a client-based architecture where all API
8080
- Sync operations in `src/bloomy/operations/` inherit from `BaseOperations`
8181
- Async operations in `src/bloomy/operations/async_/` inherit from `AsyncBaseOperations`
8282
- Both inherit from `AbstractOperations` which provides shared logic
83+
- Response transformation is handled by mixins in `src/bloomy/operations/mixins/` (e.g., `UserOperationsMixin`, `MeetingOperationsMixin`)
84+
- Mixins use `_transform` suffix naming convention (e.g., `goals_transform.py`, `users_transform.py`)
85+
- Generic bulk operations logic is provided in base classes (`_validate_bulk_item`, `_process_bulk_sync`, `_process_bulk_async`)
8386
- Operations are accessed via client attributes: `client.user`, `client.meeting`, etc.
8487

8588
4. **Models (`src/bloomy/models.py`)**:
8689
- Pydantic models for type-safe API responses
8790
- All models inherit from `BloomyBaseModel` with common config
8891
- Field aliases map PascalCase API responses to snake_case Python attributes
92+
- Reusable annotated types: `OptionalDatetime` and `OptionalFloat` using Pydantic's `BeforeValidator`
93+
- Some models are type aliases for backward compatibility (e.g., `HeadlineListItem = HeadlineDetails`)
8994

9095
5. **API Endpoints**:
9196
- 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
101106
3. **Error Handling**:
102107
- Custom exception hierarchy rooted at `BloomyError`
103108
- `APIError` includes status code
104-
- HTTP errors are raised via `response.raise_for_status()`
109+
- All operations consistently use `response.raise_for_status()` for error handling
105110

106111
4. **Type Annotations**: Uses Python 3.12+ union syntax (`|`) and Pydantic models for structured return types.
107112

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
A Python SDK for interacting with the Bloom Growth API, providing easy access to users, meetings, todos, goals, scorecards, issues, and headlines.
77

8-
**New in v0.13.0**: Full async/await support with `AsyncClient` for better performance in async applications!
8+
**New in v0.21.0**: Improved internal architecture with reusable mixins and enhanced bulk operation performance!
99

1010
## Installation
1111

@@ -59,13 +59,13 @@ asyncio.run(main())
5959
user = client.user.details()
6060

6161
# Get user with direct reports and positions
62-
user = client.user.details(user_id=123, all=True)
62+
user = client.user.details(user_id=123, include_all=True)
6363

6464
# Search users
6565
results = client.user.search("john")
6666

67-
# Get all users
68-
users = client.user.all()
67+
# List all users
68+
users = client.user.list()
6969
```
7070

7171
### Meetings

docs/guide/async.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,12 @@ async def bulk_create_todos(client: AsyncClient, todos_data: list[dict]):
226226
async def sync_user_data(client: AsyncClient):
227227
"""Sync all user data efficiently."""
228228
# Get all users
229-
all_users = await client.user.all()
230-
229+
all_users = await client.user.list()
230+
231231
# Process users in batches to avoid overwhelming the API
232232
batch_size = 10
233233
user_details = []
234-
234+
235235
for i in range(0, len(all_users), batch_size):
236236
batch = all_users[i:i + batch_size]
237237
batch_tasks = [
@@ -240,7 +240,7 @@ async def sync_user_data(client: AsyncClient):
240240
]
241241
batch_results = await asyncio.gather(*batch_tasks)
242242
user_details.extend(batch_results)
243-
243+
244244
return user_details
245245
```
246246

docs/guide/authentication.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,8 @@ client_account1 = Client(api_key="api-key-for-account-1")
260260
client_account2 = Client(api_key="api-key-for-account-2")
261261

262262
# Use different clients for different operations
263-
users_account1 = client_account1.user.all()
264-
users_account2 = client_account2.user.all()
263+
users_account1 = client_account1.user.list()
264+
users_account2 = client_account2.user.list()
265265
```
266266

267267
## Next Steps

docs/guide/bulk-operations.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Each bulk operation returns a `BulkCreateResult` containing:
2323
!!! note "Best-Effort Processing"
2424
Bulk operations are not transactional. If one item fails, the operation continues with the remaining items. Always check both successful and failed results.
2525

26+
!!! tip "Enhanced in v0.21.0"
27+
Bulk operations now use generic validation and processing helpers in the base classes, providing consistent error handling and performance across all resource types.
28+
2629
## BulkCreateResult Structure
2730

2831
```python

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "bloomy-python"
3-
version = "0.20.1"
3+
version = "0.21.0"
44
description = "Python SDK for Bloom Growth API"
55
readme = "README.md"
66
authors = [{ name = "Franccesco Orozco", email = "franccesco@codingdose.info" }]
@@ -18,7 +18,7 @@ dev = [
1818
"pytest-cov>=5.0.0",
1919
"pytest-asyncio>=0.23.0",
2020
"ruff>=0.5.0",
21-
"pyright>=1.1.0",
21+
"basedpyright>=1.1.0",
2222
]
2323

2424
[build-system]
@@ -69,7 +69,7 @@ max-complexity = 10
6969
quote-style = "double"
7070
indent-style = "space"
7171

72-
[tool.pyright]
72+
[tool.basedpyright]
7373
include = ["src"]
7474
pythonVersion = "3.12"
7575
typeCheckingMode = "strict"

src/bloomy/models.py

Lines changed: 42 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,38 @@
44

55
from datetime import datetime
66
from enum import StrEnum
7-
from typing import Any
7+
from typing import Annotated, Any
88

9-
from pydantic import BaseModel, ConfigDict, Field, field_validator
9+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
10+
11+
12+
def _parse_optional_datetime(v: Any) -> datetime | None:
13+
"""Parse optional datetime fields, treating empty strings as None.
14+
15+
Returns:
16+
The datetime value or None if empty/None.
17+
18+
"""
19+
if v is None or v == "":
20+
return None
21+
return v
22+
23+
24+
def _parse_optional_float(v: Any) -> float | None:
25+
"""Parse optional float fields, treating empty strings as None.
26+
27+
Returns:
28+
The float value or None if empty/None.
29+
30+
"""
31+
if v is None or v == "":
32+
return None
33+
return float(v)
34+
35+
36+
# Reusable annotated types for optional fields that may come as empty strings
37+
OptionalDatetime = Annotated[datetime | None, BeforeValidator(_parse_optional_datetime)]
38+
OptionalFloat = Annotated[float | None, BeforeValidator(_parse_optional_float)]
1039

1140

1241
class GoalStatus(StrEnum):
@@ -122,26 +151,13 @@ class Todo(BloomyBaseModel):
122151
id: int = Field(alias="Id")
123152
name: str = Field(alias="Name")
124153
details_url: str | None = Field(alias="DetailsUrl", default=None)
125-
due_date: datetime | None = Field(alias="DueDate", default=None)
126-
complete_date: datetime | None = Field(alias="CompleteTime", default=None)
127-
create_date: datetime | None = Field(alias="CreateTime", default=None)
154+
due_date: OptionalDatetime = Field(alias="DueDate", default=None)
155+
complete_date: OptionalDatetime = Field(alias="CompleteTime", default=None)
156+
create_date: OptionalDatetime = Field(alias="CreateTime", default=None)
128157
meeting_id: int | None = Field(alias="OriginId", default=None)
129158
meeting_name: str | None = Field(alias="Origin", default=None)
130159
complete: bool = Field(alias="Complete", default=False)
131160

132-
@field_validator("due_date", "complete_date", "create_date", mode="before")
133-
@classmethod
134-
def parse_optional_datetime(cls, v: Any) -> datetime | None:
135-
"""Parse optional datetime fields.
136-
137-
Returns:
138-
The parsed datetime or None if empty.
139-
140-
"""
141-
if v is None or v == "":
142-
return None
143-
return v
144-
145161

146162
class Issue(BloomyBaseModel):
147163
"""Model for issue."""
@@ -155,21 +171,8 @@ class Issue(BloomyBaseModel):
155171
owner_name: str = Field(alias="OwnerName")
156172
owner_id: int = Field(alias="OwnerId")
157173
owner_image_url: str = Field(alias="OwnerImageUrl")
158-
closed_date: datetime | None = Field(alias="ClosedDate", default=None)
159-
completion_date: datetime | None = Field(alias="CompletionDate", default=None)
160-
161-
@field_validator("closed_date", "completion_date", mode="before")
162-
@classmethod
163-
def parse_optional_datetime(cls, v: Any) -> datetime | None:
164-
"""Parse optional datetime fields.
165-
166-
Returns:
167-
The parsed datetime or None if empty.
168-
169-
"""
170-
if v is None or v == "":
171-
return None
172-
return v
174+
closed_date: OptionalDatetime = Field(alias="ClosedDate", default=None)
175+
completion_date: OptionalDatetime = Field(alias="CompletionDate", default=None)
173176

174177

175178
class Headline(BloomyBaseModel):
@@ -192,54 +195,28 @@ class Goal(BloomyBaseModel):
192195
id: int = Field(alias="Id")
193196
name: str = Field(alias="Name")
194197
due_date: datetime = Field(alias="DueDate")
195-
complete_date: datetime | None = Field(alias="CompleteDate", default=None)
198+
complete_date: OptionalDatetime = Field(alias="CompleteDate", default=None)
196199
create_date: datetime = Field(alias="CreateDate")
197200
is_archived: bool = Field(alias="IsArchived", default=False)
198201
percent_complete: float = Field(alias="PercentComplete", default=0.0)
199202
accountable_user_id: int = Field(alias="AccountableUserId")
200203
accountable_user_name: str | None = Field(alias="AccountableUserName", default=None)
201204

202-
@field_validator("complete_date", mode="before")
203-
@classmethod
204-
def parse_optional_datetime(cls, v: Any) -> datetime | None:
205-
"""Parse optional datetime fields.
206-
207-
Returns:
208-
The parsed datetime or None if empty.
209-
210-
"""
211-
if v is None or v == "":
212-
return None
213-
return v
214-
215205

216206
class ScorecardMetric(BloomyBaseModel):
217207
"""Model for scorecard metric."""
218208

219209
id: int = Field(alias="Id")
220210
title: str = Field(alias="Title")
221-
target: float | None = Field(alias="Target", default=None)
211+
target: OptionalFloat = Field(alias="Target", default=None)
222212
unit: str | None = Field(alias="Unit", default=None)
223213
week_number: int = Field(alias="WeekNumber")
224-
value: float | None = Field(alias="Value", default=None)
214+
value: OptionalFloat = Field(alias="Value", default=None)
225215
metric_type: str = Field(alias="MetricType")
226216
accountable_user_id: int = Field(alias="AccountableUserId")
227217
accountable_user_name: str | None = Field(alias="AccountableUserName", default=None)
228218
is_inverse: bool = Field(alias="IsInverse", default=False)
229219

230-
@field_validator("target", "value", mode="before")
231-
@classmethod
232-
def parse_optional_float(cls, v: Any) -> float | None:
233-
"""Parse optional float fields.
234-
235-
Returns:
236-
The parsed float or None if empty.
237-
238-
"""
239-
if v is None or v == "":
240-
return None
241-
return float(v)
242-
243220

244221
class CurrentWeek(BloomyBaseModel):
245222
"""Model for current week information."""
@@ -380,24 +357,16 @@ class HeadlineDetails(BloomyBaseModel):
380357

381358
id: int
382359
title: str
383-
notes_url: str
360+
notes_url: str | None = None
384361
meeting_details: MeetingInfo
385362
owner_details: OwnerDetails
386363
archived: bool
387364
created_at: str
388365
closed_at: str | None = None
389366

390367

391-
class HeadlineListItem(BloomyBaseModel):
392-
"""Model for headline list items."""
393-
394-
id: int
395-
title: str
396-
meeting_details: MeetingInfo
397-
owner_details: OwnerDetails
398-
archived: bool
399-
created_at: str
400-
closed_at: str | None = None
368+
# HeadlineListItem is identical to HeadlineDetails - use type alias
369+
HeadlineListItem = HeadlineDetails
401370

402371

403372
class BulkCreateError(BloomyBaseModel):

0 commit comments

Comments
 (0)