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,3 @@
1
+ """Teams Phone CLI - Manage Microsoft Teams Phone configurations."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Entry point for running as python -m teams_phone."""
2
+
3
+ from teams_phone.cli.main import app
4
+
5
+
6
+ if __name__ == "__main__":
7
+ app()
@@ -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
@@ -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