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,394 @@
1
+ """User management commands for Teams Phone CLI.
2
+
3
+ This module provides CLI commands for listing, showing, and searching
4
+ Teams users with their voice configurations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+ from typing import Any
11
+
12
+ import typer
13
+
14
+ from teams_phone.cli.context import get_context
15
+ from teams_phone.cli.helpers import run_with_service
16
+ from teams_phone.exceptions import TeamsPhoneError
17
+ from teams_phone.models import AccountType, UserConfiguration
18
+ from teams_phone.services.user_service import UserService
19
+
20
+
21
+ users_app = typer.Typer(
22
+ name="users",
23
+ help="Manage Teams users.",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+
28
+ class SearchField(str, Enum):
29
+ """Search field options for user search."""
30
+
31
+ NAME = "name"
32
+ UPN = "upn"
33
+ PHONE = "phone"
34
+
35
+
36
+ def _format_phone_number(user: UserConfiguration) -> str:
37
+ """Format phone number for display, or return dash if none assigned."""
38
+ if user.telephone_numbers:
39
+ return user.telephone_numbers[0].telephone_number
40
+ return "—"
41
+
42
+
43
+ def _format_license_status(user: UserConfiguration) -> str:
44
+ """Format license status for display."""
45
+ return "Licensed" if user.is_enterprise_voice_enabled else "Unlicensed"
46
+
47
+
48
+ def _format_voice_enabled(user: UserConfiguration) -> str:
49
+ """Format voice enabled status for display."""
50
+ return "Yes" if user.is_enterprise_voice_enabled else "No"
51
+
52
+
53
+ def _format_account_type(account_type: AccountType) -> str:
54
+ """Format account type for display."""
55
+ display_names = {
56
+ AccountType.USER: "User",
57
+ AccountType.RESOURCE_ACCOUNT: "Resource Account",
58
+ AccountType.GUEST: "Guest",
59
+ AccountType.SFB_ON_PREM_USER: "SfB On-Prem",
60
+ AccountType.UNKNOWN: "Unknown",
61
+ }
62
+ return display_names.get(account_type, account_type.value)
63
+
64
+
65
+ def _user_to_list_dict(user: UserConfiguration) -> dict[str, Any]:
66
+ """Convert UserConfiguration to dict for list/search table display."""
67
+ return {
68
+ "display_name": user.display_name or "—",
69
+ "upn": user.user_principal_name,
70
+ "account_type": _format_account_type(user.account_type),
71
+ "phone_number": _format_phone_number(user),
72
+ "license_status": _format_license_status(user),
73
+ "voice_enabled": _format_voice_enabled(user),
74
+ }
75
+
76
+
77
+ def _user_to_json_dict(user: UserConfiguration) -> dict[str, Any]:
78
+ """Convert UserConfiguration to dict for JSON output."""
79
+ return {
80
+ "id": user.id,
81
+ "display_name": user.display_name,
82
+ "user_principal_name": user.user_principal_name,
83
+ "account_type": user.account_type.value,
84
+ "is_enterprise_voice_enabled": user.is_enterprise_voice_enabled,
85
+ "telephone_numbers": [
86
+ {
87
+ "telephone_number": t.telephone_number,
88
+ "assignment_category": t.assignment_category,
89
+ }
90
+ for t in user.telephone_numbers
91
+ ],
92
+ "feature_types": user.feature_types,
93
+ "effective_policy_assignments": [
94
+ {
95
+ "policy_type": p.policy_type,
96
+ "policy_assignment": {
97
+ "display_name": p.policy_assignment.display_name,
98
+ "assignment_type": p.policy_assignment.assignment_type,
99
+ "policy_id": p.policy_assignment.policy_id,
100
+ "group_id": p.policy_assignment.group_id,
101
+ },
102
+ }
103
+ for p in user.effective_policy_assignments
104
+ ],
105
+ }
106
+
107
+
108
+ def _get_policy_value(user: UserConfiguration, policy_type: str) -> str:
109
+ """Get the display name for a specific policy type, or 'Global (Default)' if not set."""
110
+ for assignment in user.effective_policy_assignments:
111
+ if assignment.policy_type == policy_type:
112
+ return assignment.policy_assignment.display_name or "Global (Default)"
113
+ return "Global (Default)"
114
+
115
+
116
+ @users_app.command("list")
117
+ def list_users(
118
+ ctx: typer.Context,
119
+ licensed: bool | None = typer.Option(
120
+ None,
121
+ "--licensed/--unlicensed",
122
+ help="Filter by license status (--licensed or --unlicensed).",
123
+ ),
124
+ assigned: bool | None = typer.Option(
125
+ None,
126
+ "--assigned/--unassigned",
127
+ help="Filter by phone number assignment (--assigned or --unassigned).",
128
+ ),
129
+ account_type: AccountType | None = typer.Option(
130
+ None,
131
+ "--account-type",
132
+ "-a",
133
+ help="Filter by account type (user, resourceAccount, guest).",
134
+ ),
135
+ limit: int | None = typer.Option(
136
+ None,
137
+ "--limit",
138
+ "-l",
139
+ help="Limit results to n users.",
140
+ ),
141
+ all_users: bool = typer.Option(
142
+ False,
143
+ "--all",
144
+ help="Fetch all users (may be slow for large tenants).",
145
+ ),
146
+ ) -> None:
147
+ """List users with voice configuration."""
148
+ cli_ctx = get_context(ctx)
149
+ formatter = cli_ctx.get_output_formatter()
150
+
151
+ # Validate mutually exclusive options
152
+ if limit is not None and all_users:
153
+ formatter.error(
154
+ "Cannot specify both --limit and --all",
155
+ remediation="Use either --limit <n> to limit results or --all to fetch all users.",
156
+ )
157
+ raise typer.Exit(5) # ValidationError exit code
158
+
159
+ # Determine max_items: None for --all, provided limit, or default 50
160
+ if all_users:
161
+ max_items = None
162
+ elif limit is not None:
163
+ max_items = limit
164
+ else:
165
+ max_items = 50 # Default limit
166
+
167
+ try:
168
+ users, formatter = run_with_service(
169
+ ctx,
170
+ service_factory=UserService,
171
+ operation=lambda svc: svc.list_users(
172
+ licensed=licensed,
173
+ assigned=assigned,
174
+ account_type=account_type,
175
+ max_items=max_items,
176
+ ),
177
+ )
178
+
179
+ if cli_ctx.json_output:
180
+ formatter.json([_user_to_json_dict(u) for u in users])
181
+ else:
182
+ if not users:
183
+ formatter.info("No users found matching the specified criteria")
184
+ else:
185
+ formatter.table(
186
+ data=[_user_to_list_dict(u) for u in users],
187
+ columns=[
188
+ "display_name",
189
+ "upn",
190
+ "account_type",
191
+ "phone_number",
192
+ "license_status",
193
+ "voice_enabled",
194
+ ],
195
+ title="Users",
196
+ )
197
+ except TeamsPhoneError as e:
198
+ formatter.error(e.message, remediation=e.remediation)
199
+ raise typer.Exit(e.exit_code) from None
200
+
201
+
202
+ @users_app.command("show")
203
+ def show_user(
204
+ ctx: typer.Context,
205
+ user: str = typer.Argument(
206
+ ...,
207
+ help="User identifier: UPN (email), display name, or object ID.",
208
+ ),
209
+ ) -> None:
210
+ """Show detailed voice configuration for a specific user."""
211
+ cli_ctx = get_context(ctx)
212
+ formatter = cli_ctx.get_output_formatter()
213
+
214
+ try:
215
+ user_config, _ = run_with_service(
216
+ ctx,
217
+ service_factory=UserService,
218
+ operation=lambda svc: svc.resolve_user(user),
219
+ )
220
+
221
+ if cli_ctx.json_output:
222
+ formatter.json(_user_to_json_dict(user_config))
223
+ else:
224
+ # Identity section
225
+ formatter.table(
226
+ data=[
227
+ {"field": "Display Name", "value": user_config.display_name or "—"},
228
+ {"field": "UPN", "value": user_config.user_principal_name},
229
+ {"field": "Object ID", "value": user_config.id},
230
+ {
231
+ "field": "Account Type",
232
+ "value": _format_account_type(user_config.account_type),
233
+ },
234
+ ],
235
+ columns=["field", "value"],
236
+ title="Identity",
237
+ )
238
+
239
+ # Phone Number section
240
+ if user_config.telephone_numbers:
241
+ phone_data = [
242
+ {"number": num.telephone_number, "type": num.assignment_category}
243
+ for num in user_config.telephone_numbers
244
+ ]
245
+ else:
246
+ phone_data = [{"number": "No phone number assigned", "type": "—"}]
247
+ formatter.table(
248
+ data=phone_data,
249
+ columns=["number", "type"],
250
+ title="Phone Numbers",
251
+ )
252
+
253
+ # Licensing section
254
+ licensing_data = [
255
+ {
256
+ "field": "Enterprise Voice",
257
+ "value": _format_voice_enabled(user_config),
258
+ },
259
+ {"field": "Status", "value": _format_license_status(user_config)},
260
+ ]
261
+ if user_config.feature_types:
262
+ licensing_data.append(
263
+ {"field": "Features", "value": ", ".join(user_config.feature_types)}
264
+ )
265
+ formatter.table(
266
+ data=licensing_data,
267
+ columns=["field", "value"],
268
+ title="Licensing",
269
+ )
270
+
271
+ # Voice Policies section
272
+ formatter.table(
273
+ data=[
274
+ {
275
+ "policy": "Calling",
276
+ "value": _get_policy_value(user_config, "TeamsCallingPolicy"),
277
+ },
278
+ {
279
+ "policy": "Voicemail",
280
+ "value": _get_policy_value(user_config, "TeamsCallParkPolicy"),
281
+ },
282
+ {
283
+ "policy": "Emergency Calling",
284
+ "value": _get_policy_value(
285
+ user_config, "TeamsEmergencyCallingPolicy"
286
+ ),
287
+ },
288
+ {
289
+ "policy": "Emergency Call Routing",
290
+ "value": _get_policy_value(
291
+ user_config, "TeamsEmergencyCallRoutingPolicy"
292
+ ),
293
+ },
294
+ {
295
+ "policy": "Caller ID",
296
+ "value": _get_policy_value(user_config, "TeamsCallerIdPolicy"),
297
+ },
298
+ {
299
+ "policy": "Dial Plan",
300
+ "value": _get_policy_value(user_config, "TenantDialPlan"),
301
+ },
302
+ {
303
+ "policy": "Voice Routing",
304
+ "value": _get_policy_value(
305
+ user_config, "TeamsVoiceRoutingPolicy"
306
+ ),
307
+ },
308
+ ],
309
+ columns=["policy", "value"],
310
+ title="Voice Policies",
311
+ )
312
+
313
+ except TeamsPhoneError as e:
314
+ formatter.error(e.message, remediation=e.remediation)
315
+ raise typer.Exit(e.exit_code) from None
316
+
317
+
318
+ @users_app.command("search")
319
+ def search_users(
320
+ ctx: typer.Context,
321
+ query: str = typer.Argument(
322
+ ...,
323
+ help="Search term (partial match supported).",
324
+ ),
325
+ field: SearchField | None = typer.Option(
326
+ None,
327
+ "--field",
328
+ "-f",
329
+ help="Limit search to specific field: name, upn, phone.",
330
+ ),
331
+ limit: int = typer.Option(
332
+ 25,
333
+ "--limit",
334
+ "-l",
335
+ help="Maximum results (default: 25).",
336
+ ),
337
+ ) -> None:
338
+ """Search for users by name, UPN, or phone number."""
339
+ cli_ctx = get_context(ctx)
340
+ formatter = cli_ctx.get_output_formatter()
341
+
342
+ # Modify query based on field option to force specific search behavior
343
+ search_query = query
344
+ if field == SearchField.PHONE:
345
+ # Ensure phone number format for search
346
+ if not query.startswith("+") and not query.isdigit():
347
+ # Try to use as-is, the service will handle it
348
+ search_query = query
349
+ elif query.isdigit():
350
+ search_query = f"+{query}"
351
+ else:
352
+ search_query = query
353
+ elif field == SearchField.UPN:
354
+ # UPN search expects @ - if not present, it will do display name search
355
+ # User should provide full UPN for exact match
356
+ search_query = query
357
+ elif field == SearchField.NAME:
358
+ # Name search is the default for non-phone, non-email queries
359
+ # Force display name search by ensuring no @ or +
360
+ if "@" not in query and not query.startswith("+"):
361
+ search_query = query
362
+ else:
363
+ # User provided email or phone format but wants name search
364
+ # This is an unusual case; proceed with the query as-is
365
+ search_query = query
366
+
367
+ try:
368
+ users, _ = run_with_service(
369
+ ctx,
370
+ service_factory=UserService,
371
+ operation=lambda svc: svc.search_users(search_query, max_results=limit),
372
+ )
373
+
374
+ if cli_ctx.json_output:
375
+ formatter.json([_user_to_json_dict(u) for u in users])
376
+ else:
377
+ if not users:
378
+ formatter.info(f"No users found matching '{query}'")
379
+ else:
380
+ formatter.table(
381
+ data=[_user_to_list_dict(u) for u in users],
382
+ columns=[
383
+ "display_name",
384
+ "upn",
385
+ "account_type",
386
+ "phone_number",
387
+ "license_status",
388
+ "voice_enabled",
389
+ ],
390
+ title=f"Search Results for '{query}'",
391
+ )
392
+ except TeamsPhoneError as e:
393
+ formatter.error(e.message, remediation=e.remediation)
394
+ raise typer.Exit(e.exit_code) from None
@@ -0,0 +1,97 @@
1
+ """API URLs, exit codes, and default values for Teams Phone CLI."""
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ # =============================================================================
7
+ # Microsoft Graph API URLs
8
+ # =============================================================================
9
+
10
+ GRAPH_API_BASE = "https://graph.microsoft.com/beta"
11
+ """Base URL for Microsoft Graph API (beta).
12
+
13
+ Note: All /admin/teams/* endpoints require the beta API - no v1.0 equivalents exist.
14
+ """
15
+
16
+ GRAPH_API_V1_BASE = "https://graph.microsoft.com/v1.0"
17
+ """Base URL for Microsoft Graph API (v1.0).
18
+
19
+ Used for general user/organization queries where stable endpoints exist.
20
+ """
21
+
22
+ # =============================================================================
23
+ # Default File Paths
24
+ # =============================================================================
25
+
26
+ DEFAULT_CONFIG_PATH = "~/.teams-phone/config.toml"
27
+ """Default path for CLI configuration file."""
28
+
29
+ DEFAULT_CACHE_PATH = "~/.teams-phone/cache"
30
+ """Default directory for cached data (users, numbers, policies)."""
31
+
32
+ DEFAULT_TOKEN_PATH = "~/.teams-phone/tokens"
33
+ """Default directory for authentication token storage."""
34
+
35
+ # =============================================================================
36
+ # Cache and Timeout Settings
37
+ # =============================================================================
38
+
39
+ DEFAULT_TIMEOUT = 30
40
+ """Default HTTP request timeout in seconds."""
41
+
42
+ MAX_RETRIES = 3
43
+ """Maximum number of retry attempts for transient failures (429, 5xx)."""
44
+
45
+ CSV_STALE_DAYS = 30
46
+ """Number of days before CSV cache files (locations, policies) are considered stale."""
47
+
48
+ TOKEN_EXPIRY_BUFFER_SECONDS = 300
49
+ """Seconds before token expiry to proactively refresh (5 minutes)."""
50
+
51
+ # =============================================================================
52
+ # Exit Codes
53
+ # =============================================================================
54
+
55
+
56
+ class ExitCode(IntEnum):
57
+ """CLI exit codes for structured error handling.
58
+
59
+ Exit codes map to specific exception types for consistent error reporting.
60
+ Using IntEnum allows shell-compatible integer comparison while preserving
61
+ semantic meaning in code.
62
+
63
+ Note: Exit code 8 (CONTRACT_ERROR) is intentionally not documented in
64
+ user-facing PRD/CLI spec as it indicates internal schema validation failures
65
+ that users cannot remediate directly.
66
+ """
67
+
68
+ SUCCESS = 0
69
+ """Operation completed successfully."""
70
+
71
+ GENERAL_ERROR = 1
72
+ """Unexpected error or unhandled exception."""
73
+
74
+ AUTH_ERROR = 2
75
+ """Authentication failure (token expired, invalid credentials)."""
76
+
77
+ AUTHZ_ERROR = 3
78
+ """Authorization failure (missing Graph API permissions)."""
79
+
80
+ NOT_FOUND = 4
81
+ """Resource not found (user, number, policy, location)."""
82
+
83
+ VALIDATION_ERROR = 5
84
+ """Input validation failure (invalid format, E911 violation)."""
85
+
86
+ RATE_LIMIT = 6
87
+ """Rate limit exceeded (Graph API throttling)."""
88
+
89
+ CONFIG_ERROR = 7
90
+ """Configuration error (invalid config file, missing tenant)."""
91
+
92
+ CONTRACT_ERROR = 8
93
+ """API contract violation (schema validation failure).
94
+
95
+ This exit code is for internal use when API responses don't match
96
+ expected schemas. Users should not encounter this in normal operation.
97
+ """
@@ -0,0 +1,137 @@
1
+ """Custom exception hierarchy for Teams Phone CLI.
2
+
3
+ This module defines the base exception class and specialized exceptions
4
+ that map to CLI exit codes for structured error handling.
5
+ """
6
+
7
+ from teams_phone.constants import ExitCode
8
+
9
+
10
+ class TeamsPhoneError(Exception):
11
+ """Base exception for all Teams Phone CLI errors.
12
+
13
+ All custom exceptions inherit from this class, providing:
14
+ - An exit_code property for CLI exit status
15
+ - Optional remediation guidance for user-facing messages
16
+ - Consistent string formatting
17
+
18
+ Attributes:
19
+ message: The error description.
20
+ remediation: Optional guidance on how to fix the issue.
21
+ exit_code: CLI exit code (defaults to GENERAL_ERROR=1).
22
+ """
23
+
24
+ exit_code: int = ExitCode.GENERAL_ERROR
25
+
26
+ def __init__(self, message: str, *, remediation: str | None = None) -> None:
27
+ """Initialize the exception.
28
+
29
+ Args:
30
+ message: The error description.
31
+ remediation: Optional guidance on how to fix the issue.
32
+ """
33
+ self.message = message
34
+ self.remediation = remediation
35
+ super().__init__(message)
36
+
37
+ def __str__(self) -> str:
38
+ """Format the exception for display.
39
+
40
+ If remediation is provided, it's appended to the message.
41
+ """
42
+ if self.remediation:
43
+ return f"{self.message}\n\nTo fix:\n{self.remediation}"
44
+ return self.message
45
+
46
+
47
+ class AuthenticationError(TeamsPhoneError):
48
+ """Authentication failure (exit code 2).
49
+
50
+ Raised when authentication fails due to:
51
+ - Expired access token
52
+ - Invalid credentials
53
+ - Certificate issues
54
+ - Missing authentication configuration
55
+ """
56
+
57
+ exit_code: int = ExitCode.AUTH_ERROR
58
+
59
+
60
+ class AuthorizationError(TeamsPhoneError):
61
+ """Authorization failure (exit code 3).
62
+
63
+ Raised when the authenticated principal lacks required permissions:
64
+ - Missing Graph API application permissions
65
+ - Insufficient role assignments
66
+ - Tenant-level restrictions
67
+ """
68
+
69
+ exit_code: int = ExitCode.AUTHZ_ERROR
70
+
71
+
72
+ class NotFoundError(TeamsPhoneError):
73
+ """Resource not found (exit code 4).
74
+
75
+ Raised when a requested resource doesn't exist:
76
+ - User not found in tenant directory
77
+ - Phone number not in inventory
78
+ - Policy not configured
79
+ - Location not defined
80
+ """
81
+
82
+ exit_code: int = ExitCode.NOT_FOUND
83
+
84
+
85
+ class ValidationError(TeamsPhoneError):
86
+ """Input validation failure (exit code 5).
87
+
88
+ Raised when input data fails validation:
89
+ - Invalid phone number format
90
+ - Missing required E911 location for Calling Plan numbers
91
+ - Invalid policy name or configuration
92
+ - CSV format or content errors
93
+ """
94
+
95
+ exit_code: int = ExitCode.VALIDATION_ERROR
96
+
97
+
98
+ class RateLimitError(TeamsPhoneError):
99
+ """Rate limit exceeded (exit code 6).
100
+
101
+ Raised when Graph API returns HTTP 429 (throttled):
102
+ - Too many requests in a time window
103
+ - Tenant-level throttling limits reached
104
+
105
+ Note: The CLI will automatically retry with exponential backoff
106
+ before raising this exception.
107
+ """
108
+
109
+ exit_code: int = ExitCode.RATE_LIMIT
110
+
111
+
112
+ class ConfigurationError(TeamsPhoneError):
113
+ """Configuration error (exit code 7).
114
+
115
+ Raised when configuration is invalid or missing:
116
+ - Config file doesn't exist or is malformed
117
+ - Required tenant not configured
118
+ - Invalid configuration values
119
+ """
120
+
121
+ exit_code: int = ExitCode.CONFIG_ERROR
122
+
123
+
124
+ class ContractError(TeamsPhoneError):
125
+ """API contract violation (exit code 8).
126
+
127
+ Raised when API responses don't match expected schemas:
128
+ - Missing required fields in response
129
+ - Unexpected data types
130
+ - Schema validation failures
131
+
132
+ Note: This exception indicates an internal error that users
133
+ cannot remediate directly. It typically signals either an API
134
+ change or a bug in the CLI.
135
+ """
136
+
137
+ exit_code: int = ExitCode.CONTRACT_ERROR
@@ -0,0 +1,22 @@
1
+ """Infrastructure Layer - Graph API clients and authentication."""
2
+
3
+ from teams_phone.infrastructure.cache_manager import CacheManager
4
+ from teams_phone.infrastructure.config_manager import ConfigManager
5
+ from teams_phone.infrastructure.debug_logger import DebugLogger
6
+ from teams_phone.infrastructure.graph_client import GraphClient
7
+ from teams_phone.infrastructure.output_formatter import (
8
+ PLACEHOLDER_ASCII,
9
+ PLACEHOLDER_UNICODE,
10
+ OutputFormatter,
11
+ )
12
+
13
+
14
+ __all__ = [
15
+ "CacheManager",
16
+ "ConfigManager",
17
+ "DebugLogger",
18
+ "GraphClient",
19
+ "OutputFormatter",
20
+ "PLACEHOLDER_ASCII",
21
+ "PLACEHOLDER_UNICODE",
22
+ ]