klokku-python-client 0.1.0__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.
@@ -0,0 +1,35 @@
|
|
1
|
+
from .api_client import (
|
2
|
+
# Exception classes
|
3
|
+
KlokkuApiError,
|
4
|
+
KlokkuAuthenticationError,
|
5
|
+
KlokkuNetworkError,
|
6
|
+
KlokkuApiResponseError,
|
7
|
+
KlokkuDataParsingError,
|
8
|
+
KlokkuDataStructureError,
|
9
|
+
|
10
|
+
# Data classes
|
11
|
+
Budget,
|
12
|
+
User,
|
13
|
+
Event,
|
14
|
+
|
15
|
+
# Main API client
|
16
|
+
KlokkuApi,
|
17
|
+
)
|
18
|
+
|
19
|
+
__all__ = [
|
20
|
+
# Exception classes
|
21
|
+
'KlokkuApiError',
|
22
|
+
'KlokkuAuthenticationError',
|
23
|
+
'KlokkuNetworkError',
|
24
|
+
'KlokkuApiResponseError',
|
25
|
+
'KlokkuDataParsingError',
|
26
|
+
'KlokkuDataStructureError',
|
27
|
+
|
28
|
+
# Data classes
|
29
|
+
'Budget',
|
30
|
+
'User',
|
31
|
+
'Event',
|
32
|
+
|
33
|
+
# Main API client
|
34
|
+
'KlokkuApi',
|
35
|
+
]
|
@@ -0,0 +1,304 @@
|
|
1
|
+
import aiohttp
|
2
|
+
import logging
|
3
|
+
from typing import Optional
|
4
|
+
|
5
|
+
from dataclasses import dataclass
|
6
|
+
|
7
|
+
_LOGGER = logging.getLogger(__name__)
|
8
|
+
|
9
|
+
class KlokkuApiError(Exception):
|
10
|
+
"""Base exception for all Klokku API errors."""
|
11
|
+
pass
|
12
|
+
|
13
|
+
class KlokkuAuthenticationError(KlokkuApiError):
|
14
|
+
"""Raised when authentication fails or user is not authenticated."""
|
15
|
+
pass
|
16
|
+
|
17
|
+
class KlokkuNetworkError(KlokkuApiError):
|
18
|
+
"""Raised when there's a network-related error."""
|
19
|
+
pass
|
20
|
+
|
21
|
+
class KlokkuApiResponseError(KlokkuApiError):
|
22
|
+
"""Raised when the API returns an error response."""
|
23
|
+
def __init__(self, status_code: int, message: str = None):
|
24
|
+
self.status_code = status_code
|
25
|
+
self.message = message
|
26
|
+
super().__init__(f"API returned error {status_code}: {message}")
|
27
|
+
|
28
|
+
class KlokkuDataParsingError(KlokkuApiError):
|
29
|
+
"""Raised when there's an error parsing the API response data."""
|
30
|
+
pass
|
31
|
+
|
32
|
+
class KlokkuDataStructureError(KlokkuApiError):
|
33
|
+
"""Raised when the API response data doesn't have the expected structure."""
|
34
|
+
pass
|
35
|
+
|
36
|
+
@dataclass(frozen=True)
|
37
|
+
class Budget:
|
38
|
+
id: int
|
39
|
+
name: str
|
40
|
+
weeklyTime: int
|
41
|
+
icon: str = ""
|
42
|
+
status: str = "ACTIVE"
|
43
|
+
|
44
|
+
@dataclass(frozen=True)
|
45
|
+
class User:
|
46
|
+
id: int
|
47
|
+
username: str
|
48
|
+
display_name: str
|
49
|
+
|
50
|
+
@dataclass(frozen=True)
|
51
|
+
class Event:
|
52
|
+
id: int
|
53
|
+
startTime: str
|
54
|
+
budget: Budget
|
55
|
+
|
56
|
+
class KlokkuApi:
|
57
|
+
|
58
|
+
url: str = ""
|
59
|
+
username: str = ""
|
60
|
+
user_id: int = 0
|
61
|
+
session: Optional[aiohttp.ClientSession] = None
|
62
|
+
|
63
|
+
def __init__(self, url):
|
64
|
+
self.url = url
|
65
|
+
self.session = None
|
66
|
+
|
67
|
+
async def __aenter__(self):
|
68
|
+
self.session = aiohttp.ClientSession()
|
69
|
+
return self
|
70
|
+
|
71
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
72
|
+
if self.session:
|
73
|
+
await self.session.close()
|
74
|
+
self.session = None
|
75
|
+
|
76
|
+
async def authenticate(self, username: str) -> bool:
|
77
|
+
"""
|
78
|
+
Authenticate with the API using a username.
|
79
|
+
:param username: The username to authenticate with.
|
80
|
+
:return: True if authentication was successful, False otherwise.
|
81
|
+
:raises KlokkuNetworkError: If there's a network error.
|
82
|
+
:raises KlokkuApiResponseError: If the API returns an error response.
|
83
|
+
:raises KlokkuDataParsingError: If there's an error when parsing the response.
|
84
|
+
:raises KlokkuDataStructureError: If the response doesn't have the expected structure.
|
85
|
+
"""
|
86
|
+
try:
|
87
|
+
users = await self.get_users()
|
88
|
+
if not users:
|
89
|
+
return False
|
90
|
+
for user in users:
|
91
|
+
if user.username == username:
|
92
|
+
self.user_id = user.id
|
93
|
+
return True
|
94
|
+
return False
|
95
|
+
except KlokkuApiError as e:
|
96
|
+
_LOGGER.error(f"Authentication error: {e}")
|
97
|
+
return False
|
98
|
+
|
99
|
+
@staticmethod
|
100
|
+
def __headers(user_id: int) -> dict:
|
101
|
+
return {
|
102
|
+
"X-User-Id": str(user_id)
|
103
|
+
}
|
104
|
+
|
105
|
+
async def get_current_event(self) -> Event | None:
|
106
|
+
"""
|
107
|
+
Fetch the current budget from the API.
|
108
|
+
:return: Parsed current budget data as a dictionary.
|
109
|
+
:raises KlokkuAuthenticationError: If the user is not authenticated.
|
110
|
+
:raises KlokkuNetworkError: If there's a network error.
|
111
|
+
:raises KlokkuApiResponseError: If the API returns an error response.
|
112
|
+
:raises KlokkuDataParsingError: If there's an error when parsing the response.
|
113
|
+
:raises KlokkuDataStructureError: If the response doesn't have the expected structure.
|
114
|
+
"""
|
115
|
+
if not self.user_id:
|
116
|
+
error = KlokkuAuthenticationError("Unauthenticated - cannot fetch current budget")
|
117
|
+
_LOGGER.warning(str(error))
|
118
|
+
return None
|
119
|
+
|
120
|
+
url = f"{self.url}api/event/current"
|
121
|
+
try:
|
122
|
+
# Create a session if one doesn't exist
|
123
|
+
if not self.session:
|
124
|
+
self.session = aiohttp.ClientSession()
|
125
|
+
close_after = True
|
126
|
+
else:
|
127
|
+
close_after = False
|
128
|
+
|
129
|
+
try:
|
130
|
+
async with self.session.get(url, headers=self.__headers(self.user_id)) as response:
|
131
|
+
if response.status >= 400:
|
132
|
+
error_msg = await response.text()
|
133
|
+
raise KlokkuApiResponseError(response.status, error_msg)
|
134
|
+
|
135
|
+
try:
|
136
|
+
data = await response.json()
|
137
|
+
except aiohttp.ClientResponseError as e:
|
138
|
+
raise KlokkuDataParsingError(f"Failed to parse JSON response: {e}")
|
139
|
+
|
140
|
+
try:
|
141
|
+
result = Event(
|
142
|
+
id=data["id"],
|
143
|
+
startTime=data["startTime"],
|
144
|
+
budget=Budget(**data["budget"]),
|
145
|
+
)
|
146
|
+
except (KeyError, TypeError, ValueError) as e:
|
147
|
+
raise KlokkuDataStructureError(f"Unexpected data structure in response: {e}")
|
148
|
+
except aiohttp.ClientConnectionError as e:
|
149
|
+
raise KlokkuNetworkError(f"Connection error: {e}")
|
150
|
+
|
151
|
+
# Close the session if we created it in this method
|
152
|
+
if close_after:
|
153
|
+
await self.session.close()
|
154
|
+
self.session = None
|
155
|
+
|
156
|
+
return result
|
157
|
+
except KlokkuApiError as e:
|
158
|
+
_LOGGER.error(f"Error fetching current budget: {e}")
|
159
|
+
return None
|
160
|
+
|
161
|
+
async def get_all_budgets(self) -> list[Budget] | None:
|
162
|
+
"""
|
163
|
+
Fetch all budgets from the API.
|
164
|
+
:return: Parsed list of all budgets.
|
165
|
+
:raises KlokkuAuthenticationError: If the user is not authenticated.
|
166
|
+
:raises KlokkuNetworkError: If there's a network error.
|
167
|
+
:raises KlokkuApiResponseError: If the API returns an error response.
|
168
|
+
:raises KlokkuDataParsingError: If there's an error when parsing the response.
|
169
|
+
:raises KlokkuDataStructureError: If the response doesn't have the expected structure.
|
170
|
+
"""
|
171
|
+
if not self.user_id:
|
172
|
+
error = KlokkuAuthenticationError("Unauthenticated - cannot fetch budgets")
|
173
|
+
_LOGGER.warning(str(error))
|
174
|
+
return None
|
175
|
+
|
176
|
+
url = f"{self.url}api/budget"
|
177
|
+
try:
|
178
|
+
# Create a session if one doesn't exist
|
179
|
+
if not self.session:
|
180
|
+
self.session = aiohttp.ClientSession()
|
181
|
+
close_after = True
|
182
|
+
else:
|
183
|
+
close_after = False
|
184
|
+
|
185
|
+
try:
|
186
|
+
async with self.session.get(url, headers=self.__headers(self.user_id)) as response:
|
187
|
+
if response.status >= 400:
|
188
|
+
error_msg = await response.text()
|
189
|
+
raise KlokkuApiResponseError(response.status, error_msg)
|
190
|
+
|
191
|
+
try:
|
192
|
+
data = await response.json()
|
193
|
+
except aiohttp.ClientResponseError as e:
|
194
|
+
raise KlokkuDataParsingError(f"Failed to parse JSON response: {e}")
|
195
|
+
|
196
|
+
try:
|
197
|
+
result = [Budget(**budget) for budget in data]
|
198
|
+
except (KeyError, TypeError, ValueError) as e:
|
199
|
+
raise KlokkuDataStructureError(f"Unexpected data structure in response: {e}")
|
200
|
+
except aiohttp.ClientConnectionError as e:
|
201
|
+
raise KlokkuNetworkError(f"Connection error: {e}")
|
202
|
+
|
203
|
+
# Close the session if we created it in this method
|
204
|
+
if close_after:
|
205
|
+
await self.session.close()
|
206
|
+
self.session = None
|
207
|
+
|
208
|
+
return result
|
209
|
+
except KlokkuApiError as e:
|
210
|
+
_LOGGER.error(f"Error fetching all budgets: {e}")
|
211
|
+
return None
|
212
|
+
|
213
|
+
async def get_users(self) -> list[User] | None:
|
214
|
+
"""
|
215
|
+
Fetch all users from the API.
|
216
|
+
:return: Parsed list of all users.
|
217
|
+
:raises KlokkuNetworkError: If there's a network error.
|
218
|
+
:raises KlokkuApiResponseError: If the API returns an error response.
|
219
|
+
:raises KlokkuDataParsingError: If there's an error when parsing the response.
|
220
|
+
:raises KlokkuDataStructureError: If the response doesn't have the expected structure.
|
221
|
+
"""
|
222
|
+
url = f"{self.url}api/user"
|
223
|
+
try:
|
224
|
+
# Create a session if one doesn't exist
|
225
|
+
if not self.session:
|
226
|
+
self.session = aiohttp.ClientSession()
|
227
|
+
close_after = True
|
228
|
+
else:
|
229
|
+
close_after = False
|
230
|
+
|
231
|
+
try:
|
232
|
+
async with self.session.get(url) as response:
|
233
|
+
if response.status >= 400:
|
234
|
+
error_msg = await response.text()
|
235
|
+
raise KlokkuApiResponseError(response.status, error_msg)
|
236
|
+
|
237
|
+
try:
|
238
|
+
data = await response.json()
|
239
|
+
except aiohttp.ClientResponseError as e:
|
240
|
+
raise KlokkuDataParsingError(f"Failed to parse JSON response: {e}")
|
241
|
+
|
242
|
+
try:
|
243
|
+
result = [User(id=user["id"], username=user["username"], display_name=user["displayName"]) for user in data]
|
244
|
+
except (KeyError, TypeError) as e:
|
245
|
+
raise KlokkuDataStructureError(f"Unexpected data structure in response: {e}")
|
246
|
+
except aiohttp.ClientConnectionError as e:
|
247
|
+
raise KlokkuNetworkError(f"Connection error: {e}")
|
248
|
+
|
249
|
+
# Close the session if we created it in this method
|
250
|
+
if close_after:
|
251
|
+
await self.session.close()
|
252
|
+
self.session = None
|
253
|
+
|
254
|
+
return result
|
255
|
+
except KlokkuApiError as e:
|
256
|
+
_LOGGER.error(f"Error fetching all users: {e}")
|
257
|
+
return None
|
258
|
+
|
259
|
+
async def set_current_budget(self, budget_id: int):
|
260
|
+
"""
|
261
|
+
Sets the currently used budget.
|
262
|
+
:param budget_id: The ID of the budget to set as current.
|
263
|
+
:return: The response data or None if an error occurred.
|
264
|
+
:raises KlokkuAuthenticationError: If the user is not authenticated.
|
265
|
+
:raises KlokkuNetworkError: If there's a network error.
|
266
|
+
:raises KlokkuApiResponseError: If the API returns an error response.
|
267
|
+
:raises KlokkuDataParsingError: If there's an error when parsing the response.
|
268
|
+
"""
|
269
|
+
if not self.user_id:
|
270
|
+
error = KlokkuAuthenticationError("Unauthenticated - cannot set current budget")
|
271
|
+
_LOGGER.warning(str(error))
|
272
|
+
return None
|
273
|
+
|
274
|
+
url = f"{self.url}api/event"
|
275
|
+
try:
|
276
|
+
# Create a session if one doesn't exist
|
277
|
+
if not self.session:
|
278
|
+
self.session = aiohttp.ClientSession()
|
279
|
+
close_after = True
|
280
|
+
else:
|
281
|
+
close_after = False
|
282
|
+
|
283
|
+
try:
|
284
|
+
async with self.session.post(url, headers=self.__headers(self.user_id), json={"budgetId": budget_id}) as response:
|
285
|
+
if response.status >= 400:
|
286
|
+
error_msg = await response.text()
|
287
|
+
raise KlokkuApiResponseError(response.status, error_msg)
|
288
|
+
|
289
|
+
try:
|
290
|
+
data = await response.json()
|
291
|
+
except aiohttp.ClientResponseError as e:
|
292
|
+
raise KlokkuDataParsingError(f"Failed to parse JSON response: {e}")
|
293
|
+
except aiohttp.ClientConnectionError as e:
|
294
|
+
raise KlokkuNetworkError(f"Connection error: {e}")
|
295
|
+
|
296
|
+
# Close the session if we created it in this method
|
297
|
+
if close_after:
|
298
|
+
await self.session.close()
|
299
|
+
self.session = None
|
300
|
+
|
301
|
+
return data
|
302
|
+
except KlokkuApiError as e:
|
303
|
+
_LOGGER.error(f"Error setting current budget: {e}")
|
304
|
+
return None
|
@@ -0,0 +1,128 @@
|
|
1
|
+
Metadata-Version: 2.3
|
2
|
+
Name: klokku-python-client
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Klokku REST API client
|
5
|
+
License: MIT
|
6
|
+
Author: Mariusz Józala
|
7
|
+
Requires-Python: >=3.13
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
9
|
+
Classifier: Intended Audience :: Developers
|
10
|
+
Requires-Dist: aiohttp (>=3.11.16,<4.0.0)
|
11
|
+
Project-URL: Homepage, https://klokku.com
|
12
|
+
Project-URL: Issues, https://github.com/klokku/klokku-python-client/issues
|
13
|
+
Project-URL: Repository, https://github.com/klokku/klokku-python-client
|
14
|
+
Description-Content-Type: text/markdown
|
15
|
+
|
16
|
+
# Klokku Python Client
|
17
|
+
|
18
|
+
A Python client for interacting with the Klokku REST API.
|
19
|
+
|
20
|
+
## Installation
|
21
|
+
|
22
|
+
### Using pip
|
23
|
+
|
24
|
+
```bash
|
25
|
+
pip install klokku-python-client
|
26
|
+
```
|
27
|
+
|
28
|
+
### Using Poetry
|
29
|
+
|
30
|
+
```bash
|
31
|
+
poetry add klokku-python-client
|
32
|
+
```
|
33
|
+
|
34
|
+
## Usage
|
35
|
+
|
36
|
+
The client provides an asynchronous interface to the Klokku API:
|
37
|
+
|
38
|
+
```python
|
39
|
+
import asyncio
|
40
|
+
from klokku_python_client import KlokkuApi
|
41
|
+
|
42
|
+
async def main():
|
43
|
+
# Create a client instance
|
44
|
+
async with KlokkuApi("https://api.klokku.example.com/") as client:
|
45
|
+
# Authenticate with a username
|
46
|
+
authenticated = await client.authenticate("your_username")
|
47
|
+
if not authenticated:
|
48
|
+
print("Authentication failed")
|
49
|
+
return
|
50
|
+
|
51
|
+
# Get all budgets
|
52
|
+
budgets = await client.get_all_budgets()
|
53
|
+
if budgets:
|
54
|
+
print(f"Found {len(budgets)} budgets:")
|
55
|
+
for budget in budgets:
|
56
|
+
print(f"- {budget.name} (ID: {budget.id})")
|
57
|
+
|
58
|
+
# Get current event
|
59
|
+
current_event = await client.get_current_event()
|
60
|
+
if current_event:
|
61
|
+
print(f"Current budget: {current_event.budget.name}")
|
62
|
+
print(f"Started at: {current_event.startTime}")
|
63
|
+
|
64
|
+
# Set a different budget
|
65
|
+
if budgets and len(budgets) > 1:
|
66
|
+
new_budget_id = budgets[1].id
|
67
|
+
result = await client.set_current_budget(new_budget_id)
|
68
|
+
if result:
|
69
|
+
print(f"Set current budget to ID: {new_budget_id}")
|
70
|
+
|
71
|
+
# Run the async function
|
72
|
+
asyncio.run(main())
|
73
|
+
```
|
74
|
+
|
75
|
+
## Features
|
76
|
+
|
77
|
+
- Asynchronous API client using `aiohttp`
|
78
|
+
- Authentication with username
|
79
|
+
- Get list of users
|
80
|
+
- Get all budgets
|
81
|
+
- Get current event/budget
|
82
|
+
- Set current budget
|
83
|
+
- Context manager support for proper resource cleanup
|
84
|
+
|
85
|
+
## Development
|
86
|
+
|
87
|
+
### Setup
|
88
|
+
|
89
|
+
1. Clone the repository
|
90
|
+
2. Install dependencies with Poetry:
|
91
|
+
|
92
|
+
```bash
|
93
|
+
poetry install
|
94
|
+
```
|
95
|
+
|
96
|
+
### Testing
|
97
|
+
|
98
|
+
The project uses pytest for testing with aioresponses for mocking HTTP requests.
|
99
|
+
|
100
|
+
To install test dependencies:
|
101
|
+
|
102
|
+
```bash
|
103
|
+
pip install -e ".[test]"
|
104
|
+
```
|
105
|
+
|
106
|
+
Or with Poetry:
|
107
|
+
|
108
|
+
```bash
|
109
|
+
poetry install --with test
|
110
|
+
```
|
111
|
+
|
112
|
+
To run the tests:
|
113
|
+
|
114
|
+
```bash
|
115
|
+
pytest
|
116
|
+
```
|
117
|
+
|
118
|
+
For more details about testing, see the [tests README](tests/README.md).
|
119
|
+
|
120
|
+
## Requirements
|
121
|
+
|
122
|
+
- Python 3.13+
|
123
|
+
- aiohttp 3.11+
|
124
|
+
|
125
|
+
## License
|
126
|
+
|
127
|
+
[MIT](LICENSE)
|
128
|
+
|
@@ -0,0 +1,5 @@
|
|
1
|
+
klokku_python_client/__init__.py,sha256=1AOx109cSpBOiz_-bLMFgZxEU-EkYl9KksZL3aK3pYM,631
|
2
|
+
klokku_python_client/api_client.py,sha256=6xXKOvlTuE57ynSpN70ZSRt2w5-E3yyLMg94yW7mzeI,11735
|
3
|
+
klokku_python_client-0.1.0.dist-info/METADATA,sha256=pZXy4QbfegI_A02G7A6EKOWNinNPA8D-n6qGGaEQe6c,2767
|
4
|
+
klokku_python_client-0.1.0.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
|
5
|
+
klokku_python_client-0.1.0.dist-info/RECORD,,
|