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.
Files changed (45) hide show
  1. teams_phone/__init__.py +3 -0
  2. teams_phone/__main__.py +7 -0
  3. teams_phone/cli/__init__.py +8 -0
  4. teams_phone/cli/api_check.py +267 -0
  5. teams_phone/cli/auth.py +201 -0
  6. teams_phone/cli/context.py +108 -0
  7. teams_phone/cli/helpers.py +65 -0
  8. teams_phone/cli/locations.py +308 -0
  9. teams_phone/cli/main.py +99 -0
  10. teams_phone/cli/numbers.py +1644 -0
  11. teams_phone/cli/policies.py +893 -0
  12. teams_phone/cli/tenants.py +364 -0
  13. teams_phone/cli/users.py +394 -0
  14. teams_phone/constants.py +97 -0
  15. teams_phone/exceptions.py +137 -0
  16. teams_phone/infrastructure/__init__.py +22 -0
  17. teams_phone/infrastructure/cache_manager.py +274 -0
  18. teams_phone/infrastructure/config_manager.py +209 -0
  19. teams_phone/infrastructure/debug_logger.py +321 -0
  20. teams_phone/infrastructure/graph_client.py +666 -0
  21. teams_phone/infrastructure/output_formatter.py +234 -0
  22. teams_phone/models/__init__.py +76 -0
  23. teams_phone/models/api_responses.py +69 -0
  24. teams_phone/models/auth.py +100 -0
  25. teams_phone/models/cache.py +25 -0
  26. teams_phone/models/config.py +66 -0
  27. teams_phone/models/location.py +36 -0
  28. teams_phone/models/number.py +184 -0
  29. teams_phone/models/policy.py +26 -0
  30. teams_phone/models/tenant.py +45 -0
  31. teams_phone/models/user.py +117 -0
  32. teams_phone/services/__init__.py +21 -0
  33. teams_phone/services/auth_service.py +536 -0
  34. teams_phone/services/bulk_operations.py +562 -0
  35. teams_phone/services/location_service.py +195 -0
  36. teams_phone/services/number_service.py +489 -0
  37. teams_phone/services/policy_service.py +330 -0
  38. teams_phone/services/tenant_service.py +205 -0
  39. teams_phone/services/user_service.py +435 -0
  40. teams_phone/utils.py +172 -0
  41. teams_phone_cli-0.1.2.dist-info/METADATA +15 -0
  42. teams_phone_cli-0.1.2.dist-info/RECORD +45 -0
  43. teams_phone_cli-0.1.2.dist-info/WHEEL +4 -0
  44. teams_phone_cli-0.1.2.dist-info/entry_points.txt +2 -0
  45. 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