labelsentry 1.0.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.
- labelsentry/__init__.py +8 -0
- labelsentry/client.py +99 -0
- labelsentry/errors.py +26 -0
- labelsentry/models.py +522 -0
- labelsentry/operations.py +373 -0
- labelsentry/py.typed +0 -0
- labelsentry/webhooks.py +34 -0
- labelsentry-1.0.0.dist-info/METADATA +33 -0
- labelsentry-1.0.0.dist-info/RECORD +10 -0
- labelsentry-1.0.0.dist-info/WHEEL +4 -0
labelsentry/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The LabelSentry API for Python."""
|
|
2
|
+
|
|
3
|
+
from .client import DEFAULT_BASE_URL, VERSION, LabelSentry
|
|
4
|
+
from .errors import LabelSentryError
|
|
5
|
+
from .webhooks import verify_webhook
|
|
6
|
+
|
|
7
|
+
__all__ = ["LabelSentry", "LabelSentryError", "verify_webhook", "DEFAULT_BASE_URL", "VERSION"]
|
|
8
|
+
__version__ = VERSION
|
labelsentry/client.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Dict, Iterator, Optional
|
|
12
|
+
|
|
13
|
+
from .errors import LabelSentryError
|
|
14
|
+
from .operations import RESOURCES
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://public-api.labelsentry.com/v1"
|
|
17
|
+
VERSION = "1.0.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LabelSentry:
|
|
21
|
+
"""The LabelSentry API.
|
|
22
|
+
|
|
23
|
+
>>> ls = LabelSentry() # reads LABELSENTRY_KEY
|
|
24
|
+
>>> for change in ls.changes.list(severity="critical"):
|
|
25
|
+
... print(change["summary"])
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None,
|
|
29
|
+
max_retries: int = 3, timeout: float = 30.0) -> None:
|
|
30
|
+
key = api_key or os.environ.get("LABELSENTRY_KEY")
|
|
31
|
+
if not key:
|
|
32
|
+
raise ValueError("No API key: pass one or set LABELSENTRY_KEY.")
|
|
33
|
+
self._key = key
|
|
34
|
+
self.base_url = (base_url or os.environ.get("LABELSENTRY_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
35
|
+
self.max_retries = max_retries
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
for name, cls in RESOURCES.items():
|
|
38
|
+
setattr(self, name, cls(self))
|
|
39
|
+
|
|
40
|
+
def _url(self, path: str, query: Optional[Dict[str, Any]]) -> str:
|
|
41
|
+
params = {k: v for k, v in (query or {}).items() if v not in (None, "")}
|
|
42
|
+
return self.base_url + path + ("?" + urllib.parse.urlencode(params) if params else "")
|
|
43
|
+
|
|
44
|
+
def _send(self, method: str, path: str, query: Optional[Dict[str, Any]] = None, body: Any = None) -> bytes:
|
|
45
|
+
headers = {
|
|
46
|
+
"Authorization": f"Bearer {self._key}",
|
|
47
|
+
"Accept": "application/json",
|
|
48
|
+
"User-Agent": f"labelsentry-python/{VERSION}",
|
|
49
|
+
}
|
|
50
|
+
data = None
|
|
51
|
+
if body is not None:
|
|
52
|
+
data = json.dumps(body).encode()
|
|
53
|
+
headers["Content-Type"] = "application/json"
|
|
54
|
+
if method == "POST":
|
|
55
|
+
headers["Idempotency-Key"] = str(uuid.uuid4())
|
|
56
|
+
attempt = 0
|
|
57
|
+
while True:
|
|
58
|
+
req = urllib.request.Request(self._url(path, query), data=data, headers=headers, method=method)
|
|
59
|
+
try:
|
|
60
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
61
|
+
return res.read()
|
|
62
|
+
except urllib.error.HTTPError as err:
|
|
63
|
+
if (err.code == 429 or err.code >= 500) and attempt < self.max_retries:
|
|
64
|
+
time.sleep(_wait(err.headers.get("Retry-After"), attempt))
|
|
65
|
+
attempt += 1
|
|
66
|
+
continue
|
|
67
|
+
raise LabelSentryError.from_response(err.code, err.read(), err.headers) from None
|
|
68
|
+
except urllib.error.URLError:
|
|
69
|
+
if attempt < self.max_retries:
|
|
70
|
+
time.sleep(_wait(None, attempt))
|
|
71
|
+
attempt += 1
|
|
72
|
+
continue
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
def _request(self, method: str, path: str, *, query: Optional[Dict[str, Any]] = None,
|
|
76
|
+
body: Any = None, unwrap: bool = True) -> Any:
|
|
77
|
+
out = json.loads(self._send(method, path, query, body) or b"{}")
|
|
78
|
+
return out.get("data") if unwrap else out
|
|
79
|
+
|
|
80
|
+
def _text(self, method: str, path: str, query: Optional[Dict[str, Any]] = None) -> str:
|
|
81
|
+
return self._send(method, path, query).decode()
|
|
82
|
+
|
|
83
|
+
def _page(self, path: str, query: Optional[Dict[str, Any]]) -> Iterator[Any]:
|
|
84
|
+
cursor = None
|
|
85
|
+
while True:
|
|
86
|
+
q = dict(query or {})
|
|
87
|
+
q["cursor"] = cursor
|
|
88
|
+
page = json.loads(self._send("GET", path, q))
|
|
89
|
+
yield from page["data"]
|
|
90
|
+
cursor = page.get("next_cursor")
|
|
91
|
+
if not cursor:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _wait(retry_after: Optional[str], attempt: int) -> float:
|
|
96
|
+
try:
|
|
97
|
+
return min(float(retry_after), 60.0)
|
|
98
|
+
except (TypeError, ValueError):
|
|
99
|
+
return 0.5 * 2 ** attempt + random.random() / 10
|
labelsentry/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LabelSentryError(Exception):
|
|
8
|
+
"""An answer the API refused, with what it said about why."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, status: int, code: str, message: str, request_id: str = "",
|
|
11
|
+
field: Optional[str] = None, docs_url: str = "") -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.status, self.code, self.message = status, code, message
|
|
14
|
+
self.request_id, self.field, self.docs_url = request_id, field, docs_url
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return f"{self.status} {self.code}: {self.message}"
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_response(cls, status: int, body: bytes, headers: Mapping[str, Any]) -> "LabelSentryError":
|
|
21
|
+
try:
|
|
22
|
+
e = json.loads(body).get("error", {})
|
|
23
|
+
except ValueError:
|
|
24
|
+
e = {}
|
|
25
|
+
return cls(status, e.get("code", f"http_{status}"), e.get("message", "request failed"),
|
|
26
|
+
e.get("request_id") or headers.get("X-Request-Id", ""), e.get("field"), e.get("docs_url", ""))
|
labelsentry/models.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Generated by scripts/generate.py from openapi.json. Do not edit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, TypedDict
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from typing import NotRequired
|
|
9
|
+
except ImportError: # Python before 3.11
|
|
10
|
+
from typing_extensions import NotRequired
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AddArtist(TypedDict, total=False):
|
|
14
|
+
spotify_id: str
|
|
15
|
+
spotify_url: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AlertDelivery(TypedDict):
|
|
19
|
+
attempts: int
|
|
20
|
+
change_id: str
|
|
21
|
+
created_at: str
|
|
22
|
+
delivered_at: NotRequired[Optional[str]]
|
|
23
|
+
destination_id: str
|
|
24
|
+
id: str
|
|
25
|
+
object: str
|
|
26
|
+
status: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Artist(TypedDict):
|
|
30
|
+
added_at: str
|
|
31
|
+
followers: NotRequired[Optional[int]]
|
|
32
|
+
id: str
|
|
33
|
+
image_url: str
|
|
34
|
+
label_tag: str
|
|
35
|
+
last_checked_at: NotRequired[Optional[str]]
|
|
36
|
+
monthly_listeners: NotRequired[Optional[int]]
|
|
37
|
+
name: str
|
|
38
|
+
object: str
|
|
39
|
+
spotify_id: str
|
|
40
|
+
spotify_verified: bool
|
|
41
|
+
status: str
|
|
42
|
+
world_rank: NotRequired[Optional[int]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ArtistSearchResult(TypedDict):
|
|
46
|
+
followers: int
|
|
47
|
+
image_url: str
|
|
48
|
+
monthly_listeners: int
|
|
49
|
+
name: str
|
|
50
|
+
object: str
|
|
51
|
+
spotify_id: str
|
|
52
|
+
spotify_verified: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ArtistStats(TypedDict):
|
|
56
|
+
artist_id: str
|
|
57
|
+
days: int
|
|
58
|
+
followers: List["DayValue"]
|
|
59
|
+
listeners: List["DayValue"]
|
|
60
|
+
object: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AuditEntry(TypedDict):
|
|
64
|
+
action: str
|
|
65
|
+
actor_user_id: str
|
|
66
|
+
created_at: str
|
|
67
|
+
id: str
|
|
68
|
+
ip: str
|
|
69
|
+
object: str
|
|
70
|
+
target: Dict[str, Any]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Change(TypedDict):
|
|
74
|
+
artist: "Ref"
|
|
75
|
+
confirmed_at: str
|
|
76
|
+
dashboard_url: str
|
|
77
|
+
detail: str
|
|
78
|
+
details: Dict[str, Any]
|
|
79
|
+
entity: "Entity"
|
|
80
|
+
id: str
|
|
81
|
+
label: str
|
|
82
|
+
object: str
|
|
83
|
+
severity: str
|
|
84
|
+
status: str
|
|
85
|
+
summary: str
|
|
86
|
+
type: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ChangeType(TypedDict):
|
|
90
|
+
beta: str
|
|
91
|
+
id: str
|
|
92
|
+
label: str
|
|
93
|
+
object: str
|
|
94
|
+
severity: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DayValue(TypedDict):
|
|
98
|
+
day: str
|
|
99
|
+
value: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Deleted(TypedDict):
|
|
103
|
+
deleted: bool
|
|
104
|
+
id: str
|
|
105
|
+
object: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Destination(TypedDict):
|
|
109
|
+
active: bool
|
|
110
|
+
created_at: str
|
|
111
|
+
disabled_reason: str
|
|
112
|
+
id: str
|
|
113
|
+
kind: str
|
|
114
|
+
last_success_at: NotRequired[Optional[str]]
|
|
115
|
+
name: str
|
|
116
|
+
object: str
|
|
117
|
+
target: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DestinationInput(TypedDict, total=False):
|
|
121
|
+
config: Dict[str, Any]
|
|
122
|
+
kind: str
|
|
123
|
+
name: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class DestinationPatch(TypedDict, total=False):
|
|
127
|
+
config: Dict[str, Any]
|
|
128
|
+
name: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Entity(TypedDict):
|
|
132
|
+
id: str
|
|
133
|
+
name: str
|
|
134
|
+
type: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Event(TypedDict):
|
|
138
|
+
created_at: str
|
|
139
|
+
data: Dict[str, Any]
|
|
140
|
+
id: str
|
|
141
|
+
object: str
|
|
142
|
+
type: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Export(TypedDict):
|
|
146
|
+
created_at: str
|
|
147
|
+
download_url: str
|
|
148
|
+
error: str
|
|
149
|
+
expires_at: str
|
|
150
|
+
finished_at: NotRequired[Optional[str]]
|
|
151
|
+
format: str
|
|
152
|
+
id: str
|
|
153
|
+
kind: str
|
|
154
|
+
object: str
|
|
155
|
+
status: str
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ExportInput(TypedDict, total=False):
|
|
159
|
+
days: int
|
|
160
|
+
format: str
|
|
161
|
+
kind: str
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Impact(TypedDict):
|
|
165
|
+
artist: "Ref"
|
|
166
|
+
change_id: str
|
|
167
|
+
days_gone: int
|
|
168
|
+
lost: NotRequired[Optional[int]]
|
|
169
|
+
name: str
|
|
170
|
+
type: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Import(TypedDict):
|
|
174
|
+
object: str
|
|
175
|
+
results: List["ImportResult"]
|
|
176
|
+
verified: int
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ImportArtists(TypedDict, total=False):
|
|
180
|
+
artists: List["ImportRow"]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ImportResult(TypedDict):
|
|
184
|
+
artist_id: str
|
|
185
|
+
error: str
|
|
186
|
+
name: str
|
|
187
|
+
spotify_id: str
|
|
188
|
+
status: str
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ImportRow(TypedDict, total=False):
|
|
192
|
+
confirmed: bool
|
|
193
|
+
ref: str
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Invite(TypedDict):
|
|
197
|
+
created_at: str
|
|
198
|
+
email: str
|
|
199
|
+
expires_at: str
|
|
200
|
+
id: str
|
|
201
|
+
object: str
|
|
202
|
+
role: str
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class InviteInput(TypedDict, total=False):
|
|
206
|
+
email: str
|
|
207
|
+
role: str
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Key(TypedDict):
|
|
211
|
+
artist_ids: List[str]
|
|
212
|
+
created_at: str
|
|
213
|
+
expires_at: NotRequired[Optional[str]]
|
|
214
|
+
id: str
|
|
215
|
+
name: str
|
|
216
|
+
object: str
|
|
217
|
+
prefix: str
|
|
218
|
+
scopes: List[str]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Limit(TypedDict):
|
|
222
|
+
limit: int
|
|
223
|
+
used: int
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class Me(TypedDict):
|
|
227
|
+
key: "Key"
|
|
228
|
+
object: str
|
|
229
|
+
organization: "Organization"
|
|
230
|
+
usage: "Usage"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class Member(TypedDict):
|
|
234
|
+
display_name: str
|
|
235
|
+
email: str
|
|
236
|
+
joined_at: str
|
|
237
|
+
object: str
|
|
238
|
+
role: str
|
|
239
|
+
user_id: str
|
|
240
|
+
username: str
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Milestone(TypedDict):
|
|
244
|
+
artist: str
|
|
245
|
+
eta_days: NotRequired[Optional[int]]
|
|
246
|
+
kind: str
|
|
247
|
+
name: str
|
|
248
|
+
reached: bool
|
|
249
|
+
target: int
|
|
250
|
+
value: int
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class Mover(TypedDict):
|
|
254
|
+
artist: "Ref"
|
|
255
|
+
last_7_days: int
|
|
256
|
+
name: str
|
|
257
|
+
previous_7_days: int
|
|
258
|
+
track_id: str
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class OrgPatch(TypedDict, total=False):
|
|
262
|
+
name: str
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Organization(TypedDict):
|
|
266
|
+
created_at: str
|
|
267
|
+
id: str
|
|
268
|
+
logo_url: str
|
|
269
|
+
name: str
|
|
270
|
+
object: str
|
|
271
|
+
plan: str
|
|
272
|
+
slug: str
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class Overview(TypedDict):
|
|
276
|
+
artist_id: str
|
|
277
|
+
days: int
|
|
278
|
+
falling: List["Mover"]
|
|
279
|
+
followers: int
|
|
280
|
+
impacts: List["Impact"]
|
|
281
|
+
listeners: int
|
|
282
|
+
milestones: List["Milestone"]
|
|
283
|
+
object: str
|
|
284
|
+
rising: List["Mover"]
|
|
285
|
+
streams: int
|
|
286
|
+
streams_daily: List["DayValue"]
|
|
287
|
+
streams_prev: NotRequired[Optional[int]]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class Plan(TypedDict):
|
|
291
|
+
api: bool
|
|
292
|
+
artists: int
|
|
293
|
+
history_retention_days: int
|
|
294
|
+
id: str
|
|
295
|
+
object: str
|
|
296
|
+
releases_per_artist: int
|
|
297
|
+
requests_per_hour: int
|
|
298
|
+
seats: int
|
|
299
|
+
webhook_endpoints: int
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class Proof(TypedDict):
|
|
303
|
+
created_at: str
|
|
304
|
+
id: str
|
|
305
|
+
licensor: str
|
|
306
|
+
link: str
|
|
307
|
+
object: str
|
|
308
|
+
release_id: str
|
|
309
|
+
territory: str
|
|
310
|
+
title: str
|
|
311
|
+
valid_until: str
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class ProofInput(TypedDict, total=False):
|
|
315
|
+
licensor: str
|
|
316
|
+
link: str
|
|
317
|
+
territory: str
|
|
318
|
+
title: str
|
|
319
|
+
valid_until: str
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class Ref(TypedDict):
|
|
323
|
+
id: str
|
|
324
|
+
name: str
|
|
325
|
+
spotify_id: NotRequired[str]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class Release(TypedDict):
|
|
329
|
+
artist: "Ref"
|
|
330
|
+
c_line: str
|
|
331
|
+
id: str
|
|
332
|
+
image_url: str
|
|
333
|
+
label: str
|
|
334
|
+
name: str
|
|
335
|
+
object: str
|
|
336
|
+
p_line: str
|
|
337
|
+
proofs: int
|
|
338
|
+
release_date: str
|
|
339
|
+
spotify_id: str
|
|
340
|
+
state: str
|
|
341
|
+
tracks_offline: int
|
|
342
|
+
tracks_total: int
|
|
343
|
+
type: str
|
|
344
|
+
upc: str
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class ResolveMany(TypedDict, total=False):
|
|
348
|
+
ids: List[str]
|
|
349
|
+
resolved: bool
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class ResolveResult(TypedDict):
|
|
353
|
+
ids: List[str]
|
|
354
|
+
object: str
|
|
355
|
+
resolved: bool
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class RoleInput(TypedDict, total=False):
|
|
359
|
+
role: str
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class Rule(TypedDict):
|
|
363
|
+
active: bool
|
|
364
|
+
artist_ids: List[str]
|
|
365
|
+
change_types: List[str]
|
|
366
|
+
created_at: str
|
|
367
|
+
destination_id: str
|
|
368
|
+
digest: str
|
|
369
|
+
id: str
|
|
370
|
+
label_tag: str
|
|
371
|
+
locale: NotRequired[Optional[str]]
|
|
372
|
+
min_severity: str
|
|
373
|
+
object: str
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class RuleInput(TypedDict, total=False):
|
|
377
|
+
artist_ids: List[str]
|
|
378
|
+
change_types: List[str]
|
|
379
|
+
destination_id: str
|
|
380
|
+
digest: str
|
|
381
|
+
locale: Optional[str]
|
|
382
|
+
min_severity: str
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class RulePatch(TypedDict, total=False):
|
|
386
|
+
active: Optional[bool]
|
|
387
|
+
artist_ids: Optional[List[str]]
|
|
388
|
+
change_types: Optional[List[str]]
|
|
389
|
+
destination_id: Optional[str]
|
|
390
|
+
digest: Optional[str]
|
|
391
|
+
locale: Optional[str]
|
|
392
|
+
min_severity: Optional[str]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class TestResult(TypedDict):
|
|
396
|
+
error: str
|
|
397
|
+
object: str
|
|
398
|
+
ok: bool
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
ToolInput = Dict[str, Any]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class Track(TypedDict):
|
|
405
|
+
artist: "Ref"
|
|
406
|
+
artists: List[str]
|
|
407
|
+
disc_number: int
|
|
408
|
+
duration_ms: int
|
|
409
|
+
explicit: bool
|
|
410
|
+
id: str
|
|
411
|
+
isrc: str
|
|
412
|
+
name: str
|
|
413
|
+
object: str
|
|
414
|
+
playable: bool
|
|
415
|
+
playcount: int
|
|
416
|
+
release: "Ref"
|
|
417
|
+
removed_at: NotRequired[Optional[str]]
|
|
418
|
+
spotify_id: str
|
|
419
|
+
track_number: int
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class TrackPlays(TypedDict):
|
|
423
|
+
days: int
|
|
424
|
+
object: str
|
|
425
|
+
plays: List["DayValue"]
|
|
426
|
+
track_id: str
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class Trend(TypedDict):
|
|
430
|
+
artist_id: str
|
|
431
|
+
buckets: List["TrendBucket"]
|
|
432
|
+
days: int
|
|
433
|
+
estimated: bool
|
|
434
|
+
grain: str
|
|
435
|
+
object: str
|
|
436
|
+
songs: List["TrendSong"]
|
|
437
|
+
streams_prev: NotRequired[Optional[int]]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
TrendBucket = TypedDict("TrendBucket", {"followers": NotRequired[Optional[int]], "from": str, "listeners": NotRequired[Optional[int]], "streams": int, "streams_total": int, "to": str})
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class TrendSong(TypedDict):
|
|
444
|
+
artist: "Ref"
|
|
445
|
+
name: str
|
|
446
|
+
plays: List[int]
|
|
447
|
+
plays_prev: NotRequired[Optional[int]]
|
|
448
|
+
spotify_id: str
|
|
449
|
+
total: int
|
|
450
|
+
track_id: str
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class Usage(TypedDict):
|
|
454
|
+
api_rate_per_hour: int
|
|
455
|
+
artists: "Limit"
|
|
456
|
+
history_retention_days: int
|
|
457
|
+
object: str
|
|
458
|
+
plan: str
|
|
459
|
+
requests_per_hour: int
|
|
460
|
+
seats: "Limit"
|
|
461
|
+
webhook_endpoints: "Limit"
|
|
462
|
+
writes_per_minute: int
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class Verification(TypedDict):
|
|
466
|
+
artist_id: str
|
|
467
|
+
attest_allowed: bool
|
|
468
|
+
code: str
|
|
469
|
+
method: str
|
|
470
|
+
object: str
|
|
471
|
+
status: str
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class Verify(TypedDict, total=False):
|
|
475
|
+
confirmed: bool
|
|
476
|
+
method: str
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class WebhookDelivery(TypedDict):
|
|
480
|
+
attempts: int
|
|
481
|
+
created_at: str
|
|
482
|
+
delivered_at: NotRequired[Optional[str]]
|
|
483
|
+
duration_ms: NotRequired[Optional[int]]
|
|
484
|
+
endpoint_id: str
|
|
485
|
+
error: str
|
|
486
|
+
event_id: str
|
|
487
|
+
event_type: str
|
|
488
|
+
id: str
|
|
489
|
+
next_attempt_at: NotRequired[Optional[str]]
|
|
490
|
+
object: str
|
|
491
|
+
response_status: NotRequired[Optional[int]]
|
|
492
|
+
status: str
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class WebhookEndpoint(TypedDict):
|
|
496
|
+
artist_ids: List[str]
|
|
497
|
+
created_at: str
|
|
498
|
+
description: str
|
|
499
|
+
events: List[str]
|
|
500
|
+
failing_since: NotRequired[Optional[str]]
|
|
501
|
+
id: str
|
|
502
|
+
object: str
|
|
503
|
+
pause_reason: str
|
|
504
|
+
paused: bool
|
|
505
|
+
secret: NotRequired[str]
|
|
506
|
+
url: str
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
class WebhookInput(TypedDict, total=False):
|
|
510
|
+
artist_ids: List[str]
|
|
511
|
+
description: str
|
|
512
|
+
events: List[str]
|
|
513
|
+
url: str
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class WebhookPatch(TypedDict, total=False):
|
|
517
|
+
artist_ids: Optional[List[str]]
|
|
518
|
+
description: Optional[str]
|
|
519
|
+
events: Optional[List[str]]
|
|
520
|
+
paused: Optional[bool]
|
|
521
|
+
url: Optional[str]
|
|
522
|
+
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Generated by scripts/generate.py from openapi.json. Do not edit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional
|
|
6
|
+
|
|
7
|
+
from . import models as m
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .client import LabelSentry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Artists:
|
|
14
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def check_verification(self, id: str) -> m.Verification:
|
|
18
|
+
"""Look for the code in the Spotify biography now. Needs ``verification:write``."""
|
|
19
|
+
return self._client._request("POST", f"/artists/{id}/verification/check", query=None, body=None, unwrap=True)
|
|
20
|
+
|
|
21
|
+
def create(self, body: m.AddArtist) -> m.Artist:
|
|
22
|
+
"""Add an artist by Spotify id or link. Needs ``artists:write``."""
|
|
23
|
+
return self._client._request("POST", "/artists", query=None, body=body, unwrap=True)
|
|
24
|
+
|
|
25
|
+
def get(self, id: str) -> m.Artist:
|
|
26
|
+
"""One artist. Needs ``artists:read``."""
|
|
27
|
+
return self._client._request("GET", f"/artists/{id}", query=None, body=None, unwrap=True)
|
|
28
|
+
|
|
29
|
+
def import_(self, body: m.ImportArtists) -> m.Import:
|
|
30
|
+
"""Add up to 500 artists at once, verified directly (Business). Needs ``artists:write``."""
|
|
31
|
+
return self._client._request("POST", "/artists/import", query=None, body=body, unwrap=True)
|
|
32
|
+
|
|
33
|
+
def list(self, *, status: Optional[str] = None, limit: Optional[int] = None) -> Iterator[m.Artist]:
|
|
34
|
+
"""The roster. Needs ``artists:read``."""
|
|
35
|
+
return self._client._page("/artists", {"status": status, "limit": limit})
|
|
36
|
+
|
|
37
|
+
def releases(self, id: str, *, limit: Optional[int] = None) -> Iterator[m.Release]:
|
|
38
|
+
"""An artist's releases, newest first. Needs ``catalog:read``."""
|
|
39
|
+
return self._client._page(f"/artists/{id}/releases", {"limit": limit})
|
|
40
|
+
|
|
41
|
+
def remove(self, id: str) -> m.Deleted:
|
|
42
|
+
"""Remove an artist from the roster. Needs ``artists:write``."""
|
|
43
|
+
return self._client._request("DELETE", f"/artists/{id}", query=None, body=None, unwrap=True)
|
|
44
|
+
|
|
45
|
+
def search(self, *, q: Optional[str] = None) -> Iterator[m.ArtistSearchResult]:
|
|
46
|
+
"""Search Spotify for an artist to add. Needs ``artists:write``."""
|
|
47
|
+
return self._client._page("/artists/search", {"q": q})
|
|
48
|
+
|
|
49
|
+
def stats(self, id: str, *, days: Optional[int] = None) -> m.ArtistStats:
|
|
50
|
+
"""An artist's listeners and followers per day. Needs ``stats:read``."""
|
|
51
|
+
return self._client._request("GET", f"/artists/{id}/stats", query={"days": days}, body=None, unwrap=True)
|
|
52
|
+
|
|
53
|
+
def verification(self, id: str) -> m.Verification:
|
|
54
|
+
"""Where an artist's verification stands. Needs ``artists:read``."""
|
|
55
|
+
return self._client._request("GET", f"/artists/{id}/verification", query=None, body=None, unwrap=True)
|
|
56
|
+
|
|
57
|
+
def verify(self, id: str, body: m.Verify) -> m.Verification:
|
|
58
|
+
"""Verify an artist: confirm the rights, check the biography code, or directly. Needs ``verification:write``."""
|
|
59
|
+
return self._client._request("POST", f"/artists/{id}/verification", query=None, body=body, unwrap=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Audit:
|
|
63
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
64
|
+
self._client = client
|
|
65
|
+
|
|
66
|
+
def list(self, *, limit: Optional[int] = None) -> Iterator[m.AuditEntry]:
|
|
67
|
+
"""Who did what and when, API keys included. Needs ``audit:read``."""
|
|
68
|
+
return self._client._page("/audit", {"limit": limit})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Changes:
|
|
72
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
73
|
+
self._client = client
|
|
74
|
+
|
|
75
|
+
def get(self, id: str) -> m.Change:
|
|
76
|
+
"""One change with its details. Needs ``changes:read``."""
|
|
77
|
+
return self._client._request("GET", f"/changes/{id}", query=None, body=None, unwrap=True)
|
|
78
|
+
|
|
79
|
+
def list(self, *, artist_id: Optional[str] = None, type: Optional[str] = None, severity: Optional[str] = None, status: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, limit: Optional[int] = None) -> Iterator[m.Change]:
|
|
80
|
+
"""Changes, newest first. Needs ``changes:read``."""
|
|
81
|
+
return self._client._page("/changes", {"artist_id": artist_id, "type": type, "severity": severity, "status": status, "since": since, "until": until, "limit": limit})
|
|
82
|
+
|
|
83
|
+
def reopen(self, id: str) -> m.Change:
|
|
84
|
+
"""Open a resolved change again. Needs ``changes:write``."""
|
|
85
|
+
return self._client._request("DELETE", f"/changes/{id}/resolve", query=None, body=None, unwrap=True)
|
|
86
|
+
|
|
87
|
+
def resolve(self, id: str) -> m.Change:
|
|
88
|
+
"""Mark a change as resolved. Needs ``changes:write``."""
|
|
89
|
+
return self._client._request("POST", f"/changes/{id}/resolve", query=None, body=None, unwrap=True)
|
|
90
|
+
|
|
91
|
+
def resolve_many(self, body: m.ResolveMany) -> m.ResolveResult:
|
|
92
|
+
"""Resolve or reopen up to 200 changes. Needs ``changes:write``."""
|
|
93
|
+
return self._client._request("POST", "/changes/resolve", query=None, body=body, unwrap=True)
|
|
94
|
+
|
|
95
|
+
def types(self) -> Iterator[m.ChangeType]:
|
|
96
|
+
"""Every change type with its severity. Needs ``changes:read``."""
|
|
97
|
+
return self._client._page("/change-types", None)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Destinations:
|
|
101
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
102
|
+
self._client = client
|
|
103
|
+
|
|
104
|
+
def create(self, body: m.DestinationInput) -> m.Destination:
|
|
105
|
+
"""Add a destination. Needs ``alerts:write``."""
|
|
106
|
+
return self._client._request("POST", "/destinations", query=None, body=body, unwrap=True)
|
|
107
|
+
|
|
108
|
+
def deliveries(self, *, destination_id: Optional[str] = None, limit: Optional[int] = None) -> Iterator[m.AlertDelivery]:
|
|
109
|
+
"""Alerts sent, newest first. Needs ``alerts:read``."""
|
|
110
|
+
return self._client._page("/deliveries", {"destination_id": destination_id, "limit": limit})
|
|
111
|
+
|
|
112
|
+
def list(self) -> Iterator[m.Destination]:
|
|
113
|
+
"""Where alerts go. Needs ``alerts:read``."""
|
|
114
|
+
return self._client._page("/destinations", None)
|
|
115
|
+
|
|
116
|
+
def remove(self, id: str) -> m.Deleted:
|
|
117
|
+
"""Delete a destination and its rules. Needs ``alerts:write``."""
|
|
118
|
+
return self._client._request("DELETE", f"/destinations/{id}", query=None, body=None, unwrap=True)
|
|
119
|
+
|
|
120
|
+
def test(self, id: str) -> m.TestResult:
|
|
121
|
+
"""Send a test alert. Needs ``alerts:write``."""
|
|
122
|
+
return self._client._request("POST", f"/destinations/{id}/test", query=None, body=None, unwrap=True)
|
|
123
|
+
|
|
124
|
+
def update(self, id: str, body: m.DestinationPatch) -> m.Destination:
|
|
125
|
+
"""Rename, change or pause a destination. Needs ``alerts:write``."""
|
|
126
|
+
return self._client._request("PATCH", f"/destinations/{id}", query=None, body=body, unwrap=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Events:
|
|
130
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
131
|
+
self._client = client
|
|
132
|
+
|
|
133
|
+
def get(self, id: str) -> m.Event:
|
|
134
|
+
"""One event. Needs ``changes:read``."""
|
|
135
|
+
return self._client._request("GET", f"/events/{id}", query=None, body=None, unwrap=True)
|
|
136
|
+
|
|
137
|
+
def list(self, *, type: Optional[str] = None, artist_id: Optional[str] = None, since: Optional[str] = None, order: Optional[str] = None, limit: Optional[int] = None) -> Iterator[m.Event]:
|
|
138
|
+
"""Events of the last 30 days, the same ones webhooks send. Needs ``changes:read``."""
|
|
139
|
+
return self._client._page("/events", {"type": type, "artist_id": artist_id, "since": since, "order": order, "limit": limit})
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Exports:
|
|
143
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
144
|
+
self._client = client
|
|
145
|
+
|
|
146
|
+
def create(self, body: m.ExportInput) -> m.Export:
|
|
147
|
+
"""Start an export; export.ready is sent when it is done. Needs ``exports:write``."""
|
|
148
|
+
return self._client._request("POST", "/exports", query=None, body=body, unwrap=True)
|
|
149
|
+
|
|
150
|
+
def download(self, id: str) -> str:
|
|
151
|
+
"""The export's file, CSV or JSON. Needs ``exports:write``."""
|
|
152
|
+
return self._client._text("GET", f"/exports/{id}/download", None)
|
|
153
|
+
|
|
154
|
+
def get(self, id: str) -> m.Export:
|
|
155
|
+
"""Where an export stands. Needs ``exports:write``."""
|
|
156
|
+
return self._client._request("GET", f"/exports/{id}", query=None, body=None, unwrap=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Invites:
|
|
160
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
161
|
+
self._client = client
|
|
162
|
+
|
|
163
|
+
def create(self, body: m.InviteInput) -> m.Invite:
|
|
164
|
+
"""Invite someone by email. Needs ``members:write``."""
|
|
165
|
+
return self._client._request("POST", "/invites", query=None, body=body, unwrap=True)
|
|
166
|
+
|
|
167
|
+
def list(self) -> Iterator[m.Invite]:
|
|
168
|
+
"""Open invites. Needs ``members:read``."""
|
|
169
|
+
return self._client._page("/invites", None)
|
|
170
|
+
|
|
171
|
+
def revoke(self, id: str) -> m.Deleted:
|
|
172
|
+
"""Withdraw an invite. Needs ``members:write``."""
|
|
173
|
+
return self._client._request("DELETE", f"/invites/{id}", query=None, body=None, unwrap=True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Members:
|
|
177
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
178
|
+
self._client = client
|
|
179
|
+
|
|
180
|
+
def list(self) -> Iterator[m.Member]:
|
|
181
|
+
"""Members with their role. Needs ``members:read``."""
|
|
182
|
+
return self._client._page("/members", None)
|
|
183
|
+
|
|
184
|
+
def remove(self, id: str) -> m.Deleted:
|
|
185
|
+
"""Remove a member. Needs ``members:write``."""
|
|
186
|
+
return self._client._request("DELETE", f"/members/{id}", query=None, body=None, unwrap=True)
|
|
187
|
+
|
|
188
|
+
def update(self, id: str, body: m.RoleInput) -> m.Member:
|
|
189
|
+
"""Change a member's role. Needs ``members:write``."""
|
|
190
|
+
return self._client._request("PATCH", f"/members/{id}", query=None, body=body, unwrap=True)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Organization:
|
|
194
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
195
|
+
self._client = client
|
|
196
|
+
|
|
197
|
+
def get(self) -> m.Organization:
|
|
198
|
+
"""The organization. Needs ``organization:read``."""
|
|
199
|
+
return self._client._request("GET", "/organization", query=None, body=None, unwrap=True)
|
|
200
|
+
|
|
201
|
+
def me(self) -> m.Me:
|
|
202
|
+
"""The key, its rights, the organization and its usage."""
|
|
203
|
+
return self._client._request("GET", "/me", query=None, body=None, unwrap=True)
|
|
204
|
+
|
|
205
|
+
def plans(self) -> Iterator[m.Plan]:
|
|
206
|
+
"""Every plan with its limits. Needs ``organization:read``."""
|
|
207
|
+
return self._client._page("/plans", None)
|
|
208
|
+
|
|
209
|
+
def update(self, body: m.OrgPatch) -> m.Organization:
|
|
210
|
+
"""Rename the organization. Needs ``organization:write``."""
|
|
211
|
+
return self._client._request("PATCH", "/organization", query=None, body=body, unwrap=True)
|
|
212
|
+
|
|
213
|
+
def usage(self) -> m.Usage:
|
|
214
|
+
"""Artists, seats, requests and endpoints used against the plan. Needs ``organization:read``."""
|
|
215
|
+
return self._client._request("GET", "/usage", query=None, body=None, unwrap=True)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Proofs:
|
|
219
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
220
|
+
self._client = client
|
|
221
|
+
|
|
222
|
+
def remove(self, id: str) -> m.Deleted:
|
|
223
|
+
"""Remove a proof. Needs ``catalog:write``."""
|
|
224
|
+
return self._client._request("DELETE", f"/proofs/{id}", query=None, body=None, unwrap=True)
|
|
225
|
+
|
|
226
|
+
def update(self, id: str, body: m.ProofInput) -> m.Proof:
|
|
227
|
+
"""Change a proof. Needs ``catalog:write``."""
|
|
228
|
+
return self._client._request("PATCH", f"/proofs/{id}", query=None, body=body, unwrap=True)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Releases:
|
|
232
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
233
|
+
self._client = client
|
|
234
|
+
|
|
235
|
+
def add_proof(self, id: str, body: m.ProofInput) -> m.Proof:
|
|
236
|
+
"""Note a proof of rights on a release. Needs ``catalog:write``."""
|
|
237
|
+
return self._client._request("POST", f"/releases/{id}/proofs", query=None, body=body, unwrap=True)
|
|
238
|
+
|
|
239
|
+
def get(self, id: str) -> m.Release:
|
|
240
|
+
"""One release. Needs ``catalog:read``."""
|
|
241
|
+
return self._client._request("GET", f"/releases/{id}", query=None, body=None, unwrap=True)
|
|
242
|
+
|
|
243
|
+
def list(self, *, artist_id: Optional[str] = None, state: Optional[str] = None, type: Optional[str] = None, label: Optional[str] = None, from_: Optional[str] = None, to: Optional[str] = None, q: Optional[str] = None, limit: Optional[int] = None) -> Iterator[m.Release]:
|
|
244
|
+
"""Every release of the roster. Needs ``catalog:read``."""
|
|
245
|
+
return self._client._page("/releases", {"artist_id": artist_id, "state": state, "type": type, "label": label, "from": from_, "to": to, "q": q, "limit": limit})
|
|
246
|
+
|
|
247
|
+
def proofs(self, id: str) -> Iterator[m.Proof]:
|
|
248
|
+
"""Proofs of rights noted on a release. Needs ``catalog:read``."""
|
|
249
|
+
return self._client._page(f"/releases/{id}/proofs", None)
|
|
250
|
+
|
|
251
|
+
def tracks(self, id: str) -> Iterator[m.Track]:
|
|
252
|
+
"""A release's tracks in their order. Needs ``catalog:read``."""
|
|
253
|
+
return self._client._page(f"/releases/{id}/tracks", None)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class Rules:
|
|
257
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
258
|
+
self._client = client
|
|
259
|
+
|
|
260
|
+
def create(self, body: m.RuleInput) -> m.Rule:
|
|
261
|
+
"""Add a rule. Needs ``alerts:write``."""
|
|
262
|
+
return self._client._request("POST", "/rules", query=None, body=body, unwrap=True)
|
|
263
|
+
|
|
264
|
+
def list(self) -> Iterator[m.Rule]:
|
|
265
|
+
"""Which changes go to which destination, and when. Needs ``alerts:read``."""
|
|
266
|
+
return self._client._page("/rules", None)
|
|
267
|
+
|
|
268
|
+
def remove(self, id: str) -> m.Deleted:
|
|
269
|
+
"""Delete a rule. Needs ``alerts:write``."""
|
|
270
|
+
return self._client._request("DELETE", f"/rules/{id}", query=None, body=None, unwrap=True)
|
|
271
|
+
|
|
272
|
+
def update(self, id: str, body: m.RulePatch) -> m.Rule:
|
|
273
|
+
"""Change a rule. Needs ``alerts:write``."""
|
|
274
|
+
return self._client._request("PATCH", f"/rules/{id}", query=None, body=body, unwrap=True)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class Stats:
|
|
278
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
279
|
+
self._client = client
|
|
280
|
+
|
|
281
|
+
def overview(self, *, days: Optional[int] = None, artist_id: Optional[str] = None) -> m.Overview:
|
|
282
|
+
"""What stands out: offline losses, risers, fallers, milestones. Needs ``stats:read``."""
|
|
283
|
+
return self._client._request("GET", "/stats/overview", query={"days": days, "artist_id": artist_id}, body=None, unwrap=True)
|
|
284
|
+
|
|
285
|
+
def trend(self, *, days: Optional[int] = None, artist_id: Optional[str] = None) -> m.Trend:
|
|
286
|
+
"""Streams, listeners and followers over 7, 28 or 90 days, with every song. Needs ``stats:read``."""
|
|
287
|
+
return self._client._request("GET", "/stats/trend", query={"days": days, "artist_id": artist_id}, body=None, unwrap=True)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class Tools:
|
|
291
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
292
|
+
self._client = client
|
|
293
|
+
|
|
294
|
+
def history(self) -> Dict[str, Any]:
|
|
295
|
+
"""Earlier tool queries. Needs ``tools:use``."""
|
|
296
|
+
return self._client._request("GET", "/tools/history", query=None, body=None, unwrap=False)
|
|
297
|
+
|
|
298
|
+
def list(self, body: m.ToolInput) -> Dict[str, Any]:
|
|
299
|
+
"""ISRC by Track, for a list. Needs ``tools:use``."""
|
|
300
|
+
return self._client._request("POST", "/tools/list", query=None, body=body, unwrap=False)
|
|
301
|
+
|
|
302
|
+
def lookup(self, body: m.ToolInput) -> Dict[str, Any]:
|
|
303
|
+
"""Metadata Lookup for a track or release link. Needs ``tools:use``."""
|
|
304
|
+
return self._client._request("POST", "/tools/lookup", query=None, body=body, unwrap=False)
|
|
305
|
+
|
|
306
|
+
def reverse(self, body: m.ToolInput) -> Dict[str, Any]:
|
|
307
|
+
"""Track by ISRC. Needs ``tools:use``."""
|
|
308
|
+
return self._client._request("POST", "/tools/reverse", query=None, body=body, unwrap=False)
|
|
309
|
+
|
|
310
|
+
def usage(self) -> Dict[str, Any]:
|
|
311
|
+
"""Allowance and use of the tools. Needs ``tools:use``."""
|
|
312
|
+
return self._client._request("GET", "/tools", query=None, body=None, unwrap=False)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class Tracks:
|
|
316
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
317
|
+
self._client = client
|
|
318
|
+
|
|
319
|
+
def get(self, id: str) -> m.Track:
|
|
320
|
+
"""One track. Needs ``catalog:read``."""
|
|
321
|
+
return self._client._request("GET", f"/tracks/{id}", query=None, body=None, unwrap=True)
|
|
322
|
+
|
|
323
|
+
def list(self, *, isrc: Optional[str] = None, artist_id: Optional[str] = None, limit: Optional[int] = None) -> Iterator[m.Track]:
|
|
324
|
+
"""Find tracks by ISRC or artist. Needs ``catalog:read``."""
|
|
325
|
+
return self._client._page("/tracks", {"isrc": isrc, "artist_id": artist_id, "limit": limit})
|
|
326
|
+
|
|
327
|
+
def plays(self, id: str, *, days: Optional[int] = None) -> m.TrackPlays:
|
|
328
|
+
"""A track's plays per day, or per week over 90 days. Needs ``stats:read``."""
|
|
329
|
+
return self._client._request("GET", f"/tracks/{id}/plays", query={"days": days}, body=None, unwrap=True)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class Webhooks:
|
|
333
|
+
def __init__(self, client: "LabelSentry") -> None:
|
|
334
|
+
self._client = client
|
|
335
|
+
|
|
336
|
+
def create(self, body: m.WebhookInput) -> m.WebhookEndpoint:
|
|
337
|
+
"""Add an endpoint; the secret is in this answer only. Needs ``webhooks:write``."""
|
|
338
|
+
return self._client._request("POST", "/webhooks", query=None, body=body, unwrap=True)
|
|
339
|
+
|
|
340
|
+
def deliveries(self, id: str, *, limit: Optional[int] = None) -> Iterator[m.WebhookDelivery]:
|
|
341
|
+
"""Deliveries to an endpoint, newest first. Needs ``webhooks:read``."""
|
|
342
|
+
return self._client._page(f"/webhooks/{id}/deliveries", {"limit": limit})
|
|
343
|
+
|
|
344
|
+
def get(self, id: str) -> m.WebhookEndpoint:
|
|
345
|
+
"""One endpoint. Needs ``webhooks:read``."""
|
|
346
|
+
return self._client._request("GET", f"/webhooks/{id}", query=None, body=None, unwrap=True)
|
|
347
|
+
|
|
348
|
+
def list(self) -> Iterator[m.WebhookEndpoint]:
|
|
349
|
+
"""Webhook endpoints. Needs ``webhooks:read``."""
|
|
350
|
+
return self._client._page("/webhooks", None)
|
|
351
|
+
|
|
352
|
+
def redeliver(self, id: str) -> m.WebhookDelivery:
|
|
353
|
+
"""Send a delivery again. Needs ``webhooks:write``."""
|
|
354
|
+
return self._client._request("POST", f"/webhooks/deliveries/{id}/redeliver", query=None, body=None, unwrap=True)
|
|
355
|
+
|
|
356
|
+
def remove(self, id: str) -> m.Deleted:
|
|
357
|
+
"""Delete an endpoint. Needs ``webhooks:write``."""
|
|
358
|
+
return self._client._request("DELETE", f"/webhooks/{id}", query=None, body=None, unwrap=True)
|
|
359
|
+
|
|
360
|
+
def rotate_secret(self, id: str) -> m.WebhookEndpoint:
|
|
361
|
+
"""New secret; the old one still signs for 24 hours. Needs ``webhooks:write``."""
|
|
362
|
+
return self._client._request("POST", f"/webhooks/{id}/rotate-secret", query=None, body=None, unwrap=True)
|
|
363
|
+
|
|
364
|
+
def test(self, id: str) -> m.WebhookDelivery:
|
|
365
|
+
"""Send a ping event. Needs ``webhooks:write``."""
|
|
366
|
+
return self._client._request("POST", f"/webhooks/{id}/test", query=None, body=None, unwrap=True)
|
|
367
|
+
|
|
368
|
+
def update(self, id: str, body: m.WebhookPatch) -> m.WebhookEndpoint:
|
|
369
|
+
"""Change events, artists or address, pause or resume. Needs ``webhooks:write``."""
|
|
370
|
+
return self._client._request("PATCH", f"/webhooks/{id}", query=None, body=body, unwrap=True)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
RESOURCES = {"artists": Artists, "audit": Audit, "changes": Changes, "destinations": Destinations, "events": Events, "exports": Exports, "invites": Invites, "members": Members, "organization": Organization, "proofs": Proofs, "releases": Releases, "rules": Rules, "stats": Stats, "tools": Tools, "tracks": Tracks, "webhooks": Webhooks}
|
labelsentry/py.typed
ADDED
|
File without changes
|
labelsentry/webhooks.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
from .models import Event
|
|
10
|
+
|
|
11
|
+
TOLERANCE = 300
|
|
12
|
+
"""How old a delivery may be before it is refused, in seconds."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def verify_webhook(raw_body: Union[str, bytes], signature: str, secret: str, now: Optional[float] = None) -> Event:
|
|
16
|
+
"""Checks the X-LabelSentry-Signature header against the raw request body
|
|
17
|
+
and returns the event. Raises ValueError when it does not match or is too
|
|
18
|
+
old. Pass the body exactly as received, before parsing it."""
|
|
19
|
+
body = raw_body.decode() if isinstance(raw_body, bytes) else raw_body
|
|
20
|
+
t, sigs = "", []
|
|
21
|
+
for part in signature.split(","):
|
|
22
|
+
k, _, v = part.partition("=")
|
|
23
|
+
if k == "t":
|
|
24
|
+
t = v
|
|
25
|
+
elif k == "v1":
|
|
26
|
+
sigs.append(v)
|
|
27
|
+
if not t or not sigs:
|
|
28
|
+
raise ValueError("malformed signature header")
|
|
29
|
+
if abs((now or time.time()) - int(t)) > TOLERANCE:
|
|
30
|
+
raise ValueError("delivery is too old")
|
|
31
|
+
want = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
32
|
+
if not any(hmac.compare_digest(want, s) for s in sigs):
|
|
33
|
+
raise ValueError("signature does not match")
|
|
34
|
+
return json.loads(body)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: labelsentry
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The LabelSentry API for Python
|
|
5
|
+
Project-URL: Documentation, https://labelsentry.com/docs/api
|
|
6
|
+
Project-URL: Source, https://github.com/LabelSentrycom/labelsentry-sdks
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: typing-extensions>=4.7; python_version < '3.11'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# labelsentry
|
|
13
|
+
|
|
14
|
+
The LabelSentry API for Python 3.9 and newer, with no dependencies beyond the standard library.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from labelsentry import LabelSentry, LabelSentryError
|
|
18
|
+
|
|
19
|
+
ls = LabelSentry() # reads LABELSENTRY_KEY
|
|
20
|
+
|
|
21
|
+
artist = ls.artists.create({"spotify_url": "https://open.spotify.com/artist/..."})
|
|
22
|
+
ls.artists.verify(artist["id"], {"method": "attest", "confirmed": True})
|
|
23
|
+
|
|
24
|
+
for release in ls.releases.list(state="down"):
|
|
25
|
+
print(release["name"], release["tracks_offline"])
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
ls.changes.resolve("not-a-change")
|
|
29
|
+
except LabelSentryError as e:
|
|
30
|
+
print(e.status, e.code, e.request_id)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Lists are iterators that fetch the next page when they reach the end of one.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
labelsentry/__init__.py,sha256=sdHR33nqmusIAKX9o3aAKKi_wva-U8KI6jzJGLirMck,290
|
|
2
|
+
labelsentry/client.py,sha256=9O6HHQ1tZQUM4slF5_ulnTK72l5pnjPlRDBh7lINGEY,3858
|
|
3
|
+
labelsentry/errors.py,sha256=hLI6YjMtJfHmMxALDHPOJQX9LXg3Hh6aXgNzmQCaNNk,1068
|
|
4
|
+
labelsentry/models.py,sha256=jLV3r7JhZp_R_S65qJ47dlTgEg1qLSPU_4HJOkWTqBs,9266
|
|
5
|
+
labelsentry/operations.py,sha256=g3xQnSBvUN5grqvvYP1wYZEATgjb80-5bY10hX8AalU,18544
|
|
6
|
+
labelsentry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
labelsentry/webhooks.py,sha256=U-GZiFpt8u04kAwx9Ptk9qU2OsWMAor9v0c5Xzd1Ido,1247
|
|
8
|
+
labelsentry-1.0.0.dist-info/METADATA,sha256=1biMUVgmyxUsM_JMbvVQ4IEhQBa6S_jMyiSBDEy7Mv4,1058
|
|
9
|
+
labelsentry-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
labelsentry-1.0.0.dist-info/RECORD,,
|