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,308 @@
1
+ """Emergency location management commands for Teams Phone CLI.
2
+
3
+ This module provides CLI commands for listing, showing, and searching
4
+ emergency locations from the CSV cache.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import typer
13
+
14
+ from teams_phone.cli.context import get_context
15
+ from teams_phone.exceptions import TeamsPhoneError
16
+ from teams_phone.infrastructure import CacheManager
17
+ from teams_phone.models import EmergencyLocation
18
+ from teams_phone.services import LocationService
19
+
20
+
21
+ if TYPE_CHECKING:
22
+ from teams_phone.infrastructure import OutputFormatter
23
+
24
+ locations_app = typer.Typer(
25
+ name="locations",
26
+ help="Manage emergency locations.",
27
+ no_args_is_help=True,
28
+ )
29
+
30
+
31
+ class SearchField(str, Enum):
32
+ """Search field options for location search."""
33
+
34
+ NAME = "name"
35
+ ADDRESS = "address"
36
+ CITY = "city"
37
+ COMPANY = "company"
38
+
39
+
40
+ def _format_default(is_default: bool) -> str:
41
+ """Format default status for display."""
42
+ return "Yes" if is_default else "No"
43
+
44
+
45
+ def _location_to_list_dict(location: EmergencyLocation) -> dict[str, Any]:
46
+ """Convert EmergencyLocation to dict for list/search table display."""
47
+ return {
48
+ "description": location.description or "—",
49
+ "address": location.address or "—",
50
+ "city": location.city or "—",
51
+ "state": location.state_or_province or "—",
52
+ "default": _format_default(location.is_default),
53
+ }
54
+
55
+
56
+ def _location_to_json_dict(location: EmergencyLocation) -> dict[str, Any]:
57
+ """Convert EmergencyLocation to dict for JSON output."""
58
+ return {
59
+ "location_id": location.location_id,
60
+ "civic_address_id": location.civic_address_id,
61
+ "description": location.description,
62
+ "company_name": location.company_name,
63
+ "address": location.address,
64
+ "city": location.city,
65
+ "state_or_province": location.state_or_province,
66
+ "postal_code": location.postal_code,
67
+ "country_or_region": location.country_or_region,
68
+ "is_default": location.is_default,
69
+ }
70
+
71
+
72
+ def _get_service() -> LocationService:
73
+ """Create a LocationService instance."""
74
+ cache_manager = CacheManager()
75
+ return LocationService(cache_manager)
76
+
77
+
78
+ def _check_staleness(service: LocationService, formatter: OutputFormatter) -> None:
79
+ """Check for staleness warning and display if needed."""
80
+ staleness_warning = service.get_staleness_warning()
81
+ if staleness_warning:
82
+ formatter.warning(staleness_warning)
83
+
84
+
85
+ def _location_matches_query(
86
+ loc: EmergencyLocation, query_lower: str, field: SearchField | None
87
+ ) -> bool:
88
+ """Check if a location matches the search query.
89
+
90
+ Args:
91
+ loc: The location to check.
92
+ query_lower: The lowercase search query.
93
+ field: Optional field to limit search, or None for all fields.
94
+
95
+ Returns:
96
+ True if the location matches the query.
97
+ """
98
+ # Define field-to-value mapping
99
+ field_values: dict[SearchField | None, list[str | None]] = {
100
+ SearchField.NAME: [loc.description],
101
+ SearchField.ADDRESS: [loc.address],
102
+ SearchField.CITY: [loc.city],
103
+ SearchField.COMPANY: [loc.company_name],
104
+ None: [loc.description, loc.address, loc.city, loc.company_name],
105
+ }
106
+
107
+ values_to_check = field_values.get(field, [])
108
+ return any(query_lower in (value or "").lower() for value in values_to_check)
109
+
110
+
111
+ @locations_app.command("list")
112
+ def list_locations(
113
+ ctx: typer.Context,
114
+ city: str | None = typer.Option(
115
+ None,
116
+ "--city",
117
+ "-c",
118
+ help="Filter by city.",
119
+ ),
120
+ limit: int | None = typer.Option(
121
+ None,
122
+ "--limit",
123
+ "-l",
124
+ help="Limit results to n locations.",
125
+ ),
126
+ all_locations: bool = typer.Option(
127
+ False,
128
+ "--all",
129
+ help="Fetch all locations (default behavior when no limit specified).",
130
+ ),
131
+ ) -> None:
132
+ """List emergency locations from CSV cache."""
133
+ cli_ctx = get_context(ctx)
134
+ formatter = cli_ctx.get_output_formatter()
135
+
136
+ # Validate mutually exclusive options
137
+ if limit is not None and all_locations:
138
+ formatter.error(
139
+ "Cannot specify both --limit and --all",
140
+ remediation="Use either --limit <n> to limit results or --all to fetch all locations.",
141
+ )
142
+ raise typer.Exit(5) # ValidationError exit code
143
+
144
+ try:
145
+ service = _get_service()
146
+
147
+ # Check for staleness before output
148
+ _check_staleness(service, formatter)
149
+
150
+ locations = service.list_locations()
151
+
152
+ # Filter by city if specified
153
+ if city:
154
+ city_lower = city.lower()
155
+ locations = [
156
+ loc for loc in locations if (loc.city or "").lower() == city_lower
157
+ ]
158
+
159
+ # Apply limit if specified
160
+ if limit is not None:
161
+ locations = locations[:limit]
162
+
163
+ if cli_ctx.json_output:
164
+ formatter.json([_location_to_json_dict(loc) for loc in locations])
165
+ else:
166
+ if not locations:
167
+ formatter.info("No locations found matching the specified criteria")
168
+ else:
169
+ formatter.table(
170
+ data=[_location_to_list_dict(loc) for loc in locations],
171
+ columns=[
172
+ "description",
173
+ "address",
174
+ "city",
175
+ "state",
176
+ "default",
177
+ ],
178
+ title="Emergency Locations",
179
+ )
180
+ except TeamsPhoneError as e:
181
+ formatter.error(e.message, remediation=e.remediation)
182
+ raise typer.Exit(e.exit_code) from None
183
+
184
+
185
+ @locations_app.command("show")
186
+ def show_location(
187
+ ctx: typer.Context,
188
+ location: str = typer.Argument(
189
+ ...,
190
+ help="Location identifier: ID (GUID) or description.",
191
+ ),
192
+ ) -> None:
193
+ """Show detailed information for a specific emergency location."""
194
+ cli_ctx = get_context(ctx)
195
+ formatter = cli_ctx.get_output_formatter()
196
+
197
+ try:
198
+ service = _get_service()
199
+
200
+ # Check for staleness before output
201
+ _check_staleness(service, formatter)
202
+
203
+ loc = service.validate_location(location)
204
+
205
+ if cli_ctx.json_output:
206
+ formatter.json(_location_to_json_dict(loc))
207
+ else:
208
+ # Identity section
209
+ formatter.table(
210
+ data=[
211
+ {"field": "Description", "value": loc.description or "—"},
212
+ {"field": "Location ID", "value": loc.location_id},
213
+ {"field": "Civic Address ID", "value": loc.civic_address_id},
214
+ {"field": "Company Name", "value": loc.company_name or "—"},
215
+ ],
216
+ columns=["field", "value"],
217
+ title="Location Details",
218
+ )
219
+
220
+ # Address section
221
+ formatter.table(
222
+ data=[
223
+ {"field": "Street", "value": loc.address or "—"},
224
+ {"field": "City", "value": loc.city or "—"},
225
+ {"field": "State/Province", "value": loc.state_or_province or "—"},
226
+ {"field": "Postal Code", "value": loc.postal_code or "—"},
227
+ {"field": "Country", "value": loc.country_or_region or "—"},
228
+ ],
229
+ columns=["field", "value"],
230
+ title="Address",
231
+ )
232
+
233
+ # Status section
234
+ formatter.table(
235
+ data=[
236
+ {
237
+ "field": "Default Location",
238
+ "value": _format_default(loc.is_default),
239
+ },
240
+ ],
241
+ columns=["field", "value"],
242
+ title="Status",
243
+ )
244
+
245
+ except TeamsPhoneError as e:
246
+ formatter.error(e.message, remediation=e.remediation)
247
+ raise typer.Exit(e.exit_code) from None
248
+
249
+
250
+ @locations_app.command("search")
251
+ def search_locations(
252
+ ctx: typer.Context,
253
+ query: str = typer.Argument(
254
+ ...,
255
+ help="Search term (partial match supported, case-insensitive).",
256
+ ),
257
+ field: SearchField | None = typer.Option(
258
+ None,
259
+ "--field",
260
+ "-f",
261
+ help="Limit search to: name, address, city, company.",
262
+ ),
263
+ limit: int = typer.Option(
264
+ 25,
265
+ "--limit",
266
+ "-l",
267
+ help="Maximum results (default: 25).",
268
+ ),
269
+ ) -> None:
270
+ """Search emergency locations by name, address, or city."""
271
+ cli_ctx = get_context(ctx)
272
+ formatter = cli_ctx.get_output_formatter()
273
+
274
+ try:
275
+ service = _get_service()
276
+
277
+ # Check for staleness before output
278
+ _check_staleness(service, formatter)
279
+
280
+ # Get all locations for filtered search
281
+ all_locs = service.list_locations()
282
+ query_lower = query.lower()
283
+
284
+ # Filter locations using helper function and apply limit
285
+ results = [
286
+ loc for loc in all_locs if _location_matches_query(loc, query_lower, field)
287
+ ][:limit]
288
+
289
+ if cli_ctx.json_output:
290
+ formatter.json([_location_to_json_dict(loc) for loc in results])
291
+ else:
292
+ if not results:
293
+ formatter.info(f"No locations found matching '{query}'")
294
+ else:
295
+ formatter.table(
296
+ data=[_location_to_list_dict(loc) for loc in results],
297
+ columns=[
298
+ "description",
299
+ "address",
300
+ "city",
301
+ "state",
302
+ "default",
303
+ ],
304
+ title=f"Search Results for '{query}'",
305
+ )
306
+ except TeamsPhoneError as e:
307
+ formatter.error(e.message, remediation=e.remediation)
308
+ raise typer.Exit(e.exit_code) from None
@@ -0,0 +1,99 @@
1
+ """Main Typer application for Teams Phone CLI.
2
+
3
+ This module defines the root Typer app with global options (--json, --verbose,
4
+ --tenant, --no-color) that are captured in the context for downstream commands.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ from teams_phone.cli.api_check import api_check
14
+ from teams_phone.cli.auth import auth_app
15
+ from teams_phone.cli.context import CLIContext
16
+ from teams_phone.cli.locations import locations_app
17
+ from teams_phone.cli.numbers import numbers_app
18
+ from teams_phone.cli.policies import policies_app
19
+ from teams_phone.cli.tenants import tenants_app
20
+ from teams_phone.cli.users import users_app
21
+ from teams_phone.infrastructure import OutputFormatter
22
+ from teams_phone.infrastructure.debug_logger import DebugLogger
23
+
24
+
25
+ app = typer.Typer(
26
+ name="teams-phone",
27
+ help="Manage Microsoft Teams Phone configurations via Microsoft Graph API.",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+
32
+ @app.callback()
33
+ def main(
34
+ ctx: typer.Context,
35
+ json_output: bool = typer.Option(
36
+ False,
37
+ "--json",
38
+ "-j",
39
+ help="Output in JSON format for automation.",
40
+ ),
41
+ verbose: bool = typer.Option(
42
+ False,
43
+ "--verbose",
44
+ "-v",
45
+ help="Show detailed output and debug information.",
46
+ ),
47
+ tenant: str | None = typer.Option(
48
+ None,
49
+ "--tenant",
50
+ "-t",
51
+ help="Override default tenant for this command.",
52
+ ),
53
+ no_color: bool = typer.Option(
54
+ False,
55
+ "--no-color",
56
+ help="Disable color output.",
57
+ ),
58
+ debug_log: Path | None = typer.Option(
59
+ None,
60
+ "--debug-log",
61
+ help="Write debug logs to specified file (JSON Lines format).",
62
+ ),
63
+ ) -> None:
64
+ """Teams Phone CLI - Manage Microsoft Teams Phone configurations.
65
+
66
+ This command provides access to Teams Phone management operations including
67
+ user management, phone number assignment, policy configuration, and more.
68
+ """
69
+ # Create typed context with global options
70
+ cli_ctx = CLIContext(
71
+ json_output=json_output,
72
+ verbose=verbose,
73
+ tenant=tenant,
74
+ no_color=no_color,
75
+ debug_log_path=debug_log,
76
+ )
77
+ # Initialize OutputFormatter immediately (cheap operation)
78
+ cli_ctx.output_formatter = OutputFormatter(
79
+ json_mode=json_output,
80
+ no_color=no_color,
81
+ )
82
+ # Initialize DebugLogger if debug log path is specified
83
+ if debug_log is not None:
84
+ cli_ctx.debug_logger = DebugLogger(debug_log)
85
+ # ConfigManager is lazily initialized when needed to avoid disk I/O on --help
86
+ ctx.obj = cli_ctx
87
+
88
+
89
+ # Register command groups with main app
90
+ # Note: auth_app is imported from teams_phone.cli.auth
91
+ app.add_typer(users_app, name="users", help="Manage Teams users.")
92
+ app.add_typer(numbers_app, name="numbers", help="Manage phone numbers.")
93
+ app.add_typer(policies_app, name="policies", help="Manage voice policies.")
94
+ app.add_typer(locations_app, name="locations", help="Manage emergency locations.")
95
+ app.add_typer(tenants_app, name="tenants", help="Manage tenant profiles.")
96
+ app.add_typer(auth_app, name="auth", help="Authentication commands.")
97
+
98
+ # Register standalone commands
99
+ app.command("api-check")(api_check)