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
teams_phone/cli/users.py
ADDED
|
@@ -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
|
teams_phone/constants.py
ADDED
|
@@ -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
|
+
]
|