teams-phone-cli 0.1.2__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.
- teams_phone/__init__.py +3 -0
- teams_phone/__main__.py +7 -0
- teams_phone/cli/__init__.py +8 -0
- teams_phone/cli/api_check.py +267 -0
- teams_phone/cli/auth.py +201 -0
- teams_phone/cli/context.py +108 -0
- teams_phone/cli/helpers.py +65 -0
- teams_phone/cli/locations.py +308 -0
- teams_phone/cli/main.py +99 -0
- teams_phone/cli/numbers.py +1644 -0
- teams_phone/cli/policies.py +893 -0
- teams_phone/cli/tenants.py +364 -0
- teams_phone/cli/users.py +394 -0
- teams_phone/constants.py +97 -0
- teams_phone/exceptions.py +137 -0
- teams_phone/infrastructure/__init__.py +22 -0
- teams_phone/infrastructure/cache_manager.py +274 -0
- teams_phone/infrastructure/config_manager.py +209 -0
- teams_phone/infrastructure/debug_logger.py +321 -0
- teams_phone/infrastructure/graph_client.py +666 -0
- teams_phone/infrastructure/output_formatter.py +234 -0
- teams_phone/models/__init__.py +76 -0
- teams_phone/models/api_responses.py +69 -0
- teams_phone/models/auth.py +100 -0
- teams_phone/models/cache.py +25 -0
- teams_phone/models/config.py +66 -0
- teams_phone/models/location.py +36 -0
- teams_phone/models/number.py +184 -0
- teams_phone/models/policy.py +26 -0
- teams_phone/models/tenant.py +45 -0
- teams_phone/models/user.py +117 -0
- teams_phone/services/__init__.py +21 -0
- teams_phone/services/auth_service.py +536 -0
- teams_phone/services/bulk_operations.py +562 -0
- teams_phone/services/location_service.py +195 -0
- teams_phone/services/number_service.py +489 -0
- teams_phone/services/policy_service.py +330 -0
- teams_phone/services/tenant_service.py +205 -0
- teams_phone/services/user_service.py +435 -0
- teams_phone/utils.py +172 -0
- teams_phone_cli-0.1.2.dist-info/METADATA +15 -0
- teams_phone_cli-0.1.2.dist-info/RECORD +45 -0
- teams_phone_cli-0.1.2.dist-info/WHEEL +4 -0
- teams_phone_cli-0.1.2.dist-info/entry_points.txt +2 -0
- teams_phone_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""Graph API client for Microsoft Graph API communication.
|
|
2
|
+
|
|
3
|
+
This module provides the GraphClient class for making authenticated HTTP
|
|
4
|
+
requests to Microsoft Graph API with automatic header injection and retry logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import AsyncIterator, Callable
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from tenacity import (
|
|
17
|
+
RetryCallState,
|
|
18
|
+
retry,
|
|
19
|
+
retry_if_result,
|
|
20
|
+
stop_after_attempt,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from teams_phone.constants import DEFAULT_TIMEOUT, GRAPH_API_BASE, MAX_RETRIES
|
|
24
|
+
from teams_phone.exceptions import (
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
AuthorizationError,
|
|
27
|
+
ContractError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
TeamsPhoneError,
|
|
31
|
+
ValidationError,
|
|
32
|
+
)
|
|
33
|
+
from teams_phone.services.auth_service import AuthService
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from teams_phone.infrastructure.debug_logger import DebugLogger
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Status codes that should trigger automatic retry
|
|
42
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _make_exception_for_400(base_message: str) -> TeamsPhoneError:
|
|
46
|
+
"""Create ValidationError for 400 Bad Request."""
|
|
47
|
+
return ValidationError(f"Bad request: {base_message}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _make_exception_for_401(base_message: str) -> TeamsPhoneError:
|
|
51
|
+
"""Create AuthenticationError for 401 Unauthorized."""
|
|
52
|
+
return AuthenticationError(
|
|
53
|
+
f"Authentication failed: {base_message}",
|
|
54
|
+
remediation="Run `teams-phone auth login` to re-authenticate",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _make_exception_for_403(base_message: str) -> TeamsPhoneError:
|
|
59
|
+
"""Create AuthorizationError for 403 Forbidden."""
|
|
60
|
+
return AuthorizationError(
|
|
61
|
+
f"Access denied: {base_message}",
|
|
62
|
+
remediation="Verify application has required Graph API permissions",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _make_exception_for_404(base_message: str) -> TeamsPhoneError:
|
|
67
|
+
"""Create NotFoundError for 404 Not Found."""
|
|
68
|
+
return NotFoundError(f"Resource not found: {base_message}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _make_exception_for_409(base_message: str) -> TeamsPhoneError:
|
|
72
|
+
"""Create ValidationError for 409 Conflict."""
|
|
73
|
+
return ValidationError(f"Conflict: {base_message}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _make_exception_for_429(base_message: str) -> TeamsPhoneError:
|
|
77
|
+
"""Create RateLimitError for 429 Too Many Requests."""
|
|
78
|
+
return RateLimitError(
|
|
79
|
+
f"Rate limit exceeded: {base_message}",
|
|
80
|
+
remediation="Wait and retry later, or reduce request rate",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Lookup table mapping HTTP status codes to exception factory functions
|
|
85
|
+
_ERROR_HANDLERS: dict[int, Callable[[str], TeamsPhoneError]] = {
|
|
86
|
+
400: _make_exception_for_400,
|
|
87
|
+
401: _make_exception_for_401,
|
|
88
|
+
403: _make_exception_for_403,
|
|
89
|
+
404: _make_exception_for_404,
|
|
90
|
+
409: _make_exception_for_409,
|
|
91
|
+
429: _make_exception_for_429,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_retryable_status(response: httpx.Response) -> bool:
|
|
96
|
+
"""Check if response status code should trigger a retry.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
response: The HTTP response to check.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if the status code is retryable (429, 500, 502, 503, 504).
|
|
103
|
+
"""
|
|
104
|
+
return response.status_code in RETRYABLE_STATUS_CODES
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _get_retry_after(response: httpx.Response) -> float | None:
|
|
108
|
+
"""Parse Retry-After header value.
|
|
109
|
+
|
|
110
|
+
Handles both integer seconds and HTTP-date formats per RFC 7231.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
response: The HTTP response containing the Retry-After header.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Number of seconds to wait, or None if header is missing/unparseable.
|
|
117
|
+
"""
|
|
118
|
+
retry_after = response.headers.get("Retry-After")
|
|
119
|
+
if retry_after is None:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
# Try integer seconds first (most common)
|
|
123
|
+
try:
|
|
124
|
+
return float(retry_after)
|
|
125
|
+
except ValueError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
# Try HTTP-date format (e.g., "Wed, 21 Oct 2015 07:28:00 GMT")
|
|
129
|
+
try:
|
|
130
|
+
retry_date = parsedate_to_datetime(retry_after)
|
|
131
|
+
now = datetime.now(timezone.utc)
|
|
132
|
+
delta: float = (retry_date - now).total_seconds()
|
|
133
|
+
return max(0.0, delta)
|
|
134
|
+
except (ValueError, TypeError):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _custom_wait(retry_state: RetryCallState) -> float:
|
|
139
|
+
"""Custom wait callback that uses Retry-After header when available.
|
|
140
|
+
|
|
141
|
+
For 429 responses with Retry-After header, uses that value.
|
|
142
|
+
Otherwise falls back to exponential backoff: 1s, 2s, 4s.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
retry_state: The tenacity retry state with outcome.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Number of seconds to wait before next retry.
|
|
149
|
+
"""
|
|
150
|
+
outcome = retry_state.outcome
|
|
151
|
+
if outcome and not outcome.failed:
|
|
152
|
+
response: httpx.Response = outcome.result()
|
|
153
|
+
if response.status_code == 429:
|
|
154
|
+
retry_after = _get_retry_after(response)
|
|
155
|
+
if retry_after is not None:
|
|
156
|
+
return retry_after
|
|
157
|
+
|
|
158
|
+
# Exponential backoff: 1s, 2s, 4s (2^0, 2^1, 2^2)
|
|
159
|
+
return float(min(4.0, 2 ** (retry_state.attempt_number - 1)))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _log_retry(retry_state: RetryCallState) -> None:
|
|
163
|
+
"""Log retry attempts before sleeping.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
retry_state: The tenacity retry state with outcome.
|
|
167
|
+
"""
|
|
168
|
+
outcome = retry_state.outcome
|
|
169
|
+
if outcome and not outcome.failed:
|
|
170
|
+
response: httpx.Response = outcome.result()
|
|
171
|
+
wait_time = _custom_wait(retry_state)
|
|
172
|
+
logger.warning(
|
|
173
|
+
"Retrying request after HTTP %d (attempt %d/%d, waiting %.1fs)",
|
|
174
|
+
response.status_code,
|
|
175
|
+
retry_state.attempt_number,
|
|
176
|
+
MAX_RETRIES,
|
|
177
|
+
wait_time,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _raise_for_exhausted_retries(retry_state: RetryCallState) -> None:
|
|
182
|
+
"""Raise appropriate exception when all retries are exhausted.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
retry_state: The tenacity retry state with final outcome.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
RateLimitError: If exhausted on 429.
|
|
189
|
+
TeamsPhoneError: If exhausted on 5xx.
|
|
190
|
+
"""
|
|
191
|
+
outcome = retry_state.outcome
|
|
192
|
+
if outcome and not outcome.failed:
|
|
193
|
+
response: httpx.Response = outcome.result()
|
|
194
|
+
status_code = response.status_code
|
|
195
|
+
|
|
196
|
+
if status_code == 429:
|
|
197
|
+
raise RateLimitError(
|
|
198
|
+
f"Rate limit exceeded after {MAX_RETRIES} retries",
|
|
199
|
+
remediation="Wait and retry later, or reduce request rate",
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
# Extract error details from response body for debugging
|
|
203
|
+
error_detail = ""
|
|
204
|
+
try:
|
|
205
|
+
data = response.json()
|
|
206
|
+
if isinstance(data, dict) and "error" in data:
|
|
207
|
+
error_obj = data["error"]
|
|
208
|
+
if isinstance(error_obj, dict):
|
|
209
|
+
error_detail = f": {error_obj.get('message', '')}"
|
|
210
|
+
except (ValueError, TypeError):
|
|
211
|
+
if response.text:
|
|
212
|
+
error_detail = f": {response.text[:200]}"
|
|
213
|
+
|
|
214
|
+
raise TeamsPhoneError(
|
|
215
|
+
f"Graph API unavailable (HTTP {status_code}) after {MAX_RETRIES} retries{error_detail}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class GraphClient:
|
|
220
|
+
"""HTTP client for Microsoft Graph API requests.
|
|
221
|
+
|
|
222
|
+
Handles authentication header injection, URL construction, and
|
|
223
|
+
async HTTP operations. Implements async context manager protocol
|
|
224
|
+
for proper resource management.
|
|
225
|
+
|
|
226
|
+
Attributes:
|
|
227
|
+
auth_service: The AuthService instance for token acquisition.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def __init__(
|
|
231
|
+
self,
|
|
232
|
+
auth_service: AuthService,
|
|
233
|
+
*,
|
|
234
|
+
timeout: int | None = None,
|
|
235
|
+
debug_logger: DebugLogger | None = None,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Initialize the GraphClient.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
auth_service: The AuthService for authentication token acquisition.
|
|
241
|
+
timeout: Request timeout in seconds. Defaults to DEFAULT_TIMEOUT (30s)
|
|
242
|
+
if not specified.
|
|
243
|
+
debug_logger: Optional DebugLogger for request/response logging.
|
|
244
|
+
"""
|
|
245
|
+
self.auth_service = auth_service
|
|
246
|
+
self._timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
|
|
247
|
+
self._client: httpx.AsyncClient | None = None
|
|
248
|
+
self._debug_logger = debug_logger
|
|
249
|
+
|
|
250
|
+
def _build_url(self, endpoint: str) -> str:
|
|
251
|
+
"""Construct full URL from base and endpoint.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
endpoint: The API endpoint path, with or without leading slash.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Full URL combining GRAPH_API_BASE and the endpoint.
|
|
258
|
+
"""
|
|
259
|
+
return f"{GRAPH_API_BASE}/{endpoint.lstrip('/')}"
|
|
260
|
+
|
|
261
|
+
def _get_headers(self) -> dict[str, str]:
|
|
262
|
+
"""Get request headers with authorization token.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Headers dict with Authorization Bearer token and Content-Type.
|
|
266
|
+
"""
|
|
267
|
+
token = self.auth_service.get_token()
|
|
268
|
+
return {
|
|
269
|
+
"Authorization": f"Bearer {token}",
|
|
270
|
+
"Content-Type": "application/json",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def _handle_error(self, response: httpx.Response) -> None:
|
|
274
|
+
"""Parse HTTP error response and raise appropriate exception.
|
|
275
|
+
|
|
276
|
+
Maps HTTP status codes to the exception hierarchy with OData error
|
|
277
|
+
message extraction and remediation hints.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
response: The HTTP response with a non-success status code.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
ValidationError: For 400 (bad request) or 409 (conflict).
|
|
284
|
+
AuthenticationError: For 401 (unauthorized).
|
|
285
|
+
AuthorizationError: For 403 (forbidden).
|
|
286
|
+
NotFoundError: For 404 (not found).
|
|
287
|
+
RateLimitError: For 429 (too many requests).
|
|
288
|
+
TeamsPhoneError: For 5xx server errors or unknown status codes.
|
|
289
|
+
"""
|
|
290
|
+
status_code = response.status_code
|
|
291
|
+
|
|
292
|
+
# Extract error message from OData error format or use status text
|
|
293
|
+
error_message = response.reason_phrase or "Unknown error"
|
|
294
|
+
error_code: str | None = None
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
data = response.json()
|
|
298
|
+
if isinstance(data, dict) and "error" in data:
|
|
299
|
+
error_obj = data["error"]
|
|
300
|
+
if isinstance(error_obj, dict):
|
|
301
|
+
error_message = error_obj.get("message", error_message)
|
|
302
|
+
error_code = error_obj.get("code")
|
|
303
|
+
except (ValueError, TypeError):
|
|
304
|
+
# Non-JSON response, use response text if available
|
|
305
|
+
if response.text:
|
|
306
|
+
error_message = response.text[:200] # Truncate long responses
|
|
307
|
+
|
|
308
|
+
# Build base message with error code if available
|
|
309
|
+
base_message = f"{error_code}: {error_message}" if error_code else error_message
|
|
310
|
+
|
|
311
|
+
# Look up exception factory from table
|
|
312
|
+
handler = _ERROR_HANDLERS.get(status_code)
|
|
313
|
+
if handler:
|
|
314
|
+
raise handler(base_message)
|
|
315
|
+
|
|
316
|
+
# Handle 5xx server errors
|
|
317
|
+
if 500 <= status_code < 600:
|
|
318
|
+
raise TeamsPhoneError(
|
|
319
|
+
f"Graph API error (HTTP {status_code}): {base_message}",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Unknown status code
|
|
323
|
+
raise TeamsPhoneError(
|
|
324
|
+
f"Unexpected error (HTTP {status_code}): {base_message}",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def _request(
|
|
328
|
+
self,
|
|
329
|
+
method: str,
|
|
330
|
+
url: str,
|
|
331
|
+
**kwargs: Any,
|
|
332
|
+
) -> httpx.Response:
|
|
333
|
+
"""Make an async HTTP request with retry logic.
|
|
334
|
+
|
|
335
|
+
Implements automatic retry for transient failures:
|
|
336
|
+
- 429 (rate limit): Respects Retry-After header, max 3 retries
|
|
337
|
+
- 500, 502, 503, 504 (server errors): Exponential backoff (1s, 2s, 4s)
|
|
338
|
+
- 401 (unauthorized): Single token refresh retry
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
method: HTTP method (GET, POST, etc.).
|
|
342
|
+
url: Full URL for the request.
|
|
343
|
+
**kwargs: Additional arguments passed to httpx.AsyncClient.request().
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
The httpx.Response object.
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
RuntimeError: If client is not initialized (not in async context).
|
|
350
|
+
RateLimitError: If rate limit exceeded after max retries.
|
|
351
|
+
TeamsPhoneError: If server error persists after max retries.
|
|
352
|
+
AuthenticationError: If 401 persists after token refresh.
|
|
353
|
+
"""
|
|
354
|
+
if self._client is None:
|
|
355
|
+
raise RuntimeError(
|
|
356
|
+
"GraphClient must be used as async context manager. "
|
|
357
|
+
"Use 'async with GraphClient(...) as client:'"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Handle 401 with single token refresh retry
|
|
361
|
+
response = await self._request_with_retry(method, url, **kwargs)
|
|
362
|
+
|
|
363
|
+
if response.status_code == 401:
|
|
364
|
+
logger.warning("Received 401, refreshing token and retrying once")
|
|
365
|
+
try:
|
|
366
|
+
self.auth_service.refresh_token()
|
|
367
|
+
except AuthenticationError:
|
|
368
|
+
raise AuthenticationError(
|
|
369
|
+
"Authentication failed after token refresh",
|
|
370
|
+
remediation="Run `teams-phone auth login` to re-authenticate",
|
|
371
|
+
) from None
|
|
372
|
+
|
|
373
|
+
# Retry once with refreshed token
|
|
374
|
+
response = await self._request_with_retry(method, url, **kwargs)
|
|
375
|
+
|
|
376
|
+
if response.status_code == 401:
|
|
377
|
+
raise AuthenticationError(
|
|
378
|
+
"Authentication failed after token refresh",
|
|
379
|
+
remediation="Run `teams-phone auth login` to re-authenticate",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
return response
|
|
383
|
+
|
|
384
|
+
@retry(
|
|
385
|
+
retry=retry_if_result(_is_retryable_status),
|
|
386
|
+
stop=stop_after_attempt(MAX_RETRIES),
|
|
387
|
+
wait=_custom_wait,
|
|
388
|
+
before_sleep=_log_retry,
|
|
389
|
+
retry_error_callback=_raise_for_exhausted_retries,
|
|
390
|
+
)
|
|
391
|
+
async def _request_with_retry(
|
|
392
|
+
self,
|
|
393
|
+
method: str,
|
|
394
|
+
url: str,
|
|
395
|
+
**kwargs: Any,
|
|
396
|
+
) -> httpx.Response:
|
|
397
|
+
"""Execute HTTP request with automatic retry for transient failures.
|
|
398
|
+
|
|
399
|
+
This method is wrapped with tenacity retry decorator for 429 and 5xx.
|
|
400
|
+
Do not call directly - use _request() which handles 401 separately.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
method: HTTP method (GET, POST, etc.).
|
|
404
|
+
url: Full URL for the request.
|
|
405
|
+
**kwargs: Additional arguments passed to httpx.AsyncClient.request().
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
The httpx.Response object (may be retryable status).
|
|
409
|
+
"""
|
|
410
|
+
headers = self._get_headers()
|
|
411
|
+
return await self._client.request(method, url, headers=headers, **kwargs) # type: ignore[union-attr]
|
|
412
|
+
|
|
413
|
+
async def get(
|
|
414
|
+
self,
|
|
415
|
+
endpoint: str,
|
|
416
|
+
*,
|
|
417
|
+
params: dict[str, Any] | None = None,
|
|
418
|
+
) -> dict[str, Any]:
|
|
419
|
+
"""Make an async GET request to a Graph API endpoint.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
endpoint: The API endpoint path.
|
|
423
|
+
params: Optional query parameters.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
JSON response as a dictionary.
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ValidationError: For 400 or 409 responses.
|
|
430
|
+
AuthenticationError: For 401 responses.
|
|
431
|
+
AuthorizationError: For 403 responses.
|
|
432
|
+
NotFoundError: For 404 responses.
|
|
433
|
+
RateLimitError: For 429 responses (after retry exhaustion).
|
|
434
|
+
TeamsPhoneError: For 5xx responses (after retry exhaustion).
|
|
435
|
+
"""
|
|
436
|
+
url = self._build_url(endpoint)
|
|
437
|
+
start_time = None
|
|
438
|
+
if self._debug_logger:
|
|
439
|
+
start_time = self._debug_logger.log_graph_request("GET", url)
|
|
440
|
+
response = await self._request("GET", url, params=params)
|
|
441
|
+
if self._debug_logger and start_time is not None:
|
|
442
|
+
self._debug_logger.log_graph_response(response.status_code, start_time)
|
|
443
|
+
if not response.is_success:
|
|
444
|
+
self._handle_error(response)
|
|
445
|
+
result: dict[str, Any] = response.json()
|
|
446
|
+
return result
|
|
447
|
+
|
|
448
|
+
async def post(
|
|
449
|
+
self,
|
|
450
|
+
endpoint: str,
|
|
451
|
+
body: dict[str, Any],
|
|
452
|
+
) -> dict[str, Any]:
|
|
453
|
+
"""Make an async POST request to a Graph API endpoint.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
endpoint: The API endpoint path.
|
|
457
|
+
body: Request body as a dictionary.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
JSON response as a dictionary.
|
|
461
|
+
|
|
462
|
+
Raises:
|
|
463
|
+
ValidationError: For 400 or 409 responses.
|
|
464
|
+
AuthenticationError: For 401 responses.
|
|
465
|
+
AuthorizationError: For 403 responses.
|
|
466
|
+
NotFoundError: For 404 responses.
|
|
467
|
+
RateLimitError: For 429 responses (after retry exhaustion).
|
|
468
|
+
TeamsPhoneError: For 5xx responses (after retry exhaustion).
|
|
469
|
+
"""
|
|
470
|
+
url = self._build_url(endpoint)
|
|
471
|
+
start_time = None
|
|
472
|
+
if self._debug_logger:
|
|
473
|
+
import json as json_module
|
|
474
|
+
|
|
475
|
+
body_size = len(json_module.dumps(body).encode("utf-8"))
|
|
476
|
+
start_time = self._debug_logger.log_graph_request(
|
|
477
|
+
"POST", url, body_size=body_size
|
|
478
|
+
)
|
|
479
|
+
response = await self._request("POST", url, json=body)
|
|
480
|
+
if self._debug_logger and start_time is not None:
|
|
481
|
+
self._debug_logger.log_graph_response(response.status_code, start_time)
|
|
482
|
+
if not response.is_success:
|
|
483
|
+
self._handle_error(response)
|
|
484
|
+
result: dict[str, Any] = response.json()
|
|
485
|
+
return result
|
|
486
|
+
|
|
487
|
+
async def post_with_response(
|
|
488
|
+
self,
|
|
489
|
+
endpoint: str,
|
|
490
|
+
body: dict[str, Any],
|
|
491
|
+
) -> httpx.Response:
|
|
492
|
+
"""Make an async POST request and return the raw httpx.Response.
|
|
493
|
+
|
|
494
|
+
Use this method when you need access to response headers, such as
|
|
495
|
+
the Location header in 202 Accepted responses for async operations.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
endpoint: The API endpoint path.
|
|
499
|
+
body: Request body as a dictionary.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
The raw httpx.Response object with headers accessible.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
ValidationError: For 400 or 409 responses.
|
|
506
|
+
AuthenticationError: For 401 responses.
|
|
507
|
+
AuthorizationError: For 403 responses.
|
|
508
|
+
NotFoundError: For 404 responses.
|
|
509
|
+
RateLimitError: For 429 responses (after retry exhaustion).
|
|
510
|
+
TeamsPhoneError: For 5xx responses (after retry exhaustion).
|
|
511
|
+
"""
|
|
512
|
+
url = self._build_url(endpoint)
|
|
513
|
+
start_time = None
|
|
514
|
+
if self._debug_logger:
|
|
515
|
+
import json as json_module
|
|
516
|
+
|
|
517
|
+
body_size = len(json_module.dumps(body).encode("utf-8"))
|
|
518
|
+
start_time = self._debug_logger.log_graph_request(
|
|
519
|
+
"POST", url, body_size=body_size
|
|
520
|
+
)
|
|
521
|
+
response = await self._request("POST", url, json=body)
|
|
522
|
+
if self._debug_logger and start_time is not None:
|
|
523
|
+
self._debug_logger.log_graph_response(response.status_code, start_time)
|
|
524
|
+
if not response.is_success:
|
|
525
|
+
self._handle_error(response)
|
|
526
|
+
return response
|
|
527
|
+
|
|
528
|
+
async def get_paginated(
|
|
529
|
+
self,
|
|
530
|
+
endpoint: str,
|
|
531
|
+
*,
|
|
532
|
+
params: dict[str, Any] | None = None,
|
|
533
|
+
max_items: int | None = None,
|
|
534
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
535
|
+
"""Make paginated GET requests, yielding individual items.
|
|
536
|
+
|
|
537
|
+
Handles Microsoft Graph API pagination by following @odata.nextLink
|
|
538
|
+
until all items are retrieved or max_items limit is reached.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
endpoint: The API endpoint path.
|
|
542
|
+
params: Optional query parameters for the initial request.
|
|
543
|
+
max_items: Maximum number of items to yield. If None, yields all items.
|
|
544
|
+
If 0, returns immediately without making any requests.
|
|
545
|
+
|
|
546
|
+
Yields:
|
|
547
|
+
Individual items from the 'value' array in each response.
|
|
548
|
+
|
|
549
|
+
Raises:
|
|
550
|
+
ContractError: If response is missing the 'value' key.
|
|
551
|
+
RateLimitError: If rate limit exceeded after max retries.
|
|
552
|
+
TeamsPhoneError: If server error persists after max retries.
|
|
553
|
+
AuthenticationError: If authentication fails after token refresh.
|
|
554
|
+
"""
|
|
555
|
+
url = self._build_url(endpoint)
|
|
556
|
+
async for item in self.get_paginated_url(
|
|
557
|
+
url, params=params, max_items=max_items
|
|
558
|
+
):
|
|
559
|
+
yield item
|
|
560
|
+
|
|
561
|
+
async def get_paginated_url( # noqa: C901 - Pagination loop with debug logging and early termination
|
|
562
|
+
self,
|
|
563
|
+
url: str,
|
|
564
|
+
*,
|
|
565
|
+
params: dict[str, Any] | None = None,
|
|
566
|
+
max_items: int | None = None,
|
|
567
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
568
|
+
"""Make paginated GET requests to a full URL, yielding individual items.
|
|
569
|
+
|
|
570
|
+
Like get_paginated but accepts a full URL instead of a relative endpoint.
|
|
571
|
+
Useful for calling different API versions (e.g., v1.0 vs beta).
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
url: The full URL to request.
|
|
575
|
+
params: Optional query parameters for the initial request.
|
|
576
|
+
max_items: Maximum number of items to yield. If None, yields all items.
|
|
577
|
+
If 0, returns immediately without making any requests.
|
|
578
|
+
|
|
579
|
+
Yields:
|
|
580
|
+
Individual items from the 'value' array in each response.
|
|
581
|
+
|
|
582
|
+
Raises:
|
|
583
|
+
ContractError: If response is missing the 'value' key.
|
|
584
|
+
RateLimitError: If rate limit exceeded after max retries.
|
|
585
|
+
TeamsPhoneError: If server error persists after max retries.
|
|
586
|
+
AuthenticationError: If authentication fails after token refresh.
|
|
587
|
+
"""
|
|
588
|
+
if max_items == 0:
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
current_url: str | None = url
|
|
592
|
+
items_yielded = 0
|
|
593
|
+
is_first_request = True
|
|
594
|
+
|
|
595
|
+
while current_url is not None:
|
|
596
|
+
start_time = None
|
|
597
|
+
if self._debug_logger:
|
|
598
|
+
start_time = self._debug_logger.log_graph_request("GET", current_url)
|
|
599
|
+
|
|
600
|
+
# For initial request, use params; for nextLink, URL has params embedded
|
|
601
|
+
if is_first_request and params is not None:
|
|
602
|
+
response = await self._request("GET", current_url, params=params)
|
|
603
|
+
is_first_request = False
|
|
604
|
+
else:
|
|
605
|
+
response = await self._request("GET", current_url)
|
|
606
|
+
|
|
607
|
+
if not response.is_success:
|
|
608
|
+
if self._debug_logger and start_time is not None:
|
|
609
|
+
self._debug_logger.log_graph_response(
|
|
610
|
+
response.status_code, start_time
|
|
611
|
+
)
|
|
612
|
+
self._handle_error(response)
|
|
613
|
+
data: dict[str, Any] = response.json()
|
|
614
|
+
|
|
615
|
+
if "value" not in data:
|
|
616
|
+
raise ContractError(
|
|
617
|
+
f"Graph API response missing 'value' key for URL: {url}",
|
|
618
|
+
remediation="This may indicate an API contract change. "
|
|
619
|
+
"Please report this issue.",
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
items_in_page = len(data["value"])
|
|
623
|
+
if self._debug_logger and start_time is not None:
|
|
624
|
+
self._debug_logger.log_graph_response(
|
|
625
|
+
response.status_code, start_time, items_count=items_in_page
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
for item in data["value"]:
|
|
629
|
+
yield item
|
|
630
|
+
items_yielded += 1
|
|
631
|
+
|
|
632
|
+
if max_items is not None and items_yielded >= max_items:
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
# Get next page URL (full URL, not relative endpoint)
|
|
636
|
+
current_url = data.get("@odata.nextLink")
|
|
637
|
+
|
|
638
|
+
async def __aenter__(self) -> GraphClient:
|
|
639
|
+
"""Enter async context manager.
|
|
640
|
+
|
|
641
|
+
Creates the underlying httpx.AsyncClient with configured timeout.
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
This GraphClient instance.
|
|
645
|
+
"""
|
|
646
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
647
|
+
return self
|
|
648
|
+
|
|
649
|
+
async def __aexit__(
|
|
650
|
+
self,
|
|
651
|
+
exc_type: type[BaseException] | None,
|
|
652
|
+
exc_val: BaseException | None,
|
|
653
|
+
exc_tb: Any,
|
|
654
|
+
) -> None:
|
|
655
|
+
"""Exit async context manager.
|
|
656
|
+
|
|
657
|
+
Closes the underlying httpx.AsyncClient.
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
exc_type: Exception type if an exception was raised.
|
|
661
|
+
exc_val: Exception instance if an exception was raised.
|
|
662
|
+
exc_tb: Traceback if an exception was raised.
|
|
663
|
+
"""
|
|
664
|
+
if self._client is not None:
|
|
665
|
+
await self._client.aclose()
|
|
666
|
+
self._client = None
|