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,234 @@
1
+ """Output formatter for Teams Phone CLI.
2
+
3
+ This module provides rich terminal output formatting including tables,
4
+ JSON output mode, progress bars, and styled messages with remediation guidance.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ from contextlib import contextmanager
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from rich.console import Console
16
+ from rich.progress import (
17
+ BarColumn,
18
+ Progress,
19
+ SpinnerColumn,
20
+ TaskProgressColumn,
21
+ TextColumn,
22
+ )
23
+ from rich.table import Table
24
+
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Iterator
28
+
29
+
30
+ def _can_encode_unicode() -> bool:
31
+ """Check if stdout can encode Unicode characters.
32
+
33
+ Tests by attempting to encode a Unicode checkmark. On Windows with
34
+ legacy console (cp1252), this will fail.
35
+
36
+ Returns:
37
+ True if Unicode is supported, False if ASCII-only mode is needed.
38
+ """
39
+ try:
40
+ # Test with checkmark - if this fails, we need ASCII mode
41
+ encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
42
+ "✓".encode(encoding)
43
+ return True
44
+ except (UnicodeEncodeError, LookupError):
45
+ return False
46
+
47
+
48
+ # ASCII-safe placeholder for "no value" display
49
+ # Em-dash (—) works in most encodings but we provide a fallback
50
+ PLACEHOLDER_UNICODE = "—"
51
+ PLACEHOLDER_ASCII = "-"
52
+
53
+
54
+ class OutputFormatter:
55
+ """Format and display output for the CLI.
56
+
57
+ Provides methods for displaying data as tables, JSON, or styled messages.
58
+ Supports color control via constructor flag and NO_COLOR environment variable.
59
+
60
+ Attributes:
61
+ console: Rich Console instance for output.
62
+ json_mode: Whether to output JSON instead of Rich formatting.
63
+ no_color: Whether color output is disabled.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ console: Console | None = None,
69
+ *,
70
+ json_mode: bool = False,
71
+ no_color: bool = False,
72
+ ) -> None:
73
+ """Initialize the OutputFormatter.
74
+
75
+ Args:
76
+ console: Optional Rich Console instance. If not provided, creates one.
77
+ json_mode: If True, output JSON to stdout instead of Rich formatting.
78
+ no_color: If True, disable color output. Also respects NO_COLOR env var.
79
+ """
80
+ self.json_mode = json_mode
81
+ # Respect both the flag and the NO_COLOR environment variable
82
+ self.no_color = no_color or os.environ.get("NO_COLOR") is not None
83
+ # Detect Unicode support for Windows compatibility
84
+ self._use_ascii = not _can_encode_unicode()
85
+
86
+ if console is not None:
87
+ self.console = console
88
+ else:
89
+ self.console = Console(
90
+ force_terminal=not json_mode,
91
+ no_color=self.no_color,
92
+ )
93
+
94
+ def table(
95
+ self,
96
+ data: list[dict[str, Any]],
97
+ columns: list[str],
98
+ *,
99
+ title: str | None = None,
100
+ ) -> None:
101
+ """Display data as a formatted table.
102
+
103
+ Args:
104
+ data: List of dictionaries containing row data.
105
+ columns: List of column names to display.
106
+ title: Optional title for the table.
107
+ """
108
+ if self.json_mode:
109
+ self.json(data)
110
+ return
111
+
112
+ table = Table(title=title)
113
+
114
+ for column in columns:
115
+ table.add_column(column, style="cyan")
116
+
117
+ for row in data:
118
+ table.add_row(*[str(row.get(col, "")) for col in columns])
119
+
120
+ self.console.print(table)
121
+
122
+ def json(self, data: Any) -> None:
123
+ """Output data as JSON to stdout.
124
+
125
+ Args:
126
+ data: Data to serialize as JSON.
127
+ """
128
+ # Use plain print to stdout for JSON mode (no Rich formatting)
129
+ print(json.dumps(data, indent=2, default=str), file=sys.stdout)
130
+
131
+ def success(self, message: str) -> None:
132
+ """Display a success message in green.
133
+
134
+ Args:
135
+ message: The success message to display.
136
+ """
137
+ if self.json_mode:
138
+ return
139
+ # Use ASCII-safe marker on Windows legacy console
140
+ marker = "OK:" if self._use_ascii else "✓"
141
+ self.console.print(f"[green]{marker}[/green] {message}")
142
+
143
+ def error(self, message: str, *, remediation: str | None = None) -> None:
144
+ """Display an error message in red with optional remediation guidance.
145
+
146
+ Args:
147
+ message: The error message to display.
148
+ remediation: Optional guidance on how to fix the issue.
149
+ """
150
+ if self.json_mode:
151
+ return
152
+
153
+ self.console.print(f"[red]Error:[/red] {message}")
154
+
155
+ if remediation:
156
+ self.console.print()
157
+ self.console.print("[yellow]To fix:[/yellow]")
158
+ self.console.print(f" {remediation}")
159
+
160
+ def warning(self, message: str) -> None:
161
+ """Display a warning message in yellow.
162
+
163
+ Args:
164
+ message: The warning message to display.
165
+ """
166
+ if self.json_mode:
167
+ return
168
+ self.console.print(f"[yellow]Warning:[/yellow] {message}")
169
+
170
+ def info(self, message: str) -> None:
171
+ """Display an informational message.
172
+
173
+ Args:
174
+ message: The info message to display.
175
+ """
176
+ if self.json_mode:
177
+ return
178
+ self.console.print(message)
179
+
180
+ def get_spinner_name(self) -> str:
181
+ """Get the appropriate spinner name based on Unicode support.
182
+
183
+ Returns:
184
+ 'line' for ASCII-only mode (Windows legacy console),
185
+ 'dots' for Unicode-capable terminals.
186
+ """
187
+ return "line" if self._use_ascii else "dots"
188
+
189
+ @property
190
+ def placeholder(self) -> str:
191
+ """Get the appropriate placeholder for 'no value' display.
192
+
193
+ Returns:
194
+ '-' for ASCII-only mode, '—' (em-dash) for Unicode terminals.
195
+ """
196
+ return PLACEHOLDER_ASCII if self._use_ascii else PLACEHOLDER_UNICODE
197
+
198
+ @contextmanager
199
+ def progress(
200
+ self,
201
+ total: int,
202
+ description: str = "Processing",
203
+ ) -> Iterator[Progress]:
204
+ """Return a Progress context manager for displaying progress bars.
205
+
206
+ Args:
207
+ total: Total number of items to process.
208
+ description: Description text for the progress bar.
209
+
210
+ Yields:
211
+ Progress instance for tracking progress.
212
+ """
213
+ if self.json_mode:
214
+ # Return a minimal progress that does nothing in JSON mode
215
+ progress = Progress(
216
+ console=Console(force_terminal=False, quiet=True),
217
+ disable=True,
218
+ )
219
+ with progress:
220
+ progress.add_task(description, total=total)
221
+ yield progress
222
+ else:
223
+ # Use ASCII-safe spinner on Windows legacy console
224
+ spinner_name = self.get_spinner_name()
225
+ progress = Progress(
226
+ SpinnerColumn(spinner_name=spinner_name),
227
+ TextColumn("[progress.description]{task.description}"),
228
+ BarColumn(),
229
+ TaskProgressColumn(),
230
+ console=self.console,
231
+ )
232
+ with progress:
233
+ progress.add_task(description, total=total)
234
+ yield progress
@@ -0,0 +1,76 @@
1
+ """Pydantic models for API responses and domain objects."""
2
+
3
+ from teams_phone.models.api_responses import (
4
+ GraphErrorDetail,
5
+ GraphErrorResponse,
6
+ GraphListResponse,
7
+ )
8
+ from teams_phone.models.auth import (
9
+ AuthStatus,
10
+ AuthStatusType,
11
+ CachedToken,
12
+ ConnectionTestResult,
13
+ )
14
+ from teams_phone.models.cache import CacheMetadata
15
+ from teams_phone.models.config import CacheSettings, Config, NetworkSettings
16
+ from teams_phone.models.location import EmergencyLocation
17
+ from teams_phone.models.number import (
18
+ ActivationState,
19
+ AssignmentCategory,
20
+ AssignmentStatus,
21
+ AsyncOperation,
22
+ NumberAssignment,
23
+ NumberType,
24
+ OperationNumber,
25
+ OperationStatus,
26
+ )
27
+ from teams_phone.models.policy import Policy
28
+ from teams_phone.models.tenant import AuthMethod, TenantProfile
29
+ from teams_phone.models.user import (
30
+ AccountType,
31
+ EffectivePolicyAssignment,
32
+ PolicyAssignmentDetail,
33
+ TelephoneNumber,
34
+ UserConfiguration,
35
+ )
36
+
37
+
38
+ __all__ = [
39
+ # API response models
40
+ "GraphErrorDetail",
41
+ "GraphErrorResponse",
42
+ "GraphListResponse",
43
+ # Auth models
44
+ "AuthStatus",
45
+ "AuthStatusType",
46
+ "CachedToken",
47
+ "ConnectionTestResult",
48
+ # Cache models
49
+ "CacheMetadata",
50
+ # Config models
51
+ "CacheSettings",
52
+ "Config",
53
+ "NetworkSettings",
54
+ # Number models
55
+ "ActivationState",
56
+ "AssignmentCategory",
57
+ "AssignmentStatus",
58
+ "AsyncOperation",
59
+ "NumberAssignment",
60
+ "NumberType",
61
+ "OperationNumber",
62
+ "OperationStatus",
63
+ # Location models
64
+ "EmergencyLocation",
65
+ # Policy models
66
+ "Policy",
67
+ # Tenant models
68
+ "AuthMethod",
69
+ "TenantProfile",
70
+ # User models
71
+ "AccountType",
72
+ "EffectivePolicyAssignment",
73
+ "PolicyAssignmentDetail",
74
+ "TelephoneNumber",
75
+ "UserConfiguration",
76
+ ]
@@ -0,0 +1,69 @@
1
+ """Generic Graph API response models for pagination and error handling."""
2
+
3
+ from typing import Generic, TypeVar
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class GraphErrorDetail(BaseModel):
12
+ """Nested error details from Graph API error responses.
13
+
14
+ Graph API errors can contain nested details for complex error scenarios.
15
+ The details field is self-referential to support arbitrary nesting depth.
16
+
17
+ Attributes:
18
+ code: Error code string (e.g., "BadRequest", "ResourceNotFound").
19
+ message: Human-readable error message.
20
+ target: The target of the error, typically the field or parameter that caused it.
21
+ details: Nested error details for additional context.
22
+ """
23
+
24
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
25
+
26
+ code: str
27
+ message: str
28
+ target: str | None = None
29
+ details: list["GraphErrorDetail"] | None = None
30
+
31
+
32
+ class GraphErrorResponse(BaseModel):
33
+ """Graph API error response wrapper.
34
+
35
+ Standard error response format returned by Microsoft Graph API
36
+ when a request fails. Contains a single error object with details.
37
+
38
+ Attributes:
39
+ error: The error detail object containing code, message, and optional nested details.
40
+ """
41
+
42
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
43
+
44
+ error: GraphErrorDetail
45
+
46
+
47
+ class GraphListResponse(BaseModel, Generic[T]):
48
+ """Generic wrapper for paginated Graph API list responses.
49
+
50
+ Microsoft Graph API returns list results in a consistent format with
51
+ OData annotations for context, count, and pagination. This generic
52
+ model can wrap any item type T for type-safe parsing.
53
+
54
+ Type Parameters:
55
+ T: The type of items in the value list (e.g., UserConfiguration).
56
+
57
+ Attributes:
58
+ odata_context: OData context URL describing the response metadata.
59
+ odata_count: Total count of items when requested with $count=true.
60
+ odata_next_link: URL to fetch the next page of results.
61
+ value: List of items of type T.
62
+ """
63
+
64
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
65
+
66
+ odata_context: str | None = Field(alias="@odata.context", default=None)
67
+ odata_count: int | None = Field(alias="@odata.count", default=None)
68
+ odata_next_link: str | None = Field(alias="@odata.nextLink", default=None)
69
+ value: list[T]
@@ -0,0 +1,100 @@
1
+ """Authentication models for token caching and status tracking."""
2
+
3
+ from datetime import datetime, timezone
4
+ from enum import Enum
5
+
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+
9
+ class AuthStatusType(str, Enum):
10
+ """Authentication status states.
11
+
12
+ Attributes:
13
+ AUTHENTICATED: Successfully authenticated with valid token.
14
+ UNAUTHENTICATED: No valid authentication (requires login).
15
+ EXPIRED: Token has expired (requires refresh or re-authentication).
16
+ """
17
+
18
+ AUTHENTICATED = "authenticated"
19
+ UNAUTHENTICATED = "unauthenticated"
20
+ EXPIRED = "expired"
21
+
22
+
23
+ class CachedToken(BaseModel):
24
+ """Cached authentication token for a tenant.
25
+
26
+ Stores the access token and expiration timestamp for token caching.
27
+ MSAL handles refresh tokens internally, so only access token is stored.
28
+
29
+ Attributes:
30
+ access_token: The OAuth 2.0 access token string.
31
+ expires_at: UTC datetime when the token expires.
32
+ tenant_id: The tenant ID this token is associated with.
33
+ scopes: List of scopes granted to this token.
34
+ """
35
+
36
+ model_config = ConfigDict(extra="forbid")
37
+
38
+ access_token: str
39
+ expires_at: datetime
40
+ tenant_id: str
41
+ scopes: list[str]
42
+
43
+ def is_expired(self) -> bool:
44
+ """Check if the token has expired.
45
+
46
+ Returns:
47
+ True if current time is past expiration, False otherwise.
48
+ """
49
+ now = datetime.now(timezone.utc)
50
+ expires = self.expires_at
51
+ # Handle both naive and aware datetimes for comparison
52
+ if expires.tzinfo is None:
53
+ expires = expires.replace(tzinfo=timezone.utc)
54
+ return now >= expires
55
+
56
+
57
+ class AuthStatus(BaseModel):
58
+ """Current authentication status for a tenant.
59
+
60
+ Tracks the authentication state and provides context about
61
+ the current session.
62
+
63
+ Attributes:
64
+ status: Current authentication state.
65
+ tenant_name: Profile name of the authenticated tenant.
66
+ tenant_id: Tenant ID if authenticated.
67
+ expires_at: Token expiration time if authenticated.
68
+ error_message: Error details if authentication failed.
69
+ """
70
+
71
+ model_config = ConfigDict(extra="forbid")
72
+
73
+ status: AuthStatusType
74
+ tenant_name: str | None = None
75
+ tenant_id: str | None = None
76
+ expires_at: datetime | None = None
77
+ error_message: str | None = None
78
+
79
+
80
+ class ConnectionTestResult(BaseModel):
81
+ """Result of a tenant connection test.
82
+
83
+ Contains the outcome of testing connectivity to Microsoft Graph API
84
+ for a given tenant, including latency measurement and permission info.
85
+
86
+ Attributes:
87
+ success: Whether the connection test succeeded.
88
+ tenant_name: Name of the tenant that was tested.
89
+ latency_ms: Round-trip latency in milliseconds (None if failed before API call).
90
+ permissions: List of application permissions (scopes) available.
91
+ error_message: Error details if the test failed.
92
+ """
93
+
94
+ model_config = ConfigDict(extra="forbid")
95
+
96
+ success: bool
97
+ tenant_name: str
98
+ latency_ms: int | None = None
99
+ permissions: list[str] = []
100
+ error_message: str | None = None
@@ -0,0 +1,25 @@
1
+ """Cache-related models for metadata and staleness tracking."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class CacheMetadata(BaseModel):
9
+ """Metadata about CSV cache files.
10
+
11
+ Tracks when the cache was last updated and how much data it contains.
12
+ Used to determine if the cache is stale and needs refreshing.
13
+
14
+ Attributes:
15
+ last_updated: UTC datetime when the oldest cache file was modified.
16
+ None if no cache files exist.
17
+ locations_count: Number of location records in the cache.
18
+ policies_count: Number of policy records in the cache.
19
+ """
20
+
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+ last_updated: datetime | None
24
+ locations_count: int
25
+ policies_count: int
@@ -0,0 +1,66 @@
1
+ """Configuration models for CLI settings and defaults."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from teams_phone.constants import (
6
+ CSV_STALE_DAYS,
7
+ DEFAULT_CACHE_PATH,
8
+ DEFAULT_CONFIG_PATH,
9
+ DEFAULT_TIMEOUT,
10
+ DEFAULT_TOKEN_PATH,
11
+ MAX_RETRIES,
12
+ )
13
+ from teams_phone.models.tenant import TenantProfile
14
+
15
+
16
+ class CacheSettings(BaseModel):
17
+ """Cache configuration settings.
18
+
19
+ Attributes:
20
+ cache_path: Directory path for cached data files.
21
+ stale_days: Number of days before cache is considered stale.
22
+ """
23
+
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ cache_path: str = DEFAULT_CACHE_PATH
27
+ stale_days: int = CSV_STALE_DAYS
28
+
29
+
30
+ class NetworkSettings(BaseModel):
31
+ """Network and HTTP client settings.
32
+
33
+ Attributes:
34
+ timeout: HTTP request timeout in seconds.
35
+ max_retries: Maximum retry attempts for transient failures.
36
+ """
37
+
38
+ model_config = ConfigDict(extra="forbid")
39
+
40
+ timeout: int = DEFAULT_TIMEOUT
41
+ max_retries: int = MAX_RETRIES
42
+
43
+
44
+ class Config(BaseModel):
45
+ """Main configuration model for Teams Phone CLI.
46
+
47
+ Root configuration containing all settings, defaults, and tenant profiles.
48
+ Loaded from config.toml at startup.
49
+
50
+ Attributes:
51
+ config_path: Path to the configuration file.
52
+ token_path: Directory path for authentication token storage.
53
+ default_tenant: Name of the default tenant profile to use.
54
+ cache: Cache-related settings.
55
+ network: Network and HTTP client settings.
56
+ tenants: Dictionary of tenant profiles keyed by name.
57
+ """
58
+
59
+ model_config = ConfigDict(extra="forbid")
60
+
61
+ config_path: str = DEFAULT_CONFIG_PATH
62
+ token_path: str = DEFAULT_TOKEN_PATH
63
+ default_tenant: str | None = None
64
+ cache: CacheSettings = Field(default_factory=CacheSettings)
65
+ network: NetworkSettings = Field(default_factory=NetworkSettings)
66
+ tenants: dict[str, TenantProfile] = Field(default_factory=dict)
@@ -0,0 +1,36 @@
1
+ """Emergency location model for CSV cache data."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class EmergencyLocation(BaseModel):
7
+ """Emergency location from CSV cache.
8
+
9
+ Represents an emergency calling location exported via PowerShell.
10
+ Used for validation and display since Graph API doesn't expose location endpoints.
11
+
12
+ Attributes:
13
+ location_id: Unique identifier for the location (GUID).
14
+ civic_address_id: Associated civic address ID (GUID).
15
+ description: Location description/name.
16
+ company_name: Company name at this location.
17
+ address: Street address.
18
+ city: City name.
19
+ state_or_province: State or province.
20
+ postal_code: Postal/ZIP code.
21
+ country_or_region: Country or region code.
22
+ is_default: Whether this is the default location for the civic address.
23
+ """
24
+
25
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
26
+
27
+ location_id: str = Field(alias="locationId")
28
+ civic_address_id: str = Field(alias="civicAddressId")
29
+ description: str = Field(alias="description")
30
+ company_name: str | None = Field(alias="companyName", default=None)
31
+ address: str | None = Field(alias="address", default=None)
32
+ city: str | None = Field(alias="city", default=None)
33
+ state_or_province: str | None = Field(alias="stateOrProvince", default=None)
34
+ postal_code: str | None = Field(alias="postalCode", default=None)
35
+ country_or_region: str | None = Field(alias="countryOrRegion", default=None)
36
+ is_default: bool = Field(alias="isDefault")