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,274 @@
1
+ """Cache manager for token storage and retrieval with JSON persistence."""
2
+
3
+ import csv
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from pydantic import ValidationError as PydanticValidationError
8
+
9
+ from teams_phone.constants import CSV_STALE_DAYS, DEFAULT_CACHE_PATH, DEFAULT_TOKEN_PATH
10
+ from teams_phone.exceptions import ConfigurationError
11
+ from teams_phone.models import CachedToken, CacheMetadata
12
+
13
+
14
+ class CacheManager:
15
+ """Manages token caching with JSON persistence.
16
+
17
+ Provides methods to save, retrieve, and clear authentication tokens
18
+ for tenants. Tokens are stored as JSON files in the token directory.
19
+
20
+ Attributes:
21
+ cache_dir: Path to the cache directory for CSV files.
22
+ token_dir: Path to the token storage directory.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ cache_dir: str | None = None,
28
+ token_dir: str | None = None,
29
+ ) -> None:
30
+ """Initialize the CacheManager.
31
+
32
+ Args:
33
+ cache_dir: Path to the cache directory. Defaults to
34
+ ~/.teams-phone/cache if not specified.
35
+ token_dir: Path to the token directory. Defaults to
36
+ ~/.teams-phone/tokens if not specified.
37
+ """
38
+ cache_str = cache_dir if cache_dir is not None else DEFAULT_CACHE_PATH
39
+ self._cache_dir: Path = Path(cache_str).expanduser()
40
+
41
+ token_str = token_dir if token_dir is not None else DEFAULT_TOKEN_PATH
42
+ self._token_dir: Path = Path(token_str).expanduser()
43
+
44
+ @property
45
+ def cache_dir(self) -> Path:
46
+ """Path to the cache directory."""
47
+ return self._cache_dir
48
+
49
+ @property
50
+ def token_dir(self) -> Path:
51
+ """Path to the token directory."""
52
+ return self._token_dir
53
+
54
+ def ensure_token_dir(self) -> None:
55
+ """Create the token directory if it doesn't exist.
56
+
57
+ Creates all parent directories as needed.
58
+
59
+ Raises:
60
+ ConfigurationError: If directory creation fails due to permission error.
61
+ """
62
+ try:
63
+ self._token_dir.mkdir(parents=True, exist_ok=True)
64
+ except PermissionError as e:
65
+ raise ConfigurationError(
66
+ f"Cannot create token directory {self._token_dir}: {e}",
67
+ remediation=(
68
+ f"Check that you have write permissions to {self._token_dir}.\n"
69
+ "You may need to create the directory manually or run with elevated privileges."
70
+ ),
71
+ ) from e
72
+
73
+ def _get_token_path(self, tenant_name: str) -> Path:
74
+ """Get the file path for a tenant's token.
75
+
76
+ Args:
77
+ tenant_name: The tenant profile name.
78
+
79
+ Returns:
80
+ Path to the token JSON file.
81
+ """
82
+ return self._token_dir / f"{tenant_name}.json"
83
+
84
+ def get_token(self, tenant_name: str) -> CachedToken | None:
85
+ """Retrieve a cached token for a tenant.
86
+
87
+ Args:
88
+ tenant_name: The tenant profile name.
89
+
90
+ Returns:
91
+ CachedToken if found and valid, None if file doesn't exist or
92
+ contains invalid JSON.
93
+
94
+ Note:
95
+ Returns None silently for missing files or invalid JSON.
96
+ This enables graceful fallback to re-authentication.
97
+ """
98
+ token_path = self._get_token_path(tenant_name)
99
+
100
+ if not token_path.exists():
101
+ return None
102
+
103
+ try:
104
+ content = token_path.read_text(encoding="utf-8")
105
+ return CachedToken.model_validate_json(content)
106
+ except (PydanticValidationError, ValueError):
107
+ # Invalid JSON or failed validation - treat as missing
108
+ return None
109
+
110
+ def save_token(self, tenant_name: str, token: CachedToken) -> None:
111
+ """Save a token to the cache.
112
+
113
+ Creates the token directory if it doesn't exist.
114
+
115
+ Args:
116
+ tenant_name: The tenant profile name.
117
+ token: The token to cache.
118
+
119
+ Raises:
120
+ ConfigurationError: If the token directory cannot be created
121
+ due to permission issues.
122
+ """
123
+ self.ensure_token_dir()
124
+
125
+ token_path = self._get_token_path(tenant_name)
126
+ content = token.model_dump_json()
127
+ token_path.write_text(content, encoding="utf-8")
128
+
129
+ def clear_tokens(self, tenant_name: str | None = None) -> None:
130
+ """Clear cached tokens.
131
+
132
+ Args:
133
+ tenant_name: The tenant name to clear tokens for.
134
+ If None, clears all token files.
135
+
136
+ Note:
137
+ Does not raise errors for missing files or directories.
138
+ The token directory is preserved when clearing all tokens.
139
+ """
140
+ if tenant_name is not None:
141
+ # Clear specific tenant's token
142
+ token_path = self._get_token_path(tenant_name)
143
+ token_path.unlink(missing_ok=True)
144
+ else:
145
+ # Clear all token files
146
+ if self._token_dir.exists():
147
+ for token_file in self._token_dir.glob("*.json"):
148
+ token_file.unlink(missing_ok=True)
149
+
150
+ def _read_csv(self, filename: str) -> list[dict[str, str]]:
151
+ """Read a CSV file from the cache directory.
152
+
153
+ Args:
154
+ filename: Name of the CSV file (e.g., "locations.csv").
155
+
156
+ Returns:
157
+ List of dictionaries, one per row. Empty list if file
158
+ doesn't exist or is malformed.
159
+
160
+ Note:
161
+ Uses UTF-8 with BOM support (utf-8-sig encoding) to handle
162
+ PowerShell exports which may include a byte order mark.
163
+ """
164
+ csv_path = self._cache_dir / filename
165
+
166
+ if not csv_path.exists():
167
+ return []
168
+
169
+ try:
170
+ with csv_path.open(encoding="utf-8-sig") as f:
171
+ reader = csv.DictReader(f)
172
+ return list(reader)
173
+ except csv.Error:
174
+ return []
175
+
176
+ def read_locations_csv(self) -> list[dict[str, str]]:
177
+ """Read emergency locations from the cache.
178
+
179
+ Returns:
180
+ List of location dictionaries with keys like locationId,
181
+ description, address, city, etc. Empty list if file doesn't
182
+ exist or is malformed.
183
+ """
184
+ return self._read_csv("locations.csv")
185
+
186
+ def read_policies_csv(self) -> list[dict[str, str]]:
187
+ """Read policies from the cache.
188
+
189
+ Returns:
190
+ List of policy dictionaries with keys like policyType,
191
+ policyName, policyId, description, isGlobal. Empty list if
192
+ file doesn't exist or is malformed.
193
+ """
194
+ return self._read_csv("policies.csv")
195
+
196
+ def get_csv_metadata(self) -> CacheMetadata | None:
197
+ """Get metadata about the CSV cache files.
198
+
199
+ Checks for locations.csv and policies.csv, returning metadata
200
+ about when they were last updated and how many records they contain.
201
+
202
+ Returns:
203
+ CacheMetadata with last_updated (oldest mtime of the two files),
204
+ locations_count, and policies_count. Returns None if neither
205
+ CSV file exists.
206
+ """
207
+ locations_path = self._cache_dir / "locations.csv"
208
+ policies_path = self._cache_dir / "policies.csv"
209
+
210
+ locations_exists = locations_path.exists()
211
+ policies_exists = policies_path.exists()
212
+
213
+ if not locations_exists and not policies_exists:
214
+ return None
215
+
216
+ # Get modification times, handling potential race conditions
217
+ mtimes: list[datetime] = []
218
+
219
+ if locations_exists:
220
+ try:
221
+ mtime = locations_path.stat().st_mtime
222
+ mtimes.append(datetime.fromtimestamp(mtime, tz=timezone.utc))
223
+ except FileNotFoundError:
224
+ pass
225
+
226
+ if policies_exists:
227
+ try:
228
+ mtime = policies_path.stat().st_mtime
229
+ mtimes.append(datetime.fromtimestamp(mtime, tz=timezone.utc))
230
+ except FileNotFoundError:
231
+ pass
232
+
233
+ # Use the OLDER mtime (indicates when cache was last completely updated)
234
+ last_updated = min(mtimes) if mtimes else None
235
+
236
+ # Get row counts using existing methods
237
+ locations_count = len(self.read_locations_csv())
238
+ policies_count = len(self.read_policies_csv())
239
+
240
+ return CacheMetadata(
241
+ last_updated=last_updated,
242
+ locations_count=locations_count,
243
+ policies_count=policies_count,
244
+ )
245
+
246
+ def get_cache_age_days(self) -> int | None:
247
+ """Get the age of the cache in days.
248
+
249
+ Returns:
250
+ Number of days since the oldest CSV file was modified,
251
+ or None if no cache metadata exists or last_updated is None.
252
+ """
253
+ metadata = self.get_csv_metadata()
254
+
255
+ if metadata is None or metadata.last_updated is None:
256
+ return None
257
+
258
+ now = datetime.now(timezone.utc)
259
+ delta = now - metadata.last_updated
260
+ return delta.days
261
+
262
+ def is_cache_stale(self) -> bool:
263
+ """Check if the CSV cache is stale and needs refreshing.
264
+
265
+ Returns:
266
+ True if no cache exists or the cache is older than CSV_STALE_DAYS
267
+ (30 days). False if the cache is fresh.
268
+ """
269
+ age_days = self.get_cache_age_days()
270
+
271
+ if age_days is None:
272
+ return True
273
+
274
+ return age_days > CSV_STALE_DAYS
@@ -0,0 +1,209 @@
1
+ """Configuration manager for reading and writing TOML config files."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else:
10
+ import tomli as tomllib # type: ignore[import-not-found,unused-ignore]
11
+
12
+ import tomlkit
13
+ from pydantic import ValidationError as PydanticValidationError
14
+
15
+ from teams_phone.constants import DEFAULT_CONFIG_PATH
16
+ from teams_phone.exceptions import ConfigurationError, NotFoundError, ValidationError
17
+ from teams_phone.models import Config, TenantProfile
18
+
19
+
20
+ class ConfigManager:
21
+ """Manages Teams Phone CLI configuration file operations.
22
+
23
+ Provides methods to load and save configuration from/to TOML files,
24
+ with validation through Pydantic models and format preservation
25
+ when writing.
26
+
27
+ Attributes:
28
+ config_path: Path to the configuration file.
29
+ """
30
+
31
+ def __init__(self, config_path: str | None = None) -> None:
32
+ """Initialize the ConfigManager.
33
+
34
+ Args:
35
+ config_path: Path to the configuration file. Defaults to
36
+ ~/.teams-phone/config.toml if not specified.
37
+ """
38
+ path_str = config_path if config_path is not None else DEFAULT_CONFIG_PATH
39
+ self.config_path: Path = Path(path_str).expanduser()
40
+
41
+ def ensure_config_dir(self) -> None:
42
+ """Create the configuration directory if it doesn't exist.
43
+
44
+ Creates all parent directories as needed.
45
+
46
+ Raises:
47
+ ConfigurationError: If directory creation fails due to permission error.
48
+ """
49
+ try:
50
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
51
+ except PermissionError as e:
52
+ raise ConfigurationError(
53
+ f"Cannot create configuration directory {self.config_path.parent}: {e}",
54
+ remediation=(
55
+ f"Check that you have write permissions to {self.config_path.parent}.\n"
56
+ "You may need to create the directory manually or run with elevated privileges."
57
+ ),
58
+ ) from e
59
+
60
+ def load_config(self) -> Config:
61
+ """Load and validate configuration from the TOML file.
62
+
63
+ Returns:
64
+ Config: Validated configuration object.
65
+
66
+ Raises:
67
+ ConfigurationError: If the file exists but contains invalid
68
+ TOML syntax or fails Pydantic validation.
69
+
70
+ Note:
71
+ Returns a default Config instance if the file doesn't exist.
72
+ """
73
+ if not self.config_path.exists():
74
+ return Config()
75
+
76
+ try:
77
+ content = self.config_path.read_text(encoding="utf-8")
78
+ data = tomllib.loads(content)
79
+ return Config.model_validate(data)
80
+ except tomllib.TOMLDecodeError as e:
81
+ raise ConfigurationError(
82
+ f"Invalid TOML syntax in {self.config_path}: {e}",
83
+ remediation=(
84
+ f"Check {self.config_path} for syntax errors.\n"
85
+ "Ensure all strings are properly quoted and "
86
+ "section headers use [brackets]."
87
+ ),
88
+ ) from e
89
+ except PydanticValidationError as e:
90
+ raise ConfigurationError(
91
+ f"Invalid configuration in {self.config_path}: {e}",
92
+ remediation=(
93
+ f"Review the configuration values in {self.config_path}.\n"
94
+ "Ensure all required fields are present and have valid types."
95
+ ),
96
+ ) from e
97
+
98
+ def save_config(self, config: Config) -> None:
99
+ """Save configuration to the TOML file.
100
+
101
+ Uses tomlkit to serialize the configuration, which preserves
102
+ formatting when possible. Creates the configuration directory
103
+ if it doesn't exist.
104
+
105
+ Args:
106
+ config: Configuration object to save.
107
+
108
+ Note:
109
+ This method overwrites the existing file. To preserve
110
+ comments in an existing file, load it with tomlkit first,
111
+ update values, and write back.
112
+ """
113
+ self.ensure_config_dir()
114
+
115
+ # Serialize with mode="json" to convert UUID/Enum to strings
116
+ # exclude_none=True because tomlkit doesn't handle None values
117
+ data = config.model_dump(mode="json", exclude_none=True)
118
+ content = tomlkit.dumps(data)
119
+ self.config_path.write_text(content, encoding="utf-8")
120
+
121
+ def get_tenant(self, name: str) -> TenantProfile:
122
+ """Retrieve a tenant profile by name.
123
+
124
+ Args:
125
+ name: The tenant profile name to look up.
126
+
127
+ Returns:
128
+ TenantProfile: The tenant profile configuration.
129
+
130
+ Raises:
131
+ NotFoundError: If no tenant with the given name exists.
132
+ """
133
+ config = self.load_config()
134
+ if name not in config.tenants:
135
+ raise NotFoundError(
136
+ f"Tenant '{name}' not found",
137
+ remediation="Run 'teams-phone tenants list' to see configured tenants.",
138
+ )
139
+ return config.tenants[name]
140
+
141
+ def set_tenant(self, name: str, profile: TenantProfile) -> None:
142
+ """Add or update a tenant profile.
143
+
144
+ Args:
145
+ name: The key name for the tenant profile.
146
+ profile: The tenant profile configuration to save.
147
+
148
+ Raises:
149
+ ValidationError: If profile.name does not match the name parameter.
150
+ """
151
+ if profile.name != name:
152
+ raise ValidationError(
153
+ f"Tenant profile name '{profile.name}' does not match key '{name}'",
154
+ remediation="Ensure the profile name matches the key being set.",
155
+ )
156
+ config = self.load_config()
157
+ config.tenants[name] = profile
158
+ self.save_config(config)
159
+
160
+ def remove_tenant(self, name: str) -> None:
161
+ """Remove a tenant profile by name.
162
+
163
+ Args:
164
+ name: The tenant profile name to remove.
165
+
166
+ Raises:
167
+ NotFoundError: If no tenant with the given name exists.
168
+
169
+ Note:
170
+ If the removed tenant is the default tenant, the default_tenant
171
+ setting is cleared.
172
+ """
173
+ config = self.load_config()
174
+ if name not in config.tenants:
175
+ raise NotFoundError(
176
+ f"Tenant '{name}' not found",
177
+ remediation="Run 'teams-phone tenants list' to see configured tenants.",
178
+ )
179
+ del config.tenants[name]
180
+ if config.default_tenant == name:
181
+ config.default_tenant = None
182
+ self.save_config(config)
183
+
184
+ def get_default_tenant(self) -> str | None:
185
+ """Retrieve the default tenant name.
186
+
187
+ Returns:
188
+ The name of the default tenant, or None if not set.
189
+ """
190
+ config = self.load_config()
191
+ return config.default_tenant
192
+
193
+ def set_default_tenant(self, name: str) -> None:
194
+ """Set the default tenant.
195
+
196
+ Args:
197
+ name: The tenant name to set as default.
198
+
199
+ Raises:
200
+ NotFoundError: If the specified tenant doesn't exist.
201
+ """
202
+ config = self.load_config()
203
+ if name not in config.tenants:
204
+ raise NotFoundError(
205
+ f"Tenant '{name}' not found",
206
+ remediation="Run 'teams-phone tenants list' to see configured tenants.",
207
+ )
208
+ config.default_tenant = name
209
+ self.save_config(config)