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/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
run: uv sync --all-extras

- name: Run tests with coverage
run: uv run pytest --cov=bloomy --cov-report=term-missing --cov-report=xml
run: uv run pytest -m "not integration" --cov=bloomy --cov-report=term-missing --cov-report=xml

- name: Upload coverage report
if: matrix.python-version == '3.12'
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "bloomy-python"
version = "0.21.0"
version = "0.21.1"
description = "Python SDK for Bloom Growth API"
readme = "README.md"
authors = [{ name = "Franccesco Orozco", email = "franccesco@codingdose.info" }]
Expand Down Expand Up @@ -79,6 +79,9 @@ reportMissingImports = true
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers --cov=bloomy --cov-report=term-missing"
markers = [
"integration: marks tests that hit the real Bloom Growth API (deselect with '-m \"not integration\"')",
]

[dependency-groups]
dev = [
Expand Down
5 changes: 4 additions & 1 deletion src/bloomy/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ def __init__(self, api_key: str | None = None) -> None:
```

"""
self.api_key = api_key or os.environ.get("BG_API_KEY") or self._load_api_key()
stripped_key = api_key.strip() if api_key else api_key
self.api_key = (
stripped_key or os.environ.get("BG_API_KEY") or self._load_api_key()
)

def configure_api_key(
self, username: str, password: str, store_key: bool = False
Expand Down
10 changes: 5 additions & 5 deletions src/bloomy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ class UserSearchResult(BloomyBaseModel):

id: int
name: str
description: str
description: str | None = None
email: str
organization_id: int
image_url: str
image_url: str | None = None


class UserListItem(BloomyBaseModel):
Expand All @@ -100,8 +100,8 @@ class UserListItem(BloomyBaseModel):
id: int
name: str
email: str
position: str
image_url: str
position: str | None = None
image_url: str | None = None


class MeetingAttendee(BloomyBaseModel):
Expand Down Expand Up @@ -265,7 +265,7 @@ class CreatedGoalInfo(BloomyBaseModel):
user_name: str
title: str
meeting_id: int
meeting_title: str
meeting_title: str | None = None
status: str
created_at: str

Expand Down
7 changes: 2 additions & 5 deletions src/bloomy/operations/async_/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,14 @@ async def update(
Args:
goal_id: The ID of the goal to update
title: The new title of the goal
accountable_user: The ID of the user responsible for the goal
(default: initialized user ID)
accountable_user: The ID of the user responsible for the goal.
If not provided, the existing owner is preserved.
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.
Invalid values will raise ValueError via the update payload builder.

"""
if accountable_user is None:
accountable_user = await self.get_user_id()

payload = self._build_goal_update_payload(accountable_user, title, status)

response = await self._client.put(f"rocks/{goal_id}", json=payload)
Expand Down
6 changes: 3 additions & 3 deletions src/bloomy/operations/async_/headlines.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async def create(
id=data["Id"],
title=data["Name"],
owner_details=OwnerDetails(id=owner_id, name=None),
notes_url=data.get("DetailsUrl", ""),
notes_url=data.get("DetailsUrl") or "",
)

async def update(self, headline_id: int, title: str) -> None:
Expand Down Expand Up @@ -106,10 +106,10 @@ async def list(
ValueError: If both user_id and meeting_id are provided

"""
if user_id and meeting_id:
if user_id is not None and meeting_id is not None:
raise ValueError("Please provide either user_id or meeting_id, not both.")

if meeting_id:
if meeting_id is not None:
response = await self._client.get(f"l10/{meeting_id}/headlines")
else:
if user_id is None:
Expand Down
4 changes: 2 additions & 2 deletions src/bloomy/operations/async_/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ async def list(
ValueError: When both user_id and meeting_id are provided

"""
if user_id and meeting_id:
if user_id is not None and meeting_id is not None:
raise ValueError(
"Please provide either `user_id` or `meeting_id`, not both."
)

if meeting_id:
if meeting_id is not None:
response = await self._client.get(f"l10/{meeting_id}/issues")
else:
if user_id is None:
Expand Down
27 changes: 9 additions & 18 deletions src/bloomy/operations/async_/meetings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@

import asyncio
import builtins
from typing import TYPE_CHECKING, Any
from typing import Any

from ...exceptions import APIError
from ...models import (
BulkCreateError,
BulkCreateResult,
Expand All @@ -20,9 +19,6 @@
from ...utils.async_base_operations import AsyncBaseOperations
from ..mixins.meetings_transform import MeetingOperationsMixin

if TYPE_CHECKING:
pass


class AsyncMeetingOperations(AsyncBaseOperations, MeetingOperationsMixin):
"""Async class to handle all operations related to meetings.
Expand Down Expand Up @@ -176,9 +172,6 @@ async def details(
Returns:
A MeetingDetails model instance with comprehensive meeting information

Raises:
APIError: If the meeting with the given ID is not found

Example:
```python
await client.meeting.details(1)
Expand All @@ -187,11 +180,9 @@ async def details(
```

"""
meetings = await self.list()
meeting = next((m for m in meetings if m.id == meeting_id), None)

if not meeting:
raise APIError(f"Meeting with ID {meeting_id} not found", status_code=404)
response = await self._client.get(f"L10/{meeting_id}")
response.raise_for_status()
data: Any = response.json()

# Fetch all sub-resources in parallel for better performance
attendees_task = asyncio.create_task(self.attendees(meeting_id))
Expand All @@ -209,11 +200,11 @@ async def details(
)

return MeetingDetails(
id=meeting.id,
name=meeting.name,
start_date_utc=getattr(meeting, "start_date_utc", None),
created_date=getattr(meeting, "created_date", None),
organization_id=getattr(meeting, "organization_id", None),
id=data["Id"],
name=data.get("Basics", {}).get("Name", ""),
start_date_utc=data.get("StartDateUtc"),
created_date=data.get("CreateTime"),
organization_id=data.get("OrganizationId"),
attendees=attendees,
issues=issues,
todos=todos,
Expand Down
4 changes: 2 additions & 2 deletions src/bloomy/operations/async_/scorecard.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ async def list(
ValueError: If both user_id and meeting_id are provided

"""
if user_id and meeting_id:
if user_id is not None and meeting_id is not None:
raise ValueError(
"Please provide either `user_id` or `meeting_id`, not both."
)

if meeting_id:
if meeting_id is not None:
response = await self._client.get(f"scorecard/meeting/{meeting_id}")
else:
if user_id is None:
Expand Down
4 changes: 2 additions & 2 deletions src/bloomy/operations/async_/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import builtins
from datetime import datetime
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from ...models import BulkCreateResult, Todo
Expand Down Expand Up @@ -120,7 +120,7 @@ async def create(
"DetailsUrl": data.get("DetailsUrl"),
"DueDate": data.get("DueDate"),
"CompleteTime": None,
"CreateTime": data.get("CreateTime", datetime.now().isoformat()),
"CreateTime": data.get("CreateTime", datetime.now(UTC).isoformat()),
"OriginId": meeting_id,
"Origin": None,
"Complete": False,
Expand Down
7 changes: 2 additions & 5 deletions src/bloomy/operations/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ def update(
Args:
goal_id: The ID of the goal to update
title: The new title of the goal
accountable_user: The ID of the user responsible for the goal
(default: initialized user ID)
accountable_user: The ID of the user responsible for the goal.
If not provided, the existing owner is preserved.
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.
Expand All @@ -155,9 +155,6 @@ def update(
```

"""
if accountable_user is None:
accountable_user = self.user_id

payload = self._build_goal_update_payload(accountable_user, title, status)

response = self._client.put(f"rocks/{goal_id}", json=payload)
Expand Down
6 changes: 3 additions & 3 deletions src/bloomy/operations/headlines.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def create(
id=data["Id"],
title=data["Name"],
owner_details=OwnerDetails(id=owner_id, name=None),
notes_url=data.get("DetailsUrl", ""),
notes_url=data.get("DetailsUrl") or "",
)

def update(self, headline_id: int, title: str) -> None:
Expand Down Expand Up @@ -125,10 +125,10 @@ def list(
```

"""
if user_id and meeting_id:
if user_id is not None and meeting_id is not None:
raise ValueError("Please provide either user_id or meeting_id, not both.")

if meeting_id:
if meeting_id is not None:
response = self._client.get(f"l10/{meeting_id}/headlines")
else:
if user_id is None:
Expand Down
4 changes: 2 additions & 2 deletions src/bloomy/operations/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@ def list(
```

"""
if user_id and meeting_id:
if user_id is not None and meeting_id is not None:
raise ValueError(
"Please provide either `user_id` or `meeting_id`, not both."
)

if meeting_id:
if meeting_id is not None:
response = self._client.get(f"l10/{meeting_id}/issues")
else:
if user_id is None:
Expand Down
22 changes: 8 additions & 14 deletions src/bloomy/operations/meetings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import builtins
from typing import Any

from ..exceptions import APIError
from ..models import (
BulkCreateError,
BulkCreateResult,
Expand Down Expand Up @@ -167,9 +166,6 @@ def details(self, meeting_id: int, include_closed: bool = False) -> MeetingDetai
Returns:
A MeetingDetails model instance with comprehensive meeting information

Raises:
APIError: If the meeting with the specified ID is not found

Example:
```python
client.meeting.details(1)
Expand All @@ -178,18 +174,16 @@ def details(self, meeting_id: int, include_closed: bool = False) -> MeetingDetai
```

"""
meetings = self.list()
meeting = next((m for m in meetings if m.id == meeting_id), None)

if not meeting:
raise APIError(f"Meeting with ID {meeting_id} not found", status_code=404)
response = self._client.get(f"L10/{meeting_id}")
response.raise_for_status()
data: Any = response.json()

return MeetingDetails(
id=meeting.id,
name=meeting.name,
start_date_utc=getattr(meeting, "start_date_utc", None),
created_date=getattr(meeting, "created_date", None),
organization_id=getattr(meeting, "organization_id", None),
id=data["Id"],
name=data.get("Basics", {}).get("Name", ""),
start_date_utc=data.get("StartDateUtc"),
created_date=data.get("CreateTime"),
organization_id=data.get("OrganizationId"),
attendees=self.attendees(meeting_id),
issues=self.issues(meeting_id, include_closed=include_closed),
todos=self.todos(meeting_id, include_closed=include_closed),
Expand Down
24 changes: 19 additions & 5 deletions src/bloomy/operations/mixins/goals_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,15 @@ def _transform_goal_list(self, data: Sequence[dict[str, Any]]) -> list[GoalInfo]
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_id=(
goal["Origins"][0]["Id"]
if goal.get("Origins") and goal["Origins"][0]
else None
),
meeting_title=(
goal["Origins"][0]["Name"] if goal.get("Origins") else None
goal["Origins"][0]["Name"]
if goal.get("Origins") and goal["Origins"][0]
else None
),
)
for goal in data
Expand Down Expand Up @@ -93,21 +99,26 @@ def _transform_created_goal(
user_name=data["Owner"]["Name"],
title=title,
meeting_id=meeting_id,
meeting_title=data["Origins"][0]["Name"],
meeting_title=(
data["Origins"][0]["Name"]
if data.get("Origins") and data["Origins"][0]
else None
),
status=status,
created_at=data["CreateTime"],
)

def _build_goal_update_payload(
self,
accountable_user: int,
accountable_user: int | None = None,
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.
Only included in the payload when explicitly provided.
title: The new title of the goal.
status: The status value (GoalStatus enum or string).

Expand All @@ -118,7 +129,10 @@ def _build_goal_update_payload(
ValueError: If an invalid status value is provided.

"""
payload: dict[str, Any] = {"accountableUserId": accountable_user}
payload: dict[str, Any] = {}

if accountable_user is not None:
payload["accountableUserId"] = accountable_user

if title is not None:
payload["title"] = title
Expand Down
Loading
Loading