agentek-youtrack-mcp 1.0.0
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.
- package/LICENSE +22 -0
- package/README.md +332 -0
- package/README.npm.md +436 -0
- package/dist/bin/youtrack-mcp.js +158 -0
- package/dist/index.js +129 -0
- package/dist/python/main.py +74 -0
- package/dist/python/requirements.txt +13 -0
- package/dist/python/youtrack_mcp/__init__.py +5 -0
- package/dist/python/youtrack_mcp/__pycache__/__init__.cpython-311.pyc +0 -0
- package/dist/python/youtrack_mcp/__pycache__/version.cpython-311.pyc +0 -0
- package/dist/python/youtrack_mcp/api/__init__.py +3 -0
- package/dist/python/youtrack_mcp/api/articles.py +317 -0
- package/dist/python/youtrack_mcp/api/client.py +466 -0
- package/dist/python/youtrack_mcp/api/issues.py +3008 -0
- package/dist/python/youtrack_mcp/api/mcp_wrappers.py +304 -0
- package/dist/python/youtrack_mcp/api/projects.py +1009 -0
- package/dist/python/youtrack_mcp/api/search.py +244 -0
- package/dist/python/youtrack_mcp/api/spaces.py +46 -0
- package/dist/python/youtrack_mcp/api/users.py +149 -0
- package/dist/python/youtrack_mcp/config.py +273 -0
- package/dist/python/youtrack_mcp/mcp_server.py +158 -0
- package/dist/python/youtrack_mcp/mcp_wrappers.py +324 -0
- package/dist/python/youtrack_mcp/tools/__init__.py +8 -0
- package/dist/python/youtrack_mcp/tools/articles.py +487 -0
- package/dist/python/youtrack_mcp/tools/create_project_tool.py +51 -0
- package/dist/python/youtrack_mcp/tools/issues/__init__.py +208 -0
- package/dist/python/youtrack_mcp/tools/issues/attachments.py +161 -0
- package/dist/python/youtrack_mcp/tools/issues/basic_operations.py +322 -0
- package/dist/python/youtrack_mcp/tools/issues/custom_fields.py +365 -0
- package/dist/python/youtrack_mcp/tools/issues/dedicated_updates.py +601 -0
- package/dist/python/youtrack_mcp/tools/issues/diagnostics.py +363 -0
- package/dist/python/youtrack_mcp/tools/issues/linking.py +283 -0
- package/dist/python/youtrack_mcp/tools/issues/utilities.py +291 -0
- package/dist/python/youtrack_mcp/tools/loader.py +383 -0
- package/dist/python/youtrack_mcp/tools/projects.py +826 -0
- package/dist/python/youtrack_mcp/tools/projects.py-e +411 -0
- package/dist/python/youtrack_mcp/tools/resources.py +744 -0
- package/dist/python/youtrack_mcp/tools/search.py +297 -0
- package/dist/python/youtrack_mcp/tools/spaces.py +69 -0
- package/dist/python/youtrack_mcp/tools/users.py +185 -0
- package/dist/python/youtrack_mcp/utils.py +87 -0
- package/dist/python/youtrack_mcp/version.py +5 -0
- package/package.json +61 -0
- package/requirements.txt +13 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base client for YouTrack REST API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
import json
|
|
9
|
+
import random
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from pydantic import BaseModel, ConfigDict
|
|
13
|
+
|
|
14
|
+
from youtrack_mcp.config import config
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class YouTrackAPIError(Exception):
|
|
20
|
+
"""Base exception for YouTrack API errors."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
status_code: Optional[int] = None,
|
|
26
|
+
response: Optional[requests.Response] = None,
|
|
27
|
+
):
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.response = response
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RateLimitError(YouTrackAPIError):
|
|
34
|
+
"""Exception for API rate limiting errors."""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ResourceNotFoundError(YouTrackAPIError):
|
|
40
|
+
"""Exception for 404 Not Found errors."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuthenticationError(YouTrackAPIError):
|
|
46
|
+
"""Exception for authentication errors."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PermissionDeniedError(YouTrackAPIError):
|
|
52
|
+
"""Exception for permission-related errors."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ValidationError(YouTrackAPIError):
|
|
58
|
+
"""Exception for validation errors in API requests."""
|
|
59
|
+
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ServerError(YouTrackAPIError):
|
|
64
|
+
"""Exception for server-side errors."""
|
|
65
|
+
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class YouTrackModel(BaseModel):
|
|
70
|
+
"""Base model for YouTrack API resources."""
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(
|
|
73
|
+
extra="allow", # Allow extra fields in the model
|
|
74
|
+
populate_by_name=True, # Allow population by field name
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
id: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class YouTrackClient:
|
|
81
|
+
"""Base client for YouTrack REST API."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
base_url: Optional[str] = None,
|
|
86
|
+
api_token: Optional[str] = None,
|
|
87
|
+
verify_ssl: Optional[bool] = None,
|
|
88
|
+
max_retries: int = 3,
|
|
89
|
+
retry_delay: float = 1.0,
|
|
90
|
+
):
|
|
91
|
+
"""
|
|
92
|
+
Initialize YouTrack API client.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
base_url: YouTrack instance URL, defaults to config.get_base_url()
|
|
96
|
+
api_token: API token for authentication, defaults to config.YOUTRACK_API_TOKEN
|
|
97
|
+
verify_ssl: Whether to verify SSL certificates, defaults to config.VERIFY_SSL
|
|
98
|
+
max_retries: Maximum number of retries for transient errors
|
|
99
|
+
retry_delay: Initial delay between retries in seconds (increases exponentially)
|
|
100
|
+
"""
|
|
101
|
+
self.base_url = base_url or config.get_base_url()
|
|
102
|
+
self.api_token = api_token if api_token else config.get_api_token()
|
|
103
|
+
self.verify_ssl = (
|
|
104
|
+
verify_ssl if verify_ssl is not None else config.VERIFY_SSL
|
|
105
|
+
)
|
|
106
|
+
self.max_retries = max_retries
|
|
107
|
+
self.retry_delay = retry_delay
|
|
108
|
+
|
|
109
|
+
# Validate required configuration
|
|
110
|
+
if not self.api_token:
|
|
111
|
+
raise ValueError("API token is required")
|
|
112
|
+
|
|
113
|
+
# Session for connection pooling and header reuse
|
|
114
|
+
self.session = requests.Session()
|
|
115
|
+
self.session.headers.update(
|
|
116
|
+
{
|
|
117
|
+
"Authorization": f"Bearer {self.api_token}",
|
|
118
|
+
"Accept": "application/json",
|
|
119
|
+
"Content-Type": "application/json",
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Set SSL verification options
|
|
124
|
+
self.session.verify = self.verify_ssl
|
|
125
|
+
if not self.verify_ssl:
|
|
126
|
+
# Use the custom SSL context
|
|
127
|
+
self.session.verify = False
|
|
128
|
+
# Suppress insecure request warnings
|
|
129
|
+
from requests.packages.urllib3.exceptions import (
|
|
130
|
+
InsecureRequestWarning,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
|
134
|
+
|
|
135
|
+
logger.debug(
|
|
136
|
+
f"YouTrack client initialized for {'YouTrack Cloud' if config.is_cloud_instance() else self.base_url}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _get_api_url(self, endpoint: str) -> str:
|
|
140
|
+
"""
|
|
141
|
+
Construct full API URL from endpoint.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
endpoint: API endpoint (without leading slash)
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Full API URL
|
|
148
|
+
"""
|
|
149
|
+
# Remove trailing slash from base_url for consistent URL construction
|
|
150
|
+
base = self.base_url.rstrip('/')
|
|
151
|
+
|
|
152
|
+
if base.endswith("/api"):
|
|
153
|
+
return f"{base}/{endpoint}"
|
|
154
|
+
else:
|
|
155
|
+
return f"{base}/api/{endpoint}"
|
|
156
|
+
|
|
157
|
+
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
|
|
158
|
+
"""
|
|
159
|
+
Handle API response, raising appropriate exceptions for errors.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
response: Response from API
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Parsed JSON response
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
Various exceptions based on response status
|
|
169
|
+
"""
|
|
170
|
+
status_code = response.status_code
|
|
171
|
+
|
|
172
|
+
# Handle success
|
|
173
|
+
if 200 <= status_code < 300:
|
|
174
|
+
# Some endpoints return empty responses
|
|
175
|
+
if not response.content or response.content.strip() == b"":
|
|
176
|
+
return {}
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
return response.json()
|
|
180
|
+
except json.JSONDecodeError:
|
|
181
|
+
# Handle non-JSON responses
|
|
182
|
+
logger.warning(
|
|
183
|
+
f"Non-JSON response received from API: {response.content[:100]}"
|
|
184
|
+
)
|
|
185
|
+
return {
|
|
186
|
+
"raw_content": response.content.decode(
|
|
187
|
+
"utf-8", errors="replace"
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Handle error responses
|
|
192
|
+
error_message = f"API request failed with status {status_code}"
|
|
193
|
+
|
|
194
|
+
# Try to extract error details from response
|
|
195
|
+
try:
|
|
196
|
+
error_data = response.json()
|
|
197
|
+
if isinstance(error_data, dict) and "error" in error_data:
|
|
198
|
+
error_message = f"{error_message}: {error_data['error']}"
|
|
199
|
+
except (json.JSONDecodeError, KeyError):
|
|
200
|
+
if response.content:
|
|
201
|
+
error_message = f"{error_message}: {response.content.decode('utf-8', errors='replace')}"
|
|
202
|
+
|
|
203
|
+
# Raise appropriate exception based on status code
|
|
204
|
+
if status_code == 400:
|
|
205
|
+
raise ValidationError(error_message, status_code, response)
|
|
206
|
+
elif status_code == 401:
|
|
207
|
+
raise AuthenticationError(error_message, status_code, response)
|
|
208
|
+
elif status_code == 403:
|
|
209
|
+
raise PermissionDeniedError(error_message, status_code, response)
|
|
210
|
+
elif status_code == 404:
|
|
211
|
+
raise ResourceNotFoundError(error_message, status_code, response)
|
|
212
|
+
elif status_code == 429:
|
|
213
|
+
raise RateLimitError(error_message, status_code, response)
|
|
214
|
+
elif 500 <= status_code < 600:
|
|
215
|
+
raise ServerError(error_message, status_code, response)
|
|
216
|
+
else:
|
|
217
|
+
raise YouTrackAPIError(error_message, status_code, response)
|
|
218
|
+
|
|
219
|
+
def _make_request(
|
|
220
|
+
self, method: str, endpoint: str, **kwargs
|
|
221
|
+
) -> Dict[str, Any]:
|
|
222
|
+
"""
|
|
223
|
+
Make API request with retry logic for transient errors.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
227
|
+
endpoint: API endpoint
|
|
228
|
+
**kwargs: Additional arguments to pass to requests
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Parsed JSON response
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
YouTrackAPIError: For non-transient errors or if all retries fail
|
|
235
|
+
"""
|
|
236
|
+
url = self._get_api_url(endpoint)
|
|
237
|
+
retries = 0
|
|
238
|
+
delay = self.retry_delay
|
|
239
|
+
last_error = None
|
|
240
|
+
|
|
241
|
+
# For debugging purposes, log essential request details
|
|
242
|
+
if "json" in kwargs:
|
|
243
|
+
logger.debug(
|
|
244
|
+
f"{method} {url} with JSON: {json.dumps(kwargs['json'])}"
|
|
245
|
+
)
|
|
246
|
+
elif "data" in kwargs:
|
|
247
|
+
logger.debug(f"{method} {url} with data: {kwargs['data']}")
|
|
248
|
+
else:
|
|
249
|
+
logger.debug(f"{method} {url}")
|
|
250
|
+
|
|
251
|
+
while retries <= self.max_retries:
|
|
252
|
+
try:
|
|
253
|
+
response = self.session.request(method, url, **kwargs)
|
|
254
|
+
return self._handle_response(response)
|
|
255
|
+
except (ServerError, RateLimitError) as e:
|
|
256
|
+
# These are potentially transient, so we retry
|
|
257
|
+
last_error = e
|
|
258
|
+
retries += 1
|
|
259
|
+
|
|
260
|
+
if retries > self.max_retries:
|
|
261
|
+
logger.error(f"Maximum retries reached for {method} {url}")
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
# Calculate backoff delay (exponential with jitter)
|
|
265
|
+
backoff = delay * (2**retries) * (0.5 + 0.5 * random.random())
|
|
266
|
+
logger.warning(
|
|
267
|
+
f"Transient error, retrying in {backoff:.2f}s: {str(e)}"
|
|
268
|
+
)
|
|
269
|
+
time.sleep(backoff)
|
|
270
|
+
except YouTrackAPIError as e:
|
|
271
|
+
# Non-transient errors
|
|
272
|
+
logger.error(f"API error for {method} {url}: {str(e)}")
|
|
273
|
+
if hasattr(e, "response") and e.response is not None:
|
|
274
|
+
try:
|
|
275
|
+
error_content = e.response.content.decode(
|
|
276
|
+
"utf-8", errors="replace"
|
|
277
|
+
)
|
|
278
|
+
logger.error(f"Response content: {error_content}")
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
raise
|
|
282
|
+
except Exception as e:
|
|
283
|
+
# Unexpected errors
|
|
284
|
+
logger.exception(
|
|
285
|
+
f"Unexpected error for {method} {url}: {str(e)}"
|
|
286
|
+
)
|
|
287
|
+
raise YouTrackAPIError(f"Unexpected error: {str(e)}")
|
|
288
|
+
|
|
289
|
+
# If we got here, we've exceeded retries
|
|
290
|
+
if last_error:
|
|
291
|
+
raise last_error
|
|
292
|
+
|
|
293
|
+
# This should never happen, but just in case
|
|
294
|
+
raise YouTrackAPIError(f"Maximum retries exceeded for {method} {url}")
|
|
295
|
+
|
|
296
|
+
def get(
|
|
297
|
+
self, endpoint: str, params: Optional[Dict[str, Any]] = None, **kwargs
|
|
298
|
+
) -> Dict[str, Any]:
|
|
299
|
+
"""
|
|
300
|
+
Make GET request to API.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
endpoint: API endpoint
|
|
304
|
+
params: Query parameters
|
|
305
|
+
**kwargs: Additional arguments to pass to requests
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Parsed JSON response
|
|
309
|
+
"""
|
|
310
|
+
return self._make_request("GET", endpoint, params=params, **kwargs)
|
|
311
|
+
|
|
312
|
+
def post(
|
|
313
|
+
self,
|
|
314
|
+
endpoint: str,
|
|
315
|
+
data: Optional[Dict[str, Any]] = None,
|
|
316
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
317
|
+
**kwargs,
|
|
318
|
+
) -> Dict[str, Any]:
|
|
319
|
+
"""
|
|
320
|
+
Make POST request to API.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
endpoint: API endpoint
|
|
324
|
+
data: Form data
|
|
325
|
+
json_data: JSON data
|
|
326
|
+
**kwargs: Additional arguments to pass to requests
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Parsed JSON response
|
|
330
|
+
"""
|
|
331
|
+
# If data is provided but json_data is not, use data as json
|
|
332
|
+
if data is not None and json_data is None:
|
|
333
|
+
# Log the data being sent for debugging
|
|
334
|
+
logger.debug(f"POST {endpoint} with data: {json.dumps(data)}")
|
|
335
|
+
|
|
336
|
+
# Some endpoints expect parameters in different formats
|
|
337
|
+
# YouTrack API usually expects data as JSON
|
|
338
|
+
return self._make_request("POST", endpoint, json=data, **kwargs)
|
|
339
|
+
|
|
340
|
+
return self._make_request(
|
|
341
|
+
"POST", endpoint, data=data, json=json_data, **kwargs
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def put(
|
|
345
|
+
self,
|
|
346
|
+
endpoint: str,
|
|
347
|
+
data: Optional[Dict[str, Any]] = None,
|
|
348
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
349
|
+
**kwargs,
|
|
350
|
+
) -> Dict[str, Any]:
|
|
351
|
+
"""
|
|
352
|
+
Make PUT request to API.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
endpoint: API endpoint
|
|
356
|
+
data: Form data
|
|
357
|
+
json_data: JSON data
|
|
358
|
+
**kwargs: Additional arguments to pass to requests
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Parsed JSON response
|
|
362
|
+
"""
|
|
363
|
+
return self._make_request(
|
|
364
|
+
"PUT", endpoint, data=data, json=json_data, **kwargs
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def delete(self, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
368
|
+
"""
|
|
369
|
+
Make DELETE request to API.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
endpoint: API endpoint
|
|
373
|
+
**kwargs: Additional arguments to pass to requests
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Parsed JSON response
|
|
377
|
+
"""
|
|
378
|
+
return self._make_request("DELETE", endpoint, **kwargs)
|
|
379
|
+
|
|
380
|
+
def post_multipart(
|
|
381
|
+
self,
|
|
382
|
+
endpoint: str,
|
|
383
|
+
files: Dict[str, Any],
|
|
384
|
+
data: Optional[Dict[str, Any]] = None,
|
|
385
|
+
headers: Optional[Dict[str, str]] = None,
|
|
386
|
+
) -> Dict[str, Any]:
|
|
387
|
+
"""
|
|
388
|
+
Make POST request with multipart form-data (for file uploads).
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
endpoint: API endpoint
|
|
392
|
+
files: Mapping for requests 'files' parameter, e.g. {'file': (filename, bytes, mimeType)}
|
|
393
|
+
data: Optional additional form fields
|
|
394
|
+
headers: Optional headers to merge for this request
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Parsed JSON response
|
|
398
|
+
"""
|
|
399
|
+
url = self._get_api_url(endpoint)
|
|
400
|
+
request_headers = dict(self.session.headers)
|
|
401
|
+
# Remove JSON content-type so requests can set multipart boundary
|
|
402
|
+
if "Content-Type" in request_headers:
|
|
403
|
+
request_headers.pop("Content-Type", None)
|
|
404
|
+
if headers:
|
|
405
|
+
request_headers.update(headers)
|
|
406
|
+
|
|
407
|
+
response = self.session.request(
|
|
408
|
+
"POST",
|
|
409
|
+
url,
|
|
410
|
+
files=files,
|
|
411
|
+
data=data,
|
|
412
|
+
headers=request_headers,
|
|
413
|
+
verify=self.session.verify,
|
|
414
|
+
)
|
|
415
|
+
return self._handle_response(response)
|
|
416
|
+
|
|
417
|
+
def get_bytes(
|
|
418
|
+
self,
|
|
419
|
+
endpoint: str,
|
|
420
|
+
params: Optional[Dict[str, Any]] = None,
|
|
421
|
+
headers: Optional[Dict[str, str]] = None,
|
|
422
|
+
) -> bytes:
|
|
423
|
+
"""
|
|
424
|
+
Make GET request and return raw bytes (for binary downloads).
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
endpoint: API endpoint
|
|
428
|
+
params: Query parameters
|
|
429
|
+
headers: Optional headers (e.g., Accept: application/octet-stream)
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Raw response bytes
|
|
433
|
+
"""
|
|
434
|
+
url = self._get_api_url(endpoint)
|
|
435
|
+
request_headers = dict(self.session.headers)
|
|
436
|
+
if headers:
|
|
437
|
+
request_headers.update(headers)
|
|
438
|
+
response = self.session.request(
|
|
439
|
+
"GET",
|
|
440
|
+
url,
|
|
441
|
+
params=params,
|
|
442
|
+
headers=request_headers,
|
|
443
|
+
verify=self.session.verify,
|
|
444
|
+
stream=True,
|
|
445
|
+
)
|
|
446
|
+
status_code = response.status_code
|
|
447
|
+
if 200 <= status_code < 300:
|
|
448
|
+
return response.content
|
|
449
|
+
# Delegate error handling to common handler (will raise)
|
|
450
|
+
self._handle_response(response)
|
|
451
|
+
# Should not reach here
|
|
452
|
+
raise YouTrackAPIError(
|
|
453
|
+
f"Unexpected error downloading from {endpoint}", status_code, response
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def close(self) -> None:
|
|
457
|
+
"""Close the API client session."""
|
|
458
|
+
self.session.close()
|
|
459
|
+
|
|
460
|
+
def __enter__(self):
|
|
461
|
+
"""Enter context manager."""
|
|
462
|
+
return self
|
|
463
|
+
|
|
464
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
465
|
+
"""Exit context manager, closing session."""
|
|
466
|
+
self.close()
|