nicemail 0.1.0__tar.gz

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 (44) hide show
  1. nicemail-0.1.0/LICENSE +21 -0
  2. nicemail-0.1.0/PKG-INFO +47 -0
  3. nicemail-0.1.0/README.md +24 -0
  4. nicemail-0.1.0/nicemail/__init__.py +3 -0
  5. nicemail-0.1.0/nicemail.egg-info/PKG-INFO +47 -0
  6. nicemail-0.1.0/nicemail.egg-info/SOURCES.txt +42 -0
  7. nicemail-0.1.0/nicemail.egg-info/dependency_links.txt +1 -0
  8. nicemail-0.1.0/nicemail.egg-info/entry_points.txt +2 -0
  9. nicemail-0.1.0/nicemail.egg-info/requires.txt +9 -0
  10. nicemail-0.1.0/nicemail.egg-info/top_level.txt +2 -0
  11. nicemail-0.1.0/pyproject.toml +45 -0
  12. nicemail-0.1.0/send/__init__.py +3 -0
  13. nicemail-0.1.0/send/auth/__init__.py +4 -0
  14. nicemail-0.1.0/send/auth/google_device_code.py +301 -0
  15. nicemail-0.1.0/send/auth/msal_device_code.py +212 -0
  16. nicemail-0.1.0/send/cli.py +172 -0
  17. nicemail-0.1.0/send/client.py +388 -0
  18. nicemail-0.1.0/send/common/config.py +3 -0
  19. nicemail-0.1.0/send/credentials/__init__.py +4 -0
  20. nicemail-0.1.0/send/credentials/models.py +50 -0
  21. nicemail-0.1.0/send/credentials/paths.py +15 -0
  22. nicemail-0.1.0/send/credentials/store.py +295 -0
  23. nicemail-0.1.0/send/logging.py +73 -0
  24. nicemail-0.1.0/send/message/__init__.py +4 -0
  25. nicemail-0.1.0/send/message/builder.py +221 -0
  26. nicemail-0.1.0/send/message/models.py +58 -0
  27. nicemail-0.1.0/send/runtime/__init__.py +3 -0
  28. nicemail-0.1.0/send/runtime/context.py +62 -0
  29. nicemail-0.1.0/send/runtime/env.py +24 -0
  30. nicemail-0.1.0/send/runtime/paths.py +60 -0
  31. nicemail-0.1.0/send/transport/__init__.py +3 -0
  32. nicemail-0.1.0/send/transport/dry_run_transport.py +100 -0
  33. nicemail-0.1.0/send/transport/google_transport.py +123 -0
  34. nicemail-0.1.0/send/transport/ms_graph_transport.py +169 -0
  35. nicemail-0.1.0/send/transport/send.py +30 -0
  36. nicemail-0.1.0/setup.cfg +4 -0
  37. nicemail-0.1.0/tests/test_cli_dry_run.py +35 -0
  38. nicemail-0.1.0/tests/test_client_creation.py +36 -0
  39. nicemail-0.1.0/tests/test_email_client_device_code.py +128 -0
  40. nicemail-0.1.0/tests/test_email_client_message.py +73 -0
  41. nicemail-0.1.0/tests/test_email_client_send.py +155 -0
  42. nicemail-0.1.0/tests/test_google_device_code.py +130 -0
  43. nicemail-0.1.0/tests/test_google_transport.py +75 -0
  44. nicemail-0.1.0/tests/test_message_builder.py +81 -0
nicemail-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rajinder Mavi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: nicemail
3
+ Version: 0.1.0
4
+ Summary: A simple email package
5
+ Author-email: Rajinder Mavi <rajinder@mavi.phd>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/rajindermavi/nicemail
8
+ Project-URL: Issues, https://github.com/rajindermavi/nicemail/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: cryptography>=46.0.3
15
+ Requires-Dist: msal>=1.34.0
16
+ Requires-Dist: platformdirs>=4.5.1
17
+ Requires-Dist: requests>=2.32.5
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=9.0.2; extra == "dev"
20
+ Requires-Dist: build; extra == "dev"
21
+ Requires-Dist: twine; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # Nicemail
25
+
26
+ Nicemail is a small, explicit email-sending library for personal and small-team use.
27
+ It favors clarity and safety over abstraction.
28
+
29
+ ## Quickstart
30
+ ```python
31
+ from nicemail import EmailClient
32
+
33
+ client = EmailClient(backend="dry_run", out_dir="dry_run_out")
34
+ client.send(
35
+ to="you@example.com",
36
+ subject="Hello from Nicemail",
37
+ body_text="This is a dry-run message.",
38
+ from_address="me@example.com",
39
+ )
40
+ ```
41
+
42
+ ## CLI
43
+ ```bash
44
+ nicemail dry-run --to you@example.com --from me@example.com --subject "Hello" --body "Test" --out-dir ./dry_run_out
45
+ nicemail send --backend ms_graph --to you@example.com --subject "Hello" --body "Hello from Nicemail" --email me@example.com --client-id YOUR_CLIENT_ID
46
+ ```
47
+
@@ -0,0 +1,24 @@
1
+ # Nicemail
2
+
3
+ Nicemail is a small, explicit email-sending library for personal and small-team use.
4
+ It favors clarity and safety over abstraction.
5
+
6
+ ## Quickstart
7
+ ```python
8
+ from nicemail import EmailClient
9
+
10
+ client = EmailClient(backend="dry_run", out_dir="dry_run_out")
11
+ client.send(
12
+ to="you@example.com",
13
+ subject="Hello from Nicemail",
14
+ body_text="This is a dry-run message.",
15
+ from_address="me@example.com",
16
+ )
17
+ ```
18
+
19
+ ## CLI
20
+ ```bash
21
+ nicemail dry-run --to you@example.com --from me@example.com --subject "Hello" --body "Test" --out-dir ./dry_run_out
22
+ nicemail send --backend ms_graph --to you@example.com --subject "Hello" --body "Hello from Nicemail" --email me@example.com --client-id YOUR_CLIENT_ID
23
+ ```
24
+
@@ -0,0 +1,3 @@
1
+ from send.client import EmailClient
2
+
3
+ __all__ = ["EmailClient"]
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: nicemail
3
+ Version: 0.1.0
4
+ Summary: A simple email package
5
+ Author-email: Rajinder Mavi <rajinder@mavi.phd>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/rajindermavi/nicemail
8
+ Project-URL: Issues, https://github.com/rajindermavi/nicemail/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: cryptography>=46.0.3
15
+ Requires-Dist: msal>=1.34.0
16
+ Requires-Dist: platformdirs>=4.5.1
17
+ Requires-Dist: requests>=2.32.5
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=9.0.2; extra == "dev"
20
+ Requires-Dist: build; extra == "dev"
21
+ Requires-Dist: twine; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # Nicemail
25
+
26
+ Nicemail is a small, explicit email-sending library for personal and small-team use.
27
+ It favors clarity and safety over abstraction.
28
+
29
+ ## Quickstart
30
+ ```python
31
+ from nicemail import EmailClient
32
+
33
+ client = EmailClient(backend="dry_run", out_dir="dry_run_out")
34
+ client.send(
35
+ to="you@example.com",
36
+ subject="Hello from Nicemail",
37
+ body_text="This is a dry-run message.",
38
+ from_address="me@example.com",
39
+ )
40
+ ```
41
+
42
+ ## CLI
43
+ ```bash
44
+ nicemail dry-run --to you@example.com --from me@example.com --subject "Hello" --body "Test" --out-dir ./dry_run_out
45
+ nicemail send --backend ms_graph --to you@example.com --subject "Hello" --body "Hello from Nicemail" --email me@example.com --client-id YOUR_CLIENT_ID
46
+ ```
47
+
@@ -0,0 +1,42 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ nicemail/__init__.py
5
+ nicemail.egg-info/PKG-INFO
6
+ nicemail.egg-info/SOURCES.txt
7
+ nicemail.egg-info/dependency_links.txt
8
+ nicemail.egg-info/entry_points.txt
9
+ nicemail.egg-info/requires.txt
10
+ nicemail.egg-info/top_level.txt
11
+ send/__init__.py
12
+ send/cli.py
13
+ send/client.py
14
+ send/logging.py
15
+ send/auth/__init__.py
16
+ send/auth/google_device_code.py
17
+ send/auth/msal_device_code.py
18
+ send/common/config.py
19
+ send/credentials/__init__.py
20
+ send/credentials/models.py
21
+ send/credentials/paths.py
22
+ send/credentials/store.py
23
+ send/message/__init__.py
24
+ send/message/builder.py
25
+ send/message/models.py
26
+ send/runtime/__init__.py
27
+ send/runtime/context.py
28
+ send/runtime/env.py
29
+ send/runtime/paths.py
30
+ send/transport/__init__.py
31
+ send/transport/dry_run_transport.py
32
+ send/transport/google_transport.py
33
+ send/transport/ms_graph_transport.py
34
+ send/transport/send.py
35
+ tests/test_cli_dry_run.py
36
+ tests/test_client_creation.py
37
+ tests/test_email_client_device_code.py
38
+ tests/test_email_client_message.py
39
+ tests/test_email_client_send.py
40
+ tests/test_google_device_code.py
41
+ tests/test_google_transport.py
42
+ tests/test_message_builder.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nicemail = send.cli:main
@@ -0,0 +1,9 @@
1
+ cryptography>=46.0.3
2
+ msal>=1.34.0
3
+ platformdirs>=4.5.1
4
+ requests>=2.32.5
5
+
6
+ [dev]
7
+ pytest>=9.0.2
8
+ build
9
+ twine
@@ -0,0 +1,2 @@
1
+ nicemail
2
+ send
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nicemail"
7
+ version = "0.1.0"
8
+ description = "A simple email package"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "MIT" }
12
+
13
+ authors = [
14
+ { name = "Rajinder Mavi", email = "rajinder@mavi.phd" },
15
+ ]
16
+
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: OS Independent",
20
+ ]
21
+
22
+ dependencies = [
23
+ "cryptography>=46.0.3",
24
+ "msal>=1.34.0",
25
+ "platformdirs>=4.5.1",
26
+ "requests>=2.32.5",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=9.0.2",
32
+ "build",
33
+ "twine",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/rajindermavi/nicemail"
38
+ Issues = "https://github.com/rajindermavi/nicemail/issues"
39
+
40
+ [project.scripts]
41
+ nicemail = "send.cli:main"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["."]
45
+ include = ["send*", "nicemail*"]
@@ -0,0 +1,3 @@
1
+ from .client import EmailClient
2
+
3
+ __all__ = ["EmailClient"]
@@ -0,0 +1,4 @@
1
+ from .msal_device_code import MSalDeviceCodeTokenProvider
2
+ from .google_device_code import GoogleDeviceCodeTokenProvider
3
+
4
+ __all__ = ["MSalDeviceCodeTokenProvider", "GoogleDeviceCodeTokenProvider"]
@@ -0,0 +1,301 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any, Callable
6
+
7
+ import requests
8
+
9
+ from send.credentials.store import SecureConfig
10
+
11
+ DEVICE_CODE_URL = "https://oauth2.googleapis.com/device/code"
12
+ TOKEN_URL = "https://oauth2.googleapis.com/token"
13
+ DEFAULT_SCOPES = ["https://www.googleapis.com/auth/gmail.send"]
14
+
15
+
16
+ class GoogleDeviceCodeTokenProvider:
17
+ """
18
+ Handles access token acquisition for Google APIs using the device authorization flow.
19
+ """
20
+
21
+ TOKEN_CACHE_KEY = "google_token_cache"
22
+ GOOGLE_CONFIG_KEY = "google_api_config"
23
+ CLIENT_ID_KEY = "google_client_id"
24
+ CLIENT_SECRET_KEY = "google_client_secret"
25
+ EMAIL_KEY = "google_email_address"
26
+
27
+ def __init__(
28
+ self,
29
+ secure_config: SecureConfig | None = None,
30
+ *,
31
+ client_id: str | None = None,
32
+ client_secret: str | None = None,
33
+ scopes: list[str] | None = None,
34
+ show_message: Callable[[object], None] | None = None,
35
+ ) -> None:
36
+ self.secure_config = secure_config
37
+ self._show_message = show_message
38
+ self.client_id = client_id
39
+ self.client_secret = client_secret
40
+ self.scopes = scopes
41
+
42
+ self._config_snapshot = self._load_config()
43
+ self.client_id = self.client_id or self._extract_client_id(self._config_snapshot)
44
+ self.client_secret = self.client_secret or self._extract_client_secret(self._config_snapshot)
45
+ self.scopes = (
46
+ self._normalize_scopes(self.scopes)
47
+ or self._extract_scopes(self._config_snapshot)
48
+ or DEFAULT_SCOPES
49
+ )
50
+
51
+ if not self.client_id:
52
+ raise ValueError(
53
+ "client_id is required for Google device code flow. "
54
+ "Provide it directly or store it in SecureConfig under 'google_client_id' or 'google_api_config.client_id'."
55
+ )
56
+
57
+ self._token = self._load_token(self._config_snapshot)
58
+
59
+ def acquire_token(self, interactive: bool = True, scopes: list[str] | None = None) -> str:
60
+ """
61
+ Get a valid Google API access token.
62
+
63
+ 1. Use cached token if still valid.
64
+ 2. Refresh using refresh_token if available.
65
+ 3. If allowed, run device code flow to obtain a new token.
66
+ """
67
+ resolved_scopes = self._normalize_scopes(scopes) or self.scopes or DEFAULT_SCOPES
68
+ self.scopes = resolved_scopes
69
+
70
+ if self._is_token_valid(self._token):
71
+ return self._token["access_token"] # type: ignore[index]
72
+
73
+ refreshed = None
74
+ if self._token and self._token.get("refresh_token"):
75
+ refreshed = self._refresh_token(self._token["refresh_token"], resolved_scopes)
76
+ if refreshed:
77
+ return refreshed
78
+
79
+ if not interactive:
80
+ raise RuntimeError("Could not obtain Google access token and interactive auth is disabled.")
81
+
82
+ flow = self._initiate_device_flow(resolved_scopes)
83
+ token_data = self._poll_for_token(flow, resolved_scopes)
84
+ self._token = token_data
85
+ self._persist_token(token_data)
86
+ return token_data["access_token"]
87
+
88
+ def _load_config(self) -> dict[str, Any]:
89
+ if not self.secure_config:
90
+ return {}
91
+ return self.secure_config.load() or {}
92
+
93
+ def _extract_client_id(self, data: dict[str, Any]) -> str | None:
94
+ if not data:
95
+ return None
96
+ flat_value = data.get(self.CLIENT_ID_KEY)
97
+ if flat_value:
98
+ return str(flat_value)
99
+ google_config = data.get(self.GOOGLE_CONFIG_KEY)
100
+ if isinstance(google_config, dict):
101
+ nested_value = google_config.get("client_id")
102
+ if nested_value:
103
+ return str(nested_value)
104
+ return None
105
+
106
+ def _extract_client_secret(self, data: dict[str, Any]) -> str | None:
107
+ if not data:
108
+ return None
109
+ flat_value = data.get(self.CLIENT_SECRET_KEY)
110
+ if flat_value:
111
+ return str(flat_value)
112
+ google_config = data.get(self.GOOGLE_CONFIG_KEY)
113
+ if isinstance(google_config, dict):
114
+ nested_value = google_config.get("client_secret")
115
+ if nested_value:
116
+ return str(nested_value)
117
+ return None
118
+
119
+ def _extract_scopes(self, data: dict[str, Any]) -> list[str] | None:
120
+ if not data:
121
+ return None
122
+ google_config = data.get(self.GOOGLE_CONFIG_KEY)
123
+ if isinstance(google_config, dict):
124
+ return self._normalize_scopes(google_config.get("scopes"))
125
+ return None
126
+
127
+ def _load_token(self, data: dict[str, Any]) -> dict[str, Any] | None:
128
+ token_data = data.get(self.TOKEN_CACHE_KEY)
129
+ if isinstance(token_data, dict):
130
+ return token_data
131
+
132
+ google_config = data.get(self.GOOGLE_CONFIG_KEY)
133
+ if isinstance(google_config, dict):
134
+ token_value = google_config.get("token_value")
135
+ token_timestamp = google_config.get("token_timestamp")
136
+ if token_value and token_timestamp:
137
+ return {
138
+ "access_token": token_value,
139
+ "expires_at": token_timestamp,
140
+ }
141
+ return None
142
+
143
+ def _is_token_valid(self, token_data: dict[str, Any] | None) -> bool:
144
+ if not token_data or "access_token" not in token_data:
145
+ return False
146
+ expires_at = self._parse_datetime(token_data.get("expires_at"))
147
+ if not expires_at:
148
+ return False
149
+ return datetime.now(timezone.utc) < expires_at - timedelta(minutes=1)
150
+
151
+ def _initiate_device_flow(self, scopes: list[str]) -> dict[str, Any]:
152
+ payload = {"client_id": self.client_id, "scope": " ".join(scopes)}
153
+ response = requests.post(DEVICE_CODE_URL, data=payload, timeout=10)
154
+ if response.status_code != 200:
155
+ raise RuntimeError(f"Failed to initiate Google device flow: {response.text}")
156
+
157
+ flow = self._safe_json(response)
158
+ if "device_code" not in flow:
159
+ raise RuntimeError(f"Invalid device flow response: {flow!r}")
160
+
161
+ self._display_message(flow)
162
+ return flow
163
+
164
+ def _poll_for_token(self, flow: dict[str, Any], scopes: list[str]) -> dict[str, Any]:
165
+ interval = max(int(flow.get("interval", 5)), 1)
166
+ expires_in = int(flow.get("expires_in", 600))
167
+ deadline = time.monotonic() + expires_in
168
+
169
+ payload: dict[str, Any] = {
170
+ "client_id": self.client_id,
171
+ "device_code": flow["device_code"],
172
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
173
+ }
174
+ if self.client_secret:
175
+ payload["client_secret"] = self.client_secret
176
+
177
+ while time.monotonic() < deadline:
178
+ response = requests.post(TOKEN_URL, data=payload, timeout=10)
179
+ data = self._safe_json(response)
180
+
181
+ if response.status_code == 200 and "access_token" in data:
182
+ return self._finalize_token_payload(data, scopes)
183
+
184
+ error = data.get("error")
185
+ if error == "authorization_pending":
186
+ time.sleep(interval)
187
+ continue
188
+ if error == "slow_down":
189
+ interval += 5
190
+ time.sleep(interval)
191
+ continue
192
+ if error == "access_denied":
193
+ raise RuntimeError("User denied the Google device authorization request.")
194
+ if error == "expired_token":
195
+ break
196
+ if response.status_code >= 400:
197
+ raise RuntimeError(f"Google token endpoint returned error: {data}")
198
+
199
+ raise RuntimeError("Google device authorization timed out before completion.")
200
+
201
+ def _refresh_token(self, refresh_token: str, scopes: list[str]) -> str | None:
202
+ payload: dict[str, Any] = {
203
+ "client_id": self.client_id,
204
+ "refresh_token": refresh_token,
205
+ "grant_type": "refresh_token",
206
+ }
207
+ if self.client_secret:
208
+ payload["client_secret"] = self.client_secret
209
+
210
+ response = requests.post(TOKEN_URL, data=payload, timeout=10)
211
+ if response.status_code != 200:
212
+ return None
213
+
214
+ data = self._safe_json(response)
215
+ if "access_token" not in data:
216
+ return None
217
+
218
+ if "refresh_token" not in data:
219
+ data["refresh_token"] = refresh_token
220
+
221
+ finalized = self._finalize_token_payload(data, scopes)
222
+ self._token = finalized
223
+ self._persist_token(finalized)
224
+ return finalized["access_token"]
225
+
226
+ def _finalize_token_payload(self, payload: dict[str, Any], scopes: list[str]) -> dict[str, Any]:
227
+ expires_in = int(payload.get("expires_in", 0))
228
+ if expires_in:
229
+ payload["expires_at"] = (datetime.now(timezone.utc) + timedelta(seconds=expires_in)).isoformat()
230
+ payload["scopes"] = scopes
231
+ return payload
232
+
233
+ def _persist_token(self, token_data: dict[str, Any]) -> None:
234
+ if not self.secure_config:
235
+ return
236
+
237
+ data = self.secure_config.load() or {}
238
+ data[self.TOKEN_CACHE_KEY] = token_data
239
+
240
+ if self.client_id:
241
+ data[self.CLIENT_ID_KEY] = self.client_id
242
+ if self.client_secret:
243
+ data[self.CLIENT_SECRET_KEY] = self.client_secret
244
+
245
+ google_config = data.get(self.GOOGLE_CONFIG_KEY)
246
+ if isinstance(google_config, dict):
247
+ google_config["token_value"] = token_data.get("access_token")
248
+ google_config["token_timestamp"] = token_data.get("expires_at")
249
+ google_config.setdefault("client_id", self.client_id)
250
+ data[self.GOOGLE_CONFIG_KEY] = google_config
251
+
252
+ self.secure_config.save(data)
253
+
254
+ def _display_message(self, flow: dict[str, Any]) -> None:
255
+ if callable(self._show_message):
256
+ self._show_message(flow)
257
+ return
258
+
259
+ verification_url = (
260
+ flow.get("verification_uri_complete")
261
+ or flow.get("verification_url")
262
+ or flow.get("verification_uri")
263
+ )
264
+ user_code = flow.get("user_code")
265
+ if verification_url and user_code:
266
+ text = f"Visit {verification_url} and enter code: {user_code}"
267
+ elif verification_url:
268
+ text = f"Visit {verification_url} to authorize this device."
269
+ else:
270
+ text = str(flow)
271
+
272
+ print(text, flush=True)
273
+
274
+ def _normalize_scopes(self, scopes: Any) -> list[str] | None:
275
+ if scopes is None:
276
+ return None
277
+ if isinstance(scopes, str):
278
+ scopes = [scope.strip() for scope in scopes.split(" ") if scope.strip()]
279
+ return [str(scope) for scope in scopes if scope]
280
+
281
+ def _parse_datetime(self, value: Any) -> datetime | None:
282
+ if value is None:
283
+ return None
284
+ if isinstance(value, datetime):
285
+ return value
286
+ if isinstance(value, str):
287
+ try:
288
+ parsed = datetime.fromisoformat(value)
289
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
290
+ except ValueError:
291
+ return None
292
+ return None
293
+
294
+ def _safe_json(self, response: requests.Response) -> dict[str, Any]:
295
+ try:
296
+ data = response.json()
297
+ except Exception:
298
+ data = {}
299
+ if not isinstance(data, dict):
300
+ return {}
301
+ return data