labelsentry 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .venv/
@@ -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,22 @@
1
+ # labelsentry
2
+
3
+ The LabelSentry API for Python 3.9 and newer, with no dependencies beyond the standard library.
4
+
5
+ ```python
6
+ from labelsentry import LabelSentry, LabelSentryError
7
+
8
+ ls = LabelSentry() # reads LABELSENTRY_KEY
9
+
10
+ artist = ls.artists.create({"spotify_url": "https://open.spotify.com/artist/..."})
11
+ ls.artists.verify(artist["id"], {"method": "attest", "confirmed": True})
12
+
13
+ for release in ls.releases.list(state="down"):
14
+ print(release["name"], release["tracks_offline"])
15
+
16
+ try:
17
+ ls.changes.resolve("not-a-change")
18
+ except LabelSentryError as e:
19
+ print(e.status, e.code, e.request_id)
20
+ ```
21
+
22
+ Lists are iterators that fetch the next page when they reach the end of one.
@@ -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
@@ -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
@@ -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", ""))
@@ -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}
File without changes
@@ -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,19 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "labelsentry"
7
+ version = "1.0.0"
8
+ description = "The LabelSentry API for Python"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ dependencies = ["typing_extensions>=4.7; python_version < '3.11'"]
13
+
14
+ [project.urls]
15
+ Documentation = "https://labelsentry.com/docs/api"
16
+ Source = "https://github.com/LabelSentrycom/labelsentry-sdks"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["labelsentry"]
@@ -0,0 +1,65 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import sys
5
+ import threading
6
+ import unittest
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
+ from labelsentry import LabelSentry, LabelSentryError, verify_webhook # noqa: E402
12
+
13
+
14
+ class Handler(BaseHTTPRequestHandler):
15
+ calls = 0
16
+
17
+ def log_message(self, *args):
18
+ pass
19
+
20
+ def _send(self, status, body, headers=None):
21
+ self.send_response(status)
22
+ self.send_header("Content-Type", "application/json")
23
+ for k, v in (headers or {}).items():
24
+ self.send_header(k, v)
25
+ self.end_headers()
26
+ self.wfile.write(json.dumps(body).encode())
27
+
28
+ def do_GET(self):
29
+ Handler.calls += 1
30
+ if Handler.calls == 1:
31
+ return self._send(429, {}, {"Retry-After": "0"})
32
+ if "cursor=c2" in self.path:
33
+ return self._send(200, {"data": [{"id": "a2", "name": "Two"}], "next_cursor": None})
34
+ return self._send(200, {"data": [{"id": "a1", "name": "One"}], "next_cursor": "c2"})
35
+
36
+ def do_POST(self):
37
+ assert self.headers.get("Idempotency-Key")
38
+ self._send(404, {"error": {"code": "not_found", "message": "no change", "request_id": "req_1"}})
39
+
40
+
41
+ class SDKTest(unittest.TestCase):
42
+ def test_pages_retries_errors(self):
43
+ srv = HTTPServer(("127.0.0.1", 0), Handler)
44
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
45
+ ls = LabelSentry("k", base_url=f"http://127.0.0.1:{srv.server_port}")
46
+ self.assertEqual([a["name"] for a in ls.artists.list()], ["One", "Two"])
47
+ with self.assertRaises(LabelSentryError) as cm:
48
+ ls.changes.resolve("x")
49
+ self.assertEqual((cm.exception.code, cm.exception.request_id), ("not_found", "req_1"))
50
+ srv.shutdown()
51
+
52
+ def test_verify_webhook(self):
53
+ body = json.dumps({"id": "e1", "type": "ping", "created_at": "2026-10-22T00:00:00Z", "data": {}})
54
+ t = 1792700000
55
+ sig = hmac.new(b"whsec_a", f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
56
+ header = f"t={t},v1={sig}"
57
+ self.assertEqual(verify_webhook(body, header, "whsec_a", now=t)["type"], "ping")
58
+ with self.assertRaises(ValueError):
59
+ verify_webhook(body, header, "whsec_b", now=t)
60
+ with self.assertRaises(ValueError):
61
+ verify_webhook(body, header, "whsec_a", now=t + 3600)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ unittest.main()