spotifyify 0.2.0__tar.gz → 0.4.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 (47) hide show
  1. {spotifyify-0.2.0/spotifyify.egg-info → spotifyify-0.4.0}/PKG-INFO +38 -5
  2. spotifyify-0.2.0/PKG-INFO → spotifyify-0.4.0/README.md +37 -14
  3. {spotifyify-0.2.0 → spotifyify-0.4.0}/pyproject.toml +1 -1
  4. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/__init__.py +130 -123
  5. spotifyify-0.4.0/spotifyify/auth.py +9 -0
  6. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/cache_handler.py +63 -42
  7. spotifyify-0.4.0/spotifyify/client.py +189 -0
  8. spotifyify-0.4.0/spotifyify/http/__init__.py +24 -0
  9. spotifyify-0.4.0/spotifyify/http/response.py +57 -0
  10. spotifyify-0.4.0/spotifyify/http/retry_context.py +8 -0
  11. spotifyify-0.4.0/spotifyify/http/retry_event.py +25 -0
  12. spotifyify-0.4.0/spotifyify/http/retry_policy.py +54 -0
  13. spotifyify-0.4.0/spotifyify/http/serialization.py +26 -0
  14. spotifyify-0.4.0/spotifyify/http/transport.py +122 -0
  15. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/playlists.py +31 -6
  16. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/oauth2/oauth2.py +359 -318
  17. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/spotifyify.py +141 -126
  18. spotifyify-0.2.0/README.md → spotifyify-0.4.0/spotifyify.egg-info/PKG-INFO +47 -4
  19. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify.egg-info/SOURCES.txt +8 -0
  20. {spotifyify-0.2.0 → spotifyify-0.4.0}/tests/test_cache_handler.py +3 -1
  21. spotifyify-0.4.0/tests/test_client.py +90 -0
  22. {spotifyify-0.2.0 → spotifyify-0.4.0}/tests/test_oauth2.py +36 -0
  23. spotifyify-0.2.0/spotifyify/client.py +0 -279
  24. spotifyify-0.2.0/tests/test_client.py +0 -131
  25. {spotifyify-0.2.0 → spotifyify-0.4.0}/setup.cfg +0 -0
  26. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/credentials.py +0 -0
  27. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/exceptions.py +0 -0
  28. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/__init__.py +0 -0
  29. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/albums.py +0 -0
  30. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/artists.py +0 -0
  31. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/episodes.py +0 -0
  32. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/library.py +0 -0
  33. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/player.py +0 -0
  34. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/shows.py +0 -0
  35. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/tracks.py +0 -0
  36. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/namespaces/users.py +0 -0
  37. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/oauth2/__init__.py +0 -0
  38. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/oauth2/views.py +0 -0
  39. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/schemas.py +0 -0
  40. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify/utils.py +0 -0
  41. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify.egg-info/dependency_links.txt +0 -0
  42. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify.egg-info/requires.txt +0 -0
  43. {spotifyify-0.2.0 → spotifyify-0.4.0}/spotifyify.egg-info/top_level.txt +0 -0
  44. {spotifyify-0.2.0 → spotifyify-0.4.0}/tests/test_credentials.py +0 -0
  45. {spotifyify-0.2.0 → spotifyify-0.4.0}/tests/test_exceptions.py +0 -0
  46. {spotifyify-0.2.0 → spotifyify-0.4.0}/tests/test_scopes.py +0 -0
  47. {spotifyify-0.2.0 → spotifyify-0.4.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spotifyify
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Async wrapper for spotipy with a focus on integration with MCP agents.
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -10,7 +10,7 @@ Requires-Dist: pydantic-settings>=2.12.0
10
10
 
11
11
  # spotifyify
12
12
 
13
- An async-first Spotify client with a namespaced API and fully typed response models generated from the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
13
+ An async-first Spotify client with a namespaced API and fully typed response models maintained in the codebase and aligned with the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
14
14
 
15
15
  ## Requirements
16
16
 
@@ -130,11 +130,12 @@ Spotifyify
130
130
  | `find(query, *, limit, offset)` | Search for playlists |
131
131
  | `get(playlist_id, *, market)` | Get a single playlist |
132
132
  | `list(*, user_id, limit, offset)` | Current user's (or another user's) playlists |
133
+ | `tracks(playlist_id, *, market, fields, limit, offset, additional_types)` | Get playlist tracks |
133
134
  | `create(name, *, public, collaborative, description, user_id)` | Create a playlist |
134
135
  | `update(playlist_id, *, name, public, collaborative, description)` | Update playlist details |
135
- | `add(playlist_id, uris, *, position)` | Add tracks to a playlist |
136
- | `remove(playlist_id, uris)` | Remove tracks from a playlist |
137
- | `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder tracks |
136
+ | `add(playlist_id, uris, *, position)` | Add items to a playlist |
137
+ | `remove(playlist_id, uris)` | Remove items from a playlist |
138
+ | `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder items |
138
139
  | `cover_image(playlist_id)` | Get playlist cover images |
139
140
 
140
141
  ### Player — `sp.player`
@@ -209,6 +210,38 @@ Spotifyify
209
210
  | `unfollow(type, ids)` | Unfollow artists or users |
210
211
  | `check_following(type, ids)` | Check if following artists or users |
211
212
 
213
+ ## Retries
214
+
215
+ Spotify API requests automatically retry rate limits (`429`) and temporary server
216
+ errors (`500`, `502`, `503`, `504`). Rate limits honor Spotify's `Retry-After`
217
+ header. Server errors are only retried for idempotent HTTP methods to avoid
218
+ duplicating mutations. Configure the defaults with `max_retries` and
219
+ `retry_backoff_seconds` when constructing `Spotifyify`.
220
+
221
+ Use a request-context retry hook when retries should be reported to a caller.
222
+ The hook is isolated per async task, so one shared `Spotifyify` instance can be
223
+ used by concurrent conversations. `retry_number` is one-based and `retry_at`
224
+ contains the planned retry time in UTC:
225
+
226
+ ```python
227
+ from spotifyify import RetryEvent
228
+
229
+ async def on_retry(event: RetryEvent) -> None:
230
+ await sse_bus.emit(
231
+ conversation_id,
232
+ {
233
+ "status_code": event.status_code,
234
+ "retry_number": event.retry_number,
235
+ "max_retries": event.max_retries,
236
+ "retry_in_seconds": event.retry_in_seconds,
237
+ "retry_at": event.retry_at.isoformat(),
238
+ },
239
+ )
240
+
241
+ with spotify.retry_hook(on_retry):
242
+ track = await spotify.tracks.get(track_id)
243
+ ```
244
+
212
245
  ## Scopes
213
246
 
214
247
  Use `SpotifyScope` to declare the OAuth scopes your app requires:
@@ -1,16 +1,6 @@
1
- Metadata-Version: 2.4
2
- Name: spotifyify
3
- Version: 0.2.0
4
- Summary: Async wrapper for spotipy with a focus on integration with MCP agents.
5
- Requires-Python: >=3.13
6
- Description-Content-Type: text/markdown
7
- Requires-Dist: httpx>=0.28.0
8
- Requires-Dist: pydantic>=2.12.0
9
- Requires-Dist: pydantic-settings>=2.12.0
10
-
11
1
  # spotifyify
12
2
 
13
- An async-first Spotify client with a namespaced API and fully typed response models generated from the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
3
+ An async-first Spotify client with a namespaced API and fully typed response models maintained in the codebase and aligned with the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
14
4
 
15
5
  ## Requirements
16
6
 
@@ -130,11 +120,12 @@ Spotifyify
130
120
  | `find(query, *, limit, offset)` | Search for playlists |
131
121
  | `get(playlist_id, *, market)` | Get a single playlist |
132
122
  | `list(*, user_id, limit, offset)` | Current user's (or another user's) playlists |
123
+ | `tracks(playlist_id, *, market, fields, limit, offset, additional_types)` | Get playlist tracks |
133
124
  | `create(name, *, public, collaborative, description, user_id)` | Create a playlist |
134
125
  | `update(playlist_id, *, name, public, collaborative, description)` | Update playlist details |
135
- | `add(playlist_id, uris, *, position)` | Add tracks to a playlist |
136
- | `remove(playlist_id, uris)` | Remove tracks from a playlist |
137
- | `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder tracks |
126
+ | `add(playlist_id, uris, *, position)` | Add items to a playlist |
127
+ | `remove(playlist_id, uris)` | Remove items from a playlist |
128
+ | `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder items |
138
129
  | `cover_image(playlist_id)` | Get playlist cover images |
139
130
 
140
131
  ### Player — `sp.player`
@@ -209,6 +200,38 @@ Spotifyify
209
200
  | `unfollow(type, ids)` | Unfollow artists or users |
210
201
  | `check_following(type, ids)` | Check if following artists or users |
211
202
 
203
+ ## Retries
204
+
205
+ Spotify API requests automatically retry rate limits (`429`) and temporary server
206
+ errors (`500`, `502`, `503`, `504`). Rate limits honor Spotify's `Retry-After`
207
+ header. Server errors are only retried for idempotent HTTP methods to avoid
208
+ duplicating mutations. Configure the defaults with `max_retries` and
209
+ `retry_backoff_seconds` when constructing `Spotifyify`.
210
+
211
+ Use a request-context retry hook when retries should be reported to a caller.
212
+ The hook is isolated per async task, so one shared `Spotifyify` instance can be
213
+ used by concurrent conversations. `retry_number` is one-based and `retry_at`
214
+ contains the planned retry time in UTC:
215
+
216
+ ```python
217
+ from spotifyify import RetryEvent
218
+
219
+ async def on_retry(event: RetryEvent) -> None:
220
+ await sse_bus.emit(
221
+ conversation_id,
222
+ {
223
+ "status_code": event.status_code,
224
+ "retry_number": event.retry_number,
225
+ "max_retries": event.max_retries,
226
+ "retry_in_seconds": event.retry_in_seconds,
227
+ "retry_at": event.retry_at.isoformat(),
228
+ },
229
+ )
230
+
231
+ with spotify.retry_hook(on_retry):
232
+ track = await spotify.tracks.get(track_id)
233
+ ```
234
+
212
235
  ## Scopes
213
236
 
214
237
  Use `SpotifyScope` to declare the OAuth scopes your app requires:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spotifyify"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Async wrapper for spotipy with a focus on integration with MCP agents."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,123 +1,130 @@
1
- from .spotifyify import Spotifyify
2
- from .credentials import SpotifyCredentials
3
- from .oauth2 import SpotifyScope
4
- from .schemas import (
5
- # Core models
6
- Track,
7
- Album,
8
- Artist,
9
- Playlist,
10
- Episode,
11
- Show,
12
- Device,
13
- PlaybackState,
14
- PlayerQueue,
15
- AudioFeatures,
16
- AudioAnalysis,
17
- User,
18
- PublicUser,
19
- Image,
20
- ExternalUrl,
21
- Followers,
22
- Context,
23
- Category,
24
- PlaylistTrack,
25
- # Simplified models
26
- SimplifiedTrack,
27
- SimplifiedAlbum,
28
- SimplifiedArtist,
29
- SimplifiedPlaylist,
30
- SimplifiedEpisode,
31
- SimplifiedShow,
32
- ArtistDiscographyAlbum,
33
- # Saved-item wrappers
34
- SavedTrack,
35
- SavedAlbum,
36
- SavedShow,
37
- SavedEpisode,
38
- # Compound models
39
- PlayHistory,
40
- Recommendations,
41
- # Paging bases
42
- Paging,
43
- CursorPaging,
44
- # Typed paging
45
- PagingTrack,
46
- PagingArtist,
47
- PagingPlaylist,
48
- PagingPlaylistTrack,
49
- PagingSimplifiedTrack,
50
- PagingSimplifiedAlbum,
51
- PagingSimplifiedEpisode,
52
- PagingSimplifiedShow,
53
- PagingArtistDiscographyAlbum,
54
- PagingSavedTrack,
55
- PagingSavedAlbum,
56
- PagingSavedShow,
57
- PagingSavedEpisode,
58
- CursorPagingPlayHistory,
59
- CursorPagingSimplifiedArtist,
60
- )
61
-
62
-
63
- __all__ = [
64
- # Client & config
65
- "Spotifyify",
66
- "SpotifyCredentials",
67
- "SpotifyScope",
68
- # Core models
69
- "Track",
70
- "Album",
71
- "Artist",
72
- "Playlist",
73
- "Episode",
74
- "Show",
75
- "Device",
76
- "PlaybackState",
77
- "PlayerQueue",
78
- "AudioFeatures",
79
- "AudioAnalysis",
80
- "User",
81
- "PublicUser",
82
- "Image",
83
- "ExternalUrl",
84
- "Followers",
85
- "Context",
86
- "Category",
87
- "PlaylistTrack",
88
- # Simplified models
89
- "SimplifiedTrack",
90
- "SimplifiedAlbum",
91
- "SimplifiedArtist",
92
- "SimplifiedPlaylist",
93
- "SimplifiedEpisode",
94
- "SimplifiedShow",
95
- "ArtistDiscographyAlbum",
96
- # Saved-item wrappers
97
- "SavedTrack",
98
- "SavedAlbum",
99
- "SavedShow",
100
- "SavedEpisode",
101
- # Compound models
102
- "PlayHistory",
103
- "Recommendations",
104
- # Paging bases
105
- "Paging",
106
- "CursorPaging",
107
- # Typed paging
108
- "PagingTrack",
109
- "PagingArtist",
110
- "PagingPlaylist",
111
- "PagingPlaylistTrack",
112
- "PagingSimplifiedTrack",
113
- "PagingSimplifiedAlbum",
114
- "PagingSimplifiedEpisode",
115
- "PagingSimplifiedShow",
116
- "PagingArtistDiscographyAlbum",
117
- "PagingSavedTrack",
118
- "PagingSavedAlbum",
119
- "PagingSavedShow",
120
- "PagingSavedEpisode",
121
- "CursorPagingPlayHistory",
122
- "CursorPagingSimplifiedArtist",
123
- ]
1
+ import logging
2
+
3
+ from .spotifyify import Spotifyify
4
+ from .credentials import SpotifyCredentials
5
+ from .oauth2 import SpotifyScope
6
+ from .http import OnRetryHook, RetryEvent
7
+ from .schemas import (
8
+ # Core models
9
+ Track,
10
+ Album,
11
+ Artist,
12
+ Playlist,
13
+ Episode,
14
+ Show,
15
+ Device,
16
+ PlaybackState,
17
+ PlayerQueue,
18
+ AudioFeatures,
19
+ AudioAnalysis,
20
+ User,
21
+ PublicUser,
22
+ Image,
23
+ ExternalUrl,
24
+ Followers,
25
+ Context,
26
+ Category,
27
+ PlaylistTrack,
28
+ # Simplified models
29
+ SimplifiedTrack,
30
+ SimplifiedAlbum,
31
+ SimplifiedArtist,
32
+ SimplifiedPlaylist,
33
+ SimplifiedEpisode,
34
+ SimplifiedShow,
35
+ ArtistDiscographyAlbum,
36
+ # Saved-item wrappers
37
+ SavedTrack,
38
+ SavedAlbum,
39
+ SavedShow,
40
+ SavedEpisode,
41
+ # Compound models
42
+ PlayHistory,
43
+ Recommendations,
44
+ # Paging bases
45
+ Paging,
46
+ CursorPaging,
47
+ # Typed paging
48
+ PagingTrack,
49
+ PagingArtist,
50
+ PagingPlaylist,
51
+ PagingPlaylistTrack,
52
+ PagingSimplifiedTrack,
53
+ PagingSimplifiedAlbum,
54
+ PagingSimplifiedEpisode,
55
+ PagingSimplifiedShow,
56
+ PagingArtistDiscographyAlbum,
57
+ PagingSavedTrack,
58
+ PagingSavedAlbum,
59
+ PagingSavedShow,
60
+ PagingSavedEpisode,
61
+ CursorPagingPlayHistory,
62
+ CursorPagingSimplifiedArtist,
63
+ )
64
+
65
+
66
+ __all__ = [
67
+ # Client & config
68
+ "Spotifyify",
69
+ "SpotifyCredentials",
70
+ "SpotifyScope",
71
+ "OnRetryHook",
72
+ "RetryEvent",
73
+ # Core models
74
+ "Track",
75
+ "Album",
76
+ "Artist",
77
+ "Playlist",
78
+ "Episode",
79
+ "Show",
80
+ "Device",
81
+ "PlaybackState",
82
+ "PlayerQueue",
83
+ "AudioFeatures",
84
+ "AudioAnalysis",
85
+ "User",
86
+ "PublicUser",
87
+ "Image",
88
+ "ExternalUrl",
89
+ "Followers",
90
+ "Context",
91
+ "Category",
92
+ "PlaylistTrack",
93
+ # Simplified models
94
+ "SimplifiedTrack",
95
+ "SimplifiedAlbum",
96
+ "SimplifiedArtist",
97
+ "SimplifiedPlaylist",
98
+ "SimplifiedEpisode",
99
+ "SimplifiedShow",
100
+ "ArtistDiscographyAlbum",
101
+ # Saved-item wrappers
102
+ "SavedTrack",
103
+ "SavedAlbum",
104
+ "SavedShow",
105
+ "SavedEpisode",
106
+ # Compound models
107
+ "PlayHistory",
108
+ "Recommendations",
109
+ # Paging bases
110
+ "Paging",
111
+ "CursorPaging",
112
+ # Typed paging
113
+ "PagingTrack",
114
+ "PagingArtist",
115
+ "PagingPlaylist",
116
+ "PagingPlaylistTrack",
117
+ "PagingSimplifiedTrack",
118
+ "PagingSimplifiedAlbum",
119
+ "PagingSimplifiedEpisode",
120
+ "PagingSimplifiedShow",
121
+ "PagingArtistDiscographyAlbum",
122
+ "PagingSavedTrack",
123
+ "PagingSavedAlbum",
124
+ "PagingSavedShow",
125
+ "PagingSavedEpisode",
126
+ "CursorPagingPlayHistory",
127
+ "CursorPagingSimplifiedArtist",
128
+ ]
129
+
130
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -0,0 +1,9 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class AccessTokenProvider(Protocol):
5
+ async def get_access_token(
6
+ self,
7
+ require_user: bool,
8
+ scope: str | list[str] | tuple[str, ...] | None = None,
9
+ ) -> str: ...
@@ -1,42 +1,63 @@
1
- import abc
2
- import json
3
- from pathlib import Path
4
- from typing import Any
5
-
6
-
7
- class CacheHandler(abc.ABC):
8
- @abc.abstractmethod
9
- def get_cached_token(self) -> dict[str, Any] | None:
10
- raise NotImplementedError
11
-
12
- @abc.abstractmethod
13
- def save_token_to_cache(self, token_info: dict[str, Any]) -> None:
14
- raise NotImplementedError
15
-
16
-
17
- class MemoryCacheHandler(CacheHandler):
18
- def __init__(self, token_info: dict[str, Any] | None = None) -> None:
19
- self._token_info = token_info
20
-
21
- def get_cached_token(self) -> dict[str, Any] | None:
22
- return self._token_info
23
-
24
- def save_token_to_cache(self, token_info: dict[str, Any]) -> None:
25
- self._token_info = token_info
26
-
27
-
28
- class CacheFileHandler(CacheHandler):
29
- def __init__(self, cache_path: str | Path = ".cache") -> None:
30
- self.cache_path = Path(cache_path)
31
-
32
- def get_cached_token(self) -> dict[str, Any] | None:
33
- if not self.cache_path.exists():
34
- return None
35
- try:
36
- return json.loads(self.cache_path.read_text(encoding="utf-8"))
37
- except (OSError, json.JSONDecodeError):
38
- return None
39
-
40
- def save_token_to_cache(self, token_info: dict[str, Any]) -> None:
41
- self.cache_path.parent.mkdir(parents=True, exist_ok=True)
42
- self.cache_path.write_text(json.dumps(token_info), encoding="utf-8")
1
+ import abc
2
+ import json
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class CacheHandler(abc.ABC):
11
+ @abc.abstractmethod
12
+ def get_cached_token(self) -> dict[str, Any] | None:
13
+ raise NotImplementedError
14
+
15
+ @abc.abstractmethod
16
+ def save_token_to_cache(self, token_info: dict[str, Any]) -> None:
17
+ raise NotImplementedError
18
+
19
+
20
+ class MemoryCacheHandler(CacheHandler):
21
+ def __init__(self, token_info: dict[str, Any] | None = None) -> None:
22
+ self._token_info = token_info
23
+
24
+ def get_cached_token(self) -> dict[str, Any] | None:
25
+ logger.debug("Reading token from memory cache")
26
+ return self._token_info
27
+
28
+ def save_token_to_cache(self, token_info: dict[str, Any]) -> None:
29
+ logger.debug("Saving token to memory cache")
30
+ self._token_info = token_info
31
+
32
+
33
+ class CacheFileHandler(CacheHandler):
34
+ def __init__(self, cache_path: str | Path = ".cache") -> None:
35
+ self.cache_path = Path(cache_path)
36
+
37
+ def get_cached_token(self) -> dict[str, Any] | None:
38
+ if not self.cache_path.exists():
39
+ logger.debug("Token cache file does not exist: path=%s", self.cache_path)
40
+ return None
41
+ try:
42
+ token_info = json.loads(self.cache_path.read_text(encoding="utf-8"))
43
+ except (OSError, json.JSONDecodeError):
44
+ logger.warning(
45
+ "Unable to read token cache file: path=%s",
46
+ self.cache_path,
47
+ exc_info=True,
48
+ )
49
+ return None
50
+ logger.debug("Read token from cache file: path=%s", self.cache_path)
51
+ return token_info
52
+
53
+ def save_token_to_cache(self, token_info: dict[str, Any]) -> None:
54
+ try:
55
+ self.cache_path.parent.mkdir(parents=True, exist_ok=True)
56
+ self.cache_path.write_text(json.dumps(token_info), encoding="utf-8")
57
+ except OSError:
58
+ logger.exception(
59
+ "Unable to save token cache file: path=%s",
60
+ self.cache_path,
61
+ )
62
+ raise
63
+ logger.debug("Saved token to cache file: path=%s", self.cache_path)