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
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Debug logger for JSON Lines diagnostic output.
|
|
2
|
+
|
|
3
|
+
This module provides the DebugLogger class that writes structured debug logs
|
|
4
|
+
in JSON Lines format to a specified file. It includes secret masking to ensure
|
|
5
|
+
sensitive information never appears in logs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Patterns for secret masking
|
|
20
|
+
_BEARER_TOKEN_PATTERN = re.compile(r"Bearer [A-Za-z0-9._-]+")
|
|
21
|
+
_CLIENT_SECRET_PATTERN = re.compile(
|
|
22
|
+
r"(client[_-]?secret[\"']?\s*[:=]\s*[\"']?)[A-Za-z0-9._~+/-]+"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def mask_secrets(value: str) -> str:
|
|
27
|
+
"""Mask secrets in a string value.
|
|
28
|
+
|
|
29
|
+
Masks:
|
|
30
|
+
- Bearer tokens in Authorization headers
|
|
31
|
+
- Client secrets in configuration strings
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
value: The string value that may contain secrets.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The value with secrets replaced by [REDACTED].
|
|
38
|
+
"""
|
|
39
|
+
result = _BEARER_TOKEN_PATTERN.sub("Bearer [REDACTED]", value)
|
|
40
|
+
result = _CLIENT_SECRET_PATTERN.sub(r"\1[REDACTED]", result)
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mask_upn(upn: str) -> str:
|
|
45
|
+
"""Mask a User Principal Name (UPN) for logging.
|
|
46
|
+
|
|
47
|
+
Truncates UPN to show only first character of local part and domain.
|
|
48
|
+
Example: john.doe@contoso.com -> j***@contoso.com
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
upn: The User Principal Name to mask.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The masked UPN.
|
|
55
|
+
"""
|
|
56
|
+
if "@" not in upn:
|
|
57
|
+
# Not a UPN format, just mask most of it
|
|
58
|
+
if len(upn) > 1:
|
|
59
|
+
return upn[0] + "***"
|
|
60
|
+
return "***"
|
|
61
|
+
|
|
62
|
+
local_part, domain = upn.rsplit("@", 1)
|
|
63
|
+
if local_part:
|
|
64
|
+
return f"{local_part[0]}***@{domain}"
|
|
65
|
+
return f"***@{domain}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def mask_headers(headers: dict[str, str]) -> dict[str, str]:
|
|
69
|
+
"""Mask sensitive values in HTTP headers.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
headers: Dictionary of HTTP headers.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Copy of headers with sensitive values masked.
|
|
76
|
+
"""
|
|
77
|
+
masked = {}
|
|
78
|
+
for key, value in headers.items():
|
|
79
|
+
if key.lower() == "authorization":
|
|
80
|
+
masked[key] = mask_secrets(value)
|
|
81
|
+
else:
|
|
82
|
+
masked[key] = value
|
|
83
|
+
return masked
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DebugLogger:
|
|
87
|
+
"""JSON Lines logger for debug output with secret masking.
|
|
88
|
+
|
|
89
|
+
Writes structured debug logs to a file in JSON Lines format.
|
|
90
|
+
Each log entry is a self-contained JSON object on a single line.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
log_path: Path to the debug log file.
|
|
94
|
+
correlation_id: UUID linking all log entries for this session.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, log_path: Path) -> None:
|
|
98
|
+
"""Initialize the DebugLogger.
|
|
99
|
+
|
|
100
|
+
Creates a new correlation ID for this logging session.
|
|
101
|
+
Opens the log file in append mode with UTF-8 encoding.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
log_path: Path to the debug log file.
|
|
105
|
+
"""
|
|
106
|
+
self.log_path = log_path
|
|
107
|
+
self.correlation_id = str(uuid.uuid4())[:8]
|
|
108
|
+
# Ensure parent directory exists
|
|
109
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
def _write_entry(self, entry: dict[str, Any]) -> None:
|
|
112
|
+
"""Write a log entry to the file.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
entry: The log entry dictionary to write.
|
|
116
|
+
"""
|
|
117
|
+
with open(self.log_path, "a", encoding="utf-8") as f:
|
|
118
|
+
f.write(json.dumps(entry, default=str) + "\n")
|
|
119
|
+
|
|
120
|
+
def _create_entry(
|
|
121
|
+
self,
|
|
122
|
+
level: str,
|
|
123
|
+
event: str,
|
|
124
|
+
**kwargs: Any,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
"""Create a base log entry with common fields.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR).
|
|
130
|
+
event: Event type (command_start, graph_request, etc.).
|
|
131
|
+
**kwargs: Additional fields for the log entry.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Dictionary with timestamp, correlation_id, level, event, and kwargs.
|
|
135
|
+
"""
|
|
136
|
+
entry: dict[str, Any] = {
|
|
137
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
|
138
|
+
"correlation_id": self.correlation_id,
|
|
139
|
+
"level": level,
|
|
140
|
+
"event": event,
|
|
141
|
+
}
|
|
142
|
+
entry.update(kwargs)
|
|
143
|
+
return entry
|
|
144
|
+
|
|
145
|
+
def log_command_start(
|
|
146
|
+
self,
|
|
147
|
+
command: str,
|
|
148
|
+
*,
|
|
149
|
+
tenant: str | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Log the start of a CLI command.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
command: The command being executed (e.g., "users list").
|
|
155
|
+
tenant: The tenant name if specified.
|
|
156
|
+
"""
|
|
157
|
+
entry = self._create_entry("INFO", "command_start", command=command)
|
|
158
|
+
if tenant:
|
|
159
|
+
entry["tenant"] = tenant
|
|
160
|
+
self._write_entry(entry)
|
|
161
|
+
|
|
162
|
+
def log_command_complete(
|
|
163
|
+
self,
|
|
164
|
+
exit_code: int,
|
|
165
|
+
duration_ms: float,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Log the completion of a CLI command.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
exit_code: The command's exit code.
|
|
171
|
+
duration_ms: Command duration in milliseconds.
|
|
172
|
+
"""
|
|
173
|
+
entry = self._create_entry(
|
|
174
|
+
"INFO",
|
|
175
|
+
"command_complete",
|
|
176
|
+
exit_code=exit_code,
|
|
177
|
+
duration_ms=round(duration_ms, 1),
|
|
178
|
+
)
|
|
179
|
+
self._write_entry(entry)
|
|
180
|
+
|
|
181
|
+
def log_graph_request(
|
|
182
|
+
self,
|
|
183
|
+
method: str,
|
|
184
|
+
url: str,
|
|
185
|
+
*,
|
|
186
|
+
body_size: int | None = None,
|
|
187
|
+
) -> float:
|
|
188
|
+
"""Log a Graph API request being made.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
method: HTTP method (GET, POST, etc.).
|
|
192
|
+
url: The request URL (will have query params sanitized).
|
|
193
|
+
body_size: Size of request body in bytes, if applicable.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Start time from time.perf_counter() for duration calculation.
|
|
197
|
+
"""
|
|
198
|
+
# Sanitize URL by removing any potential secrets in query params
|
|
199
|
+
sanitized_url = mask_secrets(url)
|
|
200
|
+
entry = self._create_entry(
|
|
201
|
+
"DEBUG",
|
|
202
|
+
"graph_request",
|
|
203
|
+
method=method,
|
|
204
|
+
url=sanitized_url,
|
|
205
|
+
)
|
|
206
|
+
if body_size is not None:
|
|
207
|
+
entry["body_size"] = body_size
|
|
208
|
+
self._write_entry(entry)
|
|
209
|
+
return time.perf_counter()
|
|
210
|
+
|
|
211
|
+
def log_graph_response(
|
|
212
|
+
self,
|
|
213
|
+
status_code: int,
|
|
214
|
+
start_time: float,
|
|
215
|
+
*,
|
|
216
|
+
items_count: int | None = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Log a Graph API response received.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
status_code: HTTP status code.
|
|
222
|
+
start_time: The start time from log_graph_request().
|
|
223
|
+
items_count: Number of items in response, if applicable.
|
|
224
|
+
"""
|
|
225
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
226
|
+
entry = self._create_entry(
|
|
227
|
+
"DEBUG",
|
|
228
|
+
"graph_response",
|
|
229
|
+
status=status_code,
|
|
230
|
+
duration_ms=round(duration_ms, 1),
|
|
231
|
+
)
|
|
232
|
+
if items_count is not None:
|
|
233
|
+
entry["items_count"] = items_count
|
|
234
|
+
self._write_entry(entry)
|
|
235
|
+
|
|
236
|
+
def log_retry(
|
|
237
|
+
self,
|
|
238
|
+
attempt: int,
|
|
239
|
+
max_attempts: int,
|
|
240
|
+
status_code: int,
|
|
241
|
+
wait_seconds: float,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Log a retry attempt.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
attempt: Current attempt number.
|
|
247
|
+
max_attempts: Maximum number of attempts.
|
|
248
|
+
status_code: HTTP status code that triggered retry.
|
|
249
|
+
wait_seconds: Seconds to wait before retry.
|
|
250
|
+
"""
|
|
251
|
+
entry = self._create_entry(
|
|
252
|
+
"WARNING",
|
|
253
|
+
"retry",
|
|
254
|
+
attempt=attempt,
|
|
255
|
+
max_attempts=max_attempts,
|
|
256
|
+
status=status_code,
|
|
257
|
+
wait_seconds=round(wait_seconds, 1),
|
|
258
|
+
)
|
|
259
|
+
self._write_entry(entry)
|
|
260
|
+
|
|
261
|
+
def log_cache_hit(self, cache_key: str) -> None:
|
|
262
|
+
"""Log a cache hit.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
cache_key: The cache key that was hit.
|
|
266
|
+
"""
|
|
267
|
+
entry = self._create_entry("DEBUG", "cache_hit", cache_key=cache_key)
|
|
268
|
+
self._write_entry(entry)
|
|
269
|
+
|
|
270
|
+
def log_cache_miss(self, cache_key: str) -> None:
|
|
271
|
+
"""Log a cache miss.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
cache_key: The cache key that was missed.
|
|
275
|
+
"""
|
|
276
|
+
entry = self._create_entry("DEBUG", "cache_miss", cache_key=cache_key)
|
|
277
|
+
self._write_entry(entry)
|
|
278
|
+
|
|
279
|
+
def log_error(
|
|
280
|
+
self,
|
|
281
|
+
message: str,
|
|
282
|
+
*,
|
|
283
|
+
error_type: str | None = None,
|
|
284
|
+
details: str | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Log an error.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
message: Error message (will be sanitized for secrets).
|
|
290
|
+
error_type: Type/class of the error.
|
|
291
|
+
details: Additional error details (will be sanitized).
|
|
292
|
+
"""
|
|
293
|
+
entry = self._create_entry(
|
|
294
|
+
"ERROR",
|
|
295
|
+
"error",
|
|
296
|
+
message=mask_secrets(message),
|
|
297
|
+
)
|
|
298
|
+
if error_type:
|
|
299
|
+
entry["error_type"] = error_type
|
|
300
|
+
if details:
|
|
301
|
+
entry["details"] = mask_secrets(details)
|
|
302
|
+
self._write_entry(entry)
|
|
303
|
+
|
|
304
|
+
def log_debug(self, event: str, **kwargs: Any) -> None:
|
|
305
|
+
"""Log a generic debug event.
|
|
306
|
+
|
|
307
|
+
Values in kwargs will be converted to strings and have secrets masked.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
event: Event name for the log entry.
|
|
311
|
+
**kwargs: Additional fields for the log entry.
|
|
312
|
+
"""
|
|
313
|
+
# Mask any string values in kwargs
|
|
314
|
+
masked_kwargs = {}
|
|
315
|
+
for key, value in kwargs.items():
|
|
316
|
+
if isinstance(value, str):
|
|
317
|
+
masked_kwargs[key] = mask_secrets(value)
|
|
318
|
+
else:
|
|
319
|
+
masked_kwargs[key] = value
|
|
320
|
+
entry = self._create_entry("DEBUG", event, **masked_kwargs)
|
|
321
|
+
self._write_entry(entry)
|