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.
- discogs_sdk/__init__.py +134 -0
- discogs_sdk/_async/__init__.py +3 -0
- discogs_sdk/_async/_client.py +258 -0
- discogs_sdk/_async/_lazy.py +94 -0
- discogs_sdk/_async/_oauth.py +100 -0
- discogs_sdk/_async/_paginator.py +100 -0
- discogs_sdk/_async/_resource.py +92 -0
- discogs_sdk/_async/resources/__init__.py +75 -0
- discogs_sdk/_async/resources/artists.py +39 -0
- discogs_sdk/_async/resources/collection.py +248 -0
- discogs_sdk/_async/resources/exports.py +30 -0
- discogs_sdk/_async/resources/labels.py +32 -0
- discogs_sdk/_async/resources/lists.py +33 -0
- discogs_sdk/_async/resources/marketplace.py +122 -0
- discogs_sdk/_async/resources/masters.py +50 -0
- discogs_sdk/_async/resources/releases.py +96 -0
- discogs_sdk/_async/resources/search.py +22 -0
- discogs_sdk/_async/resources/uploads.py +35 -0
- discogs_sdk/_async/resources/users.py +168 -0
- discogs_sdk/_async/resources/wantlist.py +34 -0
- discogs_sdk/_base_client.py +177 -0
- discogs_sdk/_exceptions.py +60 -0
- discogs_sdk/_sync/__init__.py +6 -0
- discogs_sdk/_sync/_client.py +230 -0
- discogs_sdk/_sync/_lazy.py +72 -0
- discogs_sdk/_sync/_oauth.py +85 -0
- discogs_sdk/_sync/_paginator.py +89 -0
- discogs_sdk/_sync/_resource.py +66 -0
- discogs_sdk/_sync/resources/__init__.py +78 -0
- discogs_sdk/_sync/resources/artists.py +34 -0
- discogs_sdk/_sync/resources/collection.py +228 -0
- discogs_sdk/_sync/resources/exports.py +23 -0
- discogs_sdk/_sync/resources/labels.py +29 -0
- discogs_sdk/_sync/resources/lists.py +28 -0
- discogs_sdk/_sync/resources/marketplace.py +101 -0
- discogs_sdk/_sync/resources/masters.py +43 -0
- discogs_sdk/_sync/resources/releases.py +81 -0
- discogs_sdk/_sync/resources/search.py +19 -0
- discogs_sdk/_sync/resources/uploads.py +28 -0
- discogs_sdk/_sync/resources/users.py +160 -0
- discogs_sdk/_sync/resources/wantlist.py +30 -0
- discogs_sdk/models/__init__.py +109 -0
- discogs_sdk/models/_common.py +171 -0
- discogs_sdk/models/artist.py +34 -0
- discogs_sdk/models/collection.py +38 -0
- discogs_sdk/models/export.py +15 -0
- discogs_sdk/models/label.py +31 -0
- discogs_sdk/models/list_.py +38 -0
- discogs_sdk/models/marketplace.py +90 -0
- discogs_sdk/models/master.py +40 -0
- discogs_sdk/models/release.py +94 -0
- discogs_sdk/models/search.py +28 -0
- discogs_sdk/models/upload.py +17 -0
- discogs_sdk/models/user.py +46 -0
- discogs_sdk/models/wantlist.py +11 -0
- discogs_sdk/oauth.py +21 -0
- discogs_sdk/py.typed +0 -0
- discogs_sdk-0.1.0.dist-info/METADATA +339 -0
- discogs_sdk-0.1.0.dist-info/RECORD +60 -0
- discogs_sdk-0.1.0.dist-info/WHEEL +4 -0
discogs_sdk/__init__.py
ADDED
|
@@ -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,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
|