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/__init__.py
ADDED
teams_phone/__main__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""CLI Layer - Typer commands for Teams Phone management."""
|
|
2
|
+
|
|
3
|
+
from teams_phone.cli.context import CLIContext, get_context
|
|
4
|
+
from teams_phone.cli.helpers import run_with_service
|
|
5
|
+
from teams_phone.cli.main import app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = ["CLIContext", "app", "get_context", "run_with_service"]
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""API contract validation command for Teams Phone CLI.
|
|
2
|
+
|
|
3
|
+
This module provides the api-check command for validating Pydantic models
|
|
4
|
+
against live Graph API responses to detect API contract drift.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from teams_phone.cli.context import get_context
|
|
17
|
+
from teams_phone.constants import ExitCode
|
|
18
|
+
from teams_phone.exceptions import TeamsPhoneError
|
|
19
|
+
from teams_phone.infrastructure import CacheManager, GraphClient
|
|
20
|
+
from teams_phone.models import NumberAssignment, UserConfiguration
|
|
21
|
+
from teams_phone.services import AuthService
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class EndpointCheck:
|
|
26
|
+
"""Configuration for an endpoint contract check."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
endpoint: str
|
|
30
|
+
model_name: str
|
|
31
|
+
validator: Any # Pydantic model class
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CheckResult:
|
|
36
|
+
"""Result of a single endpoint validation check."""
|
|
37
|
+
|
|
38
|
+
endpoint_name: str
|
|
39
|
+
passed: bool
|
|
40
|
+
error_message: str | None = None
|
|
41
|
+
error_details: list[str] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Define endpoints to check
|
|
45
|
+
ENDPOINT_CHECKS = [
|
|
46
|
+
EndpointCheck(
|
|
47
|
+
name="userConfigurations",
|
|
48
|
+
endpoint="/admin/teams/userConfigurations",
|
|
49
|
+
model_name="UserConfiguration",
|
|
50
|
+
validator=UserConfiguration,
|
|
51
|
+
),
|
|
52
|
+
EndpointCheck(
|
|
53
|
+
name="numberAssignments",
|
|
54
|
+
endpoint="/admin/teams/telephoneNumberManagement/numberAssignments",
|
|
55
|
+
model_name="NumberAssignment",
|
|
56
|
+
validator=NumberAssignment,
|
|
57
|
+
),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _strip_odata_metadata(data: dict[str, Any]) -> dict[str, Any]:
|
|
62
|
+
"""Strip OData metadata fields from a response dict.
|
|
63
|
+
|
|
64
|
+
Graph API responses include @odata.context and other @ prefixed fields
|
|
65
|
+
that are not part of the actual data contract. This helper removes them
|
|
66
|
+
before Pydantic validation with extra="forbid".
|
|
67
|
+
"""
|
|
68
|
+
return {k: v for k, v in data.items() if not k.startswith("@")}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _validate_endpoint(
|
|
72
|
+
graph_client: GraphClient,
|
|
73
|
+
check: EndpointCheck,
|
|
74
|
+
) -> CheckResult:
|
|
75
|
+
"""Validate a single endpoint against its Pydantic model.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
graph_client: Authenticated Graph API client.
|
|
79
|
+
check: Endpoint configuration to validate.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
CheckResult with pass/fail status and any error details.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
# Fetch a single sample item from the endpoint
|
|
86
|
+
response = await graph_client.get(check.endpoint, params={"$top": "1"})
|
|
87
|
+
|
|
88
|
+
# Extract items from the list response
|
|
89
|
+
items = response.get("value", [])
|
|
90
|
+
|
|
91
|
+
if not items:
|
|
92
|
+
# No data to validate - that's still a pass, just nothing to check
|
|
93
|
+
return CheckResult(
|
|
94
|
+
endpoint_name=check.name,
|
|
95
|
+
passed=True,
|
|
96
|
+
error_message="No data available for validation",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Validate each item against the model
|
|
100
|
+
for item in items:
|
|
101
|
+
cleaned = _strip_odata_metadata(item)
|
|
102
|
+
check.validator.model_validate(cleaned)
|
|
103
|
+
|
|
104
|
+
return CheckResult(endpoint_name=check.name, passed=True)
|
|
105
|
+
|
|
106
|
+
except ValidationError as e:
|
|
107
|
+
# Extract error details for verbose output
|
|
108
|
+
error_details = []
|
|
109
|
+
for error in e.errors():
|
|
110
|
+
loc = ".".join(str(part) for part in error["loc"])
|
|
111
|
+
msg = error["msg"]
|
|
112
|
+
error_type = error["type"]
|
|
113
|
+
error_details.append(f" - {loc}: {msg} (type: {error_type})")
|
|
114
|
+
|
|
115
|
+
return CheckResult(
|
|
116
|
+
endpoint_name=check.name,
|
|
117
|
+
passed=False,
|
|
118
|
+
error_message=f"Schema validation failed for {check.model_name}",
|
|
119
|
+
error_details=error_details,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
except TeamsPhoneError as e:
|
|
123
|
+
# API errors (auth, rate limit, etc.)
|
|
124
|
+
return CheckResult(
|
|
125
|
+
endpoint_name=check.name,
|
|
126
|
+
passed=False,
|
|
127
|
+
error_message=f"API error: {e.message}",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _run_validation(
|
|
132
|
+
auth_service: AuthService,
|
|
133
|
+
endpoint_filter: str | None,
|
|
134
|
+
) -> list[CheckResult]:
|
|
135
|
+
"""Run validation checks against Graph API endpoints.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
auth_service: Authenticated service for API access.
|
|
139
|
+
endpoint_filter: Optional endpoint name to check (None = all endpoints).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List of CheckResult for each endpoint checked.
|
|
143
|
+
"""
|
|
144
|
+
# Filter endpoints if specific one requested
|
|
145
|
+
checks = ENDPOINT_CHECKS
|
|
146
|
+
if endpoint_filter:
|
|
147
|
+
checks = [c for c in ENDPOINT_CHECKS if c.name == endpoint_filter]
|
|
148
|
+
|
|
149
|
+
results: list[CheckResult] = []
|
|
150
|
+
|
|
151
|
+
async with GraphClient(auth_service) as graph_client:
|
|
152
|
+
for check in checks:
|
|
153
|
+
result = await _validate_endpoint(graph_client, check)
|
|
154
|
+
results.append(result)
|
|
155
|
+
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def api_check( # noqa: C901 - CLI command with validation, table/JSON output, and verbose mode
|
|
160
|
+
ctx: typer.Context,
|
|
161
|
+
endpoint: str | None = typer.Option(
|
|
162
|
+
None,
|
|
163
|
+
"--endpoint",
|
|
164
|
+
"-e",
|
|
165
|
+
help="Check specific endpoint (userConfigurations, numberAssignments).",
|
|
166
|
+
),
|
|
167
|
+
verbose: bool = typer.Option(
|
|
168
|
+
False,
|
|
169
|
+
"--verbose",
|
|
170
|
+
"-V",
|
|
171
|
+
help="Show detailed validation error output.",
|
|
172
|
+
),
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Validate API contracts against live Graph API responses.
|
|
175
|
+
|
|
176
|
+
Fetches sample data from Graph API endpoints and validates against
|
|
177
|
+
Pydantic models with strict mode (extra="forbid") to detect API
|
|
178
|
+
contract drift (new fields, renamed fields, removed fields).
|
|
179
|
+
|
|
180
|
+
Exits with code 8 (CONTRACT_ERROR) if any validation failures.
|
|
181
|
+
"""
|
|
182
|
+
cli_ctx = get_context(ctx)
|
|
183
|
+
formatter = cli_ctx.get_output_formatter()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
config_manager = cli_ctx.get_config_manager()
|
|
187
|
+
cache_manager = CacheManager()
|
|
188
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
189
|
+
|
|
190
|
+
# Validate endpoint filter if provided
|
|
191
|
+
valid_endpoints = [c.name for c in ENDPOINT_CHECKS]
|
|
192
|
+
if endpoint and endpoint not in valid_endpoints:
|
|
193
|
+
formatter.error(
|
|
194
|
+
f"Unknown endpoint: {endpoint}",
|
|
195
|
+
remediation=f"Valid endpoints: {', '.join(valid_endpoints)}",
|
|
196
|
+
)
|
|
197
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
198
|
+
|
|
199
|
+
# Run validation
|
|
200
|
+
results = asyncio.run(_run_validation(auth_service, endpoint))
|
|
201
|
+
|
|
202
|
+
# Handle no endpoints scenario
|
|
203
|
+
if not results:
|
|
204
|
+
formatter.warning("No endpoints to check")
|
|
205
|
+
raise typer.Exit(ExitCode.SUCCESS)
|
|
206
|
+
|
|
207
|
+
# Determine overall status
|
|
208
|
+
all_passed = all(r.passed for r in results)
|
|
209
|
+
failures = [r for r in results if not r.passed]
|
|
210
|
+
|
|
211
|
+
# Output results
|
|
212
|
+
if cli_ctx.json_output:
|
|
213
|
+
formatter.json(
|
|
214
|
+
{
|
|
215
|
+
"status": "pass" if all_passed else "fail",
|
|
216
|
+
"results": [
|
|
217
|
+
{
|
|
218
|
+
"endpoint": r.endpoint_name,
|
|
219
|
+
"status": "PASS" if r.passed else "FAIL",
|
|
220
|
+
"error": r.error_message,
|
|
221
|
+
"details": r.error_details,
|
|
222
|
+
}
|
|
223
|
+
for r in results
|
|
224
|
+
],
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
# Display results table
|
|
229
|
+
table_data = []
|
|
230
|
+
for result in results:
|
|
231
|
+
row = {
|
|
232
|
+
"endpoint": result.endpoint_name,
|
|
233
|
+
"status": "PASS" if result.passed else "FAIL",
|
|
234
|
+
"error": result.error_message or "—",
|
|
235
|
+
}
|
|
236
|
+
table_data.append(row)
|
|
237
|
+
|
|
238
|
+
formatter.table(
|
|
239
|
+
data=table_data,
|
|
240
|
+
columns=["endpoint", "status", "error"],
|
|
241
|
+
title="API Contract Validation Results",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Show detailed errors if verbose mode and there are failures
|
|
245
|
+
if verbose and failures:
|
|
246
|
+
for result in failures:
|
|
247
|
+
if result.error_details:
|
|
248
|
+
formatter.info(f"\nDetails for {result.endpoint_name}:")
|
|
249
|
+
for detail in result.error_details:
|
|
250
|
+
formatter.info(f" {detail}")
|
|
251
|
+
|
|
252
|
+
if all_passed:
|
|
253
|
+
formatter.success(
|
|
254
|
+
f"All {len(results)} endpoint(s) validated successfully"
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
formatter.error(
|
|
258
|
+
f"{len(failures)} of {len(results)} endpoint(s) failed validation"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Exit with appropriate code
|
|
262
|
+
if not all_passed:
|
|
263
|
+
raise typer.Exit(ExitCode.CONTRACT_ERROR)
|
|
264
|
+
|
|
265
|
+
except TeamsPhoneError as e:
|
|
266
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
267
|
+
raise typer.Exit(e.exit_code) from None
|
teams_phone/cli/auth.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Authentication commands for Teams Phone CLI.
|
|
2
|
+
|
|
3
|
+
This module provides CLI commands for authenticating with Microsoft Entra ID,
|
|
4
|
+
managing tokens, and checking authentication status.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from teams_phone.cli.context import get_context
|
|
12
|
+
from teams_phone.exceptions import TeamsPhoneError
|
|
13
|
+
from teams_phone.infrastructure import CacheManager
|
|
14
|
+
from teams_phone.services import AuthService
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
auth_app = typer.Typer(
|
|
18
|
+
name="auth",
|
|
19
|
+
help="Authentication commands.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@auth_app.command()
|
|
25
|
+
def status(ctx: typer.Context) -> None:
|
|
26
|
+
"""Show current authentication status."""
|
|
27
|
+
cli_ctx = get_context(ctx)
|
|
28
|
+
formatter = cli_ctx.get_output_formatter()
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
config_manager = cli_ctx.get_config_manager()
|
|
32
|
+
cache_manager = CacheManager()
|
|
33
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
34
|
+
|
|
35
|
+
tenant_name = cli_ctx.tenant
|
|
36
|
+
auth_status = auth_service.get_status(tenant_name)
|
|
37
|
+
|
|
38
|
+
if cli_ctx.json_output:
|
|
39
|
+
formatter.json(auth_status.model_dump(mode="json"))
|
|
40
|
+
else:
|
|
41
|
+
table_data = [
|
|
42
|
+
{"field": "Status", "value": auth_status.status.value},
|
|
43
|
+
]
|
|
44
|
+
if auth_status.tenant_name:
|
|
45
|
+
table_data.append({"field": "Tenant", "value": auth_status.tenant_name})
|
|
46
|
+
if auth_status.tenant_id:
|
|
47
|
+
table_data.append(
|
|
48
|
+
{"field": "Tenant ID", "value": auth_status.tenant_id}
|
|
49
|
+
)
|
|
50
|
+
if auth_status.expires_at:
|
|
51
|
+
table_data.append(
|
|
52
|
+
{"field": "Expires at", "value": auth_status.expires_at.isoformat()}
|
|
53
|
+
)
|
|
54
|
+
formatter.table(
|
|
55
|
+
data=table_data,
|
|
56
|
+
columns=["field", "value"],
|
|
57
|
+
title="Authentication Status",
|
|
58
|
+
)
|
|
59
|
+
except TeamsPhoneError as e:
|
|
60
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
61
|
+
raise typer.Exit(e.exit_code) from None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@auth_app.command()
|
|
65
|
+
def login(
|
|
66
|
+
ctx: typer.Context,
|
|
67
|
+
force: bool = typer.Option(
|
|
68
|
+
False,
|
|
69
|
+
"--force",
|
|
70
|
+
"-f",
|
|
71
|
+
help="Force new authentication even if token exists.",
|
|
72
|
+
),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Authenticate with the current tenant."""
|
|
75
|
+
cli_ctx = get_context(ctx)
|
|
76
|
+
formatter = cli_ctx.get_output_formatter()
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
config_manager = cli_ctx.get_config_manager()
|
|
80
|
+
cache_manager = CacheManager()
|
|
81
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
82
|
+
|
|
83
|
+
tenant_name = cli_ctx.tenant
|
|
84
|
+
token = auth_service.login(tenant_name, force=force)
|
|
85
|
+
|
|
86
|
+
if cli_ctx.json_output:
|
|
87
|
+
formatter.json(
|
|
88
|
+
{
|
|
89
|
+
"status": "authenticated",
|
|
90
|
+
"tenant_id": token.tenant_id,
|
|
91
|
+
"expires_at": token.expires_at.isoformat(),
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
formatter.success("Successfully authenticated")
|
|
96
|
+
formatter.info(f"Tenant ID: {token.tenant_id}")
|
|
97
|
+
formatter.info(f"Token expires at: {token.expires_at.isoformat()}")
|
|
98
|
+
except TeamsPhoneError as e:
|
|
99
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
100
|
+
raise typer.Exit(e.exit_code) from None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@auth_app.command()
|
|
104
|
+
def logout(
|
|
105
|
+
ctx: typer.Context,
|
|
106
|
+
all_tenants: bool = typer.Option(
|
|
107
|
+
False,
|
|
108
|
+
"--all",
|
|
109
|
+
"-a",
|
|
110
|
+
help="Clear tokens for all tenants.",
|
|
111
|
+
),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Clear cached authentication tokens."""
|
|
114
|
+
cli_ctx = get_context(ctx)
|
|
115
|
+
formatter = cli_ctx.get_output_formatter()
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
config_manager = cli_ctx.get_config_manager()
|
|
119
|
+
cache_manager = CacheManager()
|
|
120
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
121
|
+
|
|
122
|
+
tenant_name = cli_ctx.tenant
|
|
123
|
+
auth_service.logout(tenant_name, all_tenants=all_tenants)
|
|
124
|
+
|
|
125
|
+
if cli_ctx.json_output:
|
|
126
|
+
if all_tenants:
|
|
127
|
+
formatter.json({"status": "logged_out", "all_tenants": True})
|
|
128
|
+
else:
|
|
129
|
+
formatter.json({"status": "logged_out", "tenant": tenant_name})
|
|
130
|
+
else:
|
|
131
|
+
if all_tenants:
|
|
132
|
+
formatter.success("Logged out from all tenants")
|
|
133
|
+
else:
|
|
134
|
+
tenant_display = tenant_name or "default tenant"
|
|
135
|
+
formatter.success(f"Logged out from {tenant_display}")
|
|
136
|
+
except TeamsPhoneError as e:
|
|
137
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
138
|
+
raise typer.Exit(e.exit_code) from None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@auth_app.command()
|
|
142
|
+
def refresh(
|
|
143
|
+
ctx: typer.Context,
|
|
144
|
+
force: bool = typer.Option(
|
|
145
|
+
False,
|
|
146
|
+
"--force",
|
|
147
|
+
"-f",
|
|
148
|
+
help="Force refresh even if token is still valid.",
|
|
149
|
+
),
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Refresh cached authentication tokens."""
|
|
152
|
+
cli_ctx = get_context(ctx)
|
|
153
|
+
formatter = cli_ctx.get_output_formatter()
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
config_manager = cli_ctx.get_config_manager()
|
|
157
|
+
cache_manager = CacheManager()
|
|
158
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
159
|
+
|
|
160
|
+
tenant_name = cli_ctx.tenant
|
|
161
|
+
|
|
162
|
+
if force:
|
|
163
|
+
token = auth_service.refresh_token(tenant_name)
|
|
164
|
+
else:
|
|
165
|
+
# Check if token needs refresh before doing it
|
|
166
|
+
auth_status = auth_service.get_status(tenant_name)
|
|
167
|
+
from teams_phone.models import AuthStatusType
|
|
168
|
+
|
|
169
|
+
if auth_status.status == AuthStatusType.AUTHENTICATED:
|
|
170
|
+
if cli_ctx.json_output:
|
|
171
|
+
formatter.json(
|
|
172
|
+
{
|
|
173
|
+
"status": "already_valid",
|
|
174
|
+
"tenant_id": auth_status.tenant_id,
|
|
175
|
+
"expires_at": auth_status.expires_at.isoformat()
|
|
176
|
+
if auth_status.expires_at
|
|
177
|
+
else None,
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
formatter.info("Token is still valid, no refresh needed")
|
|
182
|
+
formatter.info("Use --force to refresh anyway")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
token = auth_service.refresh_token(tenant_name)
|
|
186
|
+
|
|
187
|
+
if cli_ctx.json_output:
|
|
188
|
+
formatter.json(
|
|
189
|
+
{
|
|
190
|
+
"status": "refreshed",
|
|
191
|
+
"tenant_id": token.tenant_id,
|
|
192
|
+
"expires_at": token.expires_at.isoformat(),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
formatter.success("Token refreshed successfully")
|
|
197
|
+
formatter.info(f"Tenant ID: {token.tenant_id}")
|
|
198
|
+
formatter.info(f"Token expires at: {token.expires_at.isoformat()}")
|
|
199
|
+
except TeamsPhoneError as e:
|
|
200
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
201
|
+
raise typer.Exit(e.exit_code) from None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""CLI context for type-safe access to global options and services.
|
|
2
|
+
|
|
3
|
+
This module provides the CLIContext dataclass that replaces the raw dict
|
|
4
|
+
used in ctx.obj, enabling type-safe access to global options and lazy
|
|
5
|
+
initialization of services like ConfigManager and OutputFormatter.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from teams_phone.infrastructure import ConfigManager, OutputFormatter
|
|
19
|
+
from teams_phone.infrastructure.debug_logger import DebugLogger
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CLIContext:
|
|
24
|
+
"""Type-safe context for CLI commands.
|
|
25
|
+
|
|
26
|
+
Stores global options and provides lazy access to services.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
json_output: Output in JSON format for automation.
|
|
30
|
+
verbose: Enable verbose output and debug information.
|
|
31
|
+
tenant: Tenant name override (None = use default from config).
|
|
32
|
+
no_color: Disable colored output.
|
|
33
|
+
debug_log_path: Path for debug log file (None = disabled).
|
|
34
|
+
config_manager: Lazily initialized ConfigManager instance.
|
|
35
|
+
output_formatter: Initialized OutputFormatter for output.
|
|
36
|
+
debug_logger: Initialized DebugLogger for debug logging.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
json_output: bool = False
|
|
40
|
+
verbose: bool = False
|
|
41
|
+
tenant: str | None = None
|
|
42
|
+
no_color: bool = False
|
|
43
|
+
debug_log_path: Path | None = None
|
|
44
|
+
config_manager: ConfigManager | None = None
|
|
45
|
+
output_formatter: OutputFormatter | None = None
|
|
46
|
+
debug_logger: DebugLogger | None = None
|
|
47
|
+
|
|
48
|
+
def get_output_formatter(self) -> OutputFormatter:
|
|
49
|
+
"""Get or create the OutputFormatter.
|
|
50
|
+
|
|
51
|
+
Creates an OutputFormatter on first access using the json_output
|
|
52
|
+
and no_color settings from this context.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The OutputFormatter instance.
|
|
56
|
+
"""
|
|
57
|
+
if self.output_formatter is None:
|
|
58
|
+
from teams_phone.infrastructure import OutputFormatter
|
|
59
|
+
|
|
60
|
+
self.output_formatter = OutputFormatter(
|
|
61
|
+
json_mode=self.json_output,
|
|
62
|
+
no_color=self.no_color,
|
|
63
|
+
)
|
|
64
|
+
return self.output_formatter
|
|
65
|
+
|
|
66
|
+
def get_config_manager(self) -> ConfigManager:
|
|
67
|
+
"""Get or create the ConfigManager.
|
|
68
|
+
|
|
69
|
+
Creates a ConfigManager on first access. This defers disk I/O
|
|
70
|
+
until actually needed, avoiding unnecessary config file reads
|
|
71
|
+
on operations like --help.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The ConfigManager instance.
|
|
75
|
+
"""
|
|
76
|
+
if self.config_manager is None:
|
|
77
|
+
from teams_phone.infrastructure import ConfigManager
|
|
78
|
+
|
|
79
|
+
self.config_manager = ConfigManager()
|
|
80
|
+
return self.config_manager
|
|
81
|
+
|
|
82
|
+
def get_debug_logger(self) -> DebugLogger | None:
|
|
83
|
+
"""Get the DebugLogger if debug logging is enabled.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The DebugLogger instance if debug_log_path is set, None otherwise.
|
|
87
|
+
"""
|
|
88
|
+
return self.debug_logger
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_context(ctx: typer.Context) -> CLIContext:
|
|
92
|
+
"""Retrieve typed CLIContext from Typer context.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
ctx: The Typer/Click context object.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The CLIContext instance stored in ctx.obj.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
RuntimeError: If ctx.obj is not a CLIContext (programming error).
|
|
102
|
+
"""
|
|
103
|
+
if not isinstance(ctx.obj, CLIContext):
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"CLIContext not found in context. "
|
|
106
|
+
"This is a programming error - ensure the main callback ran."
|
|
107
|
+
)
|
|
108
|
+
return ctx.obj
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""CLI Helper Functions - Shared utilities for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from teams_phone.cli.context import get_context
|
|
12
|
+
from teams_phone.infrastructure import CacheManager, GraphClient
|
|
13
|
+
from teams_phone.services import AuthService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from teams_phone.infrastructure import OutputFormatter
|
|
18
|
+
|
|
19
|
+
ServiceT = TypeVar("ServiceT")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_with_service(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
service_factory: Callable[[GraphClient], ServiceT],
|
|
25
|
+
operation: Callable[[ServiceT], Any],
|
|
26
|
+
) -> tuple[Any, OutputFormatter]:
|
|
27
|
+
"""Run an async operation with a service.
|
|
28
|
+
|
|
29
|
+
Creates the required infrastructure (GraphClient, service) and runs
|
|
30
|
+
the async operation using asyncio.run(). This is the generic pattern
|
|
31
|
+
for CLI commands that need to interact with services.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
ctx: Typer context with CLIContext.
|
|
35
|
+
service_factory: A callable that takes a GraphClient and returns
|
|
36
|
+
a service instance (e.g., UserService, NumberService).
|
|
37
|
+
operation: A callable that takes the service and returns a coroutine
|
|
38
|
+
to execute.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (result, OutputFormatter) where result is the coroutine's
|
|
42
|
+
return value.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
def list_users(ctx: typer.Context) -> None:
|
|
46
|
+
result, formatter = run_with_service(
|
|
47
|
+
ctx,
|
|
48
|
+
service_factory=UserService,
|
|
49
|
+
operation=lambda svc: svc.list_users(),
|
|
50
|
+
)
|
|
51
|
+
formatter.print_table(result)
|
|
52
|
+
"""
|
|
53
|
+
cli_ctx = get_context(ctx)
|
|
54
|
+
formatter = cli_ctx.get_output_formatter()
|
|
55
|
+
config_manager = cli_ctx.get_config_manager()
|
|
56
|
+
cache_manager = CacheManager()
|
|
57
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
58
|
+
|
|
59
|
+
async def _execute() -> Any:
|
|
60
|
+
async with GraphClient(auth_service) as graph_client:
|
|
61
|
+
service = service_factory(graph_client)
|
|
62
|
+
return await operation(service)
|
|
63
|
+
|
|
64
|
+
result = asyncio.run(_execute())
|
|
65
|
+
return result, formatter
|