Skip to content

Commit

Permalink
Models for API (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dr-Blank authored Oct 10, 2023
1 parent 770274e commit 5b9be4f
Show file tree
Hide file tree
Showing 7 changed files with 492 additions and 132 deletions.
7 changes: 7 additions & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[MESSAGES CONTROL]

disable=
R0903, # too-few-public-methods
R0902, # too-many-instance-attributes
W0511, # TODO fixme
C0301, # line-too-long
40 changes: 22 additions & 18 deletions lrclib/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
""" API for lrclib"""

import warnings
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional

import requests

Expand All @@ -13,6 +13,11 @@
RateLimitError,
ServerError,
)
from .models import (
CryptographicChallenge,
Lyrics,
SearchResult,
)

BASE_URL = "https://lrclib.net/api"
ENDPOINTS: Dict[str, str] = {
Expand Down Expand Up @@ -60,6 +65,7 @@ def _make_request(
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
except requests.exceptions.HTTPError as exc:
response = exc.response
match response.status_code:
case 404:
raise NotFoundError(response) from exc
Expand All @@ -81,7 +87,7 @@ def get_lyrics( # pylint: disable=too-many-arguments
album_name: str,
duration: int,
cached: bool = False,
) -> Dict[str, Any]:
) -> Lyrics:
"""
Get lyrics from LRCLIB.
Expand All @@ -96,7 +102,7 @@ def get_lyrics( # pylint: disable=too-many-arguments
:param cached: set to True to get cached lyrics, defaults to False
:type cached: bool, optional
:return: a dictionary with response data
:rtype: Dict[str, Any]
:rtype: Lyrics
"""

endpoint = ENDPOINTS["get_cached" if cached else "get"]
Expand All @@ -107,28 +113,28 @@ def get_lyrics( # pylint: disable=too-many-arguments
"duration": duration,
}
response = self._make_request("GET", endpoint, params=params)
return response.json()
return Lyrics.from_dict(response.json())

def get_lyrics_by_id(self, lrclib_id: str | int) -> Dict[str, Any]:
def get_lyrics_by_id(self, lrclib_id: str | int) -> Lyrics:
"""
Get lyrics from LRCLIB by ID.
:param lrclib_id: ID of the lyrics
:type lrclib_id: str | int
:return: a dictionary with response data
:rtype: Dict[str, Any]
:rtype: :class:`Lyrics`
"""
endpoint = ENDPOINTS["get_by_id"].format(id=lrclib_id)
response = self._make_request("GET", endpoint)
return response.json()
return Lyrics.from_dict(response.json())

def search_lyrics(
self,
query: str | None = None,
track_name: str | None = None,
artist_name: str | None = None,
album_name: str | None = None,
) -> List:
) -> SearchResult:
"""
Search lyrics from LRCLIB.
Expand All @@ -141,7 +147,7 @@ def search_lyrics(
:param album_name: defaults to None
:type album_name: str | None, optional
:return: a list of search results
:rtype: List
:rtype: :class:`SearchResult`
"""
# either query or track_name is required
if not query and not track_name:
Expand All @@ -160,10 +166,10 @@ def search_lyrics(
try:
response = self._make_request("GET", endpoint, params=params)
except NotFoundError:
return []
return response.json()
return SearchResult([])
return SearchResult.from_list(response.json())

def request_challenge(self) -> Dict[str, str]:
def request_challenge(self) -> CryptographicChallenge:
"""
Generate a pair of prefix and target strings for the \
cryptographic challenge. Each challenge has an \
Expand All @@ -172,14 +178,14 @@ def request_challenge(self) -> Dict[str, str]:
The challenge's solution is a nonce, which can be used \
to create a Publish Token for submitting lyrics to LRCLIB.
:return: A dictionary with the following keys: prefix and target.
:return: :class:`CryptographicChallenge`
"""
endpoint = ENDPOINTS["request_challenge"]
try:
response = self._make_request("POST", endpoint)
except APIError as exc:
raise exc
return response.json()
return CryptographicChallenge.from_dict(response.json())

def obtain_publish_token(self) -> str:
"""
Expand All @@ -190,10 +196,8 @@ def obtain_publish_token(self) -> str:
"""
challenge = self.request_challenge()
solver = CryptoChallengeSolver()
nonce = solver.solve_challenge(
challenge["prefix"], challenge["target"]
)
return f"{challenge['prefix']}:{nonce}"
nonce = solver.solve_challenge(challenge.prefix, challenge.target)
return f"{challenge.prefix}:{nonce}"

def publish_lyrics( # pylint: disable=too-many-arguments
self,
Expand Down
144 changes: 144 additions & 0 deletions lrclib/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Models for api.py"""
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, ClassVar, Dict, Generic, List, Optional, TypeVar

APIKey = TypeVar("APIKey", bound=str)
"""API key type as returned by the API"""

ModelAttr = TypeVar("ModelAttr", bound=str)
"""Model attribute type as used in the models"""

KeyMapping = Dict[APIKey, ModelAttr]

ModelT = TypeVar("ModelT", bound="BaseModel")


class BaseModel(Generic[ModelT]):
"""Base model"""

API_TO_MODEL_MAPPINGS: ClassVar[KeyMapping] = {}

@classmethod
def from_dict(cls, data: Dict[str, Any]) -> ModelT:
"""Create a ModelT object from a dictionary"""
kwargs = {}
for key, value in cls.API_TO_MODEL_MAPPINGS.items():
kwargs[value] = data.get(key)
return cls(**kwargs) # type: ignore


@dataclass
class LyricsMinimal(BaseModel["LyricsMinimal"]):
"""Lyrics object with minimal information"""

id: int # pylint: disable=invalid-name
name: str
track_name: str
artist_name: str
album_name: str
duration: int
instrumental: bool
plain_lyrics: Optional[str] = field(default=None, repr=False)
synced_lyrics: Optional[str] = field(default=None, repr=False)

API_TO_MODEL_MAPPINGS: ClassVar[KeyMapping] = {
"id": "id",
"name": "name",
"trackName": "track_name",
"artistName": "artist_name",
"albumName": "album_name",
"duration": "duration",
"instrumental": "instrumental",
"plainLyrics": "plain_lyrics",
"syncedLyrics": "synced_lyrics",
}


@dataclass
class Lyrics(BaseModel["Lyrics"]):
"""Lyrics object"""

id: int # pylint: disable=invalid-name
name: str
track_name: str
artist_name: str
album_name: str
duration: int
instrumental: bool
plain_lyrics: Optional[str] = field(default=None, repr=False)
synced_lyrics: Optional[str] = field(default=None, repr=False)
lang: Optional[str] = field(default=None, repr=False)
isrc: Optional[str] = field(default=None, repr=False)
spotify_id: Optional[str] = field(default=None, repr=False)
release_date: Optional[datetime] = field(default=None, repr=False)

API_TO_MODEL_MAPPINGS: ClassVar[KeyMapping] = {
"id": "id",
"name": "name",
"trackName": "track_name",
"artistName": "artist_name",
"albumName": "album_name",
"duration": "duration",
"instrumental": "instrumental",
"plainLyrics": "plain_lyrics",
"syncedLyrics": "synced_lyrics",
"lang": "lang",
"isrc": "isrc",
"spotifyId": "spotify_id",
"releaseDate": "release_date",
}

def __post_init__(self):
if self.release_date is not None:
if not isinstance(self.release_date, str):
return
# 2023-08-10T00:00:00Z
self.release_date = datetime.strptime(
self.release_date, "%Y-%m-%dT%H:%M:%SZ"
)


@dataclass
class ErrorResponse(BaseModel["ErrorResponse"]):
"""Error response"""

status_code: int
error: str
message: str

API_TO_MODEL_MAPPINGS: ClassVar[KeyMapping] = {
"statusCode": "status_code",
"error": "error",
"message": "message",
}


class SearchResult(list[LyricsMinimal]):
"""Search result"""

def __init__(self, data: List[LyricsMinimal]) -> None:
super().__init__(data)

@classmethod
def from_list(cls, data: List[Dict[str, Any]]) -> "SearchResult":
"""Create a SearchResult object from a list of dictionaries"""

results = [LyricsMinimal.from_dict(result) for result in data]

return cls(results)


@dataclass
class CryptographicChallenge(BaseModel["CryptographicChallenge"]):
"""Cryptographic Challenge"""

prefix: str
target: str

API_TO_MODEL_MAPPINGS: ClassVar[KeyMapping] = {
"prefix": "prefix",
"target": "target",
}
Loading

0 comments on commit 5b9be4f

Please sign in to comment.