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.
- nicemail-0.1.0/LICENSE +21 -0
- nicemail-0.1.0/PKG-INFO +47 -0
- nicemail-0.1.0/README.md +24 -0
- nicemail-0.1.0/nicemail/__init__.py +3 -0
- nicemail-0.1.0/nicemail.egg-info/PKG-INFO +47 -0
- nicemail-0.1.0/nicemail.egg-info/SOURCES.txt +42 -0
- nicemail-0.1.0/nicemail.egg-info/dependency_links.txt +1 -0
- nicemail-0.1.0/nicemail.egg-info/entry_points.txt +2 -0
- nicemail-0.1.0/nicemail.egg-info/requires.txt +9 -0
- nicemail-0.1.0/nicemail.egg-info/top_level.txt +2 -0
- nicemail-0.1.0/pyproject.toml +45 -0
- nicemail-0.1.0/send/__init__.py +3 -0
- nicemail-0.1.0/send/auth/__init__.py +4 -0
- nicemail-0.1.0/send/auth/google_device_code.py +301 -0
- nicemail-0.1.0/send/auth/msal_device_code.py +212 -0
- nicemail-0.1.0/send/cli.py +172 -0
- nicemail-0.1.0/send/client.py +388 -0
- nicemail-0.1.0/send/common/config.py +3 -0
- nicemail-0.1.0/send/credentials/__init__.py +4 -0
- nicemail-0.1.0/send/credentials/models.py +50 -0
- nicemail-0.1.0/send/credentials/paths.py +15 -0
- nicemail-0.1.0/send/credentials/store.py +295 -0
- nicemail-0.1.0/send/logging.py +73 -0
- nicemail-0.1.0/send/message/__init__.py +4 -0
- nicemail-0.1.0/send/message/builder.py +221 -0
- nicemail-0.1.0/send/message/models.py +58 -0
- nicemail-0.1.0/send/runtime/__init__.py +3 -0
- nicemail-0.1.0/send/runtime/context.py +62 -0
- nicemail-0.1.0/send/runtime/env.py +24 -0
- nicemail-0.1.0/send/runtime/paths.py +60 -0
- nicemail-0.1.0/send/transport/__init__.py +3 -0
- nicemail-0.1.0/send/transport/dry_run_transport.py +100 -0
- nicemail-0.1.0/send/transport/google_transport.py +123 -0
- nicemail-0.1.0/send/transport/ms_graph_transport.py +169 -0
- nicemail-0.1.0/send/transport/send.py +30 -0
- nicemail-0.1.0/setup.cfg +4 -0
- nicemail-0.1.0/tests/test_cli_dry_run.py +35 -0
- nicemail-0.1.0/tests/test_client_creation.py +36 -0
- nicemail-0.1.0/tests/test_email_client_device_code.py +128 -0
- nicemail-0.1.0/tests/test_email_client_message.py +73 -0
- nicemail-0.1.0/tests/test_email_client_send.py +155 -0
- nicemail-0.1.0/tests/test_google_device_code.py +130 -0
- nicemail-0.1.0/tests/test_google_transport.py +75 -0
- 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.
|
nicemail-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
|
nicemail-0.1.0/README.md
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|