From 42f235134f038682699c4f2ffa642e53fc8c7d0c Mon Sep 17 00:00:00 2001 From: Franccesco Orozco Date: Wed, 10 Dec 2025 20:15:43 -0600 Subject: [PATCH] feat!: improve developer experience with breaking API changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGES: - Rename `client.user.all()` to `client.user.list()` for consistency - Rename `client.user.details(all=True)` to `details(include_all=True)` - Rename `client.issue.solve()` to `client.issue.complete()` (returns IssueDetails) - `client.todo.complete()` now returns Todo instead of bool - Goal operations (update/delete/archive/restore) now return None instead of bool - Headline operations (update/delete) now return None instead of bool - Client raises ConfigurationError instead of ValueError for missing API key New Features: - Add `client.issue.update()` method for updating existing issues - Add `client.scorecard.get()` method for retrieving scorecard details - Add `GoalStatus` enum for type-safe status values - Add `base_url` and `timeout` parameters to sync Client - AsyncClient now validates API key at initialization Documentation: - All docs updated to reflect the breaking changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 27 ++++++- docs/api/async_client.md | 53 ++++++++++++- docs/api/client.md | 46 ++++++++++- docs/api/operations/goals.md | 76 ++++++++++++------ docs/api/operations/headlines.md | 17 ++-- docs/api/operations/issues.md | 96 +++++++++++++++++++---- docs/api/operations/scorecard.md | 47 +++++++---- docs/api/operations/todos.md | 14 ++-- docs/api/operations/users.md | 22 +++--- docs/guide/errors.md | 10 +-- docs/guide/usage.md | 22 +++++- pyproject.toml | 2 +- src/bloomy/__init__.py | 2 + src/bloomy/async_client.py | 15 +++- src/bloomy/client.py | 20 +++-- src/bloomy/models.py | 9 +++ src/bloomy/operations/async_/goals.py | 35 +++------ src/bloomy/operations/async_/headlines.py | 12 +-- src/bloomy/operations/async_/issues.py | 56 ++++++++++++- src/bloomy/operations/async_/scorecard.py | 29 +++++++ src/bloomy/operations/async_/todos.py | 24 ++---- src/bloomy/operations/async_/users.py | 14 ++-- src/bloomy/operations/goals.py | 51 +++++------- src/bloomy/operations/headlines.py | 12 +-- src/bloomy/operations/issues.py | 54 +++++++++++-- src/bloomy/operations/scorecard.py | 29 +++++++ src/bloomy/operations/todos.py | 24 ++---- src/bloomy/operations/users.py | 14 ++-- tests/test_async_goals.py | 8 +- tests/test_async_headlines.py | 4 +- tests/test_async_todos.py | 60 +++++++++++--- tests/test_async_users_extra.py | 4 +- tests/test_client.py | 66 +++++++++++++++- tests/test_goals.py | 8 +- tests/test_headlines.py | 4 +- tests/test_issues.py | 94 ++++++++++++++++++++-- tests/test_todos.py | 47 ++++++++--- tests/test_users.py | 12 +-- 38 files changed, 865 insertions(+), 274 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3042df4..3468c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.0] - 2025-12-10 + +### Added + +- `client.issue.update()` method for updating existing issues +- `client.scorecard.get()` method for retrieving scorecard details +- `GoalStatus` enum for type-safe status values +- `base_url` and `timeout` parameters to sync `Client` for configuration flexibility +- AsyncClient now validates API key at initialization + +### Changed + +- **BREAKING**: `client.user.all()` renamed to `client.user.list()` for consistency +- **BREAKING**: `client.user.details(all=True)` renamed to `client.user.details(include_all=True)` for clarity +- **BREAKING**: `client.issue.solve()` renamed to `client.issue.complete()` and now returns `IssueDetails` +- **BREAKING**: `client.todo.complete()` now returns `Todo` instead of `bool` +- **BREAKING**: `client.goal.update/delete/archive/restore()` now return `None` instead of `bool` +- **BREAKING**: `client.headline.update/delete()` now return `None` instead of `bool` +- **BREAKING**: Client raises `ConfigurationError` instead of `ValueError` for missing API key + +### Fixed + +- All documentation updated to reflect the breaking changes + ## [0.19.0] - 2025-12-10 ### Fixed @@ -192,7 +216,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.19.0...HEAD +[Unreleased]: https://github.com/franccesco/bloomy-python/compare/v0.20.0...HEAD +[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 [0.18.0]: https://github.com/franccesco/bloomy-python/compare/v0.17.0...v0.18.0 [0.17.0]: https://github.com/franccesco/bloomy-python/compare/v0.16.0...v0.17.0 diff --git a/docs/api/async_client.md b/docs/api/async_client.md index b6a7ead..455c331 100644 --- a/docs/api/async_client.md +++ b/docs/api/async_client.md @@ -10,4 +10,55 @@ - __aenter__ - __aexit__ - close - heading_level: 2 \ No newline at end of file + heading_level: 2 + +## Basic Usage + +```python +import asyncio +from bloomy import AsyncClient, ConfigurationError + +async def main(): + # Initialize with API key + async with AsyncClient(api_key="your-api-key") as client: + users = await client.user.list() + + # Custom base URL (for testing/staging) + async with AsyncClient( + api_key="your-api-key", + base_url="https://staging.example.com/api/v1" + ) as client: + users = await client.user.list() + + # Custom timeout (in seconds) + async with AsyncClient(api_key="your-api-key", timeout=60.0) as client: + users = await client.user.list() + + # Handle missing API key + try: + client = AsyncClient() + except ConfigurationError as e: + print(f"Configuration error: {e}") + +asyncio.run(main()) +``` + +## Parameters + +- **api_key** (str, optional): Your Bloom Growth API key. If not provided, will attempt to load from `BG_API_KEY` environment variable or `~/.bloomy/config.yaml` +- **base_url** (str, optional): Custom API endpoint. Defaults to `"https://app.bloomgrowth.com/api/v1"` +- **timeout** (float, optional): Request timeout in seconds. Defaults to `30.0` + +## Exceptions + +- **ConfigurationError**: Raised when no API key can be found from any source + +## Context Manager + +The AsyncClient supports async context manager protocol for automatic resource cleanup: + +```python +async with AsyncClient(api_key="your-api-key") as client: + users = await client.user.list() + # Client automatically closes when exiting the context +``` \ No newline at end of file diff --git a/docs/api/client.md b/docs/api/client.md index 48e24f2..eb8072f 100644 --- a/docs/api/client.md +++ b/docs/api/client.md @@ -8,4 +8,48 @@ The main entry point for interacting with the Bloomy API. show_root_heading: true show_root_full_path: false members_order: source - show_signature_annotations: true \ No newline at end of file + show_signature_annotations: true + +## Basic Usage + +```python +from bloomy import Client, ConfigurationError + +# Initialize with API key +client = Client(api_key="your-api-key") + +# Custom base URL (for testing/staging) +client = Client( + api_key="your-api-key", + base_url="https://staging.example.com/api/v1" +) + +# Custom timeout (in seconds) +client = Client(api_key="your-api-key", timeout=60.0) + +# Handle missing API key +try: + client = Client() +except ConfigurationError as e: + print(f"Configuration error: {e}") +``` + +## Parameters + +- **api_key** (str, optional): Your Bloom Growth API key. If not provided, will attempt to load from `BG_API_KEY` environment variable or `~/.bloomy/config.yaml` +- **base_url** (str, optional): Custom API endpoint. Defaults to `"https://app.bloomgrowth.com/api/v1"` +- **timeout** (float, optional): Request timeout in seconds. Defaults to `30.0` + +## Exceptions + +- **ConfigurationError**: Raised when no API key can be found from any source + +## Context Manager + +The Client supports context manager protocol for automatic resource cleanup: + +```python +with Client(api_key="your-api-key") as client: + users = client.user.list() + # Client automatically closes when exiting the context +``` \ No newline at end of file diff --git a/docs/api/operations/goals.md b/docs/api/operations/goals.md index fc4a597..52c6819 100644 --- a/docs/api/operations/goals.md +++ b/docs/api/operations/goals.md @@ -28,41 +28,63 @@ The async version `AsyncGoalOperations` provides the same methods as above, but !!! info "Async Usage" All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call. +## Goal Status Enum + +The SDK provides a `GoalStatus` enum for type-safe status updates: + +```python +from bloomy import GoalStatus + +# Enum values (recommended) +GoalStatus.ON_TRACK # "on" - Goal is on track +GoalStatus.AT_RISK # "off" - Goal is at risk +GoalStatus.COMPLETE # "complete" - Goal is complete + +# You can still use strings directly +client.goal.update(goal_id=123, status="on") +``` + +!!! tip "Using the Enum" + Using `GoalStatus` enum values is recommended for better type checking and IDE autocomplete support. + ## Usage Examples === "Sync" ```python - from bloomy import Client - + from bloomy import Client, GoalStatus + with Client(api_key="your-api-key") as client: # List active goals goals = client.goal.list() for goal in goals: print(f"{goal.title} - Status: {goal.status}") - + # List with archived goals included all_goals = client.goal.list(archived=True) print(f"Active: {len(all_goals.active)}") print(f"Archived: {len(all_goals.archived)}") - + # Create a new goal new_goal = client.goal.create( title="Increase customer retention by 20%", meeting_id=123 ) - - # Update goal status + + # Update goal status using enum (recommended) client.goal.update( goal_id=new_goal.id, - status="on" # on track + status=GoalStatus.ON_TRACK ) - - # Archive and restore + + # Or use string directly + client.goal.update(goal_id=new_goal.id, status="off") # at risk + + # Archive and restore (both return None) client.goal.archive(goal_id=new_goal.id) client.goal.restore(goal_id=new_goal.id) - - # Delete a goal + + # Delete a goal (returns None) client.goal.delete(goal_id=new_goal.id) ``` @@ -70,39 +92,42 @@ The async version `AsyncGoalOperations` provides the same methods as above, but ```python import asyncio - from bloomy import AsyncClient - + from bloomy import AsyncClient, GoalStatus + async def main(): async with AsyncClient(api_key="your-api-key") as client: # List active goals goals = await client.goal.list() for goal in goals: print(f"{goal.title} - Status: {goal.status}") - + # List with archived goals included all_goals = await client.goal.list(archived=True) print(f"Active: {len(all_goals.active)}") print(f"Archived: {len(all_goals.archived)}") - + # Create a new goal new_goal = await client.goal.create( title="Increase customer retention by 20%", meeting_id=123 ) - - # Update goal status + + # Update goal status using enum (recommended) await client.goal.update( goal_id=new_goal.id, - status="on" # on track + status=GoalStatus.ON_TRACK ) - - # Archive and restore + + # Or use string directly + await client.goal.update(goal_id=new_goal.id, status="off") # at risk + + # Archive and restore (both return None) await client.goal.archive(goal_id=new_goal.id) await client.goal.restore(goal_id=new_goal.id) - - # Delete a goal + + # Delete a goal (returns None) await client.goal.delete(goal_id=new_goal.id) - + asyncio.run(main()) ``` @@ -118,4 +143,7 @@ The async version `AsyncGoalOperations` provides the same methods as above, but | `restore()` | Restore an archived goal | `goal_id` | !!! info "Status Values" - Valid status values are: `'on'` (On Track), `'off'` (At Risk), or `'complete'` (Completed) \ No newline at end of file + Valid status values are: `'on'` (On Track), `'off'` (At Risk), or `'complete'` (Completed). Use the `GoalStatus` enum for type-safe updates. + +!!! note "Return Values" + The `update()`, `delete()`, `archive()`, and `restore()` methods return `None` instead of boolean values. \ No newline at end of file diff --git a/docs/api/operations/headlines.md b/docs/api/operations/headlines.md index 487541f..9641ca3 100644 --- a/docs/api/operations/headlines.md +++ b/docs/api/operations/headlines.md @@ -54,13 +54,13 @@ The async version `AsyncHeadlineOperations` provides the same methods as above, # List headlines for a specific meeting meeting_headlines = client.headline.list(meeting_id=123) - # Update headline title + # Update headline title (returns None) client.headline.update( headline_id=headline.id, title="Product launch exceeded expectations" ) - - # Delete headline + + # Delete headline (returns None) client.headline.delete(headline_id=headline.id) ``` @@ -90,13 +90,13 @@ The async version `AsyncHeadlineOperations` provides the same methods as above, # List headlines for a specific meeting meeting_headlines = await client.headline.list(meeting_id=123) - # Update headline title + # Update headline title (returns None) await client.headline.update( headline_id=headline.id, title="Product launch exceeded expectations" ) - - # Delete headline + + # Delete headline (returns None) await client.headline.delete(headline_id=headline.id) asyncio.run(main()) @@ -113,4 +113,7 @@ The async version `AsyncHeadlineOperations` provides the same methods as above, | `delete()` | Delete a headline | `headline_id` | !!! note "Filtering" - Like todos, headlines can be filtered by either `user_id` or `meeting_id`, but not both. \ No newline at end of file + Like todos, headlines can be filtered by either `user_id` or `meeting_id`, but not both. + +!!! note "Return Values" + The `update()` and `delete()` methods return `None` instead of boolean values. \ No newline at end of file diff --git a/docs/api/operations/issues.md b/docs/api/operations/issues.md index 9467cb0..b939b52 100644 --- a/docs/api/operations/issues.md +++ b/docs/api/operations/issues.md @@ -54,9 +54,17 @@ The async version `AsyncIssueOperations` provides the same methods as above, but # List issues for a specific meeting meeting_issues = client.issue.list(meeting_id=123) - - # Solve an issue (mark as completed) - client.issue.solve(issue_id=issue.id) + + # Update an issue + updated = client.issue.update( + issue_id=issue.id, + title="Updated: Server performance degradation", + notes="Added monitoring and identified bottleneck" + ) + + # Complete an issue (mark as solved) + completed = client.issue.complete(issue_id=issue.id) + print(f"Completed: {completed.title}") ``` === "Async" @@ -85,21 +93,81 @@ The async version `AsyncIssueOperations` provides the same methods as above, but # List issues for a specific meeting meeting_issues = await client.issue.list(meeting_id=123) - - # Solve an issue (mark as completed) - await client.issue.solve(issue_id=issue.id) + + # Update an issue + updated = await client.issue.update( + issue_id=issue.id, + title="Updated: Server performance degradation", + notes="Added monitoring and identified bottleneck" + ) + + # Complete an issue (mark as solved) + completed = await client.issue.complete(issue_id=issue.id) + print(f"Completed: {completed.title}") asyncio.run(main()) ``` ## Available Methods -| Method | Description | Parameters | -|--------|-------------|------------| -| `details()` | Get detailed issue information | `issue_id` | -| `list()` | Get issues | `user_id`, `meeting_id` | -| `create()` | Create a new issue | `meeting_id`, `title`, `user_id`, `notes` | -| `solve()` | Mark an issue as solved | `issue_id` | +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `details()` | Get detailed issue information | `issue_id` | `IssueDetails` | +| `list()` | Get issues | `user_id`, `meeting_id` | `list[IssueDetails]` | +| `create()` | Create a new issue | `meeting_id`, `title`, `user_id`, `notes` | `IssueDetails` | +| `update()` | Update an existing issue | `issue_id`, `title`, `notes` | `IssueDetails` | +| `complete()` | Mark an issue as solved | `issue_id` | `IssueDetails` | + +!!! tip "Issue Management" + - Issues can only be marked as solved, not deleted. Use the `complete()` method to close an issue. + - The `complete()` method returns the updated issue details, allowing you to verify the completion. + - Use `update()` to modify issue title or notes before completing. + +## Update Examples + +=== "Sync" + + ```python + from bloomy import Client + + with Client(api_key="your-api-key") as client: + # Update issue title only + updated = client.issue.update(123, title="New Title") + + # Update issue notes only + updated = client.issue.update(123, notes="Additional context and details") + + # Update both title and notes + updated = client.issue.update( + issue_id=123, + title="Critical: Database Connection Pool Exhausted", + notes="Increased max connections from 100 to 200" + ) + ``` + +=== "Async" + + ```python + import asyncio + from bloomy import AsyncClient + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # Update issue title only + updated = await client.issue.update(123, title="New Title") + + # Update issue notes only + updated = await client.issue.update(123, notes="Additional context and details") + + # Update both title and notes + updated = await client.issue.update( + issue_id=123, + title="Critical: Database Connection Pool Exhausted", + notes="Increased max connections from 100 to 200" + ) + + asyncio.run(main()) + ``` -!!! tip "Issue Resolution" - Issues can only be marked as solved, not deleted. Use the `solve()` method to close an issue. \ No newline at end of file +!!! note "Update Requirements" + At least one of `title` or `notes` must be provided when calling `update()`. If neither is provided, a `ValueError` will be raised. \ No newline at end of file diff --git a/docs/api/operations/scorecard.md b/docs/api/operations/scorecard.md index c90a925..fb75e78 100644 --- a/docs/api/operations/scorecard.md +++ b/docs/api/operations/scorecard.md @@ -34,29 +34,37 @@ The async version `AsyncScorecardOperations` provides the same methods as above, ```python from bloomy import Client - + with Client(api_key="your-api-key") as client: # Get current week information week = client.scorecard.current_week() print(f"Week {week.week_number}: {week.week_start} to {week.week_end}") - + # List scorecards for current user scorecards = client.scorecard.list() for s in scorecards: print(f"{s.title}: {s.value}/{s.target}") - + + # Get a specific scorecard item + item = client.scorecard.get(measurable_id=123) + if item: + print(f"{item.title}: {item.value}/{item.target}") + + # Get from a specific week + item = client.scorecard.get(measurable_id=123, week_offset=-1) + # List scorecards for a specific meeting meeting_scorecards = client.scorecard.list(meeting_id=123) - + # Include empty values all_scorecards = client.scorecard.list(show_empty=True) - + # Get scorecards for previous week last_week = client.scorecard.list(week_offset=-1) - + # Update a score for current week client.scorecard.score(measurable_id=301, score=95.5) - + # Update score for next week client.scorecard.score( measurable_id=301, @@ -70,37 +78,45 @@ The async version `AsyncScorecardOperations` provides the same methods as above, ```python import asyncio from bloomy import AsyncClient - + async def main(): async with AsyncClient(api_key="your-api-key") as client: # Get current week information week = await client.scorecard.current_week() print(f"Week {week.week_number}: {week.week_start} to {week.week_end}") - + # List scorecards for current user scorecards = await client.scorecard.list() for s in scorecards: print(f"{s.title}: {s.value}/{s.target}") - + + # Get a specific scorecard item + item = await client.scorecard.get(measurable_id=123) + if item: + print(f"{item.title}: {item.value}/{item.target}") + + # Get from a specific week + item = await client.scorecard.get(measurable_id=123, week_offset=-1) + # List scorecards for a specific meeting meeting_scorecards = await client.scorecard.list(meeting_id=123) - + # Include empty values all_scorecards = await client.scorecard.list(show_empty=True) - + # Get scorecards for previous week last_week = await client.scorecard.list(week_offset=-1) - + # Update a score for current week await client.scorecard.score(measurable_id=301, score=95.5) - + # Update score for next week await client.scorecard.score( measurable_id=301, score=100, week_offset=1 ) - + asyncio.run(main()) ``` @@ -109,6 +125,7 @@ The async version `AsyncScorecardOperations` provides the same methods as above, | Method | Description | Parameters | |--------|-------------|------------| | `current_week()` | Get current week details | - | +| `get()` | Get a single scorecard item | `measurable_id`, `user_id`, `week_offset` | | `list()` | Get scorecards | `user_id`, `meeting_id`, `show_empty`, `week_offset` | | `score()` | Update a scorecard value | `measurable_id`, `score`, `week_offset` | diff --git a/docs/api/operations/todos.md b/docs/api/operations/todos.md index d427274..6fa69ae 100644 --- a/docs/api/operations/todos.md +++ b/docs/api/operations/todos.md @@ -58,9 +58,10 @@ The async version `AsyncTodoOperations` provides the same methods as above, but due_date="2024-12-15" ) - # Mark a todo as complete - client.todo.complete(todo_id=new_todo.id) - + # Mark a todo as complete (returns the completed Todo) + completed_todo = client.todo.complete(todo_id=new_todo.id) + print(f"Completed: {completed_todo.title} at {completed_todo.complete_time}") + # Delete a todo client.todo.delete(todo_id=new_todo.id) ``` @@ -95,9 +96,10 @@ The async version `AsyncTodoOperations` provides the same methods as above, but due_date="2024-12-15" ) - # Mark a todo as complete - await client.todo.complete(todo_id=new_todo.id) - + # Mark a todo as complete (returns the completed Todo) + completed_todo = await client.todo.complete(todo_id=new_todo.id) + print(f"Completed: {completed_todo.title} at {completed_todo.complete_time}") + # Delete a todo await client.todo.delete(todo_id=new_todo.id) diff --git a/docs/api/operations/users.md b/docs/api/operations/users.md index 447ffb2..aa604f2 100644 --- a/docs/api/operations/users.md +++ b/docs/api/operations/users.md @@ -46,13 +46,14 @@ The async version `AsyncUserOperations` provides the same methods as above, but print(f"Found: {user.name}") # Get all users - all_users = client.user.all() - + all_users = client.user.list() + # Get user with direct reports and positions user_full = client.user.details( - user_id=123, + user_id=123, include_direct_reports=True, - include_positions=True + include_positions=True, + include_all=True ) ``` @@ -74,13 +75,14 @@ The async version `AsyncUserOperations` provides the same methods as above, but print(f"Found: {user.name}") # Get all users - all_users = await client.user.all() - + all_users = await client.user.list() + # Get user with direct reports and positions user_full = await client.user.details( - user_id=123, + user_id=123, include_direct_reports=True, - include_positions=True + include_positions=True, + include_all=True ) asyncio.run(main()) @@ -90,8 +92,8 @@ The async version `AsyncUserOperations` provides the same methods as above, but | Method | Description | Parameters | |--------|-------------|------------| -| `details()` | Get detailed information about a user | `user_id`, `include_direct_reports`, `include_positions`, `all` | +| `details()` | Get detailed information about a user | `user_id`, `include_direct_reports`, `include_positions`, `include_all` | | `search()` | Search for users by name or email | `term` | -| `all()` | Get all users in the system | `include_placeholders` | +| `list()` | Get all users in the system | `include_placeholders` | | `direct_reports()` | Get direct reports for a user | `user_id` | | `positions()` | Get positions held by a user | `user_id` | \ No newline at end of file diff --git a/docs/guide/errors.md b/docs/guide/errors.md index 1d8d7c2..0741b19 100644 --- a/docs/guide/errors.md +++ b/docs/guide/errors.md @@ -94,7 +94,7 @@ from bloomy import Client, APIError, ConfigurationError try: client = Client() - users = client.user.all() + users = client.user.list() except ConfigurationError: print("Please configure your API key") except APIError as e: @@ -151,7 +151,7 @@ def retry_with_backoff(func, max_retries=3, initial_delay=1): # Usage client = Client(api_key="your-api-key") -users = retry_with_backoff(lambda: client.user.all()) +users = retry_with_backoff(lambda: client.user.list()) ``` ### Graceful Degradation @@ -224,7 +224,7 @@ logger = logging.getLogger('bloomy_app') client = Client(api_key="your-api-key") try: - users = client.user.all() + users = client.user.list() logger.info(f"Successfully fetched {len(users)} users") except APIError as e: logger.error( @@ -258,7 +258,7 @@ def handle_api_errors(default=None): # Usage with handle_api_errors(default=[]): - users = client.user.all() + users = client.user.list() ``` ## Best Practices @@ -302,7 +302,7 @@ from bloomy import Client try: client = Client(api_key="your-api-key") - users = client.user.all() + users = client.user.list() except httpx.TimeoutException: print("Request timed out. Please try again.") ``` diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 0023774..9c61363 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -20,7 +20,7 @@ For automatic resource cleanup: ```python with Client(api_key="your-api-key") as client: # Use client here - users = client.user.all() + users = client.user.list() # Connection automatically closed ``` @@ -119,6 +119,26 @@ def retry_operation(func, max_retries=3, delay=1): user = retry_operation(lambda: client.user.details()) ``` +### Custom Configuration + +Configure the client with custom settings: + +```python +from bloomy import Client + +# Custom base URL for testing/staging +client = Client( + api_key="your-api-key", + base_url="https://staging.example.com/api/v1" +) + +# Custom timeout for slow networks +client = Client( + api_key="your-api-key", + timeout=60.0 # 60 seconds +) +``` + ### Custom Headers Add custom headers if needed: diff --git a/pyproject.toml b/pyproject.toml index 5774a28..cbe54ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bloomy-python" -version = "0.19.0" +version = "0.20.0" description = "Python SDK for Bloom Growth API" readme = "README.md" authors = [{ name = "Franccesco Orozco", email = "franccesco@codingdose.info" }] diff --git a/src/bloomy/__init__.py b/src/bloomy/__init__.py index 27b2b55..b9f9c1f 100644 --- a/src/bloomy/__init__.py +++ b/src/bloomy/__init__.py @@ -15,6 +15,7 @@ Goal, GoalInfo, GoalListResponse, + GoalStatus, Headline, HeadlineDetails, HeadlineInfo, @@ -56,6 +57,7 @@ "Goal", "GoalInfo", "GoalListResponse", + "GoalStatus", "Headline", "HeadlineDetails", "HeadlineInfo", diff --git a/src/bloomy/async_client.py b/src/bloomy/async_client.py index 4971ad6..61f77d4 100644 --- a/src/bloomy/async_client.py +++ b/src/bloomy/async_client.py @@ -7,6 +7,7 @@ import httpx from .configuration import Configuration +from .exceptions import ConfigurationError if TYPE_CHECKING: from types import TracebackType @@ -62,22 +63,34 @@ def __init__( self, api_key: str | None = None, base_url: str = "https://app.bloomgrowth.com/api/v1", + timeout: float = 30.0, ) -> None: """Initialize the async Bloomy client. Args: api_key: The API key for authentication. base_url: The base URL for the API. + timeout: The timeout in seconds for HTTP requests. Defaults to 30.0. + + Raises: + ConfigurationError: If no API key is provided or found in configuration. """ config = Configuration(api_key=api_key) + + if not config.api_key: + raise ConfigurationError( + "No API key provided. Set it explicitly, via BG_API_KEY " + "environment variable, or in ~/.bloomy/config.yaml configuration file." + ) + self._client = httpx.AsyncClient( base_url=base_url, headers={ "Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json", }, - timeout=30.0, + timeout=timeout, ) # Lazy imports to avoid circular dependencies diff --git a/src/bloomy/client.py b/src/bloomy/client.py index f08415c..b6d3d78 100644 --- a/src/bloomy/client.py +++ b/src/bloomy/client.py @@ -7,6 +7,7 @@ import httpx from .configuration import Configuration +from .exceptions import ConfigurationError from .operations.goals import GoalOperations from .operations.headlines import HeadlineOperations from .operations.issues import IssueOperations @@ -38,15 +39,22 @@ class Client: """ - def __init__(self, api_key: str | None = None) -> None: + def __init__( + self, + api_key: str | None = None, + base_url: str = "https://app.bloomgrowth.com/api/v1", + timeout: float = 30.0, + ) -> None: """Initialize a new Client instance. Args: api_key: The API key to use. If not provided, will attempt to load from environment variable (BG_API_KEY) or configuration file. + base_url: The base URL for the API. Defaults to the production API URL. + timeout: The timeout in seconds for HTTP requests. Defaults to 30.0. Raises: - ValueError: If no API key is provided or found in configuration. + ConfigurationError: If no API key is provided or found in configuration. """ # Use Configuration class which handles priority: @@ -56,23 +64,23 @@ def __init__(self, api_key: str | None = None) -> None: self.configuration = Configuration(api_key) if not self.configuration.api_key: - raise ValueError( + raise ConfigurationError( "No API key provided. Set it explicitly, via BG_API_KEY " "environment variable, or in ~/.bloomy/config.yaml configuration file." ) self._api_key = self.configuration.api_key - self._base_url = "https://app.bloomgrowth.com/api/v1" + self._base_url = base_url # Initialize HTTP client self._client = httpx.Client( - base_url=self._base_url, + base_url=base_url, headers={ "Accept": "*/*", "Content-Type": "application/json", "Authorization": f"Bearer {self._api_key}", }, - timeout=30.0, + timeout=timeout, ) # Initialize operation classes diff --git a/src/bloomy/models.py b/src/bloomy/models.py index 01f5d4d..d344342 100644 --- a/src/bloomy/models.py +++ b/src/bloomy/models.py @@ -3,11 +3,20 @@ from __future__ import annotations from datetime import datetime +from enum import StrEnum from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator +class GoalStatus(StrEnum): + """Valid goal status values.""" + + ON_TRACK = "on" + AT_RISK = "off" + COMPLETE = "complete" + + class BloomyBaseModel(BaseModel): """Base model with common configuration for all Bloomy models.""" diff --git a/src/bloomy/operations/async_/goals.py b/src/bloomy/operations/async_/goals.py index 6021cf3..a781f06 100644 --- a/src/bloomy/operations/async_/goals.py +++ b/src/bloomy/operations/async_/goals.py @@ -13,6 +13,7 @@ CreatedGoalInfo, GoalInfo, GoalListResponse, + GoalStatus, ) from ...utils.async_base_operations import AsyncBaseOperations @@ -120,27 +121,23 @@ async def create( created_at=data["CreateTime"], ) - async def delete(self, goal_id: int) -> bool: + async def delete(self, goal_id: int) -> None: """Delete a goal. Args: goal_id: The ID of the goal to delete - Returns: - True if deletion was successful - """ response = await self._client.delete(f"rocks/{goal_id}") response.raise_for_status() - return True async def update( self, goal_id: int, title: str | None = None, accountable_user: int | None = None, - status: str | None = None, - ) -> bool: + status: GoalStatus | str | None = None, + ) -> None: """Update a goal. Args: @@ -148,10 +145,9 @@ async def update( title: The new title of the goal accountable_user: The ID of the user responsible for the goal (default: initialized user ID) - status: The status value ('on', 'off', or 'complete') - - Returns: - True if the update was successful + 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 @@ -167,7 +163,9 @@ async def update( if status is not None: valid_status = {"on": "OnTrack", "off": "AtRisk", "complete": "Complete"} - status_key = status.lower() + # 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'." @@ -176,35 +174,26 @@ async def update( response = await self._client.put(f"rocks/{goal_id}", json=payload) response.raise_for_status() - return True - async def archive(self, goal_id: int) -> bool: + async def archive(self, goal_id: int) -> None: """Archive a rock with the specified goal ID. Args: goal_id: The ID of the goal/rock to archive - Returns: - True if the archival was successful - """ response = await self._client.put(f"rocks/{goal_id}/archive") response.raise_for_status() - return True - async def restore(self, goal_id: int) -> bool: + async def restore(self, goal_id: int) -> None: """Restore a previously archived goal identified by the provided goal ID. Args: goal_id: The unique identifier of the goal to restore - Returns: - True if the restore operation was successful - """ response = await self._client.put(f"rocks/{goal_id}/restore") response.raise_for_status() - return True async def _get_archived_goals( self, user_id: int | None = None diff --git a/src/bloomy/operations/async_/headlines.py b/src/bloomy/operations/async_/headlines.py index ee9a27d..d6dcdb1 100644 --- a/src/bloomy/operations/async_/headlines.py +++ b/src/bloomy/operations/async_/headlines.py @@ -68,21 +68,17 @@ async def create( notes_url=data.get("DetailsUrl", ""), ) - async def update(self, headline_id: int, title: str) -> bool: + async def update(self, headline_id: int, title: str) -> None: """Update a headline. Args: headline_id: The ID of the headline to update title: The new title of the headline - Returns: - True if update was successful - """ payload = {"title": title} response = await self._client.put(f"headline/{headline_id}", json=payload) response.raise_for_status() - return True async def details(self, headline_id: int) -> HeadlineDetails: """Get headline details. @@ -166,16 +162,12 @@ async def list( for headline in data ] - async def delete(self, headline_id: int) -> bool: + async def delete(self, headline_id: int) -> None: """Delete a headline. Args: headline_id: The ID of the headline to delete - Returns: - True if the deletion was successful - """ response = await self._client.delete(f"headline/{headline_id}") response.raise_for_status() - return True diff --git a/src/bloomy/operations/async_/issues.py b/src/bloomy/operations/async_/issues.py index c65a26e..2336959 100644 --- a/src/bloomy/operations/async_/issues.py +++ b/src/bloomy/operations/async_/issues.py @@ -103,21 +103,69 @@ async def list( for issue in data ] - async def solve(self, issue_id: int) -> bool: + async def complete(self, issue_id: int) -> IssueDetails: """Mark an issue as completed/solved. Args: - issue_id: Unique identifier of the issue to be solved + issue_id: Unique identifier of the issue to be completed Returns: - True if issue was successfully solved + The updated IssueDetails + + Example: + ```python + completed_issue = await client.issue.complete(123) + print(completed_issue.completed_at) + ``` """ response = await self._client.post( f"issues/{issue_id}/complete", json={"complete": True} ) response.raise_for_status() - return True + return await self.details(issue_id) + + async def update( + self, + issue_id: int, + title: str | None = None, + notes: str | None = None, + ) -> IssueDetails: + """Update an existing issue. + + Args: + issue_id: The ID of the issue to update + title: New title for the issue (optional) + notes: New notes for the issue (optional) + + Returns: + The updated IssueDetails + + Raises: + ValueError: If no update fields are provided + + Example: + ```python + updated = await client.issue.update(123, title="New Title") + print(updated.title) + ``` + + """ + if title is None and notes is None: + raise ValueError( + "At least one field (title or notes) must be provided for update" + ) + + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if notes is not None: + payload["notes"] = notes + + response = await self._client.put(f"issues/{issue_id}", json=payload) + response.raise_for_status() + + return await self.details(issue_id) async def create( self, diff --git a/src/bloomy/operations/async_/scorecard.py b/src/bloomy/operations/async_/scorecard.py index 258198e..69a10f0 100644 --- a/src/bloomy/operations/async_/scorecard.py +++ b/src/bloomy/operations/async_/scorecard.py @@ -106,6 +106,35 @@ async def list( return scorecards + async def get( + self, + measurable_id: int, + user_id: int | None = None, + week_offset: int = 0, + ) -> ScorecardItem | None: + """Get a single scorecard item by measurable ID. + + Args: + measurable_id: The ID of the measurable item + user_id: The user ID (defaults to current user) + week_offset: Week offset from current week (default: 0) + + Returns: + ScorecardItem if found, None otherwise + + Example: + ```python + item = await client.scorecard.get(measurable_id=123) + if item: + print(f"{item.title}: {item.value}/{item.target}") + ``` + + """ + scorecards = await self.list( + user_id=user_id, show_empty=True, week_offset=week_offset + ) + return next((s for s in scorecards if s.measurable_id == measurable_id), None) + async def score( self, measurable_id: int, score: float, week_offset: int = 0 ) -> bool: diff --git a/src/bloomy/operations/async_/todos.py b/src/bloomy/operations/async_/todos.py index 8cd0196..033116b 100644 --- a/src/bloomy/operations/async_/todos.py +++ b/src/bloomy/operations/async_/todos.py @@ -156,25 +156,25 @@ async def create( return Todo.model_validate(todo_data) - async def complete(self, todo_id: int) -> bool: + async def complete(self, todo_id: int) -> Todo: """Mark a todo as complete. Args: todo_id: The ID of the todo to complete Returns: - True if the operation was successful + A Todo model instance containing the completed todo details Example: ```python await client.todo.complete(1) - # Returns: True + # Returns: Todo(id=1, name='Todo', complete=True, ...) ``` """ response = await self._client.post(f"todo/{todo_id}/complete?status=true") response.raise_for_status() - return response.is_success + return await self.details(todo_id) async def update( self, @@ -221,20 +221,8 @@ async def update( if response.status_code != 200: raise RuntimeError(f"Failed to update todo. Status: {response.status_code}") - # Construct todo data for validation - todo_data = { - "Id": todo_id, - "Name": title or "", - "DetailsUrl": "", - "DueDate": due_date, - "CompleteTime": None, - "CreateTime": datetime.now().isoformat(), - "OriginId": None, - "Origin": None, - "Complete": False, - } - - return Todo.model_validate(todo_data) + # Fetch the updated todo details + return await self.details(todo_id) async def details(self, todo_id: int) -> Todo: """Retrieve the details of a specific todo item by its ID. diff --git a/src/bloomy/operations/async_/users.py b/src/bloomy/operations/async_/users.py index e988b91..9cd9dab 100644 --- a/src/bloomy/operations/async_/users.py +++ b/src/bloomy/operations/async_/users.py @@ -35,15 +35,17 @@ async def details( user_id: int | None = None, include_direct_reports: bool = False, include_positions: bool = False, - all: bool = False, + include_all: bool = False, ) -> UserDetails: """Retrieve details of a specific user. Args: user_id: The ID of the user (default: the current user ID) - include_direct_reports: Whether to include direct reports (default: False) + include_direct_reports: Whether to include direct reports + (default: False) include_positions: Whether to include positions (default: False) - all: Whether to include both direct reports and positions (default: False) + include_all: Whether to include both direct reports and positions + (default: False) Returns: A UserDetails model containing user details @@ -59,10 +61,10 @@ async def details( direct_reports_data = None positions_data = None - if include_direct_reports or all: + if include_direct_reports or include_all: direct_reports_data = await self.direct_reports(user_id) - if include_positions or all: + if include_positions or include_all: positions_data = await self.positions(user_id) return self._transform_user_details(data, direct_reports_data, positions_data) @@ -121,7 +123,7 @@ async def search(self, term: str) -> list[UserSearchResult]: return self._transform_search_results(data) - async def all(self, include_placeholders: bool = False) -> list[UserListItem]: + async def list(self, include_placeholders: bool = False) -> list[UserListItem]: """Retrieve all users in the system. Args: diff --git a/src/bloomy/operations/goals.py b/src/bloomy/operations/goals.py index 269325c..12d1bc2 100644 --- a/src/bloomy/operations/goals.py +++ b/src/bloomy/operations/goals.py @@ -12,6 +12,7 @@ CreatedGoalInfo, GoalInfo, GoalListResponse, + GoalStatus, ) from ..utils.base_operations import BaseOperations @@ -136,33 +137,28 @@ def create( created_at=data["CreateTime"], ) - def delete(self, goal_id: int) -> bool: + def delete(self, goal_id: int) -> None: """Delete a goal. Args: goal_id: The ID of the goal to delete - Returns: - True if deletion was successful - Example: ```python client.goal.delete(1) - # Returns: True ``` """ response = self._client.delete(f"rocks/{goal_id}") response.raise_for_status() - return True def update( self, goal_id: int, title: str | None = None, accountable_user: int | None = None, - status: str | None = None, - ) -> bool: + status: GoalStatus | str | None = None, + ) -> None: """Update a goal. Args: @@ -170,18 +166,22 @@ def update( title: The new title of the goal accountable_user: The ID of the user responsible for the goal (default: initialized user ID) - status: The status value ('on', 'off', or 'complete') - - Returns: - True if the update was successful + 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 Example: ```python - client.goal.update(goal_id=1, title="Updated Goal", status='on') - # Returns: True + from bloomy import GoalStatus + + # Using enum (recommended) + client.goal.update(goal_id=1, status=GoalStatus.ON_TRACK) + + # Using string + client.goal.update(goal_id=1, status='on') ``` """ @@ -195,7 +195,9 @@ def update( if status is not None: valid_status = {"on": "OnTrack", "off": "AtRisk", "complete": "Complete"} - status_key = status.lower() + # 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'." @@ -204,47 +206,36 @@ def update( response = self._client.put(f"rocks/{goal_id}", json=payload) response.raise_for_status() - return True - def archive(self, goal_id: int) -> bool: + def archive(self, goal_id: int) -> None: """Archive a rock with the specified goal ID. Args: goal_id: The ID of the goal/rock to archive - Returns: - True if the archival was successful, False otherwise - Example: ```python - goals.archive(123) - # Returns: True + client.goal.archive(123) ``` """ response = self._client.put(f"rocks/{goal_id}/archive") response.raise_for_status() - return True - def restore(self, goal_id: int) -> bool: + def restore(self, goal_id: int) -> None: """Restore a previously archived goal identified by the provided goal ID. Args: goal_id: The unique identifier of the goal to restore - Returns: - True if the restore operation was successful, False otherwise - Example: ```python - goals.restore(123) - # Returns: True + client.goal.restore(123) ``` """ response = self._client.put(f"rocks/{goal_id}/restore") response.raise_for_status() - return True def _get_archived_goals(self, user_id: int | None = None) -> list[ArchivedGoalInfo]: """Retrieve all archived goals for a specific user (private method). diff --git a/src/bloomy/operations/headlines.py b/src/bloomy/operations/headlines.py index eb58d75..f0763fb 100644 --- a/src/bloomy/operations/headlines.py +++ b/src/bloomy/operations/headlines.py @@ -55,21 +55,17 @@ def create( notes_url=data.get("DetailsUrl", ""), ) - def update(self, headline_id: int, title: str) -> bool: + def update(self, headline_id: int, title: str) -> None: """Update a headline. Args: headline_id: The ID of the headline to update title: The new title of the headline - Returns: - True if update was successful - """ payload = {"title": title} response = self._client.put(f"headline/{headline_id}", json=payload) response.raise_for_status() - return True def details(self, headline_id: int) -> HeadlineDetails: """Get headline details. @@ -176,16 +172,12 @@ def list( for headline in data ] - def delete(self, headline_id: int) -> bool: + def delete(self, headline_id: int) -> None: """Delete a headline. Args: headline_id: The ID of the headline to delete - Returns: - True if the deletion was successful - """ response = self._client.delete(f"headline/{headline_id}") response.raise_for_status() - return True diff --git a/src/bloomy/operations/issues.py b/src/bloomy/operations/issues.py index 28f9f9d..742386e 100644 --- a/src/bloomy/operations/issues.py +++ b/src/bloomy/operations/issues.py @@ -110,19 +110,19 @@ def list( for issue in data ] - def solve(self, issue_id: int) -> bool: + def complete(self, issue_id: int) -> IssueDetails: """Mark an issue as completed/solved. Args: - issue_id: Unique identifier of the issue to be solved + issue_id: Unique identifier of the issue to be completed Returns: - True if issue was successfully solved + The updated IssueDetails Example: ```python - client.issue.solve(123) - # Returns: True + completed_issue = client.issue.complete(123) + print(completed_issue.completed_at) ``` """ @@ -130,7 +130,49 @@ def solve(self, issue_id: int) -> bool: f"issues/{issue_id}/complete", json={"complete": True} ) response.raise_for_status() - return True + return self.details(issue_id) + + def update( + self, + issue_id: int, + title: str | None = None, + notes: str | None = None, + ) -> IssueDetails: + """Update an existing issue. + + Args: + issue_id: The ID of the issue to update + title: New title for the issue (optional) + notes: New notes for the issue (optional) + + Returns: + The updated IssueDetails + + Raises: + ValueError: If no update fields are provided + + Example: + ```python + updated = client.issue.update(123, title="New Title") + print(updated.title) + ``` + + """ + if title is None and notes is None: + raise ValueError( + "At least one field (title or notes) must be provided for update" + ) + + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if notes is not None: + payload["notes"] = notes + + response = self._client.put(f"issues/{issue_id}", json=payload) + response.raise_for_status() + + return self.details(issue_id) def create( self, diff --git a/src/bloomy/operations/scorecard.py b/src/bloomy/operations/scorecard.py index d409a61..a052c20 100644 --- a/src/bloomy/operations/scorecard.py +++ b/src/bloomy/operations/scorecard.py @@ -126,6 +126,35 @@ def list( return scorecards + def get( + self, + measurable_id: int, + user_id: int | None = None, + week_offset: int = 0, + ) -> ScorecardItem | None: + """Get a single scorecard item by measurable ID. + + Args: + measurable_id: The ID of the measurable item + user_id: The user ID (defaults to current user) + week_offset: Week offset from current week (default: 0) + + Returns: + ScorecardItem if found, None otherwise + + Example: + ```python + item = client.scorecard.get(measurable_id=123) + if item: + print(f"{item.title}: {item.value}/{item.target}") + ``` + + """ + scorecards = self.list( + user_id=user_id, show_empty=True, week_offset=week_offset + ) + return next((s for s in scorecards if s.measurable_id == measurable_id), None) + def score(self, measurable_id: int, score: float, week_offset: int = 0) -> bool: """Update the score for a measurable item for a specific week. diff --git a/src/bloomy/operations/todos.py b/src/bloomy/operations/todos.py index cafd120..91a3adf 100644 --- a/src/bloomy/operations/todos.py +++ b/src/bloomy/operations/todos.py @@ -144,25 +144,25 @@ def create( return Todo.model_validate(todo_data) - def complete(self, todo_id: int) -> bool: + def complete(self, todo_id: int) -> Todo: """Mark a todo as complete. Args: todo_id: The ID of the todo to complete Returns: - True if the operation was successful + A Todo model instance containing the completed todo details Example: ```python client.todo.complete(1) - # Returns: True + # Returns: Todo(id=1, name='Todo', complete=True, ...) ``` """ response = self._client.post(f"todo/{todo_id}/complete?status=true") response.raise_for_status() - return response.is_success + return self.details(todo_id) def update( self, @@ -209,20 +209,8 @@ def update( if response.status_code != 200: raise RuntimeError(f"Failed to update todo. Status: {response.status_code}") - # Construct todo data for validation - todo_data = { - "Id": todo_id, - "Name": title or "", - "DetailsUrl": "", - "DueDate": due_date, - "CompleteTime": None, - "CreateTime": datetime.now().isoformat(), - "OriginId": None, - "Origin": None, - "Complete": False, - } - - return Todo.model_validate(todo_data) + # Fetch the updated todo details + return self.details(todo_id) def details(self, todo_id: int) -> Todo: """Retrieve the details of a specific todo item by its ID. diff --git a/src/bloomy/operations/users.py b/src/bloomy/operations/users.py index 77a2444..640ad57 100644 --- a/src/bloomy/operations/users.py +++ b/src/bloomy/operations/users.py @@ -15,15 +15,17 @@ def details( user_id: int | None = None, include_direct_reports: bool = False, include_positions: bool = False, - all: bool = False, + include_all: bool = False, ) -> UserDetails: """Retrieve details of a specific user. Args: user_id: The ID of the user (default: the current user ID) - include_direct_reports: Whether to include direct reports (default: False) + include_direct_reports: Whether to include direct reports + (default: False) include_positions: Whether to include positions (default: False) - all: Whether to include both direct reports and positions (default: False) + include_all: Whether to include both direct reports and positions + (default: False) Returns: A UserDetails model containing user details @@ -39,10 +41,10 @@ def details( direct_reports_data = None positions_data = None - if include_direct_reports or all: + if include_direct_reports or include_all: direct_reports_data = self.direct_reports(user_id) - if include_positions or all: + if include_positions or include_all: positions_data = self.positions(user_id) return self._transform_user_details(data, direct_reports_data, positions_data) @@ -101,7 +103,7 @@ def search(self, term: str) -> list[UserSearchResult]: return self._transform_search_results(data) - def all(self, include_placeholders: bool = False) -> list[UserListItem]: + def list(self, include_placeholders: bool = False) -> list[UserListItem]: """Retrieve all users in the system. Args: diff --git a/tests/test_async_goals.py b/tests/test_async_goals.py index 3691a14..6b5abfd 100644 --- a/tests/test_async_goals.py +++ b/tests/test_async_goals.py @@ -182,7 +182,7 @@ async def test_delete( result = await async_client.goal.delete(123) - assert result is True + assert result is None mock_async_client.delete.assert_called_once_with("rocks/123") @pytest.mark.asyncio @@ -198,7 +198,7 @@ async def test_update( goal_id=123, title="Updated Goal", status="on" ) - assert result is True + assert result is None mock_async_client.put.assert_called_once_with( "rocks/123", json={ @@ -227,7 +227,7 @@ async def test_archive( result = await async_client.goal.archive(123) - assert result is True + assert result is None mock_async_client.put.assert_called_once_with("rocks/123/archive") @pytest.mark.asyncio @@ -241,7 +241,7 @@ async def test_restore( result = await async_client.goal.restore(123) - assert result is True + assert result is None mock_async_client.put.assert_called_once_with("rocks/123/restore") @pytest.mark.asyncio diff --git a/tests/test_async_headlines.py b/tests/test_async_headlines.py index eb0aac4..5dd9e68 100644 --- a/tests/test_async_headlines.py +++ b/tests/test_async_headlines.py @@ -94,7 +94,7 @@ async def test_update( headline_id=501, title="Updated headline" ) - assert result is True + assert result is None mock_async_client.put.assert_called_once_with( "headline/501", json={"title": "Updated headline"} ) @@ -229,5 +229,5 @@ async def test_delete( result = await async_client.headline.delete(501) - assert result is True + assert result is None mock_async_client.delete.assert_called_once_with("headline/501") diff --git a/tests/test_async_todos.py b/tests/test_async_todos.py index 7781162..dd79063 100644 --- a/tests/test_async_todos.py +++ b/tests/test_async_todos.py @@ -243,33 +243,66 @@ async def test_complete( self, async_client: AsyncClient, mock_async_client: AsyncMock ) -> None: """Test completing a todo.""" - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_response.is_success = True + # Mock complete response + mock_complete_response = MagicMock() + mock_complete_response.raise_for_status = MagicMock() + mock_complete_response.is_success = True + + # Mock details response + mock_details_response = MagicMock() + mock_details_response.json.return_value = { + "Id": 1, + "Name": "Completed Task", + "DetailsUrl": "https://example.com/todo/1", + "DueDate": "2024-12-31", + "CreateTime": "2024-01-01T10:00:00Z", + "CompleteTime": "2024-12-10T10:00:00Z", + "Complete": True, + } + mock_details_response.is_success = True + mock_details_response.raise_for_status = MagicMock() - mock_async_client.post.return_value = mock_response + mock_async_client.post.return_value = mock_complete_response + mock_async_client.get.return_value = mock_details_response # Call the method result = await async_client.todo.complete(todo_id=1) # Verify the result - assert result is True + assert isinstance(result, Todo) + assert result.id == 1 + assert result.complete is True - # Verify the API call + # Verify the API calls mock_async_client.post.assert_called_once_with("todo/1/complete?status=true") + mock_async_client.get.assert_called_once_with("todo/1") @pytest.mark.asyncio async def test_update( self, async_client: AsyncClient, mock_async_client: AsyncMock ) -> None: """Test updating a todo.""" - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - - mock_async_client.post.return_value = mock_response + # Mock update response + mock_update_response = MagicMock() + mock_update_response.raise_for_status = MagicMock() + mock_update_response.status_code = 200 + + # Mock details response + mock_details_response = MagicMock() + mock_details_response.json.return_value = { + "Id": 1, + "Name": "Updated Task", + "DetailsUrl": "https://example.com/todo/1", + "DueDate": "2024-12-01", + "CreateTime": "2024-01-01T10:00:00Z", + "CompleteTime": None, + "Complete": False, + } + mock_details_response.is_success = True + mock_details_response.raise_for_status = MagicMock() - mock_response.status_code = 200 - mock_async_client.put.return_value = mock_response + mock_async_client.put.return_value = mock_update_response + mock_async_client.get.return_value = mock_details_response # Call the method result = await async_client.todo.update( @@ -283,13 +316,14 @@ async def test_update( assert result.id == 1 assert result.name == "Updated Task" - # Verify the API call + # Verify the API calls mock_async_client.put.assert_called_once() put_args = mock_async_client.put.call_args assert put_args[0][0] == "todo/1" payload = put_args[1]["json"] assert payload["title"] == "Updated Task" assert payload["dueDate"] == "2024-12-01" + mock_async_client.get.assert_called_once_with("todo/1") @pytest.mark.asyncio async def test_create_many_all_successful( diff --git a/tests/test_async_users_extra.py b/tests/test_async_users_extra.py index 4913435..9602606 100644 --- a/tests/test_async_users_extra.py +++ b/tests/test_async_users_extra.py @@ -162,7 +162,7 @@ def get_side_effect(url: str) -> MagicMock: assert result.direct_reports[0].name == "Jane Smith" @pytest.mark.asyncio - async def test_all_users( + async def test_list_users( self, async_client: AsyncClient, mock_async_client: AsyncMock, @@ -197,7 +197,7 @@ async def test_all_users( mock_async_client.get.return_value = mock_response # Call the method - result = await async_client.user.all() + result = await async_client.user.list() # Verify the result assert len(result) == 2 diff --git a/tests/test_client.py b/tests/test_client.py index d175ed9..ddcc93f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,7 +4,8 @@ import pytest -from bloomy import Client +from bloomy import AsyncClient, Client +from bloomy.exceptions import ConfigurationError class TestClient: @@ -44,7 +45,7 @@ def test_init_no_api_key_found(self): mock_config.api_key = None mock_config_class.return_value = mock_config - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ConfigurationError) as exc_info: Client() assert "No API key provided" in str(exc_info.value) @@ -109,3 +110,64 @@ def test_base_url(self): # Test that httpx.Client was called with correct base_url call_kwargs = mock_httpx.call_args.kwargs assert call_kwargs["base_url"] == "https://app.bloomgrowth.com/api/v1" + + def test_custom_base_url(self): + """Test client initialization with custom base URL.""" + with patch("bloomy.client.httpx.Client") as mock_httpx: + custom_url = "https://custom.example.com/api" + client = Client(api_key="test-key", base_url=custom_url) + assert isinstance(client, Client) + # Test that httpx.Client was called with custom base_url + call_kwargs = mock_httpx.call_args.kwargs + assert call_kwargs["base_url"] == custom_url + + def test_custom_timeout(self): + """Test client initialization with custom timeout.""" + with patch("bloomy.client.httpx.Client") as mock_httpx: + custom_timeout = 60.0 + client = Client(api_key="test-key", timeout=custom_timeout) + assert isinstance(client, Client) + # Test that httpx.Client was called with custom timeout + call_kwargs = mock_httpx.call_args.kwargs + assert call_kwargs["timeout"] == custom_timeout + + def test_custom_base_url_and_timeout(self): + """Test client initialization with both custom base URL and timeout.""" + with patch("bloomy.client.httpx.Client") as mock_httpx: + custom_url = "https://staging.example.com/api" + custom_timeout = 45.0 + client = Client( + api_key="test-key", base_url=custom_url, timeout=custom_timeout + ) + assert isinstance(client, Client) + # Test that httpx.Client was called with both custom values + call_kwargs = mock_httpx.call_args.kwargs + assert call_kwargs["base_url"] == custom_url + assert call_kwargs["timeout"] == custom_timeout + + +class TestAsyncClient: + """Test cases for the AsyncClient class.""" + + def test_init_no_api_key_found(self): + """Test async client initialization with no API key available.""" + with patch("bloomy.async_client.Configuration") as mock_config_class: + mock_config = Mock() + mock_config.api_key = None + mock_config_class.return_value = mock_config + + with pytest.raises(ConfigurationError) as exc_info: + AsyncClient() + + assert "No API key provided" in str(exc_info.value) + + def test_init_with_api_key(self): + """Test async client initialization with API key.""" + with patch("bloomy.async_client.httpx.AsyncClient") as mock_httpx: + client = AsyncClient(api_key="test-key") + assert isinstance(client, AsyncClient) + # Test that httpx.AsyncClient was called with correct headers + mock_httpx.assert_called_once() + call_kwargs = mock_httpx.call_args.kwargs + assert "Authorization" in call_kwargs["headers"] + assert call_kwargs["headers"]["Authorization"] == "Bearer test-key" diff --git a/tests/test_goals.py b/tests/test_goals.py index 983e33f..4e6d731 100644 --- a/tests/test_goals.py +++ b/tests/test_goals.py @@ -106,7 +106,7 @@ def test_delete_goal(self, mock_http_client: Mock) -> None: goal_ops = GoalOperations(mock_http_client) result = goal_ops.delete(goal_id=101) - assert result is True + assert result is None mock_http_client.delete.assert_called_once_with("rocks/101") def test_update_goal(self, mock_http_client: Mock, mock_user_id: Mock) -> None: @@ -118,7 +118,7 @@ def test_update_goal(self, mock_http_client: Mock, mock_user_id: Mock) -> None: result = goal_ops.update(goal_id=101, title="Updated Goal", status="complete") - assert result is True + assert result is None mock_http_client.put.assert_called_once_with( "rocks/101", json={ @@ -147,7 +147,7 @@ def test_archive_goal(self, mock_http_client: Mock) -> None: goal_ops = GoalOperations(mock_http_client) result = goal_ops.archive(goal_id=101) - assert result is True + assert result is None mock_http_client.put.assert_called_once_with("rocks/101/archive") def test_restore_goal(self, mock_http_client: Mock) -> None: @@ -158,5 +158,5 @@ def test_restore_goal(self, mock_http_client: Mock) -> None: goal_ops = GoalOperations(mock_http_client) result = goal_ops.restore(goal_id=101) - assert result is True + assert result is None mock_http_client.put.assert_called_once_with("rocks/101/restore") diff --git a/tests/test_headlines.py b/tests/test_headlines.py index 4a21557..7518160 100644 --- a/tests/test_headlines.py +++ b/tests/test_headlines.py @@ -78,7 +78,7 @@ def test_update(self, mock_http_client: Mock) -> None: headline_ops = HeadlineOperations(mock_http_client) result = headline_ops.update(headline_id=501, title="Updated headline") - assert result is True + assert result is None mock_http_client.put.assert_called_once_with( "headline/501", json={"title": "Updated headline"} @@ -174,5 +174,5 @@ def test_delete(self, mock_http_client: Mock) -> None: headline_ops = HeadlineOperations(mock_http_client) result = headline_ops.delete(headline_id=501) - assert result is True + assert result is None mock_http_client.delete.assert_called_once_with("headline/501") diff --git a/tests/test_issues.py b/tests/test_issues.py index 2ea646b..480cc95 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -87,15 +87,24 @@ def test_list_both_params_error(self, mock_http_client: Mock) -> None: assert "Please provide either" in str(exc_info.value) - def test_solve(self, mock_http_client: Mock) -> None: - """Test solving an issue.""" - mock_response = Mock() - mock_http_client.post.return_value = mock_response + def test_complete( + self, mock_http_client: Mock, sample_issue_data: dict[str, Any] + ) -> None: + """Test completing an issue.""" + # Mock the complete POST response + post_response = Mock() + mock_http_client.post.return_value = post_response + + # Mock the details GET response + get_response = Mock() + get_response.json.return_value = sample_issue_data + mock_http_client.get.return_value = get_response issue_ops = IssueOperations(mock_http_client) - result = issue_ops.solve(issue_id=401) + result = issue_ops.complete(issue_id=401) - assert result is True + assert isinstance(result, type(issue_ops.details(401))) + assert result.id == 401 mock_http_client.post.assert_called_once_with( "issues/401/complete", json={"complete": True} ) @@ -159,3 +168,76 @@ def test_create_default_user( "issues/create", json={"title": "New Issue", "ownerid": 123, "meetingid": 456}, ) + + def test_update( + self, mock_http_client: Mock, sample_issue_data: dict[str, Any] + ) -> None: + """Test updating an issue.""" + # Mock the update PUT response + put_response = Mock() + mock_http_client.put.return_value = put_response + + # Mock the details GET response + get_response = Mock() + updated_data = sample_issue_data.copy() + updated_data["Name"] = "Updated Title" + get_response.json.return_value = updated_data + mock_http_client.get.return_value = get_response + + issue_ops = IssueOperations(mock_http_client) + result = issue_ops.update(issue_id=401, title="Updated Title") + + assert result.id == 401 + assert result.title == "Updated Title" + mock_http_client.put.assert_called_once_with( + "issues/401", json={"title": "Updated Title"} + ) + + def test_update_with_notes( + self, mock_http_client: Mock, sample_issue_data: dict[str, Any] + ) -> None: + """Test updating an issue with notes.""" + put_response = Mock() + mock_http_client.put.return_value = put_response + + get_response = Mock() + get_response.json.return_value = sample_issue_data + mock_http_client.get.return_value = get_response + + issue_ops = IssueOperations(mock_http_client) + result = issue_ops.update(issue_id=401, notes="Updated notes") + + assert result.id == 401 + mock_http_client.put.assert_called_once_with( + "issues/401", json={"notes": "Updated notes"} + ) + + def test_update_both_fields( + self, mock_http_client: Mock, sample_issue_data: dict[str, Any] + ) -> None: + """Test updating an issue with both title and notes.""" + put_response = Mock() + mock_http_client.put.return_value = put_response + + get_response = Mock() + get_response.json.return_value = sample_issue_data + mock_http_client.get.return_value = get_response + + issue_ops = IssueOperations(mock_http_client) + result = issue_ops.update( + issue_id=401, title="Updated Title", notes="Updated notes" + ) + + assert result.id == 401 + mock_http_client.put.assert_called_once_with( + "issues/401", json={"title": "Updated Title", "notes": "Updated notes"} + ) + + def test_update_no_fields(self, mock_http_client: Mock) -> None: + """Test updating an issue with no fields raises ValueError.""" + issue_ops = IssueOperations(mock_http_client) + + with pytest.raises(ValueError) as exc_info: + issue_ops.update(issue_id=401) + + assert "At least one field" in str(exc_info.value) diff --git a/tests/test_todos.py b/tests/test_todos.py index aba6b6a..e9f1055 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -91,27 +91,55 @@ def test_create_todo(self, mock_http_client: Mock, mock_user_id: Mock) -> None: def test_complete_todo(self, mock_http_client: Mock) -> None: """Test completing a todo.""" - mock_response = Mock() - mock_response.json.return_value = {"success": True} - mock_response.is_success = True - mock_http_client.post.return_value = mock_response + # Mock complete response + mock_complete_response = Mock() + mock_complete_response.is_success = True + + # Mock details response + mock_details_response = Mock() + mock_details_response.json.return_value = { + "Id": 789, + "Name": "Completed Todo", + "DetailsUrl": "https://example.com/todo/789", + "DueDate": "2024-12-31", + "CreateTime": "2024-01-01T10:00:00Z", + "CompleteTime": "2024-12-10T10:00:00Z", + "Complete": True, + } + mock_details_response.is_success = True + + mock_http_client.post.return_value = mock_complete_response + mock_http_client.get.return_value = mock_details_response todo_ops = TodoOperations(mock_http_client) result = todo_ops.complete(todo_id=789) - assert result is True + assert result.id == 789 + assert result.complete is True mock_http_client.post.assert_called_once_with("todo/789/complete?status=true") + mock_http_client.get.assert_called_once_with("todo/789") def test_update_todo(self, mock_http_client: Mock) -> None: """Test updating a todo.""" - mock_response = Mock() - mock_response.json.return_value = { + # Mock update response + mock_update_response = Mock() + mock_update_response.status_code = 200 + + # Mock details response + mock_details_response = Mock() + mock_details_response.json.return_value = { "Id": 789, "Name": "Updated Todo", + "DetailsUrl": "https://example.com/todo/789", "DueDate": "2024-11-01", + "CreateTime": "2024-01-01T10:00:00Z", + "CompleteTime": None, + "Complete": False, } - mock_response.status_code = 200 - mock_http_client.put.return_value = mock_response + mock_details_response.is_success = True + + mock_http_client.put.return_value = mock_update_response + mock_http_client.get.return_value = mock_details_response todo_ops = TodoOperations(mock_http_client) result = todo_ops.update( @@ -126,6 +154,7 @@ def test_update_todo(self, mock_http_client: Mock) -> None: mock_http_client.put.assert_called_once_with( "todo/789", json={"title": "Updated Todo", "dueDate": "2024-11-01"} ) + mock_http_client.get.assert_called_once_with("todo/789") def test_update_todo_no_fields(self, mock_http_client: Mock) -> None: """Test updating todo with no fields raises error.""" diff --git a/tests/test_users.py b/tests/test_users.py index c98101b..434cd73 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -63,7 +63,7 @@ def test_details_with_direct_reports( assert len(result.direct_reports) == 1 assert result.direct_reports[0].id == 456 - def test_details_with_all( + def test_details_with_include_all( self, mock_http_client: Mock, sample_user_data: dict[str, Any] ) -> None: """Test getting user details with all information.""" @@ -86,7 +86,7 @@ def test_details_with_all( ] user_ops = UserOperations(mock_http_client) - result = user_ops.details(user_id=123, all=True) + result = user_ops.details(user_id=123, include_all=True) assert result.direct_reports is not None assert result.positions is not None @@ -117,7 +117,7 @@ def test_details_with_positions_null_name( ] user_ops = UserOperations(mock_http_client) - result = user_ops.details(user_id=123, all=True) + result = user_ops.details(user_id=123, include_all=True) assert result.direct_reports is not None assert result.positions is not None @@ -223,7 +223,7 @@ def test_search(self, mock_http_client: Mock) -> None: "search/user", params={"term": "john"} ) - def test_all_users(self, mock_http_client: Mock) -> None: + def test_list_users(self, mock_http_client: Mock) -> None: """Test getting all users.""" mock_response = Mock() mock_response.json.return_value = [ @@ -248,14 +248,14 @@ def test_all_users(self, mock_http_client: Mock) -> None: mock_http_client.get.return_value = mock_response user_ops = UserOperations(mock_http_client) - result = user_ops.all() + result = user_ops.list() # Should only return non-placeholder users assert len(result) == 1 assert result[0].id == 123 # Test with placeholders included - result_with_placeholders = user_ops.all(include_placeholders=True) + result_with_placeholders = user_ops.list(include_placeholders=True) assert len(result_with_placeholders) == 2 mock_http_client.get.assert_called_with("search/all", params={"term": "%"})