bloomy-python 0.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bloomy/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """Bloomy - Python SDK for Bloom Growth API."""
2
+
3
+ import importlib.metadata
4
+
5
+ from .client import Client
6
+ from .configuration import Configuration
7
+ from .exceptions import APIError, BloomyError
8
+ from .models import (
9
+ ArchivedGoalInfo,
10
+ CreatedGoalInfo,
11
+ CreatedIssue,
12
+ CurrentWeek,
13
+ DirectReport,
14
+ Goal,
15
+ GoalInfo,
16
+ GoalListResponse,
17
+ Headline,
18
+ HeadlineDetails,
19
+ HeadlineInfo,
20
+ HeadlineListItem,
21
+ Issue,
22
+ IssueDetails,
23
+ IssueListItem,
24
+ Meeting,
25
+ MeetingAttendee,
26
+ MeetingDetails,
27
+ MeetingInfo,
28
+ MeetingListItem,
29
+ OwnerDetails,
30
+ Position,
31
+ ScorecardItem,
32
+ ScorecardMetric,
33
+ ScorecardWeek,
34
+ Todo,
35
+ UserDetails,
36
+ UserListItem,
37
+ UserSearchResult,
38
+ )
39
+
40
+ try:
41
+ __version__ = importlib.metadata.version("bloomy")
42
+ except importlib.metadata.PackageNotFoundError:
43
+ __version__ = "unknown"
44
+ __all__ = [
45
+ "Client",
46
+ "Configuration",
47
+ "APIError",
48
+ "BloomyError",
49
+ "ArchivedGoalInfo",
50
+ "CreatedGoalInfo",
51
+ "CreatedIssue",
52
+ "CurrentWeek",
53
+ "DirectReport",
54
+ "Goal",
55
+ "GoalInfo",
56
+ "GoalListResponse",
57
+ "Headline",
58
+ "HeadlineDetails",
59
+ "HeadlineInfo",
60
+ "HeadlineListItem",
61
+ "Issue",
62
+ "IssueDetails",
63
+ "IssueListItem",
64
+ "Meeting",
65
+ "MeetingAttendee",
66
+ "MeetingDetails",
67
+ "MeetingInfo",
68
+ "MeetingListItem",
69
+ "OwnerDetails",
70
+ "Position",
71
+ "ScorecardItem",
72
+ "ScorecardMetric",
73
+ "ScorecardWeek",
74
+ "Todo",
75
+ "UserDetails",
76
+ "UserListItem",
77
+ "UserSearchResult",
78
+ ]
bloomy/client.py ADDED
@@ -0,0 +1,95 @@
1
+ """Main client for interacting with the Bloom Growth API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import httpx
8
+
9
+ from .configuration import Configuration
10
+ from .operations.goals import GoalOperations
11
+ from .operations.headlines import HeadlineOperations
12
+ from .operations.issues import IssueOperations
13
+ from .operations.meetings import MeetingOperations
14
+ from .operations.scorecard import ScorecardOperations
15
+ from .operations.todos import TodoOperations
16
+ from .operations.users import UserOperations
17
+
18
+ if TYPE_CHECKING:
19
+ from typing import Any
20
+
21
+
22
+ class Client:
23
+ """The Client class is the main entry point for interacting with the Bloomy API.
24
+
25
+ It provides methods for managing Bloom Growth features.
26
+
27
+ Example:
28
+ ```python
29
+ from bloomy import Client
30
+ client = Client()
31
+ client.meeting.list()
32
+ client.user.details()
33
+ client.meeting.delete(123)
34
+ client.scorecard.list()
35
+ client.issue.list()
36
+ client.headline.list()
37
+ ```
38
+ """
39
+
40
+ def __init__(self, api_key: str | None = None) -> None:
41
+ """Initialize a new Client instance.
42
+
43
+ Args:
44
+ api_key: The API key to use. If not provided, will attempt to
45
+ load from environment variable (BG_API_KEY) or configuration file.
46
+
47
+ Raises:
48
+ ValueError: If no API key is provided or found in configuration.
49
+ """
50
+ # Use Configuration class which handles priority:
51
+ # 1. Explicit api_key parameter
52
+ # 2. BG_API_KEY environment variable
53
+ # 3. Configuration file (~/.bloomy/config.yaml)
54
+ self.configuration = Configuration(api_key)
55
+
56
+ if not self.configuration.api_key:
57
+ raise ValueError(
58
+ "No API key provided. Set it explicitly, via BG_API_KEY "
59
+ "environment variable, or in ~/.bloomy/config.yaml configuration file."
60
+ )
61
+
62
+ self._api_key = self.configuration.api_key
63
+ self._base_url = "https://app.bloomgrowth.com/api/v1"
64
+
65
+ # Initialize HTTP client
66
+ self._client = httpx.Client(
67
+ base_url=self._base_url,
68
+ headers={
69
+ "Accept": "*/*",
70
+ "Content-Type": "application/json",
71
+ "Authorization": f"Bearer {self._api_key}",
72
+ },
73
+ timeout=30.0,
74
+ )
75
+
76
+ # Initialize operation classes
77
+ self.user = UserOperations(self._client)
78
+ self.todo = TodoOperations(self._client)
79
+ self.meeting = MeetingOperations(self._client)
80
+ self.goal = GoalOperations(self._client)
81
+ self.scorecard = ScorecardOperations(self._client)
82
+ self.issue = IssueOperations(self._client)
83
+ self.headline = HeadlineOperations(self._client)
84
+
85
+ def __enter__(self) -> Client:
86
+ """Context manager entry."""
87
+ return self
88
+
89
+ def __exit__(self, *args: Any) -> None:
90
+ """Context manager exit - close the HTTP client."""
91
+ self._client.close()
92
+
93
+ def close(self) -> None:
94
+ """Close the HTTP client connection."""
95
+ self._client.close()
@@ -0,0 +1,137 @@
1
+ """Configuration management for the Bloomy SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+ from urllib.parse import urlencode
9
+
10
+ import httpx
11
+ import yaml
12
+
13
+ from .exceptions import AuthenticationError, ConfigurationError
14
+
15
+ if TYPE_CHECKING:
16
+ pass
17
+
18
+
19
+ class Configuration:
20
+ """The Configuration class is responsible for managing authentication."""
21
+
22
+ def __init__(self, api_key: str | None = None) -> None:
23
+ """Initialize a new Configuration instance.
24
+
25
+ Args:
26
+ api_key: Optional API key. If not provided, will attempt to load from
27
+ environment variable or configuration file.
28
+
29
+ Example:
30
+ ```python
31
+ config = Bloomy.Configuration(api_key)
32
+ ```
33
+ """
34
+ self.api_key = api_key or os.environ.get("BG_API_KEY") or self._load_api_key()
35
+
36
+ def configure_api_key(
37
+ self, username: str, password: str, store_key: bool = False
38
+ ) -> None:
39
+ """Configure the API key using the provided username and password.
40
+
41
+ Args:
42
+ username: The username for authentication
43
+ password: The password for authentication
44
+ store_key: Whether to store the API key (default: False)
45
+
46
+ Note:
47
+ This method only fetches and stores the API key if it is currently None.
48
+ It saves the key under '~/.bloomy/config.yaml' if 'store_key: True' is
49
+ passed.
50
+
51
+ Example:
52
+ ```python
53
+ config.configure_api_key("user", "pass", store_key=True)
54
+ config.api_key
55
+ # Returns: 'xxxx...'
56
+ ```
57
+ """
58
+ self.api_key = self._fetch_api_key(username, password)
59
+ if store_key:
60
+ self._store_api_key()
61
+
62
+ def _fetch_api_key(self, username: str, password: str) -> str:
63
+ """Fetch the API key using the provided username and password.
64
+
65
+ Args:
66
+ username: The username for authentication
67
+ password: The password for authentication
68
+
69
+ Returns:
70
+ The fetched API key
71
+
72
+ Raises:
73
+ AuthenticationError: If authentication fails
74
+ """
75
+ with httpx.Client() as client:
76
+ response = client.post(
77
+ "https://app.bloomgrowth.com/Token",
78
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
79
+ content=urlencode(
80
+ {
81
+ "grant_type": "password",
82
+ "userName": username,
83
+ "password": password,
84
+ }
85
+ ),
86
+ )
87
+
88
+ if not response.is_success:
89
+ raise AuthenticationError(
90
+ f"Failed to fetch API key: {response.status_code} - {response.text}"
91
+ )
92
+
93
+ data = response.json()
94
+ return data["access_token"]
95
+
96
+ def _store_api_key(self) -> None:
97
+ """Store the API key in a local configuration file.
98
+
99
+ Raises:
100
+ ConfigurationError: If the API key is None
101
+ """
102
+ if self.api_key is None:
103
+ raise ConfigurationError("API key is None")
104
+
105
+ config_file = self._config_file
106
+ config_file.parent.mkdir(parents=True, exist_ok=True)
107
+
108
+ config_data = {"version": 1, "api_key": self.api_key}
109
+ with open(config_file, "w") as f:
110
+ yaml.dump(config_data, f)
111
+
112
+ def _load_api_key(self) -> str | None:
113
+ """Load the API key from a local configuration file.
114
+
115
+ Returns:
116
+ The loaded API key or None if the file does not exist
117
+ """
118
+ config_file = self._config_file
119
+ if not config_file.exists():
120
+ return None
121
+
122
+ try:
123
+ with open(config_file) as f:
124
+ data = yaml.safe_load(f)
125
+ return data.get("api_key")
126
+ except Exception:
127
+ return None
128
+
129
+ @property
130
+ def _config_dir(self) -> Path:
131
+ """Return the directory path for the configuration file."""
132
+ return Path.home() / ".bloomy"
133
+
134
+ @property
135
+ def _config_file(self) -> Path:
136
+ """Return the file path for the configuration file."""
137
+ return self._config_dir / "config.yaml"
bloomy/exceptions.py ADDED
@@ -0,0 +1,27 @@
1
+ """Exceptions for the Bloomy SDK."""
2
+
3
+
4
+ class BloomyError(Exception):
5
+ """Base exception for all Bloomy-related errors."""
6
+
7
+ pass
8
+
9
+
10
+ class ConfigurationError(BloomyError):
11
+ """Raised when there's an issue with configuration."""
12
+
13
+ pass
14
+
15
+
16
+ class AuthenticationError(BloomyError):
17
+ """Raised when authentication fails."""
18
+
19
+ pass
20
+
21
+
22
+ class APIError(BloomyError):
23
+ """Raised when API returns an error response."""
24
+
25
+ def __init__(self, message: str, status_code: int | None = None) -> None:
26
+ super().__init__(message)
27
+ self.status_code = status_code
bloomy/models.py ADDED
@@ -0,0 +1,371 @@
1
+ """Pydantic models for the Bloomy SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
+
10
+
11
+ class BloomyBaseModel(BaseModel):
12
+ """Base model with common configuration for all Bloomy models."""
13
+
14
+ model_config = ConfigDict(
15
+ populate_by_name=True,
16
+ use_enum_values=True,
17
+ validate_assignment=True,
18
+ arbitrary_types_allowed=True,
19
+ str_strip_whitespace=True,
20
+ )
21
+
22
+
23
+ class DirectReport(BloomyBaseModel):
24
+ """Model for direct report information."""
25
+
26
+ id: int
27
+ name: str
28
+ image_url: str
29
+
30
+
31
+ class Position(BloomyBaseModel):
32
+ """Model for position information."""
33
+
34
+ id: int
35
+ name: str
36
+
37
+
38
+ class UserDetails(BloomyBaseModel):
39
+ """Model for user details."""
40
+
41
+ id: int
42
+ name: str
43
+ image_url: str
44
+ direct_reports: list[DirectReport] | None = None
45
+ positions: list[Position] | None = None
46
+
47
+
48
+ class UserSearchResult(BloomyBaseModel):
49
+ """Model for user search results."""
50
+
51
+ id: int
52
+ name: str
53
+ description: str
54
+ email: str
55
+ organization_id: int
56
+ image_url: str
57
+
58
+
59
+ class UserListItem(BloomyBaseModel):
60
+ """Model for user list items."""
61
+
62
+ id: int
63
+ name: str
64
+ email: str
65
+ position: str
66
+ image_url: str
67
+
68
+
69
+ class MeetingAttendee(BloomyBaseModel):
70
+ """Model for meeting attendee."""
71
+
72
+ user_id: int = Field(alias="UserId")
73
+ name: str = Field(alias="Name")
74
+ image_url: str = Field(alias="ImageUrl")
75
+
76
+
77
+ class MeetingListItem(BloomyBaseModel):
78
+ """Model for meeting list item (simplified response)."""
79
+
80
+ id: int = Field(alias="Id")
81
+ type: str = Field(alias="Type")
82
+ key: str = Field(alias="Key")
83
+ name: str = Field(alias="Name")
84
+
85
+
86
+ class Meeting(BloomyBaseModel):
87
+ """Model for meeting."""
88
+
89
+ id: int = Field(alias="Id")
90
+ name: str = Field(alias="Name")
91
+ start_date_utc: datetime = Field(alias="StartDateUtc")
92
+ created_date: datetime = Field(alias="CreateDate")
93
+ organization_id: int = Field(alias="OrganizationId")
94
+
95
+
96
+ class MeetingDetails(BloomyBaseModel):
97
+ """Model for meeting details."""
98
+
99
+ id: int
100
+ name: str
101
+ start_date_utc: datetime | None = None
102
+ created_date: datetime | None = None
103
+ organization_id: int | None = None
104
+ attendees: list[MeetingAttendee] | None = None
105
+ issues: list[Issue] | None = None
106
+ todos: list[Todo] | None = None
107
+ metrics: list[ScorecardMetric] | None = None
108
+
109
+
110
+ class Todo(BloomyBaseModel):
111
+ """Model for todo."""
112
+
113
+ id: int = Field(alias="Id")
114
+ name: str = Field(alias="Name")
115
+ details_url: str | None = Field(alias="DetailsUrl", default=None)
116
+ due_date: datetime | None = Field(alias="DueDate", default=None)
117
+ complete_date: datetime | None = Field(alias="CompleteTime", default=None)
118
+ create_date: datetime | None = Field(alias="CreateTime", default=None)
119
+ meeting_id: int | None = Field(alias="OriginId", default=None)
120
+ meeting_name: str | None = Field(alias="Origin", default=None)
121
+ complete: bool = Field(alias="Complete", default=False)
122
+
123
+ @field_validator("due_date", "complete_date", "create_date", mode="before")
124
+ @classmethod
125
+ def parse_optional_datetime(cls, v: Any) -> datetime | None:
126
+ """Parse optional datetime fields."""
127
+ if v is None or v == "":
128
+ return None
129
+ return v
130
+
131
+
132
+ class Issue(BloomyBaseModel):
133
+ """Model for issue."""
134
+
135
+ id: int = Field(alias="Id")
136
+ name: str = Field(alias="Name")
137
+ details_url: str | None = Field(alias="DetailsUrl", default=None)
138
+ created_date: datetime = Field(alias="CreateDate")
139
+ meeting_id: int = Field(alias="MeetingId")
140
+ meeting_name: str = Field(alias="MeetingName")
141
+ owner_name: str = Field(alias="OwnerName")
142
+ owner_id: int = Field(alias="OwnerId")
143
+ owner_image_url: str = Field(alias="OwnerImageUrl")
144
+ closed_date: datetime | None = Field(alias="ClosedDate", default=None)
145
+ completion_date: datetime | None = Field(alias="CompletionDate", default=None)
146
+
147
+ @field_validator("closed_date", "completion_date", mode="before")
148
+ @classmethod
149
+ def parse_optional_datetime(cls, v: Any) -> datetime | None:
150
+ """Parse optional datetime fields."""
151
+ if v is None or v == "":
152
+ return None
153
+ return v
154
+
155
+
156
+ class Headline(BloomyBaseModel):
157
+ """Model for headline."""
158
+
159
+ id: int = Field(alias="Id")
160
+ title: str = Field(alias="Title")
161
+ notes: str = Field(alias="Notes")
162
+ owner_name: str = Field(alias="OwnerName")
163
+ owner_id: int = Field(alias="OwnerId")
164
+ headline_type: str = Field(alias="HeadlineType")
165
+ create_date: datetime = Field(alias="CreateDate")
166
+ meeting_id: int = Field(alias="MeetingId")
167
+ is_archived: bool = Field(alias="IsArchived")
168
+
169
+
170
+ class Goal(BloomyBaseModel):
171
+ """Model for goal (rock)."""
172
+
173
+ id: int = Field(alias="Id")
174
+ name: str = Field(alias="Name")
175
+ due_date: datetime = Field(alias="DueDate")
176
+ complete_date: datetime | None = Field(alias="CompleteDate", default=None)
177
+ create_date: datetime = Field(alias="CreateDate")
178
+ is_archived: bool = Field(alias="IsArchived", default=False)
179
+ percent_complete: float = Field(alias="PercentComplete", default=0.0)
180
+ accountable_user_id: int = Field(alias="AccountableUserId")
181
+ accountable_user_name: str | None = Field(alias="AccountableUserName", default=None)
182
+
183
+ @field_validator("complete_date", mode="before")
184
+ @classmethod
185
+ def parse_optional_datetime(cls, v: Any) -> datetime | None:
186
+ """Parse optional datetime fields."""
187
+ if v is None or v == "":
188
+ return None
189
+ return v
190
+
191
+
192
+ class ScorecardMetric(BloomyBaseModel):
193
+ """Model for scorecard metric."""
194
+
195
+ id: int = Field(alias="Id")
196
+ title: str = Field(alias="Title")
197
+ target: float | None = Field(alias="Target", default=None)
198
+ unit: str | None = Field(alias="Unit", default=None)
199
+ week_number: int = Field(alias="WeekNumber")
200
+ value: float | None = Field(alias="Value", default=None)
201
+ metric_type: str = Field(alias="MetricType")
202
+ accountable_user_id: int = Field(alias="AccountableUserId")
203
+ accountable_user_name: str | None = Field(alias="AccountableUserName", default=None)
204
+ is_inverse: bool = Field(alias="IsInverse", default=False)
205
+
206
+ @field_validator("target", "value", mode="before")
207
+ @classmethod
208
+ def parse_optional_float(cls, v: Any) -> float | None:
209
+ """Parse optional float fields."""
210
+ if v is None or v == "":
211
+ return None
212
+ return float(v)
213
+
214
+
215
+ class CurrentWeek(BloomyBaseModel):
216
+ """Model for current week information."""
217
+
218
+ week_number: int
219
+ start_date: datetime
220
+ end_date: datetime
221
+
222
+
223
+ class GoalInfo(BloomyBaseModel):
224
+ """Model for goal information."""
225
+
226
+ id: int
227
+ user_id: int
228
+ user_name: str
229
+ title: str
230
+ created_at: str
231
+ due_date: str | None
232
+ status: str
233
+ meeting_id: int | None = None
234
+ meeting_title: str | None = None
235
+
236
+
237
+ class ArchivedGoalInfo(BloomyBaseModel):
238
+ """Model for archived goal information."""
239
+
240
+ id: int
241
+ title: str
242
+ created_at: str
243
+ due_date: str | None
244
+ status: str
245
+
246
+
247
+ class GoalListResponse(BloomyBaseModel):
248
+ """Model for goal list response with archived goals."""
249
+
250
+ active: list[GoalInfo]
251
+ archived: list[ArchivedGoalInfo]
252
+
253
+
254
+ class CreatedGoalInfo(BloomyBaseModel):
255
+ """Model for created goal information."""
256
+
257
+ id: int
258
+ user_id: int
259
+ user_name: str
260
+ title: str
261
+ meeting_id: int
262
+ meeting_title: str
263
+ status: str
264
+ created_at: str
265
+
266
+
267
+ class ScorecardWeek(BloomyBaseModel):
268
+ """Model for scorecard week details."""
269
+
270
+ id: int
271
+ week_number: int
272
+ week_start: str
273
+ week_end: str
274
+
275
+
276
+ class ScorecardItem(BloomyBaseModel):
277
+ """Model for scorecard items."""
278
+
279
+ id: int
280
+ measurable_id: int
281
+ accountable_user_id: int
282
+ title: str
283
+ target: float
284
+ value: float | None = None
285
+ week: str # Changed from int to str to handle "2024-W25" format
286
+ week_id: int
287
+ updated_at: str
288
+
289
+
290
+ class IssueDetails(BloomyBaseModel):
291
+ """Model for issue details."""
292
+
293
+ id: int
294
+ title: str
295
+ notes_url: str
296
+ created_at: str
297
+ completed_at: str | None = None
298
+ meeting_id: int
299
+ meeting_title: str
300
+ user_id: int
301
+ user_name: str
302
+
303
+
304
+ class IssueListItem(BloomyBaseModel):
305
+ """Model for issue list items."""
306
+
307
+ id: int
308
+ title: str
309
+ notes_url: str
310
+ created_at: str
311
+ meeting_id: int
312
+ meeting_title: str
313
+
314
+
315
+ class CreatedIssue(BloomyBaseModel):
316
+ """Model for created issue response."""
317
+
318
+ id: int
319
+ meeting_id: int
320
+ meeting_title: str
321
+ title: str
322
+ user_id: int
323
+ notes_url: str
324
+
325
+
326
+ class OwnerDetails(BloomyBaseModel):
327
+ """Model for owner details."""
328
+
329
+ id: int
330
+ name: str | None = None
331
+
332
+
333
+ class MeetingInfo(BloomyBaseModel):
334
+ """Model for meeting information."""
335
+
336
+ id: int
337
+ title: str | None = None
338
+
339
+
340
+ class HeadlineInfo(BloomyBaseModel):
341
+ """Model for headline information."""
342
+
343
+ id: int
344
+ title: str
345
+ notes_url: str
346
+ owner_details: OwnerDetails
347
+
348
+
349
+ class HeadlineDetails(BloomyBaseModel):
350
+ """Model for detailed headline information."""
351
+
352
+ id: int
353
+ title: str
354
+ notes_url: str
355
+ meeting_details: MeetingInfo
356
+ owner_details: OwnerDetails
357
+ archived: bool
358
+ created_at: str
359
+ closed_at: str | None = None
360
+
361
+
362
+ class HeadlineListItem(BloomyBaseModel):
363
+ """Model for headline list items."""
364
+
365
+ id: int
366
+ title: str
367
+ meeting_details: MeetingInfo
368
+ owner_details: OwnerDetails
369
+ archived: bool
370
+ created_at: str
371
+ closed_at: str | None = None