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,435 @@
1
+ """User service for Teams Phone user configuration operations.
2
+
3
+ This module provides the UserService class for listing, searching, and
4
+ retrieving Teams user voice configurations from Microsoft Graph API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from teams_phone.constants import GRAPH_API_V1_BASE
13
+ from teams_phone.exceptions import NotFoundError, ValidationError
14
+ from teams_phone.models import AccountType, UserConfiguration
15
+
16
+
17
+ if TYPE_CHECKING:
18
+ from teams_phone.infrastructure.graph_client import GraphClient
19
+
20
+
21
+ def _strip_odata_metadata(data: dict[str, Any]) -> dict[str, Any]:
22
+ """Strip OData metadata fields from a response dict.
23
+
24
+ Graph API responses include @odata.context and other @ prefixed fields
25
+ that are not part of the actual data contract. This helper removes them
26
+ before Pydantic validation with extra="forbid".
27
+ """
28
+ return {k: v for k, v in data.items() if not k.startswith("@")}
29
+
30
+
31
+ class UserService:
32
+ """Service for user voice configuration operations.
33
+
34
+ Provides methods for listing, searching, and retrieving Teams user
35
+ configurations from the Microsoft Graph API.
36
+
37
+ Attributes:
38
+ graph_client: GraphClient instance for API communication.
39
+ """
40
+
41
+ # Maximum IDs per filter query (URL length limit)
42
+ _MAX_IDS_PER_BATCH = 15
43
+
44
+ def __init__(self, graph_client: GraphClient) -> None:
45
+ """Initialize the UserService.
46
+
47
+ Args:
48
+ graph_client: GraphClient instance for API communication.
49
+ """
50
+ self.graph_client = graph_client
51
+
52
+ async def _fetch_display_names(
53
+ self,
54
+ user_ids: list[str],
55
+ ) -> dict[str, str]:
56
+ """Fetch display names for a list of user IDs from the directory.
57
+
58
+ The /admin/teams/userConfigurations endpoint doesn't return displayName,
59
+ so we fetch it separately from the v1.0 /users endpoint.
60
+
61
+ Args:
62
+ user_ids: List of Entra object IDs to fetch display names for.
63
+
64
+ Returns:
65
+ Dict mapping user ID to display name. Users not found are omitted.
66
+ """
67
+ if not user_ids:
68
+ return {}
69
+
70
+ display_names: dict[str, str] = {}
71
+
72
+ # Batch IDs to avoid URL length limits
73
+ for i in range(0, len(user_ids), self._MAX_IDS_PER_BATCH):
74
+ batch_ids = user_ids[i : i + self._MAX_IDS_PER_BATCH]
75
+
76
+ # Build filter: id in ('id1', 'id2', ...)
77
+ id_list = ", ".join(f"'{uid}'" for uid in batch_ids)
78
+ url = f"{GRAPH_API_V1_BASE}/users"
79
+ params: dict[str, Any] = {
80
+ "$filter": f"id in ({id_list})",
81
+ "$select": "id,displayName",
82
+ }
83
+
84
+ async for item in self.graph_client.get_paginated_url(
85
+ url,
86
+ params=params,
87
+ ):
88
+ user_id = item.get("id")
89
+ display_name = item.get("displayName")
90
+ if user_id and display_name:
91
+ display_names[user_id] = display_name
92
+
93
+ return display_names
94
+
95
+ async def _enrich_display_names(
96
+ self,
97
+ users: list[UserConfiguration],
98
+ ) -> list[UserConfiguration]:
99
+ """Enrich a list of users with display names from the directory.
100
+
101
+ Args:
102
+ users: List of UserConfiguration objects to enrich.
103
+
104
+ Returns:
105
+ New list with display names populated where available.
106
+ """
107
+ user_ids = [u.id for u in users if u.display_name is None]
108
+ if not user_ids:
109
+ return users
110
+
111
+ display_names = await self._fetch_display_names(user_ids)
112
+ return [
113
+ u.model_copy(update={"display_name": display_names.get(u.id)})
114
+ if u.display_name is None and u.id in display_names
115
+ else u
116
+ for u in users
117
+ ]
118
+
119
+ async def list_users(
120
+ self,
121
+ *,
122
+ licensed: bool | None = None,
123
+ assigned: bool | None = None,
124
+ account_type: AccountType | None = None,
125
+ page_size: int = 100,
126
+ max_items: int | None = None,
127
+ ) -> list[UserConfiguration]:
128
+ """List users with optional filtering and pagination.
129
+
130
+ Retrieves user voice configurations from Microsoft Graph API
131
+ with support for filtering by licensing, phone number assignment,
132
+ and account type.
133
+
134
+ Args:
135
+ licensed: If True, only return users with enterprise voice enabled.
136
+ If False, only return users without enterprise voice.
137
+ If None, return all users regardless of voice license.
138
+ assigned: If True, only return users with phone numbers assigned.
139
+ If False, only return users without phone numbers.
140
+ If None, return all users regardless of assignment status.
141
+ account_type: Filter by account type (user, resourceAccount, etc.).
142
+ If None, return all account types.
143
+ page_size: Number of items per page (default 100, max 999).
144
+ max_items: Maximum total items to return. If None, return all items.
145
+
146
+ Returns:
147
+ List of UserConfiguration objects matching the filter criteria.
148
+
149
+ Raises:
150
+ ContractError: If API response is missing expected fields.
151
+ AuthenticationError: If authentication fails.
152
+ RateLimitError: If rate limit exceeded after retries.
153
+ """
154
+ users: list[UserConfiguration] = []
155
+ params: dict[str, Any] = {"$top": str(min(page_size, 999))}
156
+
157
+ # Build OData $filter string based on parameters
158
+ # Note: assigned filter is done client-side (Graph API doesn't support $count on telephoneNumbers)
159
+ filters: list[str] = []
160
+ if licensed is not None:
161
+ filters.append(f"isEnterpriseVoiceEnabled eq {str(licensed).lower()}")
162
+ if account_type is not None:
163
+ filters.append(f"accountType eq '{account_type.value}'")
164
+
165
+ if filters:
166
+ params["$filter"] = " and ".join(filters)
167
+
168
+ async for item in self.graph_client.get_paginated(
169
+ "/admin/teams/userConfigurations",
170
+ params=params,
171
+ max_items=max_items
172
+ if assigned is None
173
+ else None, # Fetch all if filtering client-side
174
+ ):
175
+ user = UserConfiguration.model_validate(item)
176
+ # Client-side filter for assigned status
177
+ if assigned is not None:
178
+ has_numbers = len(user.telephone_numbers) > 0
179
+ if assigned and not has_numbers:
180
+ continue
181
+ if not assigned and has_numbers:
182
+ continue
183
+ users.append(user)
184
+ # Check max_items after client-side filtering
185
+ if max_items is not None and len(users) >= max_items:
186
+ break
187
+
188
+ # Enrich with display names from directory
189
+ # The Teams admin API doesn't return displayName
190
+ return await self._enrich_display_names(users)
191
+
192
+ async def get_user(self, user_id: str) -> UserConfiguration:
193
+ """Get a single user's voice configuration by ID.
194
+
195
+ Retrieves detailed voice configuration for a specific user
196
+ identified by their Entra object ID.
197
+
198
+ Args:
199
+ user_id: The user's Entra object ID (GUID).
200
+
201
+ Returns:
202
+ UserConfiguration for the specified user.
203
+
204
+ Raises:
205
+ NotFoundError: If the user is not found.
206
+ ContractError: If API response doesn't match expected schema.
207
+ AuthenticationError: If authentication fails.
208
+ """
209
+ response = await self.graph_client.get(
210
+ f"/admin/teams/userConfigurations/{user_id}"
211
+ )
212
+ user = UserConfiguration.model_validate(_strip_odata_metadata(response))
213
+
214
+ # Enrich with display name from directory if not present
215
+ enriched = await self._enrich_display_names([user])
216
+ return enriched[0]
217
+
218
+ async def _search_directory_users(
219
+ self,
220
+ query: str,
221
+ *,
222
+ max_results: int = 25,
223
+ ) -> list[dict[str, str | None]]:
224
+ """Search users by display name via v1.0 /users endpoint.
225
+
226
+ The /admin/teams/userConfigurations endpoint doesn't support
227
+ displayName filtering, so we search the directory first.
228
+ We also return displayName since the Teams admin API doesn't
229
+ populate it in the userConfigurations response.
230
+
231
+ Args:
232
+ query: Display name search query.
233
+ max_results: Maximum number of results to return.
234
+
235
+ Returns:
236
+ List of dicts with 'id' and 'displayName' for matching users.
237
+ """
238
+ # Use v1.0 /users endpoint which supports displayName filtering
239
+ # Build full URL since GraphClient defaults to beta
240
+ url = f"{GRAPH_API_V1_BASE}/users"
241
+ params: dict[str, Any] = {
242
+ "$filter": f"startsWith(displayName, '{query}')",
243
+ "$select": "id,displayName",
244
+ "$top": str(min(max_results, 999)),
245
+ }
246
+
247
+ users: list[dict[str, str | None]] = []
248
+ async for item in self.graph_client.get_paginated_url(
249
+ url,
250
+ params=params,
251
+ max_items=max_results,
252
+ ):
253
+ if "id" in item:
254
+ users.append(
255
+ {
256
+ "id": str(item["id"]),
257
+ "displayName": item.get("displayName"),
258
+ }
259
+ )
260
+
261
+ return users
262
+
263
+ async def search_users(
264
+ self,
265
+ query: str,
266
+ *,
267
+ max_results: int = 25,
268
+ ) -> list[UserConfiguration]:
269
+ """Search users by display name, UPN, or phone number.
270
+
271
+ Searches user configurations using OData filters. Automatically
272
+ detects the query type and applies appropriate filtering:
273
+ - Phone numbers (starting with +): Searches telephoneNumbers collection
274
+ - Email addresses (containing @): Searches by UPN
275
+ - Other queries: Searches by display name using v1.0 /users endpoint
276
+
277
+ Args:
278
+ query: Search query - phone number, UPN, or display name.
279
+ max_results: Maximum number of results to return (default 25).
280
+
281
+ Returns:
282
+ List of UserConfiguration objects matching the search query.
283
+
284
+ Raises:
285
+ ContractError: If API response doesn't match expected schema.
286
+ AuthenticationError: If authentication fails.
287
+ RateLimitError: If rate limit exceeded after retries.
288
+ """
289
+ # Detect query type and route to appropriate search method
290
+ if query.startswith("+") or re.match(r"^\d+$", query):
291
+ # Phone number search - normalize and search in telephoneNumbers
292
+ # Handle both E.164 format and raw digits
293
+ normalized = query if query.startswith("+") else f"+{query}"
294
+ params: dict[str, Any] = {
295
+ "$top": str(min(max_results, 999)),
296
+ "$filter": (
297
+ f"telephoneNumbers/any(t: t/telephoneNumber eq '{normalized}')"
298
+ ),
299
+ }
300
+ users: list[UserConfiguration] = []
301
+ async for item in self.graph_client.get_paginated(
302
+ "/admin/teams/userConfigurations",
303
+ params=params,
304
+ max_items=max_results,
305
+ ):
306
+ users.append(UserConfiguration.model_validate(item))
307
+ # Enrich with display names
308
+ return await self._enrich_display_names(users)
309
+
310
+ elif "@" in query:
311
+ # UPN search - exact match on userConfigurations
312
+ params = {
313
+ "$top": str(min(max_results, 999)),
314
+ "$filter": f"userPrincipalName eq '{query}'",
315
+ }
316
+ users = []
317
+ async for item in self.graph_client.get_paginated(
318
+ "/admin/teams/userConfigurations",
319
+ params=params,
320
+ max_items=max_results,
321
+ ):
322
+ users.append(UserConfiguration.model_validate(item))
323
+ # Enrich with display names
324
+ return await self._enrich_display_names(users)
325
+
326
+ else:
327
+ # Display name search - use v1.0 /users endpoint first,
328
+ # then fetch Teams configurations for matched users
329
+ directory_users = await self._search_directory_users(
330
+ query, max_results=max_results
331
+ )
332
+
333
+ if not directory_users:
334
+ return []
335
+
336
+ # Fetch Teams configurations for each matched user
337
+ # Note: The Teams admin API doesn't return displayName, so we
338
+ # inject it from the directory search result
339
+ users = []
340
+ for dir_user in directory_users:
341
+ user_id = dir_user["id"]
342
+ if user_id is None:
343
+ continue # Skip if somehow id is None (shouldn't happen)
344
+ try:
345
+ user_config = await self.get_user(user_id)
346
+ # Inject displayName from directory if Teams API didn't provide it
347
+ if user_config.display_name is None and dir_user.get("displayName"):
348
+ # Create a new instance with the displayName set
349
+ # Using model_copy to avoid mutating the original
350
+ user_config = user_config.model_copy(
351
+ update={"display_name": dir_user["displayName"]}
352
+ )
353
+ users.append(user_config)
354
+ except NotFoundError:
355
+ # User exists in directory but has no Teams configuration
356
+ # (e.g., unlicensed user) - skip them
357
+ continue
358
+
359
+ return users
360
+
361
+ async def resolve_user(self, identifier: str) -> UserConfiguration:
362
+ """Resolve a user by UPN, display name, or object ID.
363
+
364
+ Attempts to find a single user matching the identifier. Uses
365
+ intelligent detection to determine the identifier type:
366
+ - GUID format: Direct lookup by object ID
367
+ - Email format (contains @): Lookup by UPN
368
+ - Other: Search by display name
369
+
370
+ Args:
371
+ identifier: User identifier - UPN, display name, or object ID.
372
+
373
+ Returns:
374
+ UserConfiguration for the resolved user.
375
+
376
+ Raises:
377
+ NotFoundError: If no user matches the identifier.
378
+ ValidationError: If multiple users match (ambiguous identifier).
379
+ AuthenticationError: If authentication fails.
380
+ """
381
+ # Check if identifier is a GUID (object ID)
382
+ guid_pattern = re.compile(
383
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
384
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
385
+ )
386
+
387
+ if guid_pattern.match(identifier):
388
+ # Direct lookup by ID
389
+ return await self.get_user(identifier)
390
+
391
+ if "@" in identifier:
392
+ # UPN lookup - search for exact match
393
+ users = await self.search_users(identifier, max_results=1)
394
+ if not users:
395
+ raise NotFoundError(
396
+ f"User '{identifier}' not found",
397
+ remediation=(
398
+ "Verify the user principal name (email) is correct.\n"
399
+ "Use 'teams-phone users search' to find available users."
400
+ ),
401
+ )
402
+ return users[0]
403
+
404
+ # Display name search - requires disambiguation
405
+ users = await self.search_users(identifier, max_results=10)
406
+
407
+ if not users:
408
+ raise NotFoundError(
409
+ f"No user found matching '{identifier}'",
410
+ remediation=(
411
+ "No user found with a display name starting with "
412
+ f"'{identifier}'.\n"
413
+ "Use 'teams-phone users list' to see available users, or\n"
414
+ "try searching by UPN (email address) for exact matching."
415
+ ),
416
+ )
417
+
418
+ if len(users) == 1:
419
+ return users[0]
420
+
421
+ # Multiple matches - require disambiguation
422
+ user_list = "\n".join(
423
+ f" - {u.display_name or u.user_principal_name} ({u.user_principal_name})"
424
+ for u in users[:5]
425
+ )
426
+ additional = f"\n ... and {len(users) - 5} more" if len(users) > 5 else ""
427
+
428
+ raise ValidationError(
429
+ f"Multiple users match '{identifier}'",
430
+ remediation=(
431
+ f"The following users match your query:\n{user_list}{additional}\n\n"
432
+ "Please specify the user by their full UPN (email address) "
433
+ "or object ID\nto avoid ambiguity."
434
+ ),
435
+ )
teams_phone/utils.py ADDED
@@ -0,0 +1,172 @@
1
+ """Phone number normalization and helper utilities."""
2
+
3
+ import re
4
+
5
+ from teams_phone.exceptions import ValidationError
6
+
7
+
8
+ def normalize_phone_number(number: str) -> str:
9
+ """Normalize a phone number by stripping non-digit characters.
10
+
11
+ Accepts various formats and converts to digits-only format.
12
+ Validates that the result has at least 10 digits.
13
+
14
+ Args:
15
+ number: Phone number in any format (e.g., "+1 (206) 555-1234",
16
+ "1-206-555-1234", "+12065551234").
17
+
18
+ Returns:
19
+ Digits-only string (e.g., "12065551234").
20
+
21
+ Raises:
22
+ ValidationError: If the number has fewer than 10 digits after
23
+ stripping non-digit characters.
24
+
25
+ Examples:
26
+ >>> normalize_phone_number("+1 (206) 555-1234")
27
+ '12065551234'
28
+ >>> normalize_phone_number("1-206-555-1234")
29
+ '12065551234'
30
+ >>> normalize_phone_number("+12065551234")
31
+ '12065551234'
32
+ """
33
+ digits = re.sub(r"\D", "", number)
34
+
35
+ if len(digits) < 10:
36
+ raise ValidationError(
37
+ f"Invalid phone number: '{number}' has fewer than 10 digits",
38
+ remediation="Provide a valid phone number with at least 10 digits.",
39
+ )
40
+
41
+ return digits
42
+
43
+
44
+ def format_for_assign(number: str) -> str:
45
+ """Format a phone number for the assignNumber/unassignNumber API endpoints.
46
+
47
+ These endpoints require digits only (no + prefix).
48
+
49
+ Args:
50
+ number: Phone number in any format.
51
+
52
+ Returns:
53
+ Digits-only string suitable for Graph API assign/unassign operations.
54
+
55
+ Raises:
56
+ ValidationError: If the number is invalid.
57
+
58
+ Examples:
59
+ >>> format_for_assign("+1 (206) 555-1234")
60
+ '12065551234'
61
+ >>> format_for_assign("12065551234")
62
+ '12065551234'
63
+ """
64
+ return normalize_phone_number(number)
65
+
66
+
67
+ def format_for_update(number: str) -> str:
68
+ """Format a phone number for the updateNumber API endpoint.
69
+
70
+ This endpoint requires E.164 format with + prefix.
71
+
72
+ Args:
73
+ number: Phone number in any format.
74
+
75
+ Returns:
76
+ E.164 formatted string with + prefix (e.g., "+12065551234").
77
+
78
+ Raises:
79
+ ValidationError: If the number is invalid.
80
+
81
+ Examples:
82
+ >>> format_for_update("1 (206) 555-1234")
83
+ '+12065551234'
84
+ >>> format_for_update("+12065551234")
85
+ '+12065551234'
86
+ """
87
+ digits = normalize_phone_number(number)
88
+ return f"+{digits}"
89
+
90
+
91
+ def format_for_display(number: str) -> str:
92
+ """Format a phone number for user-friendly display.
93
+
94
+ Formats US/Canada NANP numbers as: +1 (NPA) NXX-XXXX
95
+ For international numbers, returns E.164 format.
96
+
97
+ Args:
98
+ number: Phone number in any format.
99
+
100
+ Returns:
101
+ Formatted string for display (e.g., "+1 (206) 555-1234").
102
+
103
+ Raises:
104
+ ValidationError: If the number is invalid.
105
+
106
+ Examples:
107
+ >>> format_for_display("12065551234")
108
+ '+1 (206) 555-1234'
109
+ >>> format_for_display("+442071234567")
110
+ '+442071234567'
111
+ """
112
+ digits = normalize_phone_number(number)
113
+
114
+ # US/Canada NANP: 11 digits starting with 1
115
+ if len(digits) == 11 and digits.startswith("1"):
116
+ return f"+1 ({digits[1:4]}) {digits[4:7]}-{digits[7:11]}"
117
+
118
+ # US/Canada NANP without country code: 10 digits
119
+ if len(digits) == 10:
120
+ return f"+1 ({digits[0:3]}) {digits[3:6]}-{digits[6:10]}"
121
+
122
+ # International numbers: return E.164 format
123
+ return f"+{digits}"
124
+
125
+
126
+ def match_pattern(number: str, pattern: str) -> bool:
127
+ """Check if a phone number matches a search pattern.
128
+
129
+ Supports wildcards for flexible searching:
130
+ - `*` matches any sequence of characters
131
+ - Exact digit sequences match those digits anywhere in the number
132
+ - Patterns are matched against the normalized (digits-only) number
133
+
134
+ Args:
135
+ number: Phone number to check (any format accepted).
136
+ pattern: Search pattern, optionally with wildcards.
137
+
138
+ Returns:
139
+ True if the number matches the pattern, False otherwise.
140
+
141
+ Raises:
142
+ ValidationError: If the number is invalid.
143
+
144
+ Examples:
145
+ >>> match_pattern("+12065551234", "206*")
146
+ True
147
+ >>> match_pattern("+12065551234", "*555*")
148
+ True
149
+ >>> match_pattern("+12065551234", "1234")
150
+ True
151
+ >>> match_pattern("+12065551234", "999")
152
+ False
153
+ """
154
+ digits = normalize_phone_number(number)
155
+
156
+ # Normalize pattern: strip non-digit except *
157
+ normalized_pattern = re.sub(r"[^\d*]", "", pattern)
158
+
159
+ if not normalized_pattern:
160
+ return False
161
+
162
+ # If pattern has no wildcards, treat as substring search
163
+ if "*" not in normalized_pattern:
164
+ return normalized_pattern in digits
165
+
166
+ # Convert wildcards to regex pattern
167
+ # Escape any regex special chars first, then replace \* with .*
168
+ regex_pattern = re.escape(normalized_pattern).replace(r"\*", ".*")
169
+
170
+ # Use search to find pattern anywhere in the number
171
+ # This allows "206*" to match area codes even with country code prefix
172
+ return bool(re.search(regex_pattern, digits))
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: teams-phone-cli
3
+ Version: 0.1.2
4
+ Summary: CLI tool for managing Microsoft Teams Phone configurations
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: cryptography>=41.0.0
8
+ Requires-Dist: httpx>=0.25.0
9
+ Requires-Dist: msal>=1.24.0
10
+ Requires-Dist: pydantic>=2.0.0
11
+ Requires-Dist: rich>=13.0.0
12
+ Requires-Dist: tenacity>=8.0.0
13
+ Requires-Dist: tomli>=2.0.0; python_version < '3.11'
14
+ Requires-Dist: tomlkit>=0.12.0
15
+ Requires-Dist: typer>=0.9.0
@@ -0,0 +1,45 @@
1
+ teams_phone/__init__.py,sha256=HDpi3pFZt7yIDIXwT7evnoqE_AIThaf33CAcIIesj2I,92
2
+ teams_phone/__main__.py,sha256=OlqRTyKnFobyvqc0RQ1pcG-SQWUoIku27Mt4yge3tKc,133
3
+ teams_phone/constants.py,sha256=_Kly2IalHvmRRdNyTMfl8STdu1MGhD3cr66_hvzidB4,3249
4
+ teams_phone/exceptions.py,sha256=1KRvVmy2shBrFKvaQ1eOhYZLtgisTrnpO25gBxR-Jko,3883
5
+ teams_phone/utils.py,sha256=nZdxHHQYUYtPAgj_GaoUtfnddyQLqrK6fTSk5j1fHSY,5022
6
+ teams_phone/cli/__init__.py,sha256=KKYQG1nFJB7lwVqb4xu0_jN7O_PyCmbJow_TYErlfo8,281
7
+ teams_phone/cli/api_check.py,sha256=U6oMSJgT618aRisypSdwXFm-O1pKKOVrimXL-nj-QWU,8679
8
+ teams_phone/cli/auth.py,sha256=iBJBCJ3ycsnFoAom5VQACANWZroV5fBHDJBmwYPjFo0,6695
9
+ teams_phone/cli/context.py,sha256=1Jn8mcZ4HJhMtuqWE1zM6kNeefgjCzKr3rqlESwBPeA,3478
10
+ teams_phone/cli/helpers.py,sha256=VkhzUrZaleI32NX8uZYeAneOmMumz5jCurLO_OR2jng,2107
11
+ teams_phone/cli/locations.py,sha256=gk8iZLaGhMvIRlxEnG0Fe9lLisOJ2xF__h1De_RPwes,9843
12
+ teams_phone/cli/main.py,sha256=mHtwei2jI6ESsL49IO2d_I9iiMqhjcfpzJ94Z5IMUqU,3218
13
+ teams_phone/cli/numbers.py,sha256=PX2OfKwaB8dSpbPtvnVZXxvvZIWwHZFL2b97MrqX0sM,62894
14
+ teams_phone/cli/policies.py,sha256=dJ61-hYC3AxafD_AtCzAU_IIxGf1CN0H7vI6vUuxRgA,32973
15
+ teams_phone/cli/tenants.py,sha256=Ivtyr0gZarkHsZ2JbZ2Kuck-yu0DvLtoEF2d2qaUe8I,12495
16
+ teams_phone/cli/users.py,sha256=Ch2K-L84aqx0PS_myM1OJLIyXvLq3N2kiG8L6gFC5hI,13572
17
+ teams_phone/infrastructure/__init__.py,sha256=GzlfRBqyswREd1W13FudNUpl1OzJiXNnM3su3F90fMQ,629
18
+ teams_phone/infrastructure/cache_manager.py,sha256=4LuGV_Tfagh48it69yjgZaFrysi0_a_VHgT2j7UnEh0,9286
19
+ teams_phone/infrastructure/config_manager.py,sha256=qAxM0dhcGGz3tWs2qDSfaJgOH_8R3LnmmrTjspgtcIs,7342
20
+ teams_phone/infrastructure/debug_logger.py,sha256=fDEspLv0SooXh8CR5S1I1aisxYsIOiaU1511pQoJuKs,9379
21
+ teams_phone/infrastructure/graph_client.py,sha256=k8-MiFqYCcfr529DoAYh4rdhiWV-t4xrsez-IE1Ztyw,23620
22
+ teams_phone/infrastructure/output_formatter.py,sha256=WTOfPq7jK_HnfNV7hYhvs9gZl8XdxDsa8l1AF723BCM,7188
23
+ teams_phone/models/__init__.py,sha256=CxtDTZ5A8Xh0s4FpD0WIxydISZ38e7-JzrEj-nLniKM,1793
24
+ teams_phone/models/api_responses.py,sha256=qA4m6X6AHZpt8_C-njQZfaIsv7lSwP8yHZ1mi7K_wiU,2362
25
+ teams_phone/models/auth.py,sha256=YohV8uKHycBo0rUHLyXw2PrpR20YrRZuD7Esyzn6A9w,3119
26
+ teams_phone/models/cache.py,sha256=faTOhi_-0hu-Q5YdS7oK6tE9mxKIgstZ6zCJrvdCO-4,756
27
+ teams_phone/models/config.py,sha256=ujJ5thvg6vVcwVF9poFMfP81EW1aKN1UaLMP9i_b0Hc,1956
28
+ teams_phone/models/location.py,sha256=NSWrXIvp9HnaXAMasE_cAWbgSdn_eBPl3xUEvEF59nA,1565
29
+ teams_phone/models/number.py,sha256=GroicsBpHaCMSYPuPlPPFM0iEFHqDPHyCZSD6VZrQ-o,6822
30
+ teams_phone/models/policy.py,sha256=FmN7ZrTz2BfFOqmorfvHVBIoxnCwo6f7rpKjCY22UAU,962
31
+ teams_phone/models/tenant.py,sha256=FEQZjT7udIocddaqvFFGLdY_-4DF5oPOt7k4UJQyU1w,1473
32
+ teams_phone/models/user.py,sha256=1hPbk7P9X8WxyzawGMQZrpPNPN8s7hxUx_ZHQfgTKko,4411
33
+ teams_phone/services/__init__.py,sha256=RvY68ZVDFFxLwu1iNbGXaIB85JwvJOQPdXDGFkYnjfM,722
34
+ teams_phone/services/auth_service.py,sha256=v5TQQzdDXlu1pWaPeITwHEOYS878B67At3FyIUoePus,20658
35
+ teams_phone/services/bulk_operations.py,sha256=oz7gpOc-SCOaK1EoS6W7QUbGlNR6V6nEH0SKAjlhDrE,19850
36
+ teams_phone/services/location_service.py,sha256=j_IqSHZjSOx-SeQGzfRYAQwpjtdkyTJjMABXoMpWUjA,6185
37
+ teams_phone/services/number_service.py,sha256=xBar84f-sy6CXOaRzJ9jXQzNP4MRgVWF52AGlaTTbEg,18837
38
+ teams_phone/services/policy_service.py,sha256=lKI9XvKePrEFG8GWwmaFt4HuXfzVS1hf7ltq4o77bJY,12358
39
+ teams_phone/services/tenant_service.py,sha256=wfzYnyOfZb_1yuzQ3vy_tX-z2OYIjpa6zqPFhsv5miI,7447
40
+ teams_phone/services/user_service.py,sha256=-FtJTfbz3oE-nD4o0kJv8jBQqIAADQWft_nn2dDiNnE,16645
41
+ teams_phone_cli-0.1.2.dist-info/METADATA,sha256=kRUfVltMAtAqT-ioI4OldD-dJZS-g5EC8Et8DNBy0Ys,468
42
+ teams_phone_cli-0.1.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
43
+ teams_phone_cli-0.1.2.dist-info/entry_points.txt,sha256=53waHsIjr6V14VxvxuC05zJJnIniudNOuneWGSPVDqs,57
44
+ teams_phone_cli-0.1.2.dist-info/licenses/LICENSE,sha256=FnxAMYZhIzUrihYFEuAr3nGudfb13-HT-3PUmwCrLi0,1069
45
+ teams_phone_cli-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ teams-phone = teams_phone.cli.main:app