discogs-sdk 0.1.0__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 (60) hide show
  1. discogs_sdk/__init__.py +134 -0
  2. discogs_sdk/_async/__init__.py +3 -0
  3. discogs_sdk/_async/_client.py +258 -0
  4. discogs_sdk/_async/_lazy.py +94 -0
  5. discogs_sdk/_async/_oauth.py +100 -0
  6. discogs_sdk/_async/_paginator.py +100 -0
  7. discogs_sdk/_async/_resource.py +92 -0
  8. discogs_sdk/_async/resources/__init__.py +75 -0
  9. discogs_sdk/_async/resources/artists.py +39 -0
  10. discogs_sdk/_async/resources/collection.py +248 -0
  11. discogs_sdk/_async/resources/exports.py +30 -0
  12. discogs_sdk/_async/resources/labels.py +32 -0
  13. discogs_sdk/_async/resources/lists.py +33 -0
  14. discogs_sdk/_async/resources/marketplace.py +122 -0
  15. discogs_sdk/_async/resources/masters.py +50 -0
  16. discogs_sdk/_async/resources/releases.py +96 -0
  17. discogs_sdk/_async/resources/search.py +22 -0
  18. discogs_sdk/_async/resources/uploads.py +35 -0
  19. discogs_sdk/_async/resources/users.py +168 -0
  20. discogs_sdk/_async/resources/wantlist.py +34 -0
  21. discogs_sdk/_base_client.py +177 -0
  22. discogs_sdk/_exceptions.py +60 -0
  23. discogs_sdk/_sync/__init__.py +6 -0
  24. discogs_sdk/_sync/_client.py +230 -0
  25. discogs_sdk/_sync/_lazy.py +72 -0
  26. discogs_sdk/_sync/_oauth.py +85 -0
  27. discogs_sdk/_sync/_paginator.py +89 -0
  28. discogs_sdk/_sync/_resource.py +66 -0
  29. discogs_sdk/_sync/resources/__init__.py +78 -0
  30. discogs_sdk/_sync/resources/artists.py +34 -0
  31. discogs_sdk/_sync/resources/collection.py +228 -0
  32. discogs_sdk/_sync/resources/exports.py +23 -0
  33. discogs_sdk/_sync/resources/labels.py +29 -0
  34. discogs_sdk/_sync/resources/lists.py +28 -0
  35. discogs_sdk/_sync/resources/marketplace.py +101 -0
  36. discogs_sdk/_sync/resources/masters.py +43 -0
  37. discogs_sdk/_sync/resources/releases.py +81 -0
  38. discogs_sdk/_sync/resources/search.py +19 -0
  39. discogs_sdk/_sync/resources/uploads.py +28 -0
  40. discogs_sdk/_sync/resources/users.py +160 -0
  41. discogs_sdk/_sync/resources/wantlist.py +30 -0
  42. discogs_sdk/models/__init__.py +109 -0
  43. discogs_sdk/models/_common.py +171 -0
  44. discogs_sdk/models/artist.py +34 -0
  45. discogs_sdk/models/collection.py +38 -0
  46. discogs_sdk/models/export.py +15 -0
  47. discogs_sdk/models/label.py +31 -0
  48. discogs_sdk/models/list_.py +38 -0
  49. discogs_sdk/models/marketplace.py +90 -0
  50. discogs_sdk/models/master.py +40 -0
  51. discogs_sdk/models/release.py +94 -0
  52. discogs_sdk/models/search.py +28 -0
  53. discogs_sdk/models/upload.py +17 -0
  54. discogs_sdk/models/user.py +46 -0
  55. discogs_sdk/models/wantlist.py +11 -0
  56. discogs_sdk/oauth.py +21 -0
  57. discogs_sdk/py.typed +0 -0
  58. discogs_sdk-0.1.0.dist-info/METADATA +339 -0
  59. discogs_sdk-0.1.0.dist-info/RECORD +60 -0
  60. discogs_sdk-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,134 @@
1
+ from discogs_sdk._async._client import AsyncDiscogs
2
+ from discogs_sdk._sync._client import Discogs
3
+ from discogs_sdk._exceptions import (
4
+ AuthenticationError,
5
+ DiscogsAPIError,
6
+ DiscogsConnectionError,
7
+ DiscogsError,
8
+ ForbiddenError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ ValidationError,
12
+ )
13
+ from discogs_sdk.models import (
14
+ Artist,
15
+ ArtistCredit,
16
+ ArtistRelease,
17
+ BasicInformation,
18
+ CollectionField,
19
+ CollectionFolder,
20
+ CollectionItem,
21
+ CollectionValue_,
22
+ Community,
23
+ CommunityRating,
24
+ CommunityRatingValue,
25
+ Company,
26
+ Condition,
27
+ CurrencyCode,
28
+ Export,
29
+ Fee,
30
+ Format,
31
+ Identifier,
32
+ Identity,
33
+ Image,
34
+ Label,
35
+ LabelCredit,
36
+ LabelRelease,
37
+ List_,
38
+ ListItem,
39
+ ListSummary,
40
+ Listing,
41
+ ListingRelease,
42
+ MarketplaceReleaseStats,
43
+ Master,
44
+ MasterVersion,
45
+ Member,
46
+ Order,
47
+ OrderItem,
48
+ OrderMessage,
49
+ OriginalPrice,
50
+ Price,
51
+ PriceSuggestions,
52
+ SDKModel,
53
+ Release,
54
+ ReleaseStats,
55
+ SearchResult,
56
+ ShippingInfo,
57
+ SleeveCondition,
58
+ SubLabel,
59
+ Track,
60
+ Upload,
61
+ User,
62
+ UserReleaseRating,
63
+ UserSummary,
64
+ Video,
65
+ Want,
66
+ )
67
+
68
+ __all__ = [
69
+ # Clients
70
+ "AsyncDiscogs",
71
+ "Discogs",
72
+ # Exceptions
73
+ "DiscogsError",
74
+ "DiscogsConnectionError",
75
+ "DiscogsAPIError",
76
+ "AuthenticationError",
77
+ "ForbiddenError",
78
+ "NotFoundError",
79
+ "RateLimitError",
80
+ "ValidationError",
81
+ # Models
82
+ "Artist",
83
+ "ArtistCredit",
84
+ "ArtistRelease",
85
+ "BasicInformation",
86
+ "CollectionField",
87
+ "CollectionFolder",
88
+ "CollectionItem",
89
+ "CollectionValue_",
90
+ "Community",
91
+ "CommunityRating",
92
+ "CommunityRatingValue",
93
+ "Company",
94
+ "Condition",
95
+ "CurrencyCode",
96
+ "Export",
97
+ "Fee",
98
+ "Format",
99
+ "Identifier",
100
+ "Identity",
101
+ "Image",
102
+ "Label",
103
+ "LabelCredit",
104
+ "LabelRelease",
105
+ "List_",
106
+ "ListItem",
107
+ "ListSummary",
108
+ "Listing",
109
+ "ListingRelease",
110
+ "MarketplaceReleaseStats",
111
+ "Master",
112
+ "MasterVersion",
113
+ "Member",
114
+ "Order",
115
+ "OrderItem",
116
+ "OrderMessage",
117
+ "OriginalPrice",
118
+ "Price",
119
+ "PriceSuggestions",
120
+ "SDKModel",
121
+ "Release",
122
+ "ReleaseStats",
123
+ "SearchResult",
124
+ "ShippingInfo",
125
+ "SleeveCondition",
126
+ "SubLabel",
127
+ "Track",
128
+ "Upload",
129
+ "User",
130
+ "UserReleaseRating",
131
+ "UserSummary",
132
+ "Video",
133
+ "Want",
134
+ ]
@@ -0,0 +1,3 @@
1
+ from discogs_sdk._async._client import AsyncDiscogs
2
+
3
+ __all__ = ["AsyncDiscogs"]
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+
6
+ if True: # ASYNC
7
+ import asyncio
8
+ from functools import cached_property
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from typing_extensions import Self
12
+
13
+ import httpx
14
+
15
+ from discogs_sdk._base_client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, BaseClient, _RETRY_STATUSES
16
+ from discogs_sdk._exceptions import DiscogsConnectionError
17
+ from discogs_sdk._async.resources.artists import Artists
18
+ from discogs_sdk._async.resources.exports import Exports
19
+ from discogs_sdk._async.resources.labels import Labels
20
+ from discogs_sdk._async.resources.lists import Lists
21
+ from discogs_sdk._async.resources.marketplace import Marketplace
22
+ from discogs_sdk._async.resources.masters import Masters
23
+ from discogs_sdk._async.resources.releases import Releases
24
+ from discogs_sdk._async.resources.search import SearchResource
25
+ from discogs_sdk._async.resources.uploads import Uploads
26
+ from discogs_sdk._async.resources.users import UserNamespace, Users
27
+
28
+ if TYPE_CHECKING:
29
+ from discogs_sdk._async._paginator import AsyncPage
30
+ from discogs_sdk.models.search import SearchResult
31
+
32
+ logger = logging.getLogger("discogs_sdk")
33
+
34
+
35
+ class AsyncDiscogs(BaseClient):
36
+ """Async client for the Discogs API.
37
+
38
+ Use as a context manager to ensure the HTTP client is properly closed::
39
+
40
+ async with AsyncDiscogs(token="...") as client:
41
+ release = await client.releases.get(249504)
42
+ print(release.title)
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ token: str | None = None,
49
+ consumer_key: str | None = None,
50
+ consumer_secret: str | None = None,
51
+ access_token: str | None = None,
52
+ access_token_secret: str | None = None,
53
+ base_url: str = DEFAULT_BASE_URL,
54
+ timeout: float = DEFAULT_TIMEOUT,
55
+ max_retries: int = 3,
56
+ cache: bool = False,
57
+ http_client: httpx.AsyncClient | None = None,
58
+ user_agent: str | None = None,
59
+ ) -> None:
60
+ """Create an async Discogs client.
61
+
62
+ Args:
63
+ token: Personal access token from https://www.discogs.com/settings/developers.
64
+ consumer_key: OAuth consumer key for app-level auth.
65
+ consumer_secret: OAuth consumer secret for app-level auth.
66
+ access_token: OAuth access token for user-level auth.
67
+ access_token_secret: OAuth access token secret for user-level auth.
68
+ base_url: API base URL.
69
+ timeout: Request timeout in seconds.
70
+ max_retries: Max retries on 429/5xx/connection errors.
71
+ cache: Enable HTTP caching (requires ``discogs-sdk[cache]``).
72
+ http_client: Custom ``httpx.AsyncClient`` to use instead of creating one.
73
+ user_agent: Custom User-Agent string. Replaces the default entirely.
74
+ Should follow RFC 1945 product token format for best compatibility with Discogs.
75
+ """
76
+ super().__init__(
77
+ token=token,
78
+ consumer_key=consumer_key,
79
+ consumer_secret=consumer_secret,
80
+ access_token=access_token,
81
+ access_token_secret=access_token_secret,
82
+ base_url=base_url,
83
+ timeout=timeout,
84
+ max_retries=max_retries,
85
+ user_agent=user_agent,
86
+ )
87
+ if http_client is not None:
88
+ self._http_client = http_client
89
+ self._owns_client = False
90
+ elif cache:
91
+ try:
92
+ from hishel.httpx import AsyncCacheClient # ty: ignore[unresolved-import]
93
+ except ImportError:
94
+ raise ImportError(
95
+ "hishel is required for caching support. Install it with: pip install discogs-sdk[cache]"
96
+ ) from None
97
+ self._http_client = AsyncCacheClient(
98
+ headers=self._build_headers(),
99
+ timeout=self.timeout,
100
+ )
101
+ self._owns_client = True
102
+ else:
103
+ self._http_client = httpx.AsyncClient(
104
+ headers=self._build_headers(),
105
+ timeout=self.timeout,
106
+ )
107
+ self._owns_client = True
108
+
109
+ async def _send(
110
+ self,
111
+ method: str,
112
+ url: str,
113
+ *,
114
+ json: dict[str, Any] | None = None,
115
+ params: dict[str, Any] | None = None,
116
+ files: dict[str, Any] | None = None,
117
+ ) -> httpx.Response:
118
+ kwargs: dict[str, Any] = {}
119
+ if json is not None:
120
+ kwargs["json"] = json
121
+ if params is not None:
122
+ kwargs["params"] = params
123
+ if files is not None:
124
+ kwargs["files"] = files
125
+ if self._uses_oauth:
126
+ kwargs.setdefault("headers", {})["Authorization"] = self._build_oauth_header_for_request()
127
+
128
+ for attempt in range(self.max_retries + 1):
129
+ logger.debug("HTTP request: %s %s", method, url)
130
+ t0 = time.monotonic() # Unaffected by system clock adjustments (NTP, DST)
131
+ try:
132
+ response = await self._http_client.request(method, url, **kwargs)
133
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
134
+ elapsed_ms = (time.monotonic() - t0) * 1000
135
+ if attempt == self.max_retries:
136
+ logger.debug("HTTP connection error after %.0fms: %s", elapsed_ms, exc)
137
+ raise DiscogsConnectionError(str(exc)) from exc
138
+ delay = self._retry_delay(attempt)
139
+ logger.info(
140
+ "Retrying %s %s (attempt %d/%d) after connection error (%.0fms), waiting %.1fs",
141
+ method,
142
+ url,
143
+ attempt + 2,
144
+ self.max_retries + 1,
145
+ elapsed_ms,
146
+ delay,
147
+ )
148
+ if True: # ASYNC
149
+ await asyncio.sleep(delay)
150
+ else:
151
+ time.sleep(delay)
152
+ continue
153
+
154
+ elapsed_ms = (time.monotonic() - t0) * 1000
155
+ logger.debug(
156
+ "HTTP response: %s %s -> %d (%.0fms)",
157
+ method,
158
+ url,
159
+ response.status_code,
160
+ elapsed_ms,
161
+ )
162
+
163
+ if response.status_code not in _RETRY_STATUSES or attempt == self.max_retries:
164
+ return response
165
+
166
+ delay = self._retry_delay(attempt, retry_after=response.headers.get("Retry-After"))
167
+ logger.info(
168
+ "Retrying %s %s (attempt %d/%d) after status %d, waiting %.1fs",
169
+ method,
170
+ url,
171
+ attempt + 2,
172
+ self.max_retries + 1,
173
+ response.status_code,
174
+ delay,
175
+ )
176
+ if True: # ASYNC
177
+ await asyncio.sleep(delay)
178
+ else:
179
+ time.sleep(delay)
180
+
181
+ return response # pragma: no cover — unreachable but satisfies type checker
182
+
183
+ # --- Database ---
184
+
185
+ @cached_property
186
+ def artists(self) -> Artists:
187
+ return Artists(self)
188
+
189
+ @cached_property
190
+ def labels(self) -> Labels:
191
+ return Labels(self)
192
+
193
+ @cached_property
194
+ def masters(self) -> Masters:
195
+ return Masters(self)
196
+
197
+ @cached_property
198
+ def releases(self) -> Releases:
199
+ return Releases(self)
200
+
201
+ def search(self, **params: Any) -> AsyncPage[SearchResult]:
202
+ """Search the Discogs database.
203
+
204
+ Accepts any Discogs search parameter as a keyword argument
205
+ (e.g. ``query``, ``type``, ``title``, ``artist``, ``label``, ``genre``).
206
+
207
+ Returns an auto-paging iterator of search results.
208
+ """
209
+ return SearchResource(self)(**params)
210
+
211
+ # --- Marketplace ---
212
+
213
+ @cached_property
214
+ def marketplace(self) -> Marketplace:
215
+ return Marketplace(self)
216
+
217
+ # --- Inventory ---
218
+
219
+ @cached_property
220
+ def exports(self) -> Exports:
221
+ return Exports(self)
222
+
223
+ @cached_property
224
+ def uploads(self) -> Uploads:
225
+ return Uploads(self)
226
+
227
+ # --- Users ---
228
+
229
+ @cached_property
230
+ def user(self) -> UserNamespace:
231
+ return UserNamespace(self)
232
+
233
+ @cached_property
234
+ def users(self) -> Users:
235
+ return Users(self)
236
+
237
+ # --- Lists ---
238
+
239
+ @cached_property
240
+ def lists(self) -> Lists:
241
+ return Lists(self)
242
+
243
+ # --- Lifecycle ---
244
+
245
+ async def close(self) -> None:
246
+ """Close the underlying HTTP client.
247
+
248
+ Only closes the client if it was created by this instance,
249
+ not if a custom ``http_client`` was passed to the constructor.
250
+ """
251
+ if self._owns_client:
252
+ await self._http_client.aclose()
253
+
254
+ async def __aenter__(self) -> Self:
255
+ return self
256
+
257
+ async def __aexit__(self, *args: object) -> None:
258
+ await self.close()
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable
4
+
5
+ from pydantic import BaseModel
6
+
7
+ if TYPE_CHECKING:
8
+ from discogs_sdk._async._client import AsyncDiscogs
9
+
10
+
11
+ class AsyncLazyResource:
12
+ """Proxy that defers the HTTP call until explicitly awaited (async)
13
+ or until a data attribute is accessed (sync, via generated code).
14
+
15
+ Sub-resource accessors (defined in ``sub_resources``) are returned
16
+ immediately without triggering any HTTP call in both variants.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ client: AsyncDiscogs,
22
+ path: str,
23
+ model_cls: type[BaseModel],
24
+ sub_resources: dict[str, Callable[[], Any]] | None = None,
25
+ ) -> None:
26
+ # Use object.__setattr__ to bypass __getattr__, which would trigger _resolve() and defeat lazy loading
27
+ object.__setattr__(self, "_client", client)
28
+ object.__setattr__(self, "_path", path)
29
+ object.__setattr__(self, "_model_cls", model_cls)
30
+ object.__setattr__(self, "_sub_resources", sub_resources or {})
31
+ object.__setattr__(self, "_resolved", None)
32
+
33
+ async def _resolve(self) -> BaseModel:
34
+ resolved = object.__getattribute__(self, "_resolved")
35
+ if resolved is not None:
36
+ return resolved
37
+
38
+ client = object.__getattribute__(self, "_client")
39
+ path = object.__getattribute__(self, "_path")
40
+ model_cls = object.__getattribute__(self, "_model_cls")
41
+
42
+ response = await client._send("GET", client._build_url(path))
43
+ body = response.json()
44
+ client._maybe_raise(response.status_code, body, retry_after=response.headers.get("Retry-After"))
45
+
46
+ resolved = model_cls.model_validate(body)
47
+ object.__setattr__(self, "_resolved", resolved)
48
+ return resolved
49
+
50
+ def __getattr__(self, name: str) -> Any:
51
+ # Check sub-resources first (no HTTP call)
52
+ sub_resources = object.__getattribute__(self, "_sub_resources")
53
+ if name in sub_resources:
54
+ resource = sub_resources[name]()
55
+ object.__setattr__(self, name, resource)
56
+ return resource
57
+
58
+ if True: # ASYNC
59
+ # Already resolved (after await)? Delegate to model.
60
+ resolved = object.__getattribute__(self, "_resolved")
61
+ if resolved is not None:
62
+ return getattr(resolved, name)
63
+ raise AttributeError(
64
+ f"Cannot access '{name}' on unresolved AsyncLazyResource. Use: resolved = await resource"
65
+ )
66
+ else:
67
+ # Otherwise, resolve the model via HTTP and delegate
68
+ model = self._resolve()
69
+ return getattr(model, name)
70
+
71
+ def __getitem__(self, key: str) -> Any:
72
+ if True: # ASYNC
73
+ resolved = object.__getattribute__(self, "_resolved")
74
+ if resolved is None:
75
+ raise TypeError("Cannot subscript unresolved AsyncLazyResource. Use: resolved = await resource")
76
+ else:
77
+ resolved = self._resolve()
78
+ getter = getattr(resolved, "__getitem__", None)
79
+ if getter is None:
80
+ raise TypeError(f"'{type(resolved).__name__}' object is not subscriptable")
81
+ return getter(key) # pragma: no cover — no current model supports subscript
82
+
83
+ if True: # ASYNC
84
+
85
+ def __await__(self):
86
+ return self._resolve().__await__()
87
+
88
+ def __repr__(self) -> str:
89
+ path = object.__getattribute__(self, "_path")
90
+ model_cls = object.__getattribute__(self, "_model_cls")
91
+ resolved = object.__getattribute__(self, "_resolved")
92
+ if resolved is not None:
93
+ return repr(resolved)
94
+ return f"<AsyncLazyResource {model_cls.__name__} path={path!r}>"
@@ -0,0 +1,100 @@
1
+ """OAuth 1.0a helpers for the Discogs API (PLAINTEXT signature method)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from urllib.parse import parse_qs
7
+
8
+ import httpx
9
+
10
+ from discogs_sdk._base_client import DEFAULT_BASE_URL, USER_AGENT, build_oauth_header
11
+
12
+ _ACCESS_TOKEN_PATH = "/oauth/access_token"
13
+ _AUTHORIZE_URL = "https://www.discogs.com/oauth/authorize"
14
+ _REQUEST_TOKEN_PATH = "/oauth/request_token"
15
+
16
+
17
+ @dataclass
18
+ class RequestToken:
19
+ authorize_url: str
20
+ oauth_token_secret: str
21
+ oauth_token: str
22
+
23
+
24
+ @dataclass
25
+ class AccessToken:
26
+ oauth_token: str
27
+ oauth_token_secret: str
28
+
29
+
30
+ async def get_request_token(
31
+ consumer_key: str,
32
+ consumer_secret: str,
33
+ callback_url: str = "oob",
34
+ *,
35
+ base_url: str = DEFAULT_BASE_URL,
36
+ ) -> RequestToken:
37
+ """Step 1-2 of the OAuth flow: obtain a request token and authorize URL.
38
+
39
+ Returns a RequestToken with the token, secret, and a URL to redirect
40
+ the user to for authorization.
41
+ """
42
+ auth_header = build_oauth_header(
43
+ callback=callback_url,
44
+ consumer_key=consumer_key,
45
+ consumer_secret=consumer_secret,
46
+ )
47
+ headers = {
48
+ "Content-Type": "application/x-www-form-urlencoded",
49
+ "Authorization": auth_header,
50
+ "User-Agent": USER_AGENT,
51
+ }
52
+ async with httpx.AsyncClient() as client:
53
+ response = await client.get(f"{base_url.rstrip('/')}{_REQUEST_TOKEN_PATH}", headers=headers)
54
+ response.raise_for_status()
55
+
56
+ parsed = parse_qs(response.text)
57
+ token = parsed["oauth_token"][0]
58
+ token_secret = parsed["oauth_token_secret"][0]
59
+ return RequestToken(
60
+ authorize_url=f"{_AUTHORIZE_URL}?oauth_token={token}",
61
+ oauth_token_secret=token_secret,
62
+ oauth_token=token,
63
+ )
64
+
65
+
66
+ async def get_access_token(
67
+ consumer_key: str,
68
+ consumer_secret: str,
69
+ request_token: str,
70
+ request_token_secret: str,
71
+ verifier: str,
72
+ *,
73
+ base_url: str = DEFAULT_BASE_URL,
74
+ ) -> AccessToken:
75
+ """Step 4 of the OAuth flow: exchange the request token for an access token.
76
+
77
+ The verifier is obtained after the user authorizes the app at the
78
+ authorize URL from get_request_token().
79
+ """
80
+ auth_header = build_oauth_header(
81
+ consumer_key=consumer_key,
82
+ consumer_secret=consumer_secret,
83
+ token_secret=request_token_secret,
84
+ token=request_token,
85
+ verifier=verifier,
86
+ )
87
+ headers = {
88
+ "Content-Type": "application/x-www-form-urlencoded",
89
+ "Authorization": auth_header,
90
+ "User-Agent": USER_AGENT,
91
+ }
92
+ async with httpx.AsyncClient() as client:
93
+ response = await client.post(f"{base_url.rstrip('/')}{_ACCESS_TOKEN_PATH}", headers=headers)
94
+ response.raise_for_status()
95
+
96
+ parsed = parse_qs(response.text)
97
+ return AccessToken(
98
+ oauth_token=parsed["oauth_token"][0],
99
+ oauth_token_secret=parsed["oauth_token_secret"][0],
100
+ )
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Generic, TypeVar
4
+
5
+ from pydantic import BaseModel
6
+
7
+ if TYPE_CHECKING:
8
+ from discogs_sdk._async._client import AsyncDiscogs
9
+
10
+ T = TypeVar("T", bound=BaseModel)
11
+
12
+
13
+ class AsyncPage(Generic[T]):
14
+ """Auto-paging async iterator over Discogs paginated responses.
15
+
16
+ Discogs pagination format::
17
+
18
+ {
19
+ "pagination": {"page": 1, "pages": 5, "urls": {"next": "..."}},
20
+ "<items_key>": [...]
21
+ }
22
+
23
+ For nested responses (e.g. submissions), set ``items_path`` to traverse
24
+ into the response body before extracting items::
25
+
26
+ {"submissions": {"releases": [...]}}
27
+ items_path=["submissions", "releases"]
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ client: AsyncDiscogs,
33
+ path: str,
34
+ model_cls: type[T],
35
+ items_key: str,
36
+ *,
37
+ params: dict[str, Any] | None = None,
38
+ items_path: list[str] | None = None,
39
+ ) -> None:
40
+ self._client = client
41
+ self._path = path
42
+ self._params = {**(params or {}), "page": 1}
43
+ self._model_cls = model_cls
44
+ self._items_key = items_key
45
+ self._items_path = items_path
46
+
47
+ self._items: list[T] = []
48
+ self._index = 0
49
+ self._next_url: str | None = None
50
+ self._exhausted = False
51
+ self._first_page_fetched = False
52
+
53
+ async def _fetch_page(self) -> None:
54
+ if self._next_url:
55
+ response = await self._client._send("GET", self._next_url)
56
+ else:
57
+ response = await self._client._send(
58
+ "GET",
59
+ self._client._build_url(self._path),
60
+ params=self._params,
61
+ )
62
+
63
+ body = response.json()
64
+ self._client._maybe_raise(response.status_code, body, retry_after=response.headers.get("Retry-After"))
65
+
66
+ pagination = body.get("pagination", {})
67
+ urls = pagination.get("urls", {})
68
+ self._next_url = urls.get("next")
69
+ if not self._next_url:
70
+ self._exhausted = True
71
+
72
+ if self._items_path:
73
+ container = body
74
+ for key in self._items_path:
75
+ container = container.get(key, {})
76
+ raw_items = container if isinstance(container, list) else []
77
+ else:
78
+ raw_items = body.get(self._items_key, [])
79
+
80
+ self._items = [self._model_cls.model_validate(item) for item in raw_items]
81
+ self._index = 0
82
+ self._first_page_fetched = True
83
+
84
+ def __aiter__(self) -> AsyncIterator[T]:
85
+ return self
86
+
87
+ async def __anext__(self) -> T:
88
+ if not self._first_page_fetched:
89
+ await self._fetch_page()
90
+
91
+ if self._index >= len(self._items):
92
+ if self._exhausted:
93
+ raise StopAsyncIteration
94
+ await self._fetch_page()
95
+ if not self._items:
96
+ raise StopAsyncIteration
97
+
98
+ item = self._items[self._index]
99
+ self._index += 1
100
+ return item