godfather-cli 2.0.1__tar.gz → 2.2.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 (28) hide show
  1. {godfather_cli-2.0.1/godfather_cli.egg-info → godfather_cli-2.2.0}/PKG-INFO +35 -17
  2. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/README.md +33 -16
  3. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli/__init__.py +1 -1
  4. godfather_cli-2.2.0/godfather_cli/auth.py +278 -0
  5. godfather_cli-2.2.0/godfather_cli/cli.py +436 -0
  6. godfather_cli-2.2.0/godfather_cli/login_server.py +72 -0
  7. godfather_cli-2.2.0/godfather_cli/picker.py +160 -0
  8. godfather_cli-2.2.0/godfather_cli/pod_manager.py +322 -0
  9. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli/ssh_connector.py +18 -10
  10. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli/update_checker.py +18 -20
  11. {godfather_cli-2.0.1 → godfather_cli-2.2.0/godfather_cli.egg-info}/PKG-INFO +35 -17
  12. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/SOURCES.txt +5 -1
  13. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/requires.txt +1 -0
  14. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/pyproject.toml +15 -2
  15. godfather_cli-2.2.0/tests/test_cli.py +262 -0
  16. godfather_cli-2.2.0/tests/test_login.py +151 -0
  17. godfather_cli-2.2.0/tests/test_sessions.py +109 -0
  18. godfather_cli-2.0.1/godfather_cli/auth.py +0 -150
  19. godfather_cli-2.0.1/godfather_cli/cli.py +0 -248
  20. godfather_cli-2.0.1/godfather_cli/pod_manager.py +0 -149
  21. godfather_cli-2.0.1/tests/test_cli.py +0 -92
  22. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/LICENSE +0 -0
  23. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/MANIFEST.in +0 -0
  24. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli/ui.py +1 -1
  25. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/dependency_links.txt +0 -0
  26. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/entry_points.txt +0 -0
  27. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/top_level.txt +0 -0
  28. {godfather_cli-2.0.1 → godfather_cli-2.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godfather-cli
3
- Version: 2.0.1
3
+ Version: 2.2.0
4
4
  Summary: Free compute for ASU students: connect to AI Society at ASU GPU pods
5
5
  Author-email: AI Society ASU <theaisocietyasu@gmail.com>
6
6
  License: MIT
@@ -24,6 +24,7 @@ License-File: LICENSE
24
24
  Requires-Dist: requests>=2.25.0
25
25
  Requires-Dist: rich>=13.0.0
26
26
  Requires-Dist: packaging>=20.0
27
+ Requires-Dist: questionary>=2.0.0
27
28
  Dynamic: license-file
28
29
 
29
30
  # Godfather CLI
@@ -61,10 +62,13 @@ pip install -e .
61
62
 
62
63
  1. Run `godfather` with no arguments. On first run you won't be logged in
63
64
  yet, so it'll walk you into the login flow.
64
- 2. It opens the AI Society platform's sign-in page in your browser (and
65
- prints the link). Sign in with Discord and copy the token shown there.
66
- 3. Paste the token back into the terminal. The CLI checks it with the
67
- platform and stores it in `~/.godfather/config.json`.
65
+ 2. It opens the sign-in page in your browser and prints the link. Sign in
66
+ with Discord. The browser sends the login back to the CLI, which checks it
67
+ and stores the token in `~/.godfather/config.json`.
68
+ 3. If the browser cannot reach the CLI (no browser on this computer, an
69
+ older server, or no sign-in within 5 minutes), the CLI asks you to paste
70
+ the token that the sign-in page shows. `godfather auth --paste` goes
71
+ straight to that, for a browser on another computer.
68
72
  4. From the menu (or `godfather connect`), pick a pod. The CLI creates an SSH
69
73
  key on your machine the first time, asks the server for a 12-hour
70
74
  certificate for that pod, and opens the connection.
@@ -79,8 +83,18 @@ Windows 10+ ship it.
79
83
 
80
84
  ### Interactive menu
81
85
 
82
- Running `godfather` with no arguments opens a menu to list pods, connect,
83
- check status, or log out.
86
+ Running `godfather` with no arguments opens a menu. Use the arrow keys to
87
+ choose Connect to a pod, List pods, Sessions, Status, Log out or Exit, and
88
+ press Enter. Ctrl-C quits.
89
+
90
+ Connect to a pod shows a list of your pods with their status, access and
91
+ pod ID. A pod that is not running shows with the reason and cannot be
92
+ chosen. If only one pod is running, the CLI asks for a yes instead.
93
+ If none of your pods is running, the CLI shows the next session on your
94
+ pods. A pod starts 10 minutes before its session.
95
+
96
+ When input is not a terminal (for example in a script), the menu and the pod
97
+ list use numbered prompts.
84
98
 
85
99
  ### Commands
86
100
 
@@ -88,8 +102,11 @@ check status, or log out.
88
102
  godfather list # List pods you can connect to
89
103
  godfather connect # Connect to a pod, picking from a list
90
104
  godfather connect <pod-id> # Connect to a specific pod
91
- godfather status # Show login and configuration status
92
- godfather auth # Log in, or refresh an expired session
105
+ godfather connect --admin # Ask for a root shell (officers, when pod admin is on)
106
+ godfather sessions # Upcoming sessions on your pods, in local time
107
+ godfather status # Show server, org, login, token expiry and version
108
+ godfather auth # Log in with Discord in your browser
109
+ godfather auth --paste # Log in by pasting the token
93
110
  godfather logout # Clear the stored session
94
111
  godfather update # Update the CLI to the latest version
95
112
 
@@ -118,7 +135,8 @@ another server or org; the ones you last logged in with are remembered.
118
135
  - **"Couldn't reach \<url\>"** — check your internet connection and that the
119
136
  API URL is correct (`godfather status` shows what's currently configured).
120
137
  - **"Your token is invalid or expired"** — run `godfather auth` and sign in
121
- again.
138
+ again. In the menu, the CLI offers to do this for you.
139
+ - **"Pod is not running"** — the pod stopped. Ask an officer to start it.
122
140
  - **"You are no longer a member of this organization"** — rejoin the club's
123
141
  Discord server, then try again.
124
142
  - **"SSH could not log in to the pod"** — the pod does not run the
@@ -130,24 +148,24 @@ another server or org; the ones you last logged in with are remembered.
130
148
 
131
149
  ## Development
132
150
 
133
- The code is in `godfather_cli/`: `cli.py` (commands and menu), `auth.py`
134
- (platform sign-in), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
151
+ The code is in `godfather_cli/`: `cli.py` (commands and menu), `picker.py` (arrow-key menu and pod picker), `auth.py`
152
+ (platform sign-in), `login_server.py` (the sign-in callback), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
135
153
  certificate, running ssh), `update_checker.py` (PyPI version check), `ui.py`
136
154
  (shared console styling). Tests are in `tests/`:
137
155
 
138
156
  ```bash
139
- pip install -e . pytest
140
- pytest -q tests
157
+ uv sync --group dev
158
+ uv run pytest -q tests
141
159
  ```
142
160
 
161
+ `make check-cli` at the repo root runs ruff, the tests and a build, as CI does.
162
+
143
163
  Releases go to PyPI when a version bump is merged to main; see the
144
164
  repo README.
145
165
 
146
166
  ## Contributing
147
167
 
148
- 1. Fork the repository.
149
- 2. Create a feature branch (`git checkout -b feature/your-feature`).
150
- 3. Commit your changes and open a Pull Request.
168
+ See [CONTRIBUTING.md](../CONTRIBUTING.md).
151
169
 
152
170
  ## License
153
171
 
@@ -33,10 +33,13 @@ pip install -e .
33
33
 
34
34
  1. Run `godfather` with no arguments. On first run you won't be logged in
35
35
  yet, so it'll walk you into the login flow.
36
- 2. It opens the AI Society platform's sign-in page in your browser (and
37
- prints the link). Sign in with Discord and copy the token shown there.
38
- 3. Paste the token back into the terminal. The CLI checks it with the
39
- platform and stores it in `~/.godfather/config.json`.
36
+ 2. It opens the sign-in page in your browser and prints the link. Sign in
37
+ with Discord. The browser sends the login back to the CLI, which checks it
38
+ and stores the token in `~/.godfather/config.json`.
39
+ 3. If the browser cannot reach the CLI (no browser on this computer, an
40
+ older server, or no sign-in within 5 minutes), the CLI asks you to paste
41
+ the token that the sign-in page shows. `godfather auth --paste` goes
42
+ straight to that, for a browser on another computer.
40
43
  4. From the menu (or `godfather connect`), pick a pod. The CLI creates an SSH
41
44
  key on your machine the first time, asks the server for a 12-hour
42
45
  certificate for that pod, and opens the connection.
@@ -51,8 +54,18 @@ Windows 10+ ship it.
51
54
 
52
55
  ### Interactive menu
53
56
 
54
- Running `godfather` with no arguments opens a menu to list pods, connect,
55
- check status, or log out.
57
+ Running `godfather` with no arguments opens a menu. Use the arrow keys to
58
+ choose Connect to a pod, List pods, Sessions, Status, Log out or Exit, and
59
+ press Enter. Ctrl-C quits.
60
+
61
+ Connect to a pod shows a list of your pods with their status, access and
62
+ pod ID. A pod that is not running shows with the reason and cannot be
63
+ chosen. If only one pod is running, the CLI asks for a yes instead.
64
+ If none of your pods is running, the CLI shows the next session on your
65
+ pods. A pod starts 10 minutes before its session.
66
+
67
+ When input is not a terminal (for example in a script), the menu and the pod
68
+ list use numbered prompts.
56
69
 
57
70
  ### Commands
58
71
 
@@ -60,8 +73,11 @@ check status, or log out.
60
73
  godfather list # List pods you can connect to
61
74
  godfather connect # Connect to a pod, picking from a list
62
75
  godfather connect <pod-id> # Connect to a specific pod
63
- godfather status # Show login and configuration status
64
- godfather auth # Log in, or refresh an expired session
76
+ godfather connect --admin # Ask for a root shell (officers, when pod admin is on)
77
+ godfather sessions # Upcoming sessions on your pods, in local time
78
+ godfather status # Show server, org, login, token expiry and version
79
+ godfather auth # Log in with Discord in your browser
80
+ godfather auth --paste # Log in by pasting the token
65
81
  godfather logout # Clear the stored session
66
82
  godfather update # Update the CLI to the latest version
67
83
 
@@ -90,7 +106,8 @@ another server or org; the ones you last logged in with are remembered.
90
106
  - **"Couldn't reach \<url\>"** — check your internet connection and that the
91
107
  API URL is correct (`godfather status` shows what's currently configured).
92
108
  - **"Your token is invalid or expired"** — run `godfather auth` and sign in
93
- again.
109
+ again. In the menu, the CLI offers to do this for you.
110
+ - **"Pod is not running"** — the pod stopped. Ask an officer to start it.
94
111
  - **"You are no longer a member of this organization"** — rejoin the club's
95
112
  Discord server, then try again.
96
113
  - **"SSH could not log in to the pod"** — the pod does not run the
@@ -102,24 +119,24 @@ another server or org; the ones you last logged in with are remembered.
102
119
 
103
120
  ## Development
104
121
 
105
- The code is in `godfather_cli/`: `cli.py` (commands and menu), `auth.py`
106
- (platform sign-in), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
122
+ The code is in `godfather_cli/`: `cli.py` (commands and menu), `picker.py` (arrow-key menu and pod picker), `auth.py`
123
+ (platform sign-in), `login_server.py` (the sign-in callback), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
107
124
  certificate, running ssh), `update_checker.py` (PyPI version check), `ui.py`
108
125
  (shared console styling). Tests are in `tests/`:
109
126
 
110
127
  ```bash
111
- pip install -e . pytest
112
- pytest -q tests
128
+ uv sync --group dev
129
+ uv run pytest -q tests
113
130
  ```
114
131
 
132
+ `make check-cli` at the repo root runs ruff, the tests and a build, as CI does.
133
+
115
134
  Releases go to PyPI when a version bump is merged to main; see the
116
135
  repo README.
117
136
 
118
137
  ## Contributing
119
138
 
120
- 1. Fork the repository.
121
- 2. Create a feature branch (`git checkout -b feature/your-feature`).
122
- 3. Commit your changes and open a Pull Request.
139
+ See [CONTRIBUTING.md](../CONTRIBUTING.md).
123
140
 
124
141
  ## License
125
142
 
@@ -2,6 +2,6 @@
2
2
  Godfather CLI package
3
3
  """
4
4
 
5
- __version__ = "2.0.1"
5
+ __version__ = "2.2.0"
6
6
  __author__ = "AI Society ASU"
7
7
  __email__ = "admin@ais-asu.com"
@@ -0,0 +1,278 @@
1
+ """CLI authentication: token storage, login flow, and token verification."""
2
+
3
+ import base64
4
+ import datetime
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import secrets
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from typing import Dict, Optional
12
+ from urllib.parse import urlencode
13
+
14
+ import requests
15
+ from rich.panel import Panel
16
+ from rich.prompt import Prompt
17
+
18
+ from .login_server import LoopbackReceiver
19
+ from .ui import BORDER, BOX, console, error, spinner, success, warning
20
+
21
+ TOKEN_PREFIX = 'plat_'
22
+ # The platform issues CLI tokens for 90 days.
23
+ TOKEN_DAYS = 90
24
+
25
+ # How long the CLI waits for the browser sign-in.
26
+ LOGIN_WAIT_SECONDS = 300
27
+
28
+ # Token states that check_token returns.
29
+ TOKEN_OK = 'ok'
30
+ TOKEN_MISSING = 'missing'
31
+ TOKEN_EXPIRED = 'expired'
32
+ TOKEN_UNREACHABLE = 'unreachable'
33
+ TOKEN_REFUSED = 'refused'
34
+
35
+
36
+ def make_verifier() -> str:
37
+ """A PKCE code verifier (RFC 7636): 64 URL-safe characters."""
38
+ return secrets.token_urlsafe(48)
39
+
40
+
41
+ def challenge_for(verifier: str) -> str:
42
+ """The S256 code challenge of a verifier: base64url SHA-256 without padding."""
43
+ digest = hashlib.sha256(verifier.encode('ascii')).digest()
44
+ return base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
45
+
46
+
47
+ class CLIAuthenticator:
48
+ """Handle CLI authentication."""
49
+
50
+ def __init__(self, api_base: str, org: str, config_dir: Path):
51
+ self.api_base = api_base
52
+ self.org = org
53
+ self.config_dir = config_dir
54
+ self.config_file = config_dir / 'config.json'
55
+ self.config = self.load_config()
56
+
57
+ @staticmethod
58
+ def read_config(config_dir: Path) -> Dict:
59
+ """The stored config in config_dir, or an empty dict."""
60
+ config_file = config_dir / 'config.json'
61
+ if config_file.exists():
62
+ try:
63
+ with open(config_file, 'r') as f:
64
+ data = json.load(f)
65
+ return data if isinstance(data, dict) else {}
66
+ except (json.JSONDecodeError, IOError):
67
+ pass
68
+ return {}
69
+
70
+ def load_config(self) -> Dict:
71
+ """Load stored config from disk, if any."""
72
+ return self.read_config(self.config_dir)
73
+
74
+ def save_config(self):
75
+ self.config_dir.mkdir(exist_ok=True)
76
+ with open(self.config_file, 'w') as f:
77
+ json.dump(self.config, f, indent=2)
78
+ os.chmod(self.config_file, 0o600)
79
+
80
+ def compute_url(self, path: str = '') -> str:
81
+ """URL of a platform compute route for the configured org."""
82
+ return f"{self.api_base}/api/compute/{self.org}{path}"
83
+
84
+ def authenticate(self, paste: bool = False) -> bool:
85
+ """Sign in with Discord. The browser hands the token back; the paste flow is the fallback."""
86
+ if not paste:
87
+ if self.supports_loopback():
88
+ token = self._browser_login()
89
+ if token:
90
+ return self._verify_and_save(token)
91
+ else:
92
+ console.print("[dim]This server does not support browser sign-in yet. Paste the token instead.[/dim]")
93
+ return self._paste_login()
94
+
95
+ def supports_loopback(self) -> bool:
96
+ """True when the server has the cli/token route. It answers 400 to an empty body."""
97
+ try:
98
+ response = requests.post(self.compute_url('/cli/token'), json={}, timeout=10)
99
+ except requests.RequestException:
100
+ return False
101
+ return response.status_code == 400
102
+
103
+ def _browser_login(self) -> Optional[str]:
104
+ """The token from the localhost callback with PKCE, or None after printing why not."""
105
+ state = secrets.token_urlsafe(16)
106
+ verifier = make_verifier()
107
+ try:
108
+ receiver = LoopbackReceiver(state)
109
+ except OSError:
110
+ console.print("[dim]Could not listen on 127.0.0.1. Paste the token instead.[/dim]")
111
+ return None
112
+ try:
113
+ query = urlencode(
114
+ {
115
+ 'port': receiver.port,
116
+ 'state': state,
117
+ 'code_challenge': challenge_for(verifier),
118
+ 'code_challenge_method': 'S256',
119
+ }
120
+ )
121
+ login_url = f"{self.compute_url('/cli/login')}?{query}"
122
+ try:
123
+ opened = webbrowser.open(login_url)
124
+ except webbrowser.Error:
125
+ opened = False
126
+ if not opened:
127
+ console.print("[dim]No browser opened on this computer. Paste the token instead.[/dim]")
128
+ return None
129
+ console.print(f"Sign in with Discord in your browser. If it did not open, go to:\n{login_url}")
130
+ try:
131
+ with spinner("Waiting for the browser sign-in..."):
132
+ code = receiver.wait(LOGIN_WAIT_SECONDS)
133
+ except KeyboardInterrupt:
134
+ console.print("[dim]Cancelled[/dim]")
135
+ return None
136
+ finally:
137
+ receiver.close()
138
+ if not code:
139
+ console.print("[dim]The browser sign-in did not finish in time. Paste the token instead.[/dim]")
140
+ return None
141
+ return self._redeem(code, verifier)
142
+
143
+ def _redeem(self, code: str, verifier: str) -> Optional[str]:
144
+ """Exchange the one-time code and the PKCE verifier for a token."""
145
+ try:
146
+ with spinner("Getting your token..."):
147
+ response = requests.post(
148
+ self.compute_url('/cli/token'),
149
+ json={'code': code, 'code_verifier': verifier},
150
+ timeout=10,
151
+ )
152
+ token = str(response.json().get('token') or '') if response.status_code == 200 else ''
153
+ except (requests.RequestException, ValueError, AttributeError):
154
+ token = ''
155
+ if not token.startswith(TOKEN_PREFIX):
156
+ console.print("[dim]The server did not return a token. Paste the token instead.[/dim]")
157
+ return None
158
+ return token
159
+
160
+ def _paste_login(self) -> bool:
161
+ """Open the sign-in page and take the token the member pastes."""
162
+ login_url = self.compute_url('/cli/login')
163
+ panel = Panel(
164
+ f"[bold]1.[/bold] Open [bold]{login_url}[/bold] and sign in with Discord\n"
165
+ f"[bold]2.[/bold] Copy the token shown there\n"
166
+ f"[bold]3.[/bold] Paste it below",
167
+ title="Log in to Godfather",
168
+ border_style=BORDER,
169
+ box=BOX,
170
+ )
171
+ console.print(panel)
172
+ console.print()
173
+ try:
174
+ webbrowser.open(login_url)
175
+ except webbrowser.Error:
176
+ pass
177
+
178
+ try:
179
+ token = Prompt.ask("Token", password=True).strip()
180
+ except EOFError:
181
+ token = ''
182
+ if not token:
183
+ error("No token entered")
184
+ return False
185
+
186
+ if not token.startswith(TOKEN_PREFIX):
187
+ error("That doesn't look like a valid Godfather token")
188
+ console.print(f"[dim]Tokens start with {TOKEN_PREFIX}. Get a fresh one from {login_url}[/dim]")
189
+ return False
190
+ return self._verify_and_save(token)
191
+
192
+ def _verify_and_save(self, token: str) -> bool:
193
+ """Check the token with GET /me/pods, then save it with the server and org."""
194
+ try:
195
+ with spinner("Verifying token..."):
196
+ response = requests.get(
197
+ self.compute_url('/me/pods'), headers={'Authorization': f'Bearer {token}'}, timeout=10
198
+ )
199
+ except requests.ConnectionError:
200
+ error(f"Couldn't reach {self.api_base}")
201
+ console.print("[dim]Check your internet connection, or that the API URL is correct.[/dim]")
202
+ return False
203
+ except requests.Timeout:
204
+ error("Request timed out")
205
+ console.print("[dim]The server took too long to respond. Try again in a moment.[/dim]")
206
+ return False
207
+ except requests.RequestException as e:
208
+ error(f"Connection error: {e}")
209
+ return False
210
+
211
+ if response.status_code != 200:
212
+ try:
213
+ message = response.json().get('error', 'Authentication failed')
214
+ except ValueError:
215
+ message = 'Authentication failed'
216
+ error(message)
217
+ return False
218
+
219
+ self.config = {'token': token, 'api_url': self.api_base, 'org': self.org}
220
+ self.save_config()
221
+ success(f"Logged in to {self.org}")
222
+ return True
223
+
224
+ def get_token(self) -> Optional[str]:
225
+ return self.config.get('token')
226
+
227
+ def auth_headers(self) -> Dict[str, str]:
228
+ """Headers that authenticate an API request."""
229
+ return {'Authorization': f"Bearer {self.config.get('token', '')}"}
230
+
231
+ def is_authenticated(self) -> bool:
232
+ """A platform token is stored for this server and org."""
233
+ return (
234
+ str(self.config.get('token', '')).startswith(TOKEN_PREFIX)
235
+ and self.config.get('api_url') == self.api_base
236
+ and self.config.get('org') == self.org
237
+ )
238
+
239
+ def logout(self):
240
+ if 'token' in self.config:
241
+ del self.config['token']
242
+ self.save_config()
243
+ success("Logged out")
244
+ else:
245
+ warning("You weren't logged in")
246
+
247
+ def check_token(self) -> str:
248
+ """Ask the server whether the stored token works. Returns one of the TOKEN_ states."""
249
+ if not self.is_authenticated():
250
+ return TOKEN_MISSING
251
+
252
+ try:
253
+ response = requests.get(self.compute_url('/me/pods'), headers=self.auth_headers(), timeout=5)
254
+ except requests.RequestException:
255
+ return TOKEN_UNREACHABLE
256
+ if response.status_code == 200:
257
+ return TOKEN_OK
258
+ if response.status_code == 401:
259
+ return TOKEN_EXPIRED
260
+ return TOKEN_REFUSED
261
+
262
+ def verify_token(self) -> bool:
263
+ """Check the stored token is still accepted by the backend."""
264
+ return self.check_token() == TOKEN_OK
265
+
266
+ def token_saved_at(self) -> Optional[datetime.datetime]:
267
+ """When the stored token was saved, from the config file time. Only login writes a token."""
268
+ if not self.is_authenticated():
269
+ return None
270
+ try:
271
+ return datetime.datetime.fromtimestamp(self.config_file.stat().st_mtime)
272
+ except OSError:
273
+ return None
274
+
275
+ def token_expires_at(self) -> Optional[datetime.datetime]:
276
+ """About when the stored token expires: TOKEN_DAYS after it was saved."""
277
+ saved = self.token_saved_at()
278
+ return saved + datetime.timedelta(days=TOKEN_DAYS) if saved else None