Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

drop 3.8, add pytest-retry #592

Merged
merged 4 commits into from
Jul 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ jobs:
curl -o tests/test.mp3 https://www.kozco.com/tech/piano2-CoolEdit.mp3
cat <<< "$HEADERS_AUTH" > tests/browser.json
cat <<< "$TEST_CFG" > tests/test.cfg
(echo "===== tests attempt: 1 ====" && pdm run pytest) || \
(echo "===== tests attempt: 2 ====" && pdm run pytest)
pdm run pytest
pdm run coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.8"
python-version: "3.9"
- run: pip install mypy==1.10.0
- run: mypy --install-types --non-interactive
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.5.0
rev: v0.5.1
hooks:
# Run the linter.
- id: ruff
Expand Down
410 changes: 180 additions & 230 deletions pdm.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "ytmusicapi"
description = "Unofficial API for YouTube Music"
requires-python = ">=3.8"
requires-python = ">=3.9"
authors=[{name = "sigma67", email= "[email protected]"}]
license={file="LICENSE"}
classifiers = [
Expand Down Expand Up @@ -43,7 +43,7 @@ include-package-data=false
[tool.pytest.ini_options]
python_functions = "test_*"
testpaths = ["tests"]
addopts = "--verbose --cov"
addopts = "--verbose --cov --retries 2 --retry-delay 5"

[tool.coverage.run]
source = ["ytmusicapi"]
Expand Down Expand Up @@ -78,4 +78,5 @@ dev = [
"pytest>=7.4.4",
"pytest-cov>=4.1.0",
"types-requests>=2.31.0.20240218",
"pytest-retry>=1.6.3",
]
12 changes: 7 additions & 5 deletions tests/auth/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import tempfile
import time
from pathlib import Path
from typing import Any, Dict
from typing import Any
from unittest import mock

import pytest
Expand All @@ -17,7 +17,7 @@


@pytest.fixture(name="blank_code")
def fixture_blank_code() -> Dict[str, Any]:
def fixture_blank_code() -> dict[str, Any]:
return {
"device_code": "",
"user_code": "",
Expand Down Expand Up @@ -46,9 +46,11 @@ def test_setup_oauth(self, session_mock, json_mock, blank_code, config):
json_mock.side_effect = [blank_code, token_code]
oauth_file = tempfile.NamedTemporaryFile(delete=False)
oauth_filepath = oauth_file.name
with mock.patch("builtins.input", return_value="y"), mock.patch(
"sys.argv", ["ytmusicapi", "oauth", "--file", oauth_filepath]
), mock.patch("webbrowser.open"):
with (
mock.patch("builtins.input", return_value="y"),
mock.patch("sys.argv", ["ytmusicapi", "oauth", "--file", oauth_filepath]),
mock.patch("webbrowser.open"),
):
main()
assert Path(oauth_filepath).exists()

Expand Down
9 changes: 5 additions & 4 deletions tests/mixins/test_playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from ytmusicapi import YTMusic
from ytmusicapi.constants import SUPPORTED_LANGUAGES
from ytmusicapi.enums import ResponseStatus


class TestPlaylists:
Expand Down Expand Up @@ -85,7 +86,7 @@ def test_edit_playlist(self, config, yt_brand):
playlist["tracks"][0]["setVideoId"],
),
)
assert response1 == "STATUS_SUCCEEDED", "Playlist edit 1 failed"
assert response1 == ResponseStatus.SUCCEEDED, "Playlist edit 1 failed"
response2 = yt_brand.edit_playlist(
playlist["id"],
title=playlist["title"],
Expand All @@ -96,7 +97,7 @@ def test_edit_playlist(self, config, yt_brand):
playlist["tracks"][1]["setVideoId"],
),
)
assert response2 == "STATUS_SUCCEEDED", "Playlist edit 2 failed"
assert response2 == ResponseStatus.SUCCEEDED, "Playlist edit 2 failed"
response3 = yt_brand.edit_playlist(
playlist["id"],
title=playlist["title"],
Expand All @@ -120,13 +121,13 @@ def test_end2end(self, yt_brand, sample_video):
source_playlist="OLAK5uy_nvjTE32aFYdFN7HCyMv3cGqD3wqBb4Jow",
duplicates=True,
)
assert response["status"] == "STATUS_SUCCEEDED", "Adding playlist item failed"
assert response["status"] == ResponseStatus.SUCCEEDED, "Adding playlist item failed"
assert len(response["playlistEditResults"]) > 0, "Adding playlist item failed"
time.sleep(3)
yt_brand.edit_playlist(playlist_id, addToTop=False)
time.sleep(3)
playlist = yt_brand.get_playlist(playlist_id, related=True)
assert len(playlist["tracks"]) == 46, "Getting playlist items failed"
response = yt_brand.remove_playlist_items(playlist_id, playlist["tracks"])
assert response == "STATUS_SUCCEEDED", "Playlist item removal failed"
assert response == ResponseStatus.SUCCEEDED, "Playlist item removal failed"
yt_brand.delete_playlist(playlist_id)
12 changes: 7 additions & 5 deletions tests/mixins/test_uploads.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from tests.conftest import get_resource
from ytmusicapi.enums import ResponseStatus
from ytmusicapi.ytmusic import YTMusic


Expand Down Expand Up @@ -38,8 +39,9 @@ def test_get_library_upload_artists(self, config, yt_oauth, yt_empty):
def test_upload_song_exceptions(self, config, yt_auth, yt_oauth):
with pytest.raises(Exception, match="The provided file does not exist."):
yt_auth.upload_song("song.wav")
with tempfile.NamedTemporaryFile(suffix="wav") as temp, pytest.raises(
Exception, match="The provided file type is not supported"
with (
tempfile.NamedTemporaryFile(suffix="wav") as temp,
pytest.raises(Exception, match="The provided file type is not supported"),
):
yt_auth.upload_song(temp.name)
with pytest.raises(Exception, match="Please provide browser authentication"):
Expand All @@ -59,14 +61,14 @@ def test_upload_song_and_verify(self, config, yt_auth: YTMusic):
for song in songs:
if song.get("title") in config["uploads"]["file"]:
delete_response = yt_auth.delete_upload_entity(song["entityId"])
assert delete_response == "STATUS_SUCCEEDED"
assert delete_response == ResponseStatus.SUCCEEDED
# Need to wait for song to be fully deleted
time.sleep(10)
# Now re-upload
upload_response = yt_auth.upload_song(get_resource(config["uploads"]["file"]))

assert (
upload_response == "STATUS_SUCCEEDED" or upload_response.status_code == 200
upload_response == ResponseStatus.SUCCEEDED or upload_response.status_code == 200
), f"Song failed to upload {upload_response}"

# Wait for upload to finish processing and verify it can be retrieved
Expand All @@ -86,7 +88,7 @@ def test_upload_song_and_verify(self, config, yt_auth: YTMusic):
def test_delete_upload_entity(self, yt_oauth):
results = yt_oauth.get_library_upload_songs()
response = yt_oauth.delete_upload_entity(results[0]["entityId"])
assert response == "STATUS_SUCCEEDED"
assert response == ResponseStatus.SUCCEEDED

def test_get_library_upload_album(self, config, yt_oauth):
album = yt_oauth.get_library_upload_album(config["uploads"]["private_album_id"])
Expand Down
5 changes: 3 additions & 2 deletions ytmusicapi/auth/oauth/credentials.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from abc import ABC, abstractmethod
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Dict, Mapping, Optional
from typing import Optional

import requests

Expand Down Expand Up @@ -48,7 +49,7 @@ def __init__(
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
session: Optional[requests.Session] = None,
proxies: Optional[Dict] = None,
proxies: Optional[dict] = None,
):
"""
:param client_id: Optional. Set the GoogleAPI client_id used for auth flows.
Expand Down
3 changes: 1 addition & 2 deletions ytmusicapi/auth/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""enum representing types of authentication supported by this library"""

from enum import Enum, auto
from typing import List


class AuthType(int, Enum):
Expand All @@ -21,5 +20,5 @@ class AuthType(int, Enum):
OAUTH_CUSTOM_FULL = auto()

@classmethod
def oauth_types(cls) -> List["AuthType"]:
def oauth_types(cls) -> list["AuthType"]:
return [cls.OAUTH_DEFAULT, cls.OAUTH_CUSTOM_CLIENT, cls.OAUTH_CUSTOM_FULL]
5 changes: 5 additions & 0 deletions ytmusicapi/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from enum import Enum


class ResponseStatus(str, Enum):
SUCCEEDED = "STATUS_SUCCEEDED"
10 changes: 5 additions & 5 deletions ytmusicapi/mixins/_protocol.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""protocol that defines the functions available to mixins"""

from typing import Dict, Optional, Protocol
from typing import Optional, Protocol

from requests import Response

Expand All @@ -15,17 +15,17 @@ class MixinProtocol(Protocol):

parser: Parser

proxies: Optional[Dict[str, str]]
proxies: Optional[dict[str, str]]

def _check_auth(self) -> None:
"""checks if self has authentication"""

def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict:
def _send_request(self, endpoint: str, body: dict, additionalParams: str = "") -> dict:
"""for sending post requests to YouTube Music"""

def _send_get_request(self, url: str, params: Optional[Dict] = None) -> Response:
def _send_get_request(self, url: str, params: Optional[dict] = None) -> Response:
"""for sending get requests to YouTube Music"""

@property
def headers(self) -> Dict[str, str]:
def headers(self) -> dict[str, str]:
"""property for getting request headers"""
24 changes: 12 additions & 12 deletions ytmusicapi/mixins/browsing.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import re
import warnings
from typing import Any, Dict, List, Optional
from typing import Any, Optional

from ytmusicapi.continuations import (
get_continuations,
Expand All @@ -18,7 +18,7 @@


class BrowsingMixin(MixinProtocol):
def get_home(self, limit=3) -> List[Dict]:
def get_home(self, limit=3) -> list[dict]:
"""
Get the home page.
The home page is structured as titled rows, returning 3 rows of music suggestions at a time.
Expand Down Expand Up @@ -124,7 +124,7 @@ def get_home(self, limit=3) -> List[Dict]:

return home

def get_artist(self, channelId: str) -> Dict:
def get_artist(self, channelId: str) -> dict:
"""
Get information about an artist and their top releases (songs,
albums, singles, videos, and related artists). The top lists
Expand Down Expand Up @@ -237,7 +237,7 @@ def get_artist(self, channelId: str) -> Dict:
response = self._send_request(endpoint, body)
results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST)

artist: Dict[str, Any] = {"description": None, "views": None}
artist: dict[str, Any] = {"description": None, "views": None}
header = response["header"]["musicImmersiveHeaderRenderer"]
artist["name"] = nav(header, TITLE_TEXT)
descriptionShelf = find_object_by_key(results, DESCRIPTION_SHELF[0], is_key=True)
Expand Down Expand Up @@ -271,7 +271,7 @@ def get_artist(self, channelId: str) -> Dict:

def get_artist_albums(
self, channelId: str, params: str, limit: Optional[int] = 100, order: Optional[str] = None
) -> List[Dict]:
) -> list[dict]:
"""
Get the full list of an artist's albums, singles or shows

Expand Down Expand Up @@ -350,7 +350,7 @@ def get_artist_albums(

return albums

def get_user(self, channelId: str) -> Dict:
def get_user(self, channelId: str) -> dict:
"""
Retrieve a user's page. A user may own videos or playlists.

Expand Down Expand Up @@ -406,7 +406,7 @@ def get_user(self, channelId: str) -> Dict:
user.update(self.parser.parse_channel_contents(results))
return user

def get_user_playlists(self, channelId: str, params: str) -> List[Dict]:
def get_user_playlists(self, channelId: str, params: str) -> list[dict]:
"""
Retrieve a list of playlists for a given user.
Call this function again with the returned ``params`` to get the full list.
Expand Down Expand Up @@ -448,7 +448,7 @@ def get_album_browse_id(self, audioPlaylistId: str) -> Optional[str]:
browse_id = matches.group().strip('"')
return browse_id

def get_album(self, browseId: str) -> Dict:
def get_album(self, browseId: str) -> dict:
"""
Get information and tracks of an album

Expand Down Expand Up @@ -532,7 +532,7 @@ def get_album(self, browseId: str) -> Dict:

return album

def get_song(self, videoId: str, signatureTimestamp: Optional[int] = None) -> Dict:
def get_song(self, videoId: str, signatureTimestamp: Optional[int] = None) -> dict:
"""
Returns metadata and streaming information about a song or video.

Expand Down Expand Up @@ -799,7 +799,7 @@ def get_song_related(self, browseId: str):
sections = nav(response, ["contents", *SECTION_LIST])
return parse_mixed_content(sections)

def get_lyrics(self, browseId: str) -> Dict:
def get_lyrics(self, browseId: str) -> dict:
"""
Returns lyrics of a song or video.

Expand Down Expand Up @@ -859,7 +859,7 @@ def get_signatureTimestamp(self, url: Optional[str] = None) -> int:

return int(match.group(1))

def get_tasteprofile(self) -> Dict:
def get_tasteprofile(self) -> dict:
"""
Fetches suggested artists from taste profile (music.youtube.com/tasteprofile).
Tasteprofile allows users to pick artists to update their recommendations.
Expand Down Expand Up @@ -891,7 +891,7 @@ def get_tasteprofile(self) -> Dict:
}
return taste_profiles

def set_tasteprofile(self, artists: List[str], taste_profile: Optional[Dict] = None) -> None:
def set_tasteprofile(self, artists: list[str], taste_profile: Optional[dict] = None) -> None:
"""
Favorites artists to see more recommendations from the artist.
Use :py:func:`get_tasteprofile` to see which artists are available to be recommended
Expand Down
Loading