songstats-sdk 0.1.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,26 @@
1
+ # Python bytecode and caches
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Test and type-check caches
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+ env/
15
+
16
+ # Packaging artifacts
17
+ build/
18
+ dist/
19
+ *.egg-info/
20
+
21
+ # Coverage artifacts
22
+ .coverage
23
+ htmlcov/
24
+
25
+ # macOS
26
+ .DS_Store
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## [0.1.0] - 2026-02-19
6
+
7
+ ### Added
8
+
9
+ - Initial standalone Python SDK repo for Songstats Enterprise API (`/enterprise/v1`)
10
+ - Full resource coverage:
11
+ - `info`
12
+ - `tracks`
13
+ - `artists`
14
+ - `collaborators`
15
+ - `labels`
16
+ - Shared HTTP client with:
17
+ - `apikey` header auth
18
+ - JSON response decoding
19
+ - retry/backoff on transport errors and retryable status codes
20
+ - Structured exception types for API and transport failures
21
+ - Route coverage audit doc mapping Rails routes to SDK methods
22
+ - Test suite covering route mapping, header auth, validation, and error handling
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Songstats
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: songstats-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the Songstats Enterprise API
5
+ Project-URL: Homepage, https://songstats.com
6
+ Project-URL: Documentation, https://docs.songstats.com
7
+ Author: Songstats
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: api,music,sdk,songstats
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx<1.0.0,>=0.27.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest<9.0.0,>=8.0.0; extra == 'dev'
23
+ Requires-Dist: respx<1.0.0,>=0.21.0; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Songstats Python SDK
27
+
28
+ Official Python client for the **Songstats Enterprise API**.
29
+
30
+ 📚 API Documentation: https://docs.songstats.com
31
+ 🔑 API Key Access: Please contact api@songstats.com
32
+
33
+ ---
34
+
35
+ ## Requirements
36
+
37
+ - Python >= 3.10
38
+
39
+ ---
40
+
41
+ ## Installation
42
+
43
+ Install from PyPI:
44
+
45
+ pip install songstats-sdk
46
+
47
+ For local development:
48
+
49
+ pip install -e ".[dev]"
50
+
51
+ ---
52
+
53
+ ## Quick Start
54
+
55
+ ```python
56
+ from songstats_sdk import SongstatsClient
57
+
58
+ client = SongstatsClient(api_key="YOUR_API_KEY")
59
+
60
+ # API status
61
+ status = client.info.status()
62
+
63
+ # Track information
64
+ track = client.tracks.info(songstats_track_id="abcd1234")
65
+
66
+ # Artist statistics
67
+ artist_stats = client.artists.stats(
68
+ songstats_artist_id="abcd1234",
69
+ source="spotify",
70
+ )
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Authentication
76
+
77
+ All requests include your API key in the `apikey` header.
78
+
79
+ You can generate an API key in your Songstats Enterprise dashboard.
80
+
81
+ We recommend storing your key securely in environment variables:
82
+
83
+ export SONGSTATS_API_KEY=your_key_here
84
+
85
+ ---
86
+
87
+ ## Available Resource Clients
88
+
89
+ - `client.info`
90
+ - `client.tracks`
91
+ - `client.artists`
92
+ - `client.collaborators`
93
+ - `client.labels`
94
+
95
+ Info endpoints:
96
+ - `client.info.sources()` -> `/sources`
97
+ - `client.info.status()` -> `/status`
98
+ - `client.info.definitions()` -> `/definitions`
99
+
100
+ ---
101
+
102
+ ## Error Handling
103
+
104
+ ```python
105
+ from songstats_sdk import SongstatsAPIError, SongstatsTransportError
106
+
107
+ try:
108
+ client.tracks.info(songstats_track_id="invalid")
109
+ except SongstatsAPIError as exc:
110
+ print(f"API error: {exc}")
111
+ except SongstatsTransportError as exc:
112
+ print(f"Transport error: {exc}")
113
+ ```
114
+
115
+ ---
116
+
117
+ ## Development
118
+
119
+ To work on the SDK locally:
120
+
121
+ git clone https://github.com/songstats/songstats-python-sdk.git
122
+ cd songstats-python-sdk
123
+ pip install -e ".[dev]"
124
+ pytest
125
+
126
+ ---
127
+
128
+ ## Versioning
129
+
130
+ This SDK follows Semantic Versioning (SemVer).
131
+
132
+ ---
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,111 @@
1
+ # Songstats Python SDK
2
+
3
+ Official Python client for the **Songstats Enterprise API**.
4
+
5
+ 📚 API Documentation: https://docs.songstats.com
6
+ 🔑 API Key Access: Please contact api@songstats.com
7
+
8
+ ---
9
+
10
+ ## Requirements
11
+
12
+ - Python >= 3.10
13
+
14
+ ---
15
+
16
+ ## Installation
17
+
18
+ Install from PyPI:
19
+
20
+ pip install songstats-sdk
21
+
22
+ For local development:
23
+
24
+ pip install -e ".[dev]"
25
+
26
+ ---
27
+
28
+ ## Quick Start
29
+
30
+ ```python
31
+ from songstats_sdk import SongstatsClient
32
+
33
+ client = SongstatsClient(api_key="YOUR_API_KEY")
34
+
35
+ # API status
36
+ status = client.info.status()
37
+
38
+ # Track information
39
+ track = client.tracks.info(songstats_track_id="abcd1234")
40
+
41
+ # Artist statistics
42
+ artist_stats = client.artists.stats(
43
+ songstats_artist_id="abcd1234",
44
+ source="spotify",
45
+ )
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Authentication
51
+
52
+ All requests include your API key in the `apikey` header.
53
+
54
+ You can generate an API key in your Songstats Enterprise dashboard.
55
+
56
+ We recommend storing your key securely in environment variables:
57
+
58
+ export SONGSTATS_API_KEY=your_key_here
59
+
60
+ ---
61
+
62
+ ## Available Resource Clients
63
+
64
+ - `client.info`
65
+ - `client.tracks`
66
+ - `client.artists`
67
+ - `client.collaborators`
68
+ - `client.labels`
69
+
70
+ Info endpoints:
71
+ - `client.info.sources()` -> `/sources`
72
+ - `client.info.status()` -> `/status`
73
+ - `client.info.definitions()` -> `/definitions`
74
+
75
+ ---
76
+
77
+ ## Error Handling
78
+
79
+ ```python
80
+ from songstats_sdk import SongstatsAPIError, SongstatsTransportError
81
+
82
+ try:
83
+ client.tracks.info(songstats_track_id="invalid")
84
+ except SongstatsAPIError as exc:
85
+ print(f"API error: {exc}")
86
+ except SongstatsTransportError as exc:
87
+ print(f"Transport error: {exc}")
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Development
93
+
94
+ To work on the SDK locally:
95
+
96
+ git clone https://github.com/songstats/songstats-python-sdk.git
97
+ cd songstats-python-sdk
98
+ pip install -e ".[dev]"
99
+ pytest
100
+
101
+ ---
102
+
103
+ ## Versioning
104
+
105
+ This SDK follows Semantic Versioning (SemVer).
106
+
107
+ ---
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,107 @@
1
+ # Enterprise Routes Audit (Songstats Rails -> Python SDK)
2
+
3
+ Audited against:
4
+
5
+ - `/Users/Oskar/1001tl/config/routes.rb`
6
+ - `/Users/Oskar/1001tl/app/controllers/enterprise/v1/*.rb`
7
+ - `/Users/Oskar/1001tl/app/helpers/enterprise_helper.rb`
8
+
9
+ Authentication observed in Rails: `apikey` request header.
10
+
11
+ ## `/enterprise/v1/info`
12
+
13
+ | HTTP | Route | SDK Method |
14
+ | ---- | --------------- | ---------------------------- |
15
+ | GET | `/sources` | `client.info.sources()` |
16
+ | GET | `/status` | `client.info.status()` |
17
+ | GET | `/uptime_check` | `client.info.uptime_check()` |
18
+ | GET | `/definitions` | `client.info.definitions()` |
19
+
20
+ ## `/enterprise/v1/tracks`
21
+
22
+ | HTTP | Route | SDK Method |
23
+ | ------ | ----------------------------------- | ------------------------------------------------------- |
24
+ | GET | `/info` | `client.tracks.info(...)` |
25
+ | GET | `/stats` | `client.tracks.stats(...)` |
26
+ | GET | `/historic_stats` | `client.tracks.historic_stats(...)` |
27
+ | GET | `/search` | `client.tracks.search(q=..., ...)` |
28
+ | GET | `/activities` | `client.tracks.activities(...)` |
29
+ | GET | `/comments` | `client.tracks.comments(...)` |
30
+ | GET | `/songshare` | `client.tracks.songshare(...)` |
31
+ | GET | `/locations` | `client.tracks.locations(...)` |
32
+ | POST | `/link_request` | `client.tracks.add_link_request(link=..., ...)` |
33
+ | DELETE | `/link_request` | `client.tracks.remove_link_request(link=..., ...)` |
34
+ | POST | `/add_to_member_relevant_list` | `client.tracks.add_to_member_relevant_list(...)` |
35
+ | DELETE | `/remove_from_member_relevant_list` | `client.tracks.remove_from_member_relevant_list(...)` |
36
+
37
+ ## `/enterprise/v1/artists`
38
+
39
+ | HTTP | Route | SDK Method |
40
+ | ------ | ----------------------------------- | ----------------------------------------------------------- |
41
+ | GET | `/info` | `client.artists.info(...)` |
42
+ | GET | `/stats` | `client.artists.stats(...)` |
43
+ | GET | `/historic_stats` | `client.artists.historic_stats(...)` |
44
+ | GET | `/audience` | `client.artists.audience(...)` |
45
+ | GET | `/audience/details` | `client.artists.audience_details(country_code=..., ...)` |
46
+ | GET | `/catalog` | `client.artists.catalog(...)` |
47
+ | GET | `/search` | `client.artists.search(q=..., ...)` |
48
+ | GET | `/activities` | `client.artists.activities(...)` |
49
+ | GET | `/songshare` | `client.artists.songshare(...)` |
50
+ | GET | `/top_tracks` | `client.artists.top_tracks(...)` |
51
+ | GET | `/top_playlists` | `client.artists.top_playlists(...)` |
52
+ | GET | `/top_curators` | `client.artists.top_curators(...)` |
53
+ | GET | `/top_commentors` | `client.artists.top_commentors(...)` |
54
+ | POST | `/link_request` | `client.artists.add_link_request(link=..., ...)` |
55
+ | DELETE | `/link_request` | `client.artists.remove_link_request(link=..., ...)` |
56
+ | POST | `/track_request` | `client.artists.add_track_request(...)` |
57
+ | DELETE | `/track_request` | `client.artists.remove_track_request(...)` |
58
+ | POST | `/add_to_member_relevant_list` | `client.artists.add_to_member_relevant_list(...)` |
59
+ | DELETE | `/remove_from_member_relevant_list` | `client.artists.remove_from_member_relevant_list(...)` |
60
+
61
+ ## `/enterprise/v1/collaborators`
62
+
63
+ | HTTP | Route | SDK Method |
64
+ | ------ | ----------------------------------- | ----------------------------------------------------------------- |
65
+ | GET | `/info` | `client.collaborators.info(...)` |
66
+ | GET | `/stats` | `client.collaborators.stats(...)` |
67
+ | GET | `/historic_stats` | `client.collaborators.historic_stats(...)` |
68
+ | GET | `/audience` | `client.collaborators.audience(...)` |
69
+ | GET | `/audience/details` | `client.collaborators.audience_details(country_code=..., ...)` |
70
+ | GET | `/catalog` | `client.collaborators.catalog(...)` |
71
+ | GET | `/search` | `client.collaborators.search(q=..., ...)` |
72
+ | GET | `/activities` | `client.collaborators.activities(...)` |
73
+ | GET | `/songshare` | `client.collaborators.songshare(...)` |
74
+ | GET | `/top_tracks` | `client.collaborators.top_tracks(...)` |
75
+ | GET | `/top_playlists` | `client.collaborators.top_playlists(...)` |
76
+ | GET | `/top_curators` | `client.collaborators.top_curators(...)` |
77
+ | GET | `/top_commentors` | `client.collaborators.top_commentors(...)` |
78
+ | POST | `/link_request` | `client.collaborators.add_link_request(link=..., ...)` |
79
+ | DELETE | `/link_request` | `client.collaborators.remove_link_request(link=..., ...)` |
80
+ | POST | `/track_request` | `client.collaborators.add_track_request(...)` |
81
+ | DELETE | `/track_request` | `client.collaborators.remove_track_request(...)` |
82
+ | POST | `/add_to_member_relevant_list` | `client.collaborators.add_to_member_relevant_list(...)` |
83
+ | DELETE | `/remove_from_member_relevant_list` | `client.collaborators.remove_from_member_relevant_list(...)` |
84
+
85
+ ## `/enterprise/v1/labels`
86
+
87
+ | HTTP | Route | SDK Method |
88
+ | ------ | ----------------------------------- | ---------------------------------------------------------- |
89
+ | GET | `/info` | `client.labels.info(...)` |
90
+ | GET | `/stats` | `client.labels.stats(...)` |
91
+ | GET | `/historic_stats` | `client.labels.historic_stats(...)` |
92
+ | GET | `/audience` | `client.labels.audience(...)` |
93
+ | GET | `/audience/details` | `client.labels.audience_details(country_code=..., ...)` |
94
+ | GET | `/catalog` | `client.labels.catalog(...)` |
95
+ | GET | `/search` | `client.labels.search(q=..., ...)` |
96
+ | GET | `/activities` | `client.labels.activities(...)` |
97
+ | GET | `/songshare` | `client.labels.songshare(...)` |
98
+ | GET | `/top_tracks` | `client.labels.top_tracks(...)` |
99
+ | GET | `/top_playlists` | `client.labels.top_playlists(...)` |
100
+ | GET | `/top_curators` | `client.labels.top_curators(...)` |
101
+ | GET | `/top_commentors` | `client.labels.top_commentors(...)` |
102
+ | POST | `/link_request` | `client.labels.add_link_request(link=..., ...)` |
103
+ | DELETE | `/link_request` | `client.labels.remove_link_request(link=..., ...)` |
104
+ | POST | `/track_request` | `client.labels.add_track_request(...)` |
105
+ | DELETE | `/track_request` | `client.labels.remove_track_request(...)` |
106
+ | POST | `/add_to_member_relevant_list` | `client.labels.add_to_member_relevant_list(...)` |
107
+ | DELETE | `/remove_from_member_relevant_list` | `client.labels.remove_from_member_relevant_list(...)` |
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "songstats-sdk"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the Songstats Enterprise API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Songstats" }
14
+ ]
15
+ keywords = ["songstats", "sdk", "api", "music"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries",
25
+ ]
26
+ dependencies = [
27
+ "httpx>=0.27.0,<1.0.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=8.0.0,<9.0.0",
33
+ "respx>=0.21.0,<1.0.0",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://songstats.com"
38
+ Documentation = "https://docs.songstats.com"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/songstats_sdk"]
42
+
43
+ [tool.pytest.ini_options]
44
+ addopts = "-q"
45
+ testpaths = ["tests"]
@@ -0,0 +1,11 @@
1
+ from .client import SongstatsClient
2
+ from .exceptions import SongstatsAPIError, SongstatsError, SongstatsTransportError
3
+ from .version import VERSION
4
+
5
+ __all__ = [
6
+ "SongstatsAPIError",
7
+ "SongstatsClient",
8
+ "SongstatsError",
9
+ "SongstatsTransportError",
10
+ "VERSION",
11
+ ]
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from .http import SongstatsHTTPClient
6
+ from .resources import (
7
+ ArtistsAPI,
8
+ CollaboratorsAPI,
9
+ InfoAPI,
10
+ LabelsAPI,
11
+ TracksAPI,
12
+ )
13
+
14
+
15
+ class SongstatsClient:
16
+ def __init__(
17
+ self,
18
+ *,
19
+ api_key: str,
20
+ base_url: str = "https://data.songstats.com",
21
+ timeout: float = 30.0,
22
+ max_retries: int = 2,
23
+ user_agent: str | None = None,
24
+ httpx_client: httpx.Client | None = None,
25
+ ) -> None:
26
+ self._http = SongstatsHTTPClient(
27
+ api_key=api_key,
28
+ base_url=base_url,
29
+ timeout=timeout,
30
+ max_retries=max_retries,
31
+ user_agent=user_agent,
32
+ httpx_client=httpx_client,
33
+ )
34
+
35
+ self.info = InfoAPI(self._http)
36
+ self.tracks = TracksAPI(self._http)
37
+ self.artists = ArtistsAPI(self._http)
38
+ self.collaborators = CollaboratorsAPI(self._http)
39
+ self.labels = LabelsAPI(self._http)
40
+
41
+ def close(self) -> None:
42
+ self._http.close()
43
+
44
+ def __enter__(self) -> "SongstatsClient":
45
+ return self
46
+
47
+ def __exit__(self, exc_type, exc, tb) -> None:
48
+ self.close()
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class SongstatsError(Exception):
7
+ """Base exception for SDK failures."""
8
+
9
+
10
+ class SongstatsTransportError(SongstatsError):
11
+ """Raised for network/transport failures before an HTTP response is received."""
12
+
13
+
14
+ class SongstatsAPIError(SongstatsError):
15
+ """Raised when the Songstats API responds with a non-2xx status code."""
16
+
17
+ def __init__(self, message: str, status_code: int, payload: Any = None) -> None:
18
+ super().__init__(message)
19
+ self.message = message
20
+ self.status_code = status_code
21
+ self.payload = payload
22
+
23
+ def __str__(self) -> str:
24
+ return f"Songstats API error ({self.status_code}): {self.message}"
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Mapping
5
+
6
+ import httpx
7
+
8
+ from .exceptions import SongstatsAPIError, SongstatsTransportError
9
+ from .version import VERSION
10
+
11
+ DEFAULT_BASE_URL = "https://data.songstats.com"
12
+ DEFAULT_TIMEOUT_SECONDS = 30.0
13
+ RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
14
+
15
+
16
+ class SongstatsHTTPClient:
17
+ def __init__(
18
+ self,
19
+ *,
20
+ api_key: str,
21
+ base_url: str = DEFAULT_BASE_URL,
22
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
23
+ max_retries: int = 2,
24
+ user_agent: str | None = None,
25
+ httpx_client: httpx.Client | None = None,
26
+ ) -> None:
27
+ if not api_key:
28
+ raise ValueError("api_key is required")
29
+ if max_retries < 0:
30
+ raise ValueError("max_retries must be >= 0")
31
+
32
+ self.base_url = base_url.rstrip("/")
33
+ self.max_retries = max_retries
34
+ self._owns_client = httpx_client is None
35
+ self._client = httpx_client or httpx.Client(
36
+ base_url=self.base_url,
37
+ timeout=timeout,
38
+ headers={
39
+ "apikey": api_key,
40
+ "accept": "application/json",
41
+ "user-agent": user_agent or f"songstats-python-sdk/{VERSION}",
42
+ },
43
+ )
44
+
45
+ def close(self) -> None:
46
+ if self._owns_client:
47
+ self._client.close()
48
+
49
+ def request(
50
+ self,
51
+ method: str,
52
+ path: str,
53
+ *,
54
+ params: Mapping[str, Any] | None = None,
55
+ json: Mapping[str, Any] | None = None,
56
+ ) -> Any:
57
+ endpoint = f"/enterprise/v1/{path.lstrip('/')}"
58
+ last_transport_error: Exception | None = None
59
+
60
+ for attempt in range(self.max_retries + 1):
61
+ try:
62
+ response = self._client.request(method=method, url=endpoint, params=params, json=json)
63
+ except httpx.RequestError as exc:
64
+ last_transport_error = exc
65
+ if attempt < self.max_retries:
66
+ time.sleep(0.2 * (2**attempt))
67
+ continue
68
+ raise SongstatsTransportError(str(exc)) from exc
69
+
70
+ if response.status_code in RETRYABLE_STATUS_CODES and attempt < self.max_retries:
71
+ time.sleep(0.2 * (2**attempt))
72
+ continue
73
+
74
+ if response.is_success:
75
+ return _decode_json_response(response)
76
+
77
+ raise _build_api_error(response)
78
+
79
+ if last_transport_error is not None:
80
+ raise SongstatsTransportError(str(last_transport_error)) from last_transport_error
81
+
82
+ raise SongstatsTransportError("Request failed without response")
83
+
84
+
85
+ def _decode_json_response(response: httpx.Response) -> Any:
86
+ if not response.text:
87
+ return None
88
+ try:
89
+ return response.json()
90
+ except ValueError:
91
+ return {"raw": response.text}
92
+
93
+
94
+ def _build_api_error(response: httpx.Response) -> SongstatsAPIError:
95
+ payload: Any
96
+ message = response.reason_phrase
97
+
98
+ try:
99
+ payload = response.json()
100
+ except ValueError:
101
+ payload = {"raw": response.text}
102
+ else:
103
+ if isinstance(payload, dict):
104
+ message = str(payload.get("message") or payload.get("error") or message)
105
+
106
+ return SongstatsAPIError(message=message, status_code=response.status_code, payload=payload)
File without changes
@@ -0,0 +1,11 @@
1
+ from .entities import ArtistsAPI, CollaboratorsAPI, LabelsAPI
2
+ from .info import InfoAPI
3
+ from .tracks import TracksAPI
4
+
5
+ __all__ = [
6
+ "ArtistsAPI",
7
+ "CollaboratorsAPI",
8
+ "InfoAPI",
9
+ "LabelsAPI",
10
+ "TracksAPI",
11
+ ]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterable, Mapping
4
+
5
+ from ..http import SongstatsHTTPClient
6
+
7
+
8
+ class ResourceAPI:
9
+ def __init__(self, http_client: SongstatsHTTPClient) -> None:
10
+ self._http = http_client
11
+
12
+ def _get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
13
+ return self._http.request("GET", path, params=_normalize_params(params))
14
+
15
+ def _post(
16
+ self,
17
+ path: str,
18
+ *,
19
+ params: Mapping[str, Any] | None = None,
20
+ json: Mapping[str, Any] | None = None,
21
+ ) -> Any:
22
+ clean_params = _normalize_params(params)
23
+ clean_json = _normalize_params(json)
24
+ return self._http.request("POST", path, params=clean_params, json=clean_json)
25
+
26
+ def _delete(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
27
+ return self._http.request("DELETE", path, params=_normalize_params(params))
28
+
29
+
30
+ def _normalize_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
31
+ if not params:
32
+ return None
33
+
34
+ normalized: dict[str, Any] = {}
35
+ for key, value in params.items():
36
+ if value is None:
37
+ continue
38
+ if isinstance(value, bool):
39
+ normalized[key] = "true" if value else "false"
40
+ continue
41
+ if isinstance(value, (list, tuple, set)):
42
+ normalized[key] = ",".join(str(item) for item in value)
43
+ continue
44
+ normalized[key] = value
45
+
46
+ return normalized or None
47
+
48
+
49
+ def require_any_identifier(params: Mapping[str, Any], identifier_keys: Iterable[str]) -> None:
50
+ if any(params.get(key) not in (None, "") for key in identifier_keys):
51
+ return
52
+ joined = ", ".join(identifier_keys)
53
+ raise ValueError(f"One identifier is required. Supported keys: {joined}")
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .base import ResourceAPI, require_any_identifier
6
+
7
+
8
+ class EntityAPI(ResourceAPI):
9
+ def __init__(self, http_client, *, resource: str, identifier_keys: tuple[str, ...]) -> None:
10
+ super().__init__(http_client)
11
+ self._resource = resource
12
+ self._identifier_keys = identifier_keys
13
+
14
+ def info(self, **params: Any) -> Any:
15
+ return self._get(f"{self._resource}/info", params=self._with_identifier(params))
16
+
17
+ def stats(self, **params: Any) -> Any:
18
+ return self._get(f"{self._resource}/stats", params=self._with_identifier(params))
19
+
20
+ def historic_stats(self, **params: Any) -> Any:
21
+ return self._get(f"{self._resource}/historic_stats", params=self._with_identifier(params))
22
+
23
+ def audience(self, **params: Any) -> Any:
24
+ return self._get(f"{self._resource}/audience", params=self._with_identifier(params))
25
+
26
+ def audience_details(self, *, country_code: str, **params: Any) -> Any:
27
+ if not country_code:
28
+ raise ValueError("country_code is required")
29
+
30
+ query = self._with_identifier(params)
31
+ query["country_code"] = country_code
32
+ return self._get(f"{self._resource}/audience/details", params=query)
33
+
34
+ def catalog(self, **params: Any) -> Any:
35
+ return self._get(f"{self._resource}/catalog", params=self._with_identifier(params))
36
+
37
+ def search(self, *, q: str, **params: Any) -> Any:
38
+ if not q:
39
+ raise ValueError("q is required")
40
+
41
+ query = {"q": q}
42
+ query.update(params)
43
+ return self._get(f"{self._resource}/search", params=query)
44
+
45
+ def activities(self, **params: Any) -> Any:
46
+ return self._get(f"{self._resource}/activities", params=self._with_identifier(params))
47
+
48
+ def songshare(self, **params: Any) -> Any:
49
+ return self._get(f"{self._resource}/songshare", params=self._with_identifier(params))
50
+
51
+ def top_tracks(self, **params: Any) -> Any:
52
+ return self._get(f"{self._resource}/top_tracks", params=self._with_identifier(params))
53
+
54
+ def top_playlists(self, **params: Any) -> Any:
55
+ return self._get(f"{self._resource}/top_playlists", params=self._with_identifier(params))
56
+
57
+ def top_curators(self, **params: Any) -> Any:
58
+ return self._get(f"{self._resource}/top_curators", params=self._with_identifier(params))
59
+
60
+ def top_commentors(self, **params: Any) -> Any:
61
+ return self._get(f"{self._resource}/top_commentors", params=self._with_identifier(params))
62
+
63
+ def add_link_request(self, *, link: str, **params: Any) -> Any:
64
+ if not link:
65
+ raise ValueError("link is required")
66
+
67
+ query = self._with_identifier(params)
68
+ query["link"] = link
69
+ return self._post(f"{self._resource}/link_request", params=query)
70
+
71
+ def remove_link_request(self, *, link: str, **params: Any) -> Any:
72
+ if not link:
73
+ raise ValueError("link is required")
74
+
75
+ query = self._with_identifier(params)
76
+ query["link"] = link
77
+ return self._delete(f"{self._resource}/link_request", params=query)
78
+
79
+ def add_track_request(
80
+ self,
81
+ *,
82
+ link: str | None = None,
83
+ spotify_track_id: str | None = None,
84
+ isrc: str | None = None,
85
+ **params: Any,
86
+ ) -> Any:
87
+ if not any([link, spotify_track_id, isrc]):
88
+ raise ValueError("One of link, spotify_track_id, or isrc is required")
89
+
90
+ query = self._with_identifier(params)
91
+ query.update({
92
+ "link": link,
93
+ "spotify_track_id": spotify_track_id,
94
+ "isrc": isrc,
95
+ })
96
+ return self._post(f"{self._resource}/track_request", params=query)
97
+
98
+ def remove_track_request(
99
+ self,
100
+ *,
101
+ songstats_track_id: str | None = None,
102
+ spotify_track_id: str | None = None,
103
+ **params: Any,
104
+ ) -> Any:
105
+ if not songstats_track_id and not spotify_track_id:
106
+ raise ValueError("songstats_track_id or spotify_track_id is required")
107
+
108
+ query = self._with_identifier(params)
109
+ query.update({
110
+ "songstats_track_id": songstats_track_id,
111
+ "spotify_track_id": spotify_track_id,
112
+ })
113
+ return self._delete(f"{self._resource}/track_request", params=query)
114
+
115
+ def add_to_member_relevant_list(self, **params: Any) -> Any:
116
+ return self._post(
117
+ f"{self._resource}/add_to_member_relevant_list",
118
+ params=self._with_identifier(params),
119
+ )
120
+
121
+ def remove_from_member_relevant_list(self, **params: Any) -> Any:
122
+ return self._delete(
123
+ f"{self._resource}/remove_from_member_relevant_list",
124
+ params=self._with_identifier(params),
125
+ )
126
+
127
+ def _with_identifier(self, params: dict[str, Any]) -> dict[str, Any]:
128
+ query = dict(params)
129
+ require_any_identifier(query, self._identifier_keys)
130
+ return query
131
+
132
+
133
+ class ArtistsAPI(EntityAPI):
134
+ def __init__(self, http_client) -> None:
135
+ super().__init__(
136
+ http_client,
137
+ resource="artists",
138
+ identifier_keys=("songstats_artist_id", "spotify_artist_id", "apple_music_artist_id"),
139
+ )
140
+
141
+
142
+ class CollaboratorsAPI(EntityAPI):
143
+ def __init__(self, http_client) -> None:
144
+ super().__init__(
145
+ http_client,
146
+ resource="collaborators",
147
+ identifier_keys=(
148
+ "songstats_collaborator_id",
149
+ "spotify_artist_id",
150
+ "apple_music_artist_id",
151
+ "tidal_artist_id",
152
+ ),
153
+ )
154
+
155
+
156
+ class LabelsAPI(EntityAPI):
157
+ def __init__(self, http_client) -> None:
158
+ super().__init__(
159
+ http_client,
160
+ resource="labels",
161
+ identifier_keys=("songstats_label_id", "beatport_label_id"),
162
+ )
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .base import ResourceAPI
6
+
7
+
8
+ class InfoAPI(ResourceAPI):
9
+ def sources(self) -> Any:
10
+ return self._get("sources")
11
+
12
+ def status(self) -> Any:
13
+ return self._get("status")
14
+
15
+ def uptime_check(self) -> Any:
16
+ return self._get("uptime_check")
17
+
18
+ def definitions(self) -> Any:
19
+ return self._get("definitions")
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .base import ResourceAPI, require_any_identifier
6
+
7
+ _TRACK_IDENTIFIER_KEYS = (
8
+ "songstats_track_id",
9
+ "spotify_track_id",
10
+ "apple_music_track_id",
11
+ "isrc",
12
+ )
13
+
14
+
15
+ class TracksAPI(ResourceAPI):
16
+ def info(self, **params: Any) -> Any:
17
+ query = _require_track_identifier(params)
18
+ return self._get("tracks/info", params=query)
19
+
20
+ def stats(self, **params: Any) -> Any:
21
+ query = _require_track_identifier(params)
22
+ return self._get("tracks/stats", params=query)
23
+
24
+ def historic_stats(self, **params: Any) -> Any:
25
+ query = _require_track_identifier(params)
26
+ return self._get("tracks/historic_stats", params=query)
27
+
28
+ def activities(self, **params: Any) -> Any:
29
+ query = _require_track_identifier(params)
30
+ return self._get("tracks/activities", params=query)
31
+
32
+ def comments(self, **params: Any) -> Any:
33
+ query = _require_track_identifier(params)
34
+ return self._get("tracks/comments", params=query)
35
+
36
+ def songshare(self, **params: Any) -> Any:
37
+ query = _require_track_identifier(params)
38
+ return self._get("tracks/songshare", params=query)
39
+
40
+ def locations(self, **params: Any) -> Any:
41
+ query = _require_track_identifier(params)
42
+ return self._get("tracks/locations", params=query)
43
+
44
+ def search(self, *, q: str, **params: Any) -> Any:
45
+ if not q:
46
+ raise ValueError("q is required")
47
+
48
+ query = {"q": q}
49
+ query.update(params)
50
+ return self._get("tracks/search", params=query)
51
+
52
+ def add_link_request(self, *, link: str, **params: Any) -> Any:
53
+ if not link:
54
+ raise ValueError("link is required")
55
+
56
+ query = _require_track_identifier(params)
57
+ query["link"] = link
58
+ return self._post("tracks/link_request", params=query)
59
+
60
+ def remove_link_request(self, *, link: str, **params: Any) -> Any:
61
+ if not link:
62
+ raise ValueError("link is required")
63
+
64
+ query = _require_track_identifier(params)
65
+ query["link"] = link
66
+ return self._delete("tracks/link_request", params=query)
67
+
68
+ def add_to_member_relevant_list(self, **params: Any) -> Any:
69
+ query = _require_track_identifier(params)
70
+ return self._post("tracks/add_to_member_relevant_list", params=query)
71
+
72
+ def remove_from_member_relevant_list(self, **params: Any) -> Any:
73
+ query = _require_track_identifier(params)
74
+ return self._delete("tracks/remove_from_member_relevant_list", params=query)
75
+
76
+
77
+ def _require_track_identifier(params: dict[str, Any]) -> dict[str, Any]:
78
+ query = dict(params)
79
+ require_any_identifier(query, _TRACK_IDENTIFIER_KEYS)
80
+ return query
@@ -0,0 +1,2 @@
1
+ VERSION = "0.1.0"
2
+
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parents[1]
7
+ SRC = ROOT / "src"
8
+
9
+ if str(SRC) not in sys.path:
10
+ sys.path.insert(0, str(SRC))
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import pytest
5
+ import respx
6
+
7
+ from songstats_sdk import SongstatsAPIError, SongstatsClient
8
+
9
+
10
+ @respx.mock
11
+ def test_info_status_sends_apikey_header() -> None:
12
+ route = respx.get("https://data.songstats.com/enterprise/v1/status").mock(
13
+ return_value=httpx.Response(200, json={"result": "success"})
14
+ )
15
+
16
+ client = SongstatsClient(api_key="test_key")
17
+ data = client.info.status()
18
+
19
+ assert data["result"] == "success"
20
+ assert route.called
21
+ assert route.calls.last.request.headers["apikey"] == "test_key"
22
+
23
+
24
+ @respx.mock
25
+ def test_info_sources_and_definitions_routes() -> None:
26
+ sources = respx.get("https://data.songstats.com/enterprise/v1/sources").mock(
27
+ return_value=httpx.Response(200, json={"result": "success", "sources": []})
28
+ )
29
+ definitions = respx.get("https://data.songstats.com/enterprise/v1/definitions").mock(
30
+ return_value=httpx.Response(200, json={"result": "success", "definitions": {}})
31
+ )
32
+
33
+ client = SongstatsClient(api_key="test_key")
34
+ client.info.sources()
35
+ client.info.definitions()
36
+
37
+ assert sources.called
38
+ assert definitions.called
39
+
40
+
41
+ @respx.mock
42
+ def test_tracks_info_hits_expected_route_and_params() -> None:
43
+ route = respx.get("https://data.songstats.com/enterprise/v1/tracks/info").mock(
44
+ return_value=httpx.Response(200, json={"result": "success"})
45
+ )
46
+
47
+ client = SongstatsClient(api_key="test_key")
48
+ client.tracks.info(songstats_track_id="abcd1234", with_links=True)
49
+
50
+ request = route.calls.last.request
51
+ assert request.url.params["songstats_track_id"] == "abcd1234"
52
+ assert request.url.params["with_links"] == "true"
53
+
54
+
55
+ @respx.mock
56
+ def test_collaborators_top_curators_is_mapped() -> None:
57
+ route = respx.get("https://data.songstats.com/enterprise/v1/collaborators/top_curators").mock(
58
+ return_value=httpx.Response(200, json={"result": "success"})
59
+ )
60
+
61
+ client = SongstatsClient(api_key="test_key")
62
+ client.collaborators.top_curators(songstats_collaborator_id="collab1234", source="spotify")
63
+
64
+ request = route.calls.last.request
65
+ assert request.url.params["songstats_collaborator_id"] == "collab1234"
66
+ assert request.url.params["source"] == "spotify"
67
+
68
+
69
+ @respx.mock
70
+ def test_api_error_raises_songstats_api_error() -> None:
71
+ respx.get("https://data.songstats.com/enterprise/v1/status").mock(
72
+ return_value=httpx.Response(401, json={"result": "error", "message": "Invalid Api Key"})
73
+ )
74
+
75
+ client = SongstatsClient(api_key="bad_key")
76
+
77
+ with pytest.raises(SongstatsAPIError) as exc:
78
+ client.info.status()
79
+
80
+ assert exc.value.status_code == 401
81
+ assert "Invalid Api Key" in str(exc.value)
82
+
83
+
84
+ def test_identifier_validation() -> None:
85
+ client = SongstatsClient(api_key="test_key")
86
+
87
+ with pytest.raises(ValueError):
88
+ client.labels.info()
89
+
90
+
91
+ @respx.mock
92
+ def test_artists_search_route() -> None:
93
+ route = respx.get("https://data.songstats.com/enterprise/v1/artists/search").mock(
94
+ return_value=httpx.Response(200, json={"result": "success", "results": []})
95
+ )
96
+
97
+ client = SongstatsClient(api_key="test_key")
98
+ client.artists.search(q="fred again", limit=10)
99
+
100
+ request = route.calls.last.request
101
+ assert request.url.params["q"] == "fred again"
102
+ assert request.url.params["limit"] == "10"