tescmd 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 (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
tescmd/auth/oauth.py ADDED
@@ -0,0 +1,312 @@
1
+ """OAuth2 PKCE helpers, partner registration, and interactive login flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import logging
8
+ import secrets
9
+ import time
10
+ import webbrowser
11
+ from typing import TYPE_CHECKING, Any
12
+ from urllib.parse import urlencode
13
+
14
+ import httpx
15
+
16
+ from tescmd.api.client import REGION_BASE_URLS
17
+ from tescmd.api.errors import AuthError
18
+ from tescmd.auth.server import OAuthCallbackServer
19
+ from tescmd.models.auth import (
20
+ AUTHORIZE_URL,
21
+ PARTNER_SCOPES,
22
+ TOKEN_URL,
23
+ TokenData,
24
+ decode_jwt_scopes,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from tescmd.auth.token_store import TokenStore
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # PKCE helpers
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ def _generate_code_verifier() -> str:
38
+ """Return a 128-character base64url code verifier (no padding)."""
39
+ return secrets.token_urlsafe(96)[:128]
40
+
41
+
42
+ def _generate_code_challenge(verifier: str) -> str:
43
+ """Compute S256 code challenge for the given *verifier*."""
44
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
45
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
46
+
47
+
48
+ def build_auth_url(
49
+ client_id: str,
50
+ redirect_uri: str,
51
+ scopes: list[str],
52
+ code_challenge: str,
53
+ state: str,
54
+ *,
55
+ force_consent: bool = False,
56
+ ) -> str:
57
+ """Build the full Tesla authorization URL.
58
+
59
+ When *force_consent* is ``True`` the ``prompt_missing_scopes=true``
60
+ parameter is added so Tesla prompts the user to approve any scopes
61
+ that were not granted in a previous consent. This is Tesla's
62
+ proprietary parameter (not the standard ``prompt=consent``).
63
+ """
64
+ params: dict[str, str] = {
65
+ "response_type": "code",
66
+ "client_id": client_id,
67
+ "redirect_uri": redirect_uri,
68
+ "scope": " ".join(scopes),
69
+ "state": state,
70
+ "code_challenge": code_challenge,
71
+ "code_challenge_method": "S256",
72
+ }
73
+ if force_consent:
74
+ params["prompt_missing_scopes"] = "true"
75
+ return f"{AUTHORIZE_URL}?{urlencode(params)}"
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Token exchange / refresh
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ async def exchange_code(
84
+ code: str,
85
+ code_verifier: str,
86
+ client_id: str,
87
+ client_secret: str | None = None,
88
+ redirect_uri: str = "http://localhost:8085/callback",
89
+ ) -> TokenData:
90
+ """Exchange an authorization code for tokens."""
91
+ payload: dict[str, Any] = {
92
+ "grant_type": "authorization_code",
93
+ "code": code,
94
+ "code_verifier": code_verifier,
95
+ "client_id": client_id,
96
+ "redirect_uri": redirect_uri,
97
+ }
98
+ if client_secret is not None:
99
+ payload["client_secret"] = client_secret
100
+
101
+ async with httpx.AsyncClient() as client:
102
+ resp = await client.post(TOKEN_URL, data=payload)
103
+ if resp.status_code != 200:
104
+ raise AuthError(f"Token exchange failed: {resp.text}", status_code=resp.status_code)
105
+ return TokenData.model_validate(resp.json())
106
+
107
+
108
+ async def refresh_access_token(
109
+ refresh_token: str,
110
+ client_id: str,
111
+ client_secret: str | None = None,
112
+ ) -> TokenData:
113
+ """Use a refresh token to obtain new tokens."""
114
+ payload: dict[str, Any] = {
115
+ "grant_type": "refresh_token",
116
+ "refresh_token": refresh_token,
117
+ "client_id": client_id,
118
+ }
119
+ if client_secret is not None:
120
+ payload["client_secret"] = client_secret
121
+
122
+ async with httpx.AsyncClient() as client:
123
+ resp = await client.post(TOKEN_URL, data=payload)
124
+ if resp.status_code != 200:
125
+ raise AuthError(f"Token refresh failed: {resp.text}", status_code=resp.status_code)
126
+ return TokenData.model_validate(resp.json())
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Partner registration (one-time per region)
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ async def get_partner_token(
135
+ client_id: str,
136
+ client_secret: str,
137
+ region: str = "na",
138
+ ) -> tuple[str, list[str]]:
139
+ """Obtain a partner token via *client_credentials* grant.
140
+
141
+ The ``audience`` parameter tells Tesla which regional endpoint the
142
+ token is for.
143
+
144
+ Returns a ``(token, granted_scopes)`` tuple. *granted_scopes* are
145
+ decoded from the JWT ``scp`` claim so callers can verify that the
146
+ partner registration will cover all requested scopes.
147
+ """
148
+ audience = REGION_BASE_URLS.get(region)
149
+ if audience is None:
150
+ msg = f"Unknown region {region!r}; expected one of {sorted(REGION_BASE_URLS)}"
151
+ raise AuthError(msg)
152
+
153
+ payload = {
154
+ "grant_type": "client_credentials",
155
+ "client_id": client_id,
156
+ "client_secret": client_secret,
157
+ "scope": " ".join(PARTNER_SCOPES),
158
+ "audience": audience,
159
+ }
160
+
161
+ async with httpx.AsyncClient() as client:
162
+ resp = await client.post(TOKEN_URL, data=payload)
163
+ if resp.status_code != 200:
164
+ raise AuthError(
165
+ f"Partner token request failed: {resp.text}",
166
+ status_code=resp.status_code,
167
+ )
168
+ data: dict[str, Any] = resp.json()
169
+ token: str = data["access_token"]
170
+
171
+ granted = decode_jwt_scopes(token) or []
172
+ logger.debug("Partner token scopes: requested=%s, granted=%s", PARTNER_SCOPES, granted)
173
+
174
+ return token, granted
175
+
176
+
177
+ async def register_partner_account(
178
+ client_id: str,
179
+ client_secret: str,
180
+ domain: str = "localhost",
181
+ region: str = "na",
182
+ ) -> tuple[dict[str, Any], list[str]]:
183
+ """Register the application with the Tesla Fleet API for *region*.
184
+
185
+ This must be called once per region before the Fleet API will accept
186
+ requests. It is safe to call more than once (idempotent).
187
+
188
+ Returns a ``(response_body, partner_scopes)`` tuple.
189
+ *partner_scopes* are the scopes actually granted in the partner
190
+ token, which determines the ceiling for user token scopes.
191
+ """
192
+ base_url = REGION_BASE_URLS.get(region)
193
+ if base_url is None:
194
+ msg = f"Unknown region {region!r}; expected one of {sorted(REGION_BASE_URLS)}"
195
+ raise AuthError(msg)
196
+
197
+ partner_token, granted_scopes = await get_partner_token(client_id, client_secret, region)
198
+
199
+ async with httpx.AsyncClient() as client:
200
+ resp = await client.post(
201
+ f"{base_url}/api/1/partner_accounts",
202
+ json={"domain": domain},
203
+ headers={"Authorization": f"Bearer {partner_token}"},
204
+ )
205
+
206
+ if resp.status_code >= 400:
207
+ raise AuthError(
208
+ f"Partner registration failed (HTTP {resp.status_code}): {resp.text}",
209
+ status_code=resp.status_code,
210
+ )
211
+ result: dict[str, Any] = resp.json()
212
+ return result, granted_scopes
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Scope extraction
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ def _extract_granted_scopes(token: TokenData, *, requested: list[str]) -> list[str]:
221
+ """Return the scopes actually granted in the token response.
222
+
223
+ Checks three sources in priority order:
224
+ 1. ``token.scope`` — the ``scope`` field from the OAuth token response
225
+ 2. JWT ``scp`` claim — decoded from the access token payload
226
+ 3. *requested* — falls back to whatever we asked for
227
+ """
228
+ if token.scope:
229
+ return token.scope.split()
230
+
231
+ jwt_scopes = decode_jwt_scopes(token.access_token)
232
+ if jwt_scopes is not None:
233
+ return jwt_scopes
234
+
235
+ return requested
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Full interactive login flow
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ async def login_flow(
244
+ client_id: str,
245
+ client_secret: str | None,
246
+ redirect_uri: str,
247
+ scopes: list[str],
248
+ port: int,
249
+ token_store: TokenStore,
250
+ region: str = "na",
251
+ *,
252
+ force_consent: bool = False,
253
+ ) -> TokenData:
254
+ """Run the full OAuth2 PKCE login flow interactively.
255
+
256
+ 1. Generate PKCE pair
257
+ 2. Start local callback server
258
+ 3. Open browser to authorization URL
259
+ 4. Wait for redirect with auth code
260
+ 5. Exchange code for tokens
261
+ 6. Persist to *token_store*
262
+
263
+ When *force_consent* is ``True`` the authorization URL includes
264
+ ``prompt_missing_scopes=true`` so Tesla prompts for any new scopes.
265
+ """
266
+ verifier = _generate_code_verifier()
267
+ challenge = _generate_code_challenge(verifier)
268
+ state = secrets.token_urlsafe(32)
269
+
270
+ server = OAuthCallbackServer(port=port)
271
+ server.start()
272
+ try:
273
+ url = build_auth_url(
274
+ client_id,
275
+ redirect_uri,
276
+ scopes,
277
+ challenge,
278
+ state,
279
+ force_consent=force_consent,
280
+ )
281
+ webbrowser.open(url)
282
+
283
+ code, callback_state = server.wait_for_callback(timeout=120)
284
+ finally:
285
+ server.stop()
286
+
287
+ if code is None:
288
+ raise AuthError("OAuth callback timed out or was cancelled")
289
+
290
+ if callback_state != state:
291
+ raise AuthError("OAuth state mismatch — possible CSRF attack")
292
+
293
+ token = await exchange_code(
294
+ code=code,
295
+ code_verifier=verifier,
296
+ client_id=client_id,
297
+ client_secret=client_secret,
298
+ redirect_uri=redirect_uri,
299
+ )
300
+
301
+ # Determine actually granted scopes — prefer the token response's scope
302
+ # field, then JWT payload, then fall back to what we requested.
303
+ granted = _extract_granted_scopes(token, requested=scopes)
304
+
305
+ token_store.save(
306
+ access_token=token.access_token,
307
+ refresh_token=token.refresh_token or "",
308
+ expires_at=time.time() + token.expires_in,
309
+ scopes=granted,
310
+ region=region,
311
+ )
312
+ return token
tescmd/auth/server.py ADDED
@@ -0,0 +1,108 @@
1
+ """Lightweight local HTTP server for the OAuth2 redirect callback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from typing import Any
8
+ from urllib.parse import parse_qs, urlparse
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # HTML templates
12
+ # ---------------------------------------------------------------------------
13
+
14
+ _SUCCESS_HTML = """\
15
+ <!DOCTYPE html>
16
+ <html><head><title>tescmd</title></head>
17
+ <body style="font-family:sans-serif;text-align:center;margin-top:80px">
18
+ <h1>Authentication Successful</h1>
19
+ <p>You can close this window and return to the terminal.</p>
20
+ </body></html>
21
+ """
22
+
23
+ _FAILURE_HTML = """\
24
+ <!DOCTYPE html>
25
+ <html><head><title>tescmd</title></head>
26
+ <body style="font-family:sans-serif;text-align:center;margin-top:80px">
27
+ <h1>Authentication Failed</h1>
28
+ <p>{error}</p>
29
+ </body></html>
30
+ """
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Request handler
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ class _CallbackHandler(BaseHTTPRequestHandler):
39
+ """Handle a single GET from the OAuth redirect."""
40
+
41
+ server: OAuthCallbackServer
42
+
43
+ def do_GET(self) -> None:
44
+ qs = parse_qs(urlparse(self.path).query)
45
+
46
+ error = qs.get("error", [None])[0]
47
+ if error is not None:
48
+ self._respond(400, _FAILURE_HTML.format(error=error))
49
+ self.server.callback_result = (None, None, error)
50
+ self.server.callback_event.set()
51
+ return
52
+
53
+ code = qs.get("code", [None])[0]
54
+ state = qs.get("state", [None])[0]
55
+ self._respond(200, _SUCCESS_HTML)
56
+ self.server.callback_result = (code, state, None)
57
+ self.server.callback_event.set()
58
+
59
+ def _respond(self, status: int, body: str) -> None:
60
+ self.send_response(status)
61
+ self.send_header("Content-Type", "text/html; charset=utf-8")
62
+ self.end_headers()
63
+ self.wfile.write(body.encode())
64
+
65
+ # Silence default request logging.
66
+ def log_message(self, format: str, *args: Any) -> None:
67
+ pass
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Server
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ class OAuthCallbackServer(HTTPServer):
76
+ """HTTPServer that waits for a single OAuth redirect callback."""
77
+
78
+ def __init__(self, port: int = 8085) -> None:
79
+ super().__init__(("127.0.0.1", port), _CallbackHandler)
80
+ self.callback_event = threading.Event()
81
+ self.callback_result: tuple[str | None, str | None, str | None] = (
82
+ None,
83
+ None,
84
+ None,
85
+ )
86
+ self._thread: threading.Thread | None = None
87
+
88
+ def start(self) -> None:
89
+ """Start serving in a daemon thread."""
90
+ self._thread = threading.Thread(target=self.serve_forever, daemon=True)
91
+ self._thread.start()
92
+
93
+ def wait_for_callback(self, timeout: float = 120) -> tuple[str | None, str | None]:
94
+ """Block until the callback is received or *timeout* elapses.
95
+
96
+ Returns ``(code, state)`` on success; ``(None, None)`` on timeout or error.
97
+ """
98
+ received = self.callback_event.wait(timeout=timeout)
99
+ if not received:
100
+ return (None, None)
101
+ code, state, _error = self.callback_result
102
+ return (code, state)
103
+
104
+ def stop(self) -> None:
105
+ """Shut down the server and join the daemon thread."""
106
+ self.shutdown()
107
+ if self._thread is not None:
108
+ self._thread.join(timeout=5)
@@ -0,0 +1,273 @@
1
+ """Token persistence with keyring and file-based backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import tempfile
9
+ import warnings
10
+ from pathlib import Path
11
+ from typing import Any, Protocol
12
+
13
+ SERVICE_NAME = "tescmd"
14
+
15
+ # Module-level flag so the file-fallback warning fires at most once.
16
+ _warned = False
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Backend protocol
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ class _TokenBackend(Protocol):
25
+ """Minimal interface for reading/writing credential entries."""
26
+
27
+ @property
28
+ def backend_name(self) -> str: ...
29
+ def get(self, key: str) -> str | None: ...
30
+ def set(self, key: str, value: str) -> None: ...
31
+ def delete(self, key: str) -> None: ...
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Keyring backend (delegates to the OS keyring)
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ class _KeyringBackend:
40
+ """Wraps the ``keyring`` library."""
41
+
42
+ @property
43
+ def backend_name(self) -> str:
44
+ return "keyring"
45
+
46
+ def get(self, key: str) -> str | None:
47
+ import keyring as _kr
48
+
49
+ return _kr.get_password(SERVICE_NAME, key)
50
+
51
+ def set(self, key: str, value: str) -> None:
52
+ import keyring as _kr
53
+
54
+ _kr.set_password(SERVICE_NAME, key, value)
55
+
56
+ def delete(self, key: str) -> None:
57
+ import keyring as _kr
58
+ from keyring.errors import PasswordDeleteError
59
+
60
+ with contextlib.suppress(PasswordDeleteError):
61
+ _kr.delete_password(SERVICE_NAME, key)
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # File backend (JSON file with atomic writes)
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ class _FileBackend:
70
+ """Stores credentials in a JSON file at *path*.
71
+
72
+ The file is written atomically (write-to-temp + rename) and
73
+ permissions are restricted to the current user.
74
+ """
75
+
76
+ def __init__(self, path: Path) -> None:
77
+ self._path = path
78
+
79
+ @property
80
+ def backend_name(self) -> str:
81
+ return f"file ({self._path})"
82
+
83
+ # -- helpers -------------------------------------------------------------
84
+
85
+ def _read_store(self) -> dict[str, str]:
86
+ if not self._path.exists():
87
+ return {}
88
+ try:
89
+ data: dict[str, str] = json.loads(self._path.read_text("utf-8"))
90
+ return data
91
+ except (json.JSONDecodeError, OSError):
92
+ return {}
93
+
94
+ def _write_store(self, data: dict[str, str]) -> None:
95
+ self._path.parent.mkdir(parents=True, exist_ok=True)
96
+
97
+ from tescmd._internal.permissions import secure_file
98
+
99
+ # Atomic write: temp file in the same directory → rename
100
+ fd, tmp = tempfile.mkstemp(dir=str(self._path.parent), suffix=".tmp", prefix=".tokens_")
101
+ try:
102
+ os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
103
+ os.close(fd)
104
+ fd = -1 # mark as closed
105
+ Path(tmp).replace(self._path)
106
+ except BaseException:
107
+ if fd != -1:
108
+ os.close(fd)
109
+ with contextlib.suppress(OSError):
110
+ os.unlink(tmp)
111
+ raise
112
+
113
+ secure_file(self._path)
114
+
115
+ # -- interface -----------------------------------------------------------
116
+
117
+ def get(self, key: str) -> str | None:
118
+ return self._read_store().get(key)
119
+
120
+ def set(self, key: str, value: str) -> None:
121
+ store = self._read_store()
122
+ store[key] = value
123
+ self._write_store(store)
124
+
125
+ def delete(self, key: str) -> None:
126
+ store = self._read_store()
127
+ store.pop(key, None)
128
+ self._write_store(store)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Backend resolution
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def _resolve_backend(
137
+ token_file: str | None = None,
138
+ config_dir: str | None = None,
139
+ ) -> _TokenBackend:
140
+ """Choose the best available backend.
141
+
142
+ 1. Explicit *token_file* → file backend (no keyring probe).
143
+ 2. Keyring probe succeeds → keyring backend.
144
+ 3. Keyring probe fails → file backend at ``{config_dir}/tokens.json``
145
+ with a one-time warning.
146
+ """
147
+ # 1. Explicit token file — skip keyring entirely
148
+ if token_file:
149
+ return _FileBackend(Path(token_file).expanduser())
150
+
151
+ # 2. Probe keyring
152
+ try:
153
+ import keyring as _kr
154
+
155
+ _kr.get_password(SERVICE_NAME, "__probe__")
156
+ return _KeyringBackend()
157
+ except Exception:
158
+ # NoKeyringError, RuntimeError, or any other keyring failure
159
+ pass
160
+
161
+ # 3. Fall back to file
162
+ global _warned
163
+ resolved_dir = Path(config_dir or "~/.config/tescmd").expanduser()
164
+ fallback_path = resolved_dir / "tokens.json"
165
+ if not _warned:
166
+ _warned = True
167
+ warnings.warn(
168
+ f"OS keyring unavailable — storing tokens in {fallback_path} "
169
+ "(plaintext with restricted permissions). "
170
+ "Set TESLA_TOKEN_FILE to choose a different path.",
171
+ UserWarning,
172
+ stacklevel=3,
173
+ )
174
+ return _FileBackend(fallback_path)
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Public API (unchanged surface)
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ class TokenStore:
183
+ """Read / write OAuth tokens via the OS keyring or a file fallback."""
184
+
185
+ def __init__(
186
+ self,
187
+ profile: str = "default",
188
+ *,
189
+ token_file: str | None = None,
190
+ config_dir: str | None = None,
191
+ ) -> None:
192
+ self._profile = profile
193
+ self._backend: _TokenBackend = _resolve_backend(token_file, config_dir)
194
+
195
+ # -- key helpers ---------------------------------------------------------
196
+
197
+ def _key(self, name: str) -> str:
198
+ return f"{self._profile}/{name}"
199
+
200
+ # -- diagnostics ---------------------------------------------------------
201
+
202
+ @property
203
+ def backend_name(self) -> str:
204
+ """Return a human-readable description of the active backend."""
205
+ return self._backend.backend_name
206
+
207
+ # -- properties ----------------------------------------------------------
208
+
209
+ @property
210
+ def access_token(self) -> str | None:
211
+ """Return the stored access token, or *None*."""
212
+ return self._backend.get(self._key("access_token"))
213
+
214
+ @property
215
+ def refresh_token(self) -> str | None:
216
+ """Return the stored refresh token, or *None*."""
217
+ return self._backend.get(self._key("refresh_token"))
218
+
219
+ @property
220
+ def has_token(self) -> bool:
221
+ """Return *True* if an access token is stored."""
222
+ return self.access_token is not None
223
+
224
+ @property
225
+ def metadata(self) -> dict[str, Any] | None:
226
+ """Return the parsed metadata dict, or *None*."""
227
+ raw = self._backend.get(self._key("metadata"))
228
+ if raw is None:
229
+ return None
230
+ result: dict[str, Any] = json.loads(raw)
231
+ return result
232
+
233
+ # -- mutators ------------------------------------------------------------
234
+
235
+ def save(
236
+ self,
237
+ access_token: str,
238
+ refresh_token: str,
239
+ expires_at: float,
240
+ scopes: list[str],
241
+ region: str,
242
+ ) -> None:
243
+ """Persist all three entries."""
244
+ self._backend.set(self._key("access_token"), access_token)
245
+ self._backend.set(self._key("refresh_token"), refresh_token)
246
+ meta = json.dumps({"expires_at": expires_at, "scopes": scopes, "region": region})
247
+ self._backend.set(self._key("metadata"), meta)
248
+
249
+ def clear(self) -> None:
250
+ """Delete all stored credentials."""
251
+ for name in ("access_token", "refresh_token", "metadata"):
252
+ self._backend.delete(self._key(name))
253
+
254
+ # -- import / export -----------------------------------------------------
255
+
256
+ def export_dict(self) -> dict[str, Any]:
257
+ """Return a plain dict of all stored values (for ``auth export``)."""
258
+ return {
259
+ "access_token": self.access_token,
260
+ "refresh_token": self.refresh_token,
261
+ "metadata": self.metadata,
262
+ }
263
+
264
+ def import_dict(self, data: dict[str, Any]) -> None:
265
+ """Restore tokens from a previously exported dict."""
266
+ meta: dict[str, Any] = data.get("metadata") or {}
267
+ self.save(
268
+ access_token=data["access_token"],
269
+ refresh_token=data["refresh_token"],
270
+ expires_at=meta.get("expires_at", 0.0),
271
+ scopes=meta.get("scopes", []),
272
+ region=meta.get("region", "na"),
273
+ )
tescmd/ble/__init__.py ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Disk-based response cache for reducing Tesla Fleet API costs."""
2
+
3
+ from tescmd.cache.keys import generic_cache_key
4
+ from tescmd.cache.response_cache import CacheResult, ResponseCache
5
+
6
+ __all__ = ["CacheResult", "ResponseCache", "generic_cache_key"]