Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,4 @@ jobs:
run: uv sync --all-extras

- name: Type check
run: uv run pyright
run: uv run basedpyright
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/guide/async.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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
```

Expand Down
4 changes: 2 additions & 2 deletions docs/guide/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/guide/bulk-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }]
Expand All @@ -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]
Expand Down Expand Up @@ -69,7 +69,7 @@ max-complexity = 10
quote-style = "double"
indent-style = "space"

[tool.pyright]
[tool.basedpyright]
include = ["src"]
pythonVersion = "3.12"
typeCheckingMode = "strict"
Expand Down
115 changes: 42 additions & 73 deletions src/bloomy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."""
Expand All @@ -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):
Expand All @@ -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."""
Expand Down Expand Up @@ -380,24 +357,16 @@ class HeadlineDetails(BloomyBaseModel):

id: int
title: str
notes_url: str
notes_url: str | None = None
meeting_details: MeetingInfo
owner_details: OwnerDetails
archived: bool
created_at: str
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):
Expand Down
Loading