yutipy 2.2.5__py3-none-any.whl → 2.2.7__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.

Potentially problematic release.


This version of yutipy might be problematic. Click here for more details.

yutipy/base_clients.py ADDED
@@ -0,0 +1,582 @@
1
+ __all__ = ["BaseClient", "BaseAuthClient"]
2
+
3
+ import base64
4
+ import secrets
5
+ from time import time
6
+ from urllib.parse import urlencode
7
+
8
+ import requests
9
+
10
+ from yutipy.exceptions import AuthenticationException, InvalidValueException
11
+ from yutipy.logger import logger
12
+
13
+
14
+ class BaseClient:
15
+ """Base class for Client Credentials grant type/flow."""
16
+
17
+ SERVICE_NAME: str
18
+ ACCESS_TOKEN_URL: str
19
+
20
+ def __init__(
21
+ self,
22
+ service_name: str,
23
+ access_token_url: str,
24
+ client_id: str = None,
25
+ client_secret: str = None,
26
+ defer_load: bool = False,
27
+ ) -> None:
28
+ """Initializes client (using Client Credentials grant type/flow) and sets up the session.
29
+
30
+ Parameters
31
+ ----------
32
+ service_name : str
33
+ The service name class belongs to. For example, "Spotify".
34
+ access_token_url : str
35
+ The url endpoint to request access token.
36
+ """
37
+
38
+ self.SERVICE_NAME = service_name
39
+ self.ACCESS_TOKEN_URL = access_token_url
40
+
41
+ self._client_id = client_id
42
+ self._client_secret = client_secret
43
+
44
+ self._defer_load = defer_load
45
+ self._is_session_closed = False
46
+ self._normalize_non_english = True
47
+
48
+ self._access_token = None
49
+ self._token_expires_in = None
50
+ self._token_requested_at = None
51
+ self._session = requests.Session()
52
+ self._translation_session = requests.Session()
53
+
54
+ if not defer_load:
55
+ # Attempt to load access token during initialization if not deferred
56
+ self.load_token_after_init()
57
+ else:
58
+ logger.warning(
59
+ "`defer_load` is set to `True`. Make sure to call `load_token_after_init()`."
60
+ )
61
+
62
+ def __enter__(self):
63
+ """Enters the runtime context related to this object."""
64
+ return self
65
+
66
+ def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
67
+ """Exits the runtime context related to this object."""
68
+ self.close_session()
69
+
70
+ def close_session(self) -> None:
71
+ """Closes the current session(s)."""
72
+ if not self.is_session_closed:
73
+ self._session.close()
74
+ self._translation_session.close()
75
+ self._is_session_closed = True
76
+
77
+ @property
78
+ def is_session_closed(self) -> bool:
79
+ """Checks if the session is closed."""
80
+ return self._is_session_closed
81
+
82
+ def load_token_after_init(self) -> None:
83
+ """
84
+ Explicitly load the access token after initialization.
85
+ This is useful when ``defer_load`` is set to ``True`` during initialization.
86
+ """
87
+ token_info = None
88
+ try:
89
+ token_info = self.load_access_token()
90
+ if not isinstance(token_info, dict):
91
+ raise InvalidValueException(
92
+ "`load_access_token()` should return a dict."
93
+ )
94
+ except NotImplementedError:
95
+ logger.warning(
96
+ "`load_access_token` is not implemented. Falling back to in-memory storage and requesting new access token."
97
+ )
98
+ finally:
99
+ if not token_info:
100
+ token_info = self._get_access_token()
101
+ self._access_token = token_info.get("access_token")
102
+ self._token_expires_in = token_info.get("expires_in")
103
+ self._token_requested_at = token_info.get("requested_at")
104
+
105
+ try:
106
+ self.save_access_token(token_info)
107
+ except NotImplementedError:
108
+ logger.warning(
109
+ "`save_access_token` is not implemented, falling back to in-memory storage. Access token will not be saved."
110
+ )
111
+
112
+ def _authorization_header(self) -> dict:
113
+ """
114
+ Generates the authorization header for API requests.
115
+
116
+ Returns
117
+ -------
118
+ dict
119
+ A dictionary containing the Bearer token for authentication.
120
+ """
121
+ return {"Authorization": f"Bearer {self._access_token}"}
122
+
123
+ def _get_access_token(self) -> dict:
124
+ """
125
+ Gets the API access token information.
126
+
127
+ Returns
128
+ -------
129
+ dict
130
+ The API access token, with additional information such as expires in, etc.
131
+ """
132
+ auth_string = f"{self._client_id}:{self._client_secret}"
133
+ auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
134
+
135
+ url = self.ACCESS_TOKEN_URL
136
+ headers = {
137
+ "Authorization": f"Basic {auth_base64}",
138
+ "Content-Type": "application/x-www-form-urlencoded",
139
+ }
140
+ data = {"grant_type": "client_credentials"}
141
+
142
+ try:
143
+ logger.info(
144
+ f"Authenticating with {self.SERVICE_NAME} API using Client Credentials grant type."
145
+ )
146
+ response = self._session.post(
147
+ url=url, headers=headers, data=data, timeout=30
148
+ )
149
+ logger.debug(f"Authentication response status code: {response.status_code}")
150
+ response.raise_for_status()
151
+ except requests.RequestException as e:
152
+ raise AuthenticationException(
153
+ f"Something went wrong authenticating with {self.SERVICE_NAME}: {e}"
154
+ )
155
+
156
+ response_json = response.json()
157
+ response_json["requested_at"] = time()
158
+ return response_json
159
+
160
+ def _refresh_access_token(self) -> None:
161
+ """Refreshes the token if it has expired."""
162
+ try:
163
+ if time() - self._token_requested_at >= self._token_expires_in:
164
+ token_info = self._get_access_token()
165
+
166
+ try:
167
+ self.save_access_token(token_info)
168
+ except NotImplementedError as e:
169
+ logger.warning(e)
170
+
171
+ self._access_token = token_info.get("access_token")
172
+ self._token_expires_in = token_info.get("expires_in")
173
+ self._token_requested_at = token_info.get("requested_at")
174
+ else:
175
+ logger.info("The access token is still valid, no need to refresh.")
176
+ except TypeError:
177
+ logger.debug(
178
+ f"token requested at: {self._token_requested_at} | token expires in: {self._token_expires_in}"
179
+ )
180
+ logger.info(
181
+ "Something went wrong while trying to refresh the access token. Set logging level to `DEBUG` to see the issue."
182
+ )
183
+
184
+ def save_access_token(self, token_info: dict) -> None:
185
+ """
186
+ Saves the access token and related information.
187
+
188
+ This method must be overridden in a subclass to persist the access token and other
189
+ related information (e.g., expiration time). If not implemented,
190
+ the access token will not be saved, and it will be requested each time the
191
+ application restarts.
192
+
193
+ Parameters
194
+ ----------
195
+ token_info : dict
196
+ A dictionary containing the access token and related information, such as
197
+ refresh token, expiration time, etc.
198
+
199
+ Raises
200
+ ------
201
+ NotImplementedError
202
+ If the method is not overridden in a subclass.
203
+ """
204
+ raise NotImplementedError(
205
+ "The `save_access_token` method must be overridden in a subclass to save the access token and related information. "
206
+ "If not implemented, access token information will not be persisted, and users will need to re-authenticate after application restarts."
207
+ )
208
+
209
+ def load_access_token(self) -> dict:
210
+ """
211
+ Loads the access token and related information.
212
+
213
+ This method must be overridden in a subclass to retrieve the access token and other
214
+ related information (e.g., expiration time) from persistent storage.
215
+ If not implemented, the access token will not be loaded, and it will be requested
216
+ each time the application restarts.
217
+
218
+ Returns
219
+ -------
220
+ dict | None
221
+ A dictionary containing the access token and related information, such as
222
+ refresh token, expiration time, etc., or None if no token is found.
223
+
224
+ Raises
225
+ ------
226
+ NotImplementedError
227
+ If the method is not overridden in a subclass.
228
+ """
229
+ raise NotImplementedError(
230
+ "The `load_access_token` method must be overridden in a subclass to load access token and related information. "
231
+ "If not implemented, access token information will not be loaded, and users will need to re-authenticate after application restarts."
232
+ )
233
+
234
+
235
+ class BaseAuthClient:
236
+ """Base class for Authorization Code grant type/flow."""
237
+
238
+ SERVICE_NAME: str
239
+ ACCESS_TOKEN_URL: str
240
+ USER_AUTH_URL: str
241
+
242
+ def __init__(
243
+ self,
244
+ service_name: str,
245
+ access_token_url: str,
246
+ user_auth_url: str,
247
+ client_id: str,
248
+ client_secret: str,
249
+ redirect_uri: str,
250
+ scopes: str = None,
251
+ defer_load: bool = False,
252
+ ):
253
+ """
254
+ Initializes client (using Authorization Code grant type/flow) and sets up the session.
255
+
256
+ Parameters
257
+ ----------
258
+ service_name : str
259
+ The service name class belongs to. For example, "Spotify".
260
+ access_token_url : str
261
+ The url endpoint to request access token.
262
+ user_auth_url : str
263
+ The url endpoint for user authentication.
264
+ """
265
+
266
+ self.SERVICE_NAME = service_name
267
+ self.ACCESS_TOKEN_URL = access_token_url
268
+ self.USER_AUTH_URL = user_auth_url
269
+
270
+ self._client_id = client_id
271
+ self._client_secret = client_secret
272
+ self._redirect_uri = redirect_uri
273
+ self._scopes = scopes
274
+
275
+ self._defer_load = defer_load
276
+ self._is_session_closed = False
277
+
278
+ self._access_token = None
279
+ self._refresh_token = None
280
+ self._token_expires_in = None
281
+ self._token_requested_at = None
282
+ self._session = requests.Session()
283
+
284
+ if not defer_load:
285
+ # Attempt to load access token during initialization if not deferred
286
+ self.load_token_after_init()
287
+ else:
288
+ logger.warning(
289
+ "`defer_load` is set to `True`. Make sure to call `load_token_after_init()`."
290
+ )
291
+
292
+ def __enter__(self):
293
+ """Enters the runtime context related to this object."""
294
+ return self
295
+
296
+ def __exit__(self, exc_type, exc_value, exc_traceback):
297
+ """Exits the runtime context related to this object."""
298
+ self.close_session()
299
+
300
+ def close_session(self) -> None:
301
+ """Closes the current session(s)."""
302
+ if not self.is_session_closed:
303
+ self._session.close()
304
+ self._is_session_closed = True
305
+
306
+ @property
307
+ def is_session_closed(self) -> bool:
308
+ """Checks if the session is closed."""
309
+ return self._is_session_closed
310
+
311
+ def load_token_after_init(self):
312
+ """
313
+ Explicitly load the access token after initialization.
314
+ This is useful when ``defer_load`` is set to ``True`` during initialization.
315
+ """
316
+ token_info = None
317
+ try:
318
+ token_info = self.load_access_token()
319
+ if not isinstance(token_info, dict):
320
+ raise InvalidValueException(
321
+ "`load_access_token()` should return a dict."
322
+ )
323
+ except NotImplementedError:
324
+ logger.warning(
325
+ "`load_access_token` is not implemented. Falling back to in-memory storage."
326
+ )
327
+ finally:
328
+ if token_info:
329
+ self._access_token = token_info.get("access_token")
330
+ self._refresh_token = token_info.get("refresh_token")
331
+ self._token_expires_in = token_info.get("expires_in")
332
+ self._token_requested_at = token_info.get("requested_at")
333
+ else:
334
+ logger.warning(
335
+ "No access token found during initialization. You must authenticate to obtain a new token."
336
+ )
337
+
338
+ def _authorization_header(self) -> dict:
339
+ """
340
+ Generates the authorization header for Spotify API requests.
341
+
342
+ Returns
343
+ -------
344
+ dict
345
+ A dictionary containing the Bearer token for authentication.
346
+ """
347
+ return {"Authorization": f"Bearer {self._access_token}"}
348
+
349
+ def _get_access_token(
350
+ self,
351
+ authorization_code: str = None,
352
+ refresh_token: str = None,
353
+ ) -> dict:
354
+ """
355
+ Gets the API access token information.
356
+
357
+ If ``authorization_code`` provided, it will try to get a new access token. Otherwise, if ``refresh_token`` is provided,
358
+ it will refresh the access token using it and return new access token information.
359
+
360
+ Returns
361
+ -------
362
+ dict
363
+ The API access token, with additional information such as expires in, refresh token, etc.
364
+ """
365
+ auth_string = f"{self.client_id}:{self.client_secret}"
366
+ auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
367
+
368
+ url = self.ACCESS_TOKEN_URL
369
+ headers = {
370
+ "Authorization": f"Basic {auth_base64}",
371
+ "Content-Type": "application/x-www-form-urlencoded",
372
+ }
373
+
374
+ if authorization_code:
375
+ data = {
376
+ "grant_type": "authorization_code",
377
+ "code": authorization_code,
378
+ "redirect_uri": self.redirect_uri,
379
+ }
380
+
381
+ if refresh_token:
382
+ data = {
383
+ "grant_type": "refresh_token",
384
+ "refresh_token": self._refresh_token,
385
+ }
386
+
387
+ try:
388
+ logger.info(
389
+ f"Authenticating with {self.SERVICE_NAME} API using Authorization Code grant type."
390
+ )
391
+ response = self._session.post(
392
+ url=url, headers=headers, data=data, timeout=30
393
+ )
394
+ logger.debug(f"Authentication response status code: {response.status_code}")
395
+ response.raise_for_status()
396
+ except requests.RequestException as e:
397
+ raise AuthenticationException(
398
+ f"Something went wrong authenticating with {self.SERVICE_NAME}: {e}"
399
+ )
400
+
401
+ response_json = response.json()
402
+ response_json["requested_at"] = time()
403
+ return response_json
404
+
405
+ def _refresh_access_token(self):
406
+ """Refreshes the token if it has expired."""
407
+ try:
408
+ if time() - self._token_requested_at >= self._token_expires_in:
409
+ token_info = self._get_access_token(refresh_token=self._refresh_token)
410
+
411
+ try:
412
+ self.save_access_token(token_info)
413
+ except NotImplementedError as e:
414
+ logger.warning(e)
415
+
416
+ self._access_token = token_info.get("access_token")
417
+ self._refresh_token = token_info.get("refresh_token")
418
+ self._token_expires_in = token_info.get("expires_in")
419
+ self._token_requested_at = token_info.get("requested_at")
420
+
421
+ else:
422
+ logger.info("The access token is still valid, no need to refresh.")
423
+ except TypeError:
424
+ logger.debug(
425
+ f"token requested at: {self._token_requested_at} | token expires in: {self._token_expires_in}"
426
+ )
427
+ logger.warning(
428
+ "Something went wrong while trying to refresh the access token. Set logging level to `DEBUG` to see the issue."
429
+ )
430
+
431
+ @staticmethod
432
+ def generate_state() -> str:
433
+ """
434
+ Generates a random state string for use in OAuth 2.0 authorization.
435
+
436
+ This method creates a cryptographically secure, URL-safe string that can be used
437
+ to prevent cross-site request forgery (CSRF) attacks during the authorization process.
438
+
439
+ Returns
440
+ -------
441
+ str
442
+ A random URL-safe string to be used as the state parameter in OAuth 2.0.
443
+ """
444
+ return secrets.token_urlsafe()
445
+
446
+ def get_authorization_url(
447
+ self, state: str = None, show_dialog: bool = False
448
+ ) -> str:
449
+ """
450
+ Constructs the Spotify authorization URL for user authentication.
451
+
452
+ This method generates a URL that can be used to redirect users to Spotify's
453
+ authorization page for user authentication.
454
+
455
+ Parameters
456
+ ----------
457
+ state : str, optional
458
+ A random string to maintain state between the request and callback.
459
+ If not provided, no state parameter is included.
460
+
461
+ You may use :meth:`SpotifyAuth.generate_state` method to generate one.
462
+ show_dialog : bool, optional
463
+ Whether or not to force the user to approve the app again if they’ve already done so.
464
+ If ``False`` (default), a user who has already approved the application may be automatically
465
+ redirected to the URI specified by redirect_uri. If ``True``, the user will not be automatically
466
+ redirected and will have to approve the app again.
467
+
468
+ Returns
469
+ -------
470
+ str
471
+ The full authorization URL to redirect users for Spotify authentication.
472
+ """
473
+ payload = {
474
+ "response_type": "code",
475
+ "client_id": self.client_id,
476
+ "redirect_uri": self.redirect_uri,
477
+ }
478
+
479
+ if self._scopes:
480
+ payload["scope"] = self._scopes
481
+
482
+ if show_dialog:
483
+ payload["show_dialog"] = show_dialog
484
+
485
+ if state:
486
+ payload["state"] = state
487
+
488
+ return f"{self.USER_AUTH_URL}?{urlencode(payload)}"
489
+
490
+ def save_access_token(self, token_info: dict) -> None:
491
+ """
492
+ Saves the access token and related information.
493
+
494
+ This method must be overridden in a subclass to persist the access token and other
495
+ related information (e.g., refresh token, expiration time). If not implemented,
496
+ the access token will not be saved, and users will need to re-authenticate after
497
+ application restarts.
498
+
499
+ Parameters
500
+ ----------
501
+ token_info : dict
502
+ A dictionary containing the access token and related information, such as
503
+ refresh token, expiration time, etc.
504
+
505
+ Raises
506
+ ------
507
+ NotImplementedError
508
+ If the method is not overridden in a subclass.
509
+ """
510
+ raise NotImplementedError(
511
+ "The `save_access_token` method must be overridden in a subclass to save the access token and related information. "
512
+ "If not implemented, access token information will not be persisted, and users will need to re-authenticate after application restarts."
513
+ )
514
+
515
+ def load_access_token(self) -> dict:
516
+ """
517
+ Loads the access token and related information.
518
+
519
+ This method must be overridden in a subclass to retrieve the access token and other
520
+ related information (e.g., refresh token, expiration time) from persistent storage.
521
+ If not implemented, the access token will not be loaded, and users will need to
522
+ re-authenticate after application restarts.
523
+
524
+ Returns
525
+ -------
526
+ dict | None
527
+ A dictionary containing the access token and related information, such as
528
+ refresh token, expiration time, etc., or None if no token is found.
529
+
530
+ Raises
531
+ ------
532
+ NotImplementedError
533
+ If the method is not overridden in a subclass.
534
+ """
535
+ raise NotImplementedError(
536
+ "The `load_access_token` method must be overridden in a subclass to load access token and related information. "
537
+ "If not implemented, access token information will not be loaded, and users will need to re-authenticate after application restarts."
538
+ )
539
+
540
+ def callback_handler(self, code, state, expected_state):
541
+ """
542
+ Handles the callback phase of the OAuth 2.0 authorization process.
543
+
544
+ This method processes the authorization code and state returned by Spotify after the user
545
+ has granted permission. It validates the state to prevent CSRF attacks, exchanges the
546
+ authorization code for an access token, and saves the token for future use.
547
+
548
+ Parameters
549
+ ----------
550
+ code : str
551
+ The authorization code returned by Spotify after user authorization.
552
+ state : str
553
+ The state parameter returned by Spotify to ensure the request's integrity.
554
+ expected_state : str
555
+ The original state parameter sent during the authorization request, used to validate the response.
556
+
557
+ Raises
558
+ ------
559
+ SpotifyAuthException
560
+ If the returned state does not match the expected state.
561
+
562
+ Notes
563
+ -----
564
+ - This method can be used in a web application (e.g., Flask) in the `/callback` route to handle
565
+ successful authorization.
566
+ - Ensure that the ``save_access_token`` and ``load_access_token`` methods are implemented in a subclass
567
+ if token persistence is required.
568
+ """
569
+ if state != expected_state:
570
+ raise AuthenticationException("state does not match!")
571
+
572
+ token_info = self._get_access_token(authorization_code=code)
573
+
574
+ self._access_token = token_info.get("access_token")
575
+ self._refresh_token = token_info.get("refresh_token")
576
+ self._token_expires_in = token_info.get("expires_in")
577
+ self._token_requested_at = token_info.get("requested_at")
578
+
579
+ try:
580
+ self.save_access_token(token_info)
581
+ except NotImplementedError as e:
582
+ logger.warning(e)
yutipy/deezer.py CHANGED
@@ -5,11 +5,7 @@ from typing import Dict, List, Optional
5
5
 
6
6
  import requests
7
7
 
8
- from yutipy.exceptions import (
9
- DeezerException,
10
- InvalidResponseException,
11
- InvalidValueException,
12
- )
8
+ from yutipy.exceptions import DeezerException, InvalidValueException
13
9
  from yutipy.logger import logger
14
10
  from yutipy.models import MusicInfo
15
11
  from yutipy.utils.helpers import are_strings_similar, is_valid_string
@@ -26,7 +22,7 @@ class Deezer:
26
22
  self.__session = requests.Session()
27
23
  self._translation_session = requests.Session()
28
24
 
29
- def __enter__(self) -> "Deezer":
25
+ def __enter__(self):
30
26
  """Enters the runtime context related to this object."""
31
27
  return self
32
28
 
@@ -81,7 +77,6 @@ class Deezer:
81
77
  self.normalize_non_english = normalize_non_english
82
78
 
83
79
  search_types = ["track", "album"]
84
-
85
80
  for search_type in search_types:
86
81
  endpoint = f"{self.api_url}/search/{search_type}"
87
82
  query = f'?q=artist:"{artist}" {search_type}:"{song}"&limit={limit}'
@@ -96,18 +91,15 @@ class Deezer:
96
91
  logger.debug(f"Response status code: {response.status_code}")
97
92
  response.raise_for_status()
98
93
  except requests.RequestException as e:
99
- logger.warning(f"Network error while fetching music info: {e}")
100
- return None
101
- except Exception as e:
102
94
  logger.warning(f"Unexpected error while searching Deezer: {e}")
103
- raise DeezerException(f"An error occurred while searching Deezer: {e}")
95
+ return None
104
96
 
105
97
  try:
106
98
  logger.debug(f"Parsing response JSON: {response.json()}")
107
99
  result = response.json()["data"]
108
100
  except (IndexError, KeyError, ValueError) as e:
109
101
  logger.warning(f"Invalid response structure from Deezer: {e}")
110
- raise InvalidResponseException(f"Invalid response received: {e}")
102
+ return None
111
103
 
112
104
  music_info = self._parse_results(artist, song, result)
113
105
  if music_info:
@@ -139,7 +131,7 @@ class Deezer:
139
131
  elif music_type == "album":
140
132
  return self._get_album_info(music_id)
141
133
  else:
142
- raise DeezerException(f"Invalid music type: {music_type}")
134
+ raise InvalidValueException(f"Invalid music type: {music_type}")
143
135
 
144
136
  def _get_track_info(self, track_id: int) -> Optional[Dict]:
145
137
  """
@@ -165,16 +157,13 @@ class Deezer:
165
157
  except requests.RequestException as e:
166
158
  logger.warning(f"Error fetching track info: {e}")
167
159
  return None
168
- except Exception as e:
169
- logger.warning(f"Error fetching track info: {e}")
170
- raise DeezerException(f"An error occurred while fetching track info: {e}")
171
160
 
172
161
  try:
173
162
  logger.debug(f"Response JSON: {response.json()}")
174
163
  result = response.json()
175
164
  except ValueError as e:
176
165
  logger.warning(f"Invalid response received from Deezer: {e}")
177
- raise InvalidResponseException(f"Invalid response received: {e}")
166
+ return None
178
167
 
179
168
  return {
180
169
  "isrc": result.get("isrc"),
@@ -206,16 +195,13 @@ class Deezer:
206
195
  except requests.RequestException as e:
207
196
  logger.warning(f"Error fetching album info: {e}")
208
197
  return None
209
- except Exception as e:
210
- logger.warning(f"Error fetching album info: {e}")
211
- raise DeezerException(f"An error occurred while fetching album info: {e}")
212
198
 
213
199
  try:
214
200
  logger.debug(f"Response JSON: {response.json()}")
215
201
  result = response.json()
216
202
  except ValueError as e:
217
203
  logger.warning(f"Invalid response received from Deezer: {e}")
218
- raise InvalidResponseException(f"Invalid response received: {e}")
204
+ return None
219
205
 
220
206
  return {
221
207
  "genre": (