├── spotrpy ├── __init__.py ├── api │ ├── __init__.py │ └── API.py ├── assets │ ├── __init__.py │ └── LOGO.py ├── config │ ├── __init__.py │ └── config.py ├── core │ ├── __init__.py │ └── BaseController.py ├── logging │ ├── __init__.py │ └── Logging.py ├── parser │ ├── __init__.py │ └── parser.py ├── util │ ├── __init__.py │ ├── Helpers.py │ └── ASCII.py ├── controllers │ ├── __init__.py │ ├── authController.py │ ├── previousController.py │ ├── nextController.py │ ├── startController.py │ ├── stopController.py │ ├── replayController.py │ ├── seekController.py │ ├── webController.py │ ├── queueController.py │ ├── qsearchController.py │ ├── shuffleController.py │ ├── playbackController.py │ ├── volumeController.py │ ├── asciiController.py │ ├── playlistController.py │ ├── recentController.py │ ├── playlistaddController.py │ ├── recommendController.py │ ├── searchController.py │ ├── artistController.py │ └── currentController.py ├── interface │ ├── __init__.py │ └── Command.py ├── main.py ├── commands.py └── .pylintrc ├── .gitignore ├── requirements.txt ├── setup.py ├── LICENSE.md └── README.md /spotrpy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotrpy/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotrpy/assets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotrpy/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotrpy/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotrpy/logging/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotrpy/parser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotrpy/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotrpy/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotrpy/interface/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | build/* 3 | dist/* 4 | **/__pycache__ 5 | **/config.json 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow==10.1.0 2 | questionary==2.0.1 3 | requests==2.28.1 4 | rich==13.6.0 5 | tqdm==4.66.1 -------------------------------------------------------------------------------- /spotrpy/main.py: -------------------------------------------------------------------------------- 1 | from spotrpy.parser.parser import Parser 2 | 3 | def main(): 4 | Parser() 5 | 6 | if __name__ == "__main__": 7 | main() 8 | -------------------------------------------------------------------------------- /spotrpy/controllers/authController.py: -------------------------------------------------------------------------------- 1 | from spotrpy.core.BaseController import BaseController 2 | 3 | 4 | class authController(BaseController): 5 | 6 | def auth(self): 7 | self.authorise() 8 | -------------------------------------------------------------------------------- /spotrpy/controllers/previousController.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin 2 | 3 | from spotrpy.core.BaseController import BaseController 4 | 5 | class previousController(BaseController): 6 | 7 | def run(self): 8 | self.request("POST", urljoin(self.API_PLAYER, "previous")) 9 | -------------------------------------------------------------------------------- /spotrpy/controllers/nextController.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin 2 | 3 | from spotrpy.core.BaseController import BaseController 4 | 5 | 6 | class nextController(BaseController): 7 | 8 | def run(self): 9 | self.request("POST", urljoin(self.API_PLAYER, "next")) 10 | -------------------------------------------------------------------------------- /spotrpy/controllers/startController.py: -------------------------------------------------------------------------------- 1 | 2 | from urllib.parse import urljoin 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | 6 | 7 | class startController(BaseController): 8 | 9 | def run(self): 10 | self.request("PUT", urljoin(self.API_PLAYER, "play")) 11 | -------------------------------------------------------------------------------- /spotrpy/controllers/stopController.py: -------------------------------------------------------------------------------- 1 | 2 | from urllib.parse import urljoin 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | 6 | 7 | class stopController(BaseController): 8 | 9 | def run(self): 10 | self.request("PUT", urljoin(self.API_PLAYER, "pause")) 11 | -------------------------------------------------------------------------------- /spotrpy/controllers/replayController.py: -------------------------------------------------------------------------------- 1 | 2 | from urllib.parse import urljoin, urlencode 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | 6 | 7 | class replayController(BaseController): 8 | 9 | def run(self): 10 | self.request("PUT", f"{urljoin(self.API_PLAYER, 'seek')}?{urlencode({'position_ms': 0})}") 11 | -------------------------------------------------------------------------------- /spotrpy/assets/LOGO.py: -------------------------------------------------------------------------------- 1 | SPOTR_LOGO = r""" 2 | ______ ______ ______ ______ ______ 3 | /\ ___\ /\ == \ /\ __ \ /\__ _\ /\ == \ 4 | \ \___ \ \ \ _-/ \ \ \/\ \ \/_/\ \/ \ \ __< 5 | \/\_____\ \ \_\ \ \_____\ \ \_\ \ \_\ \_\ 6 | \/_____/ \/_/ \/_____/ \/_/ \/_/ /_/ 7 | by Havard03 8 | """ 9 | -------------------------------------------------------------------------------- /spotrpy/controllers/seekController.py: -------------------------------------------------------------------------------- 1 | 2 | from urllib.parse import urljoin, urlencode 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | 6 | 7 | class seekController(BaseController): 8 | 9 | def run(self): 10 | self.request( 11 | "PUT", 12 | str(f"{urljoin(self.API_PLAYER, 'seek')}?{urlencode({'position_ms': int(self.args.seconds) * 1000})}") 13 | ) 14 | -------------------------------------------------------------------------------- /spotrpy/controllers/webController.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from urllib.parse import urljoin 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | 6 | class webController(BaseController): 7 | 8 | def run(self): 9 | data = self.request("GET", urljoin(self.API_PLAYER, "currently-playing")) 10 | if data is None: return 11 | webbrowser.open_new_tab(data["item"]["external_urls"]["spotify"]) 12 | -------------------------------------------------------------------------------- /spotrpy/controllers/queueController.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin 2 | 3 | from spotrpy.core.BaseController import BaseController 4 | 5 | 6 | class queueController(BaseController): 7 | 8 | def run(self): 9 | self.fetch() 10 | for track in self.response["queue"]: 11 | self.console.print(f"{track['name']}") 12 | 13 | def fetch(self): 14 | self.response = self.request("GET", urljoin(self.API_PLAYER, "queue")) 15 | -------------------------------------------------------------------------------- /spotrpy/interface/Command.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command interface 3 | """ 4 | 5 | from typing import Any, Callable, List, Union 6 | 7 | 8 | class Command(): 9 | """ Command """ 10 | 11 | commands = {} 12 | 13 | def command(self, names: List[str], desc: str, controller: Union[Callable[..., Any], List[Any]], options=None) -> None: 14 | for name in names: 15 | self.commands[name]=({ 16 | "name": name, 17 | "desc": desc, 18 | "callable": controller or [], 19 | "options": options or [] 20 | }) 21 | -------------------------------------------------------------------------------- /spotrpy/core/BaseController.py: -------------------------------------------------------------------------------- 1 | from spotrpy.logging.Logging import Logging 2 | from spotrpy.config.config import Config 3 | from spotrpy.api.API import API 4 | 5 | """ 6 | BaseController for bootstrapping essential functionality 7 | """ 8 | 9 | class BaseController(Logging, Config, API): 10 | """ BaseController """ 11 | 12 | def __init__(self, args): 13 | self.args = args 14 | 15 | self.__initLogging__() 16 | self.log.debug("[green]BaseController initialized [purple]Logging") 17 | 18 | self.__initConfig__() 19 | self.log.debug("[green]BaseController initialized [purple]Config") 20 | 21 | self.__initAPI__() 22 | self.log.debug("[green]BaseController initialized [purple]API") 23 | -------------------------------------------------------------------------------- /spotrpy/controllers/qsearchController.py: -------------------------------------------------------------------------------- 1 | 2 | from urllib.parse import urljoin, urlencode 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | 6 | 7 | class qsearchController(BaseController): 8 | 9 | def run(self): 10 | self.fetch() 11 | json_id = [self.response['tracks']['items'][0]['uri']] 12 | json = {"uris": json_id, "offset": {"position": "0"}} 13 | 14 | self.request("PUT", str(urljoin(self.API_PLAYER, "play")), json=json) 15 | 16 | def fetch(self): 17 | self.response = self.request( 18 | "GET", 19 | str( 20 | f"{urljoin(self.API_BASE_VERSION, 'search')}?{urlencode({'q': ' '.join(self.args.query), 'type': 'track', 'limit': 1})}" 21 | ), 22 | ) 23 | -------------------------------------------------------------------------------- /spotrpy/controllers/shuffleController.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from urllib.parse import urljoin, urlencode 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | 6 | 7 | class shuffleController(BaseController): 8 | 9 | def run(self): 10 | if not self.args.state: 11 | state = questionary.select( 12 | "Choose shuffle state", 13 | choices=["true", "false"], 14 | erase_when_done=True, 15 | use_shortcuts=True, 16 | ).ask() 17 | else: 18 | state = self.args.state 19 | 20 | if state is None: 21 | return 22 | 23 | self.request("PUT", str(f"{urljoin(self.API_PLAYER, 'shuffle')}?{urlencode({'state': state})}")) 24 | -------------------------------------------------------------------------------- /spotrpy/controllers/playbackController.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from urllib.parse import urljoin, urlencode 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | 6 | 7 | class playbackController(BaseController): 8 | 9 | def run(self): 10 | if not self.args.state: 11 | state = questionary.select( 12 | "Choose a play state", 13 | choices=["track", "context", "off"], 14 | erase_when_done=True, 15 | use_shortcuts=True, 16 | ).ask() 17 | else: 18 | state = self.args.state 19 | 20 | if state is None: 21 | return 22 | 23 | self.request("PUT", str(f"{urljoin(self.API_PLAYER, 'repeat')}?{urlencode({'state': state})}")) 24 | 25 | -------------------------------------------------------------------------------- /spotrpy/controllers/volumeController.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from urllib.parse import urljoin, urlencode 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | 6 | class volumeController(BaseController): 7 | 8 | def run(self): 9 | if self.args.percentage is None: 10 | volume = questionary.select( 11 | "Choose volume precentage", 12 | choices=["25%", "50%", "75%", "100%"], 13 | erase_when_done=True, 14 | use_shortcuts=True, 15 | ).ask() 16 | else: 17 | volume = self.args.percentage 18 | 19 | self.request( 20 | "PUT", 21 | str( 22 | f"{urljoin(self.API_PLAYER, 'volume')}?{urlencode({'volume_percent': str(volume).replace('%', '')})}" 23 | ), 24 | ) 25 | -------------------------------------------------------------------------------- /spotrpy/logging/Logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rich.console import Console 4 | from rich.logging import RichHandler 5 | 6 | """ 7 | Universal logging config 8 | """ 9 | 10 | class Logging: 11 | """ Logging """ 12 | 13 | def __initLogging__(self): 14 | self.log = logging.getLogger() 15 | self.console = Console() 16 | 17 | if self.args.debug: 18 | logging.basicConfig( 19 | level="NOTSET", 20 | format="%(message)s", 21 | datefmt="[%X]", 22 | handlers=[RichHandler(markup=True, rich_tracebacks=True)], 23 | ) 24 | else: 25 | logging.basicConfig( 26 | level="INFO", 27 | datefmt="[%X]", 28 | format="%(message)s", 29 | handlers=[RichHandler(markup=True, rich_tracebacks=True)], 30 | ) 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup script for spotrpy package""" 2 | 3 | from setuptools import setup, find_packages 4 | 5 | with open("README.md", "r", encoding = "utf-8") as fh: 6 | long_description = fh.read() 7 | 8 | setup( 9 | name='spotrpy', 10 | version='4.1', 11 | packages=find_packages(), 12 | python_requires = ">=3.6", 13 | author = "Havard03", 14 | author_email = "havard.buvang@gmail.com", 15 | description = "A simple spotify tool for the terminal", 16 | long_description = long_description, 17 | long_description_content_type = "text/markdown", 18 | url = "https://github.com/Havard03/spotr", 19 | keywords = 'cli', 20 | install_requires=[ 21 | 'Pillow', 22 | 'questionary', 23 | 'requests', 24 | 'rich', 25 | 'tqdm', 26 | ], 27 | entry_points={ 28 | 'console_scripts': [ 29 | 'spotr=spotrpy.main:main', 30 | ], 31 | }, 32 | classifiers=[ 33 | 'Programming Language :: Python :: 3', 34 | 'License :: OSI Approved :: MIT License', 35 | 'Operating System :: OS Independent', 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Havard03 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. 22 | -------------------------------------------------------------------------------- /spotrpy/controllers/asciiController.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from urllib.parse import urljoin 4 | 5 | from spotrpy.core.BaseController import BaseController 6 | from spotrpy.util.ASCII import ASCII 7 | 8 | 9 | class asciiController(BaseController, ASCII): 10 | 11 | def run(self): 12 | self.fetch() 13 | if self.args.width is None: 14 | width, height = os.get_terminal_size() 15 | else: 16 | width = self.args.width 17 | 18 | ascii_str = ( 19 | self.image_to_ascii_color( 20 | self.response["item"]["album"]["images"][0]["url"], int(width) 21 | ) 22 | if eval(self.CONFIG["ASCII_IMAGE_COLOR"]) 23 | else self.image_to_ascii( 24 | self.response["item"]["album"]["images"][0]["url"], int(width) 25 | ) 26 | ) 27 | lines = ascii_str.splitlines() 28 | for line in lines: 29 | print(line) 30 | 31 | def fetch(self): 32 | self.response = self.request("GET", urljoin(self.API_PLAYER, "currently-playing")) 33 | if self.response is None or self.response["item"] is None: 34 | self.log.error("No data") 35 | sys.exit() 36 | -------------------------------------------------------------------------------- /spotrpy/controllers/playlistController.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | 3 | from urllib.parse import urljoin, urlencode 4 | 5 | from spotrpy.core.BaseController import BaseController 6 | from spotrpy.util.Helpers import Helpers 7 | 8 | 9 | class playlistController(BaseController, Helpers): 10 | 11 | def run(self): 12 | self.fetch() 13 | 14 | choices = self.parse_items( 15 | self.response, 16 | accessor=["items"], 17 | return_value=["uri"], 18 | name_value=["name"], 19 | artists_value=False, 20 | ) 21 | 22 | selected = questionary.select( 23 | "What playlist do you want to play?", 24 | choices=choices, 25 | erase_when_done=True, 26 | use_arrow_keys=True, 27 | use_jk_keys=False, 28 | ).ask() 29 | if selected is None: 30 | return 31 | 32 | json = {"context_uri": selected, "offset": {"position": "0"}} 33 | self.request("PUT", str(urljoin(self.API_PLAYER, "play")), json=json) 34 | 35 | def fetch(self): 36 | self.response = self.request("GET", f"{urljoin(self.API_BASE_VERSION, 'me/playlists')}?{urlencode({'limit': self.SPOTIFY_LIMIT})}") 37 | -------------------------------------------------------------------------------- /spotrpy/controllers/recentController.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from urllib.parse import urljoin, urlencode 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | from spotrpy.util.Helpers import Helpers 6 | 7 | 8 | class recentController(BaseController, Helpers): 9 | 10 | def run(self): 11 | self.fetch() 12 | 13 | choices = self.parse_items( 14 | self.response, 15 | accessor=["items"], 16 | return_value=["track", "uri"], 17 | name_value=["track", "name"], 18 | artists_value=["track", "artists"], 19 | ) 20 | 21 | selected = questionary.select( 22 | "What song do you want to play?", 23 | choices=choices, 24 | erase_when_done=True, 25 | use_shortcuts=True, 26 | use_arrow_keys=True, 27 | use_jk_keys=False, 28 | ).ask() 29 | 30 | if selected is None: 31 | return 32 | 33 | json = {"uris": [selected]} 34 | self.request("PUT", str(urljoin(self.API_PLAYER, "play")), json=json) 35 | 36 | def fetch(self): 37 | self.response = self.request("GET", f"{urljoin(self.API_PLAYER, 'recently-played')}?{urlencode({'limit' : self.QUSTIONARY_LIMIT})}") 38 | -------------------------------------------------------------------------------- /spotrpy/controllers/playlistaddController.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from urllib.parse import urljoin, urlencode 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | from spotrpy.util.Helpers import Helpers 6 | 7 | 8 | class playlistaddController(BaseController, Helpers): 9 | 10 | def run(self): 11 | self.fetch() 12 | choices = self.parse_items( 13 | self.response, 14 | accessor=["items"], 15 | return_value=["id"], 16 | name_value=["name"], 17 | artists_value=False, 18 | ) 19 | 20 | selected = questionary.select( 21 | "what playlist do you want to add track to?", 22 | choices=choices, 23 | erase_when_done=True, 24 | use_arrow_keys=True, 25 | use_jk_keys=False, 26 | ).ask() 27 | 28 | if selected is None: 29 | return 30 | 31 | self.request( 32 | "POST", 33 | str(f'{urljoin(self.API_BASE_VERSION, f"playlists/{selected}/tracks")}?{urlencode({"uris": self.current_song["item"]["uri"]})}') 34 | ) 35 | 36 | def fetch(self): 37 | self.response = self.request("GET", f"{urljoin(self.API_BASE_VERSION, 'me/playlists')}?{urlencode({'limit': self.SPOTIFY_LIMIT})}") 38 | self.current_song = self.request("GET", urljoin(self.API_PLAYER, "currently-playing")) 39 | 40 | -------------------------------------------------------------------------------- /spotrpy/controllers/recommendController.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin, urlencode 2 | 3 | from spotrpy.core.BaseController import BaseController 4 | 5 | 6 | class recommendController(BaseController): 7 | 8 | def run(self): 9 | self.fetch() 10 | 11 | seed_arists = [] 12 | seed_generes = ["all"] 13 | seed_tracks = [] 14 | 15 | for track in self.response["items"]: 16 | seed_tracks.append(track["track"]["id"]) 17 | seed_arists.append(track["track"]["artists"][0]["id"]) 18 | 19 | params = urlencode( 20 | { 21 | 'seed_arists': ','.join(seed_arists), 22 | 'seed_generes': ','.join(seed_generes), 23 | 'seed_tracks': ','.join(seed_tracks), 24 | 'limit': 5, 25 | } 26 | ) 27 | 28 | recommended = self.request( 29 | "GET", 30 | str( 31 | f"{urljoin(self.API_BASE_VERSION, 'recommendations')}?{params}" 32 | ), 33 | ) 34 | results = [] 35 | for track in recommended["tracks"]: 36 | results.append(track["uri"]) 37 | json = {"uris": results, "offset": {"position": "0"}} 38 | 39 | self.request("PUT", str(urljoin(self.API_PLAYER, "play")), json=json) 40 | 41 | def fetch(self): 42 | self.response = self.request( 43 | "GET", f"{urljoin(self.API_PLAYER, 'recently-played')}?{urlencode({'limit': 5})}" 44 | ) 45 | -------------------------------------------------------------------------------- /spotrpy/util/Helpers.py: -------------------------------------------------------------------------------- 1 | class Helpers(): 2 | """ Helpers """ 3 | 4 | def __get_item(self, data, keys): 5 | """ Access data with accessor """ 6 | 7 | for key in keys: 8 | data = data[key] 9 | return data 10 | 11 | def parse_items( 12 | self, 13 | data, 14 | name_value, 15 | accessor=None, 16 | return_value=None, 17 | artists_value=False, 18 | artists_array=True, 19 | ): 20 | """ Parse tracks for questionary """ 21 | 22 | choices = [] 23 | track_width = 0 24 | 25 | if artists_value: 26 | names = [ 27 | self.__get_item(item, name_value) 28 | for item in (self.__get_item(data, accessor) if accessor else data) 29 | ] 30 | if names: 31 | track_width = max(map(len, names)) 32 | 33 | for item in (self.__get_item(data, accessor) if accessor else data): 34 | track_name = self.__get_item(item, name_value) 35 | if artists_value: 36 | if artists_array: 37 | artist_names = ", ".join( 38 | artist["name"] 39 | for artist in (self.__get_item(item, artists_value)) 40 | ) 41 | else: 42 | artist_names = self.__get_item(item, artists_value) 43 | choices.append( 44 | { 45 | "name": "{0:<{track_width}} -- {1}".format( 46 | track_name, artist_names, track_width=track_width 47 | ), 48 | "value": self.__get_item(item, return_value) if return_value else item, 49 | } 50 | ) 51 | else: 52 | choices.append( 53 | { 54 | "name": track_name, 55 | "value": self.__get_item(item, return_value) if return_value else item, 56 | } 57 | ) 58 | 59 | return choices 60 | 61 | 62 | -------------------------------------------------------------------------------- /spotrpy/util/ASCII.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from io import BytesIO 4 | from PIL import Image 5 | 6 | class ASCII: 7 | """ ASCII class """ 8 | 9 | def resize_image(self, image, new_width=100): 10 | """ Rezise image """ 11 | 12 | width, height = image.size 13 | ratio = height / width / 2.25 14 | new_height = int(new_width * ratio) 15 | resized_image = image.resize((new_width, new_height)) 16 | 17 | return resized_image 18 | 19 | def image_to_ascii(self, image_url, desired_width=75): 20 | """ Convert image to ascii """ 21 | 22 | response = requests.get(image_url, timeout=10) 23 | image_data = response.content 24 | image = Image.open(BytesIO(image_data)) 25 | image = self.resize_image(image, desired_width) 26 | image = image.convert("L") 27 | pixels = image.getdata() 28 | ascii_str = "" 29 | ascii_art = "" 30 | 31 | for pixel_value in pixels: 32 | ascii_str += self.CONFIG["ASCII_IMAGE_CHARS"][ 33 | max( 34 | 0, min(pixel_value // 16, len(self.CONFIG["ASCII_IMAGE_CHARS"]) - 1) 35 | ) 36 | ] 37 | 38 | for i in range(0, len(ascii_str), int(desired_width)): 39 | row = ascii_str[i : i + int(desired_width)] 40 | ascii_art += row + "\n" 41 | 42 | return ascii_art 43 | 44 | def rgb_to_ansi(self, r, g, b): 45 | """ Convert RGB color values to ANSI color codes for console """ 46 | 47 | return f"\x1b[38;2;{r};{g};{b}m" 48 | 49 | def image_to_ascii_color(self, image_url, width=75): 50 | """ Convert image to ASCII art with color """ 51 | 52 | 53 | response = requests.get(image_url, timeout=10) 54 | self.log.debug("[green]Converting image to ASCII") 55 | image = Image.open(BytesIO(response.content)) 56 | aspect_ratio = image.height / image.width 57 | new_height = int(width * aspect_ratio * 0.5) 58 | resized_image = image.resize((width, new_height)) 59 | pixels = resized_image.convert("RGB").load() 60 | ascii_art = "" 61 | 62 | for y in range(resized_image.height): 63 | for x in range(resized_image.width): 64 | r, g, b, *_ = pixels[x, y] 65 | char = "\u2588" if eval(self.CONFIG["ASCII_IMAGE_UNICODE"]) else "@" 66 | ascii_art += self.rgb_to_ansi(r, g, b) + char 67 | if y != resized_image.height - 1: 68 | ascii_art += "\n" 69 | 70 | ascii_art += "\x1b[0m" 71 | 72 | return ascii_art 73 | -------------------------------------------------------------------------------- /spotrpy/parser/parser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import textwrap 3 | import inspect 4 | 5 | from spotrpy.commands import Command 6 | from spotrpy.assets.LOGO import SPOTR_LOGO 7 | 8 | from spotrpy.logging.Logging import Logging 9 | 10 | """ 11 | Initializes ArgumentParser and parses commands from commands.py then runs given method 12 | """ 13 | 14 | class Parser(Logging): 15 | """ Parser """ 16 | 17 | def __init__(self) -> None: 18 | """ Overview of entire command execution """ 19 | 20 | self.parser = argparse.ArgumentParser(description="Spotr Command Line Interface") 21 | self.global_args() 22 | self.subParsers() 23 | 24 | super().__initLogging__() 25 | self.log.debug(self.args) 26 | 27 | self.exec() 28 | self.postExec() 29 | 30 | def global_args(self) -> None: 31 | """ Universal arguments """ 32 | 33 | self.parser.add_argument("-d", "--debug", help="Run in debug", action="store_true") 34 | self.parser.add_argument("-c", "--current", help="Run current, post exec", action="store_true") 35 | self.parser.add_argument("-r", "--response", help="Dump response", action="store_true") 36 | 37 | def subParsers(self) -> None: 38 | """ Parse defined commands and add to ArgumentParser """ 39 | 40 | subparser = self.parser.add_subparsers(dest='command') 41 | 42 | for command in Command.commands: 43 | command_parser = subparser.add_parser( 44 | Command.commands[command]['name'], 45 | help=Command.commands[command]['desc'], 46 | description=Command.commands[command]['desc'] 47 | ) 48 | 49 | if Command.commands[command]['options']: 50 | for option in Command.commands[command]['options']: 51 | command_parser.add_argument( 52 | *option['flags'], 53 | **option['kwargs'] 54 | ) 55 | 56 | self.args = self.parser.parse_args() 57 | 58 | def exec(self, command=None) -> None: 59 | """ Command execution logic """ 60 | 61 | if command is None: command = self.args.command 62 | 63 | if self.args.command: 64 | self.log.debug(f"Executing {Command.commands[command]['callable']}") 65 | 66 | callable = Command.commands[command]['callable'] 67 | 68 | if isinstance(callable, list): 69 | controller = callable[0](self.args) 70 | getattr(controller, callable[1])() 71 | elif inspect.isfunction(callable): 72 | Command.commands[command]['callable'](self.args) 73 | else: 74 | self.log.error("Callable must be of type class or function") 75 | else: 76 | print(textwrap.indent(SPOTR_LOGO, " " * 2)) 77 | self.parser.print_help() 78 | 79 | def postExec(self) -> None: 80 | """ Post execution logic """ 81 | 82 | if self.args.current: self.exec("current") 83 | 84 | 85 | -------------------------------------------------------------------------------- /spotrpy/config/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | 5 | class Config(): 6 | """ Config """ 7 | 8 | def __initConfig__(self): 9 | try: 10 | with open( 11 | os.path.join( 12 | os.path.dirname(os.path.realpath(__file__)), "config.json" 13 | ), 14 | "r", 15 | encoding="utf-8", 16 | ) as file: 17 | self.CONFIG = json.load(file) 18 | self.validate_config() 19 | except FileNotFoundError: 20 | self.log.critical("Config file not found") 21 | create_file = str(input("Create config? y/n: ")) 22 | if create_file.lower() == "y": 23 | self.CONFIG = { 24 | "path": os.path.dirname(os.path.realpath(__file__)), 25 | "refresh_token": "", 26 | "base_64": "", 27 | "key": "", 28 | "API_PROCESS_DELAY": "2", 29 | "ANSI_COLORS": "True", 30 | "USE_ASCII_LOGO": "True", 31 | "LOG_TRACK_URIS": "True", 32 | "ASCII_IMAGE": "True", 33 | "ASCII_IMAGE_SIZE_WIDTH": "50", 34 | "ASCII_IMAGE_COLOR": "True", 35 | "ASCII_IMAGE_UNICODE": "True", 36 | "ASCII_IMAGE_CHARS": '@%#*+=-:.`^",:;Il!i~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$', 37 | "PRINT_DELAY_ACTIVE": "True", 38 | "PRINT_DELAY": "0.01", 39 | } 40 | with open( 41 | os.path.join( 42 | os.path.dirname(os.path.realpath(__file__)), "config.json" 43 | ), 44 | "w", 45 | encoding="utf-8", 46 | ) as file: 47 | json.dump(self.CONFIG, file, indent=4) 48 | else: 49 | sys.exit() 50 | 51 | def write_config(self): 52 | """ Write config data """ 53 | 54 | with open( 55 | os.path.join(self.CONFIG["path"], "config.json"), "w", encoding="utf-8" 56 | ) as file: 57 | json.dump(self.CONFIG, file, indent=4) 58 | 59 | def validate_config(self): 60 | """ Validate config """ 61 | 62 | required_keys = [ 63 | "path", 64 | "refresh_token", 65 | "base_64", 66 | "key", 67 | "API_PROCESS_DELAY", 68 | "ANSI_COLORS", 69 | "USE_ASCII_LOGO", 70 | "LOG_TRACK_URIS", 71 | "ASCII_IMAGE", 72 | "ASCII_IMAGE_SIZE_WIDTH", 73 | "ASCII_IMAGE_COLOR", 74 | "ASCII_IMAGE_UNICODE", 75 | "ASCII_IMAGE_CHARS", 76 | "PRINT_DELAY_ACTIVE", 77 | "PRINT_DELAY", 78 | ] 79 | config_keys = set(self.CONFIG.keys()) 80 | 81 | if not set(required_keys).issubset(config_keys): 82 | missing_keys = set(required_keys) - config_keys 83 | self.log.error("The following keys are missing in the config.json: %s", ", ".join(missing_keys)) 84 | sys.exit() 85 | 86 | return 87 | -------------------------------------------------------------------------------- /spotrpy/controllers/searchController.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from urllib.parse import urljoin, urlencode 3 | 4 | from spotrpy.core.BaseController import BaseController 5 | from spotrpy.util.Helpers import Helpers 6 | 7 | 8 | class searchController(BaseController, Helpers): 9 | 10 | def run(self): 11 | search_types = { 12 | "track": { 13 | "accessor": ["tracks", "items"], 14 | "return_value": ["uri"], 15 | "name_value": ["name"], 16 | "artists_value": ["artists"], 17 | "artists_array": True, 18 | "json_value": "uris", 19 | "json_value_array": True, 20 | "json": {"uris": []}, 21 | }, 22 | "playlist": { 23 | "accessor": ["playlists", "items"], 24 | "return_value": ["uri"], 25 | "name_value": ["name"], 26 | "artists_value": ["owner", "display_name"], 27 | "artists_array": False, 28 | "json_value": "context_uri", 29 | "json_value_array": False, 30 | "json": {"context_uri": [], "offset": {"position": "0"}}, 31 | }, 32 | "album": { 33 | "accessor": ["albums", "items"], 34 | "return_value": ["uri"], 35 | "name_value": ["name"], 36 | "artists_value": ["artists"], 37 | "artists_array": True, 38 | "json_value": "context_uri", 39 | "json_value_array": False, 40 | "json": {"context_uri": [], "offset": {"position": "0"}}, 41 | }, 42 | } 43 | 44 | if not self.args.type: 45 | available_types = ["track", "playlist", "album"] 46 | search_type = questionary.select( 47 | "Select search type", 48 | choices=available_types, 49 | erase_when_done=True, 50 | use_shortcuts=True, 51 | use_arrow_keys=True, 52 | use_jk_keys=False, 53 | ).ask() 54 | if search_type is None: 55 | return 56 | else: 57 | search_type = self.args.type 58 | 59 | self.fetch(search_type) 60 | 61 | choices = self.parse_items( 62 | self.response, 63 | accessor=search_types[search_type]["accessor"], 64 | return_value=search_types[search_type]["return_value"], 65 | name_value=search_types[search_type]["name_value"], 66 | artists_value=search_types[search_type]["artists_value"], 67 | artists_array=search_types[search_type]["artists_array"], 68 | ) 69 | 70 | selected = questionary.select( 71 | "What song do you want to play?", 72 | choices=choices, 73 | erase_when_done=True, 74 | use_shortcuts=True, 75 | use_arrow_keys=True, 76 | use_jk_keys=False, 77 | ).ask() 78 | 79 | if selected is None: 80 | return 81 | 82 | json = search_types[search_type]["json"] 83 | if search_types[search_type]["json_value_array"]: 84 | json[search_types[search_type]["json_value"]] = [selected] 85 | else: 86 | json[search_types[search_type]["json_value"]] = selected 87 | 88 | self.request("PUT", str(urljoin(self.API_PLAYER, "play")), json=json) 89 | 90 | def fetch(self, search_type): 91 | self.response = self.request( 92 | "GET", 93 | str( 94 | f"{urljoin(self.API_BASE_VERSION, 'search')}?{urlencode({'q': ' '.join(self.args.query), 'type': search_type, 'limit': self.QUSTIONARY_LIMIT})}" 95 | ) 96 | ) 97 | 98 | 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

Spotr - A spotify tool for the terminal

3 | 4 |

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 | 17 |

18 | A very simple CLI for controlling your spotify on the fly in the terminal. Made in python for simplicity 19 |

20 | 21 | 22 | 23 |

Debug

24 | 25 | 26 | 27 |

Argparser

28 | 29 | 30 | 31 |

Installation

32 | 33 | ``` 34 | $ pip install spotrpy 35 | ``` 36 | Or clone the repo and install locally 37 | ``` 38 | $ pip install -e . 39 | ``` 40 | 41 | 2. Register an app in spotify for developers "https://developer.spotify.com/dashboard/applications" 42 | - Also set callback URI to "https://www.google.com/" 43 | 44 | 3. Run any spotr command (If command is not recognized check if python bin is in PATH) 45 | 46 | 4. You will be prompted to create config.json and paste in client and secret id from spotify app 47 | 48 | 5. After these steps everything should work and you can enjoy spotr 49 | 50 |

51 | 52 |

Built-in Commands

53 | 54 | auth Authorize Spotify api 55 | authorize Authorize Spotify api 56 | artist Display artist information 57 | ascii Ascii image for current track 58 | current Display information about the currently playing track 59 | next Play next track 60 | playback Set playback state 61 | playlist Start playing one of your playlists 62 | playlistadd Add currently playing track to a playlist 63 | previous Play previous track 64 | qsearch Quicksearch for tracks 65 | queue Display current queue 66 | recent Select one of recently played tracks 67 | recommend Play random / recommended tracks based on recent tracks 68 | replay Replay/Restart currently playing track 69 | search Search for anything on spotify, Types - track, playlist, album 70 | seek Seek posistion for track (in seconds) 71 | shuffle Toggle shuffle, on / off 72 | start Start/resume playing 73 | resume Start/resume playing 74 | stop Stop/Pause playing 75 | pause Stop/Pause playing 76 | volume Ajust volume 77 | vol Ajust volume 78 | web Open currently playing track in a broswer 79 | 80 |

Modifications

81 |

82 | 83 | Commands are declared in commands.py, Then you can set any function or class-method as callable. 84 | 85 |

API documentation

86 | 87 |

88 | 89 | 90 | -------------------------------------------------------------------------------- /spotrpy/controllers/artistController.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import time 4 | import textwrap 5 | import questionary 6 | from urllib.parse import urljoin, urlencode 7 | 8 | from spotrpy.core.BaseController import BaseController 9 | from spotrpy.util.ASCII import ASCII 10 | from spotrpy.util.Helpers import Helpers 11 | 12 | 13 | class artistController (BaseController, Helpers, ASCII): 14 | 15 | def run(self): 16 | if self.args.artist: 17 | self.response = self.request( 18 | "GET", 19 | str( 20 | f"{urljoin(self.API_BASE_VERSION, 'search')}?{urlencode({'q': ' '.join(self.args.artist), 'type': 'artist', 'limit': self.QUSTIONARY_LIMIT})}" 21 | ) 22 | ) 23 | else: 24 | current = self.request("GET", urljoin(self.API_PLAYER, "currently-playing")) 25 | if current is None: 26 | self.log.error("No track currently playing") 27 | sys.exit() 28 | self.response = self.request("GET", urljoin(self.API_BASE_VERSION, f"artists/{current['item']['artists'][0]['id']}")) 29 | 30 | if self.args.artist: 31 | choices = self.parse_items( 32 | self.response, 33 | accessor=(["artists", "items"] if self.args.artist else None), 34 | name_value=["name"], 35 | artists_value=False, 36 | ) 37 | 38 | selected = questionary.select( 39 | "Select artist", 40 | choices=choices, 41 | erase_when_done=True, 42 | use_shortcuts=True, 43 | use_arrow_keys=True, 44 | use_jk_keys=False, 45 | ).ask() 46 | if selected is None: 47 | return 48 | else: 49 | selected = self.response 50 | 51 | color_start = "\x1b[{}m".format 52 | color_end = "\x1b[0m" 53 | 54 | strings = textwrap.dedent( 55 | f""" 56 | {color_start('31')}Artist{color_end} 57 | {color_start('32')}------------------------------{color_end} 58 | {color_start('37')}Name{color_end}{color_start('32')} - {selected['name']}{color_end} 59 | {color_start('37')}Popularity{color_end}{color_start('32')} - {selected['popularity']}{color_end} 60 | {color_start('37')}Genres{color_end}{color_start('32')} - {", ".join(selected['genres'])}{color_end} 61 | {color_start('37')}Followers{color_end}{color_start('32')} - {selected['followers']['total']}{color_end} 62 | 63 | {color_start('31')}Details{color_end} 64 | {color_start('32')}------------------------------{color_end} 65 | {color_start('37')}URL{color_end}{color_start('32')} - {selected['href']}{color_end} 66 | {color_start('37')}URI{color_end}{color_start('32')} - {selected['uri']}{color_end} 67 | {color_start('37')}Image{color_end}{color_start('32')} - {selected['images'][0]['url']}{color_end} 68 | """ 69 | ) 70 | ansi_color_escape = re.compile(r"\x1b\[\d{1,2}m") 71 | strings_no_color = ansi_color_escape.sub("", strings) 72 | if not eval(self.CONFIG["ANSI_COLORS"]): 73 | strings = strings_no_color 74 | 75 | strings = strings.strip().splitlines() 76 | 77 | if eval(self.CONFIG["ASCII_IMAGE"]) or eval(self.CONFIG["USE_ASCII_LOGO"]): 78 | if eval(self.CONFIG["ASCII_IMAGE"]): 79 | width = self.CONFIG["ASCII_IMAGE_SIZE_WIDTH"] 80 | ascii_str = ( 81 | self.image_to_ascii_color( 82 | selected["images"][0]["url"], int(width) 83 | ) 84 | if eval(self.CONFIG["ASCII_IMAGE_COLOR"]) 85 | else self.image_to_ascii( 86 | selected["images"][0]["url"], int(width) 87 | ) 88 | ).splitlines() 89 | elif eval(self.CONFIG["USE_ASCII_LOGO"]): 90 | ascii_str = self.CONFIG["ASCII_LOGO"] 91 | print() 92 | for i, line in enumerate(ascii_str): 93 | if eval(self.CONFIG["PRINT_DELAY_ACTIVE"]): 94 | time.sleep(float(self.CONFIG["PRINT_DELAY"])) 95 | print(f" {line} {strings[i] if i < len(strings) else ''}") 96 | print() 97 | else: 98 | print() 99 | for line in strings: 100 | if eval(self.CONFIG["PRINT_DELAY_ACTIVE"]): 101 | time.sleep(float(self.CONFIG["PRINT_DELAY"])) 102 | print(f" {line}") 103 | print() 104 | 105 | -------------------------------------------------------------------------------- /spotrpy/api/API.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import sys 3 | import requests 4 | 5 | from urllib.parse import urljoin 6 | 7 | class API(): 8 | """ API """ 9 | 10 | def __initAPI__(self): 11 | self.SPOTIFY_LIMIT = 50 12 | self.QUSTIONARY_LIMIT = 36 13 | self.API_VERSION = "v1" 14 | self.API_BASE = "api.spotify.com" 15 | self.ACCOUNT_URL = "https://accounts.spotify.com" 16 | self.API_PLAYER = urljoin("https://api.spotify.com", f"{self.API_VERSION}/me/player/") 17 | self.API_BASE_VERSION = urljoin("https://api.spotify.com", f"{self.API_VERSION}/") 18 | 19 | def request( 20 | self, 21 | method, 22 | url, 23 | headers=None, 24 | json=None 25 | ): 26 | """ Spotify API request """ 27 | 28 | if headers is None: headers = {"Authorization": f"Bearer {self.CONFIG['key']}"} 29 | 30 | response = requests.request(method, url, headers=headers, json=json, timeout=10) 31 | 32 | if not response.ok: 33 | status_code = response.status_code 34 | 35 | self.log.error(f"Requst error - {status_code}") 36 | 37 | if status_code in (401, 400): 38 | self.__refresh_token() 39 | 40 | headers = {"Authorization": f"Bearer {self.CONFIG['key']}"} 41 | response = requests.request(method, url, headers=headers, json=json, timeout=10) 42 | 43 | try: 44 | data = response.json() 45 | except ValueError: 46 | data = None 47 | 48 | if self.args.response: 49 | self.log.info(data) 50 | 51 | return data 52 | 53 | def __refresh_token(self): 54 | """ Refresh Spotify API token """ 55 | 56 | self.log.debug(f"Refreshing API-Token") 57 | url = urljoin(self.ACCOUNT_URL, "api/token") 58 | 59 | response = requests.post( 60 | url, 61 | data={ 62 | "grant_type": "refresh_token", 63 | "refresh_token": self.CONFIG["refresh_token"], 64 | }, 65 | headers={"Authorization": "Basic " + self.CONFIG["base_64"]}, 66 | timeout=10, 67 | ) 68 | 69 | if not response.ok: 70 | self.log.critical(f"Request error - status-code: {response.status_code}") 71 | self.log.critical("Indication of invalid refresh_token, or base_64") 72 | 73 | sys.exit() 74 | 75 | data = response.json() 76 | self.CONFIG["key"] = data["access_token"] 77 | self.write_config() 78 | 79 | def authorise(self, client_id=None, client_secret=None): 80 | """ Authorise CLI with Spotify API """ 81 | 82 | auth_url = urljoin(self.ACCOUNT_URL, "authorize") 83 | token_url = urljoin(self.ACCOUNT_URL, "api/token") 84 | 85 | if not client_id: client_id = str(input("Spotify-App Client id: ")) 86 | if not client_secret: client_secret = str(input("Spotify-App Client secret: ")) 87 | 88 | auth_request = requests.get( 89 | auth_url, 90 | { 91 | "client_id": client_id, 92 | "response_type": "code", 93 | "redirect_uri": "https://www.google.com/", 94 | "scope": "playlist-read-collaborative playlist-read-private user-read-playback-state user-modify-playback-state user-read-currently-playing user-read-recently-played playlist-modify-private playlist-modify-public", 95 | }, 96 | timeout=10, 97 | ) 98 | 99 | print("\nGo to the Following URL, Accept the terms, Copy the code in the redirected URL, Then paste the code into the terminal\n") 100 | print(f"\n{auth_request.url}\n") 101 | 102 | auth_code = str(input("Enter code from the URL: ")) 103 | client_creds = f"{client_id}:{client_secret}" 104 | client_creds_b64 = base64.b64encode(client_creds.encode()) 105 | 106 | headers = { 107 | "Content-Type": "application/x-www-form-urlencoded", 108 | "Authorization": "Basic %s" % client_creds_b64.decode(), 109 | } 110 | payload = { 111 | "grant_type": "authorization_code", 112 | "code": auth_code, 113 | "redirect_uri": "https://www.google.com/", 114 | } 115 | 116 | access_token_request = requests.post( 117 | url=token_url, data=payload, headers=headers, timeout=10 118 | ) 119 | 120 | if not access_token_request.ok: 121 | self.log.warning("Request error: %d", access_token_request.status_code) 122 | sys.exit() 123 | 124 | access_token_response_data = access_token_request.json() 125 | self.CONFIG["refresh_token"] = access_token_response_data["refresh_token"] 126 | self.CONFIG["base_64"] = client_creds_b64.decode() 127 | 128 | self.write_config() 129 | print("All done!") 130 | -------------------------------------------------------------------------------- /spotrpy/controllers/currentController.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import sys 4 | import textwrap 5 | from urllib.parse import urljoin 6 | 7 | from spotrpy.core.BaseController import BaseController 8 | from spotrpy.util.ASCII import ASCII 9 | 10 | class currentController(BaseController, ASCII): 11 | 12 | def run(self): 13 | self.fetch() 14 | 15 | current_track = self.response["item"] 16 | album_data = current_track["album"] 17 | artist_names = ", ".join([artist["name"] for artist in current_track["artists"]]) 18 | track_duration_ms = current_track["duration_ms"] 19 | track_duration_m, track_duration_s = divmod(track_duration_ms // 1000, 60) 20 | progress_ms = self.response["progress_ms"] 21 | progress_m, progress_s = divmod(progress_ms // 1000, 60) 22 | track_id = current_track["id"] 23 | track_name = current_track["name"] 24 | track_type = album_data["album_type"] 25 | album_name = album_data["name"] 26 | track_release_date = album_data["release_date"] 27 | track_url = current_track["external_urls"]["spotify"] 28 | track_image = album_data["images"][0]["url"] 29 | 30 | color_start = "\x1b[{}m".format 31 | color_end = "\x1b[0m" 32 | 33 | strings = textwrap.dedent( 34 | f""" 35 | {color_start('31')}Current track{color_end} 36 | {color_start('32')}------------------------------{color_end} 37 | {color_start('37')}Name{color_end}{color_start('32')} - {track_name}{color_end} 38 | {color_start('37')}Artits{color_end}{color_start('32')} - {artist_names}{color_end} 39 | {color_start('37')}Duration{color_end}{color_start('32')} - {track_duration_m} minutes {track_duration_s} seconds{color_end} 40 | {color_start('37')}Progress{color_end}{color_start('32')} - {progress_m} minutes {progress_s} seconds{color_end} 41 | {color_start('37')}Release date{color_end}{color_start('32')} - {track_release_date}{color_end} 42 | {color_start('37')}From{color_end}{color_start('32')} - {track_type} - {album_name}{color_end} 43 | 44 | {color_start('31')}Track details{color_end} 45 | {color_start('32')}------------------------------{color_end} 46 | {color_start('37')}Id{color_end}{color_start('32')} - {track_id}{color_end} 47 | {color_start('37')}URL{color_end}{color_start('32')} - {track_url}{color_end} 48 | {color_start('37')}Image{color_end}{color_start('32')} - {track_image}{color_end} 49 | """ 50 | ) 51 | 52 | ansi_color_escape = re.compile(r"\x1b\[\d{1,2}m") 53 | strings_no_color = ansi_color_escape.sub("", strings) 54 | if not eval(self.CONFIG["ANSI_COLORS"]): 55 | strings = strings_no_color 56 | 57 | strings = strings.strip().splitlines() 58 | 59 | if eval(self.CONFIG["ASCII_IMAGE"]) or eval(self.CONFIG["USE_ASCII_LOGO"]): 60 | if eval(self.CONFIG["ASCII_IMAGE"]): 61 | width = self.CONFIG["ASCII_IMAGE_SIZE_WIDTH"] 62 | ascii_str = ( 63 | self.image_to_ascii_color( 64 | self.response["item"]["album"]["images"][0]["url"], int(width) 65 | ) 66 | if eval(self.CONFIG["ASCII_IMAGE_COLOR"]) 67 | else self.image_to_ascii( 68 | self.response["item"]["album"]["images"][0]["url"], int(width) 69 | ) 70 | ).splitlines() 71 | elif eval(self.CONFIG["USE_ASCII_LOGO"]): 72 | ascii_str = self.CONFIG["ASCII_LOGO"] 73 | print() 74 | for i, line in enumerate(ascii_str): 75 | if eval(self.CONFIG["PRINT_DELAY_ACTIVE"]): 76 | time.sleep(float(self.CONFIG["PRINT_DELAY"])) 77 | print(f" {line} {strings[i] if i < len(strings) else ''}") 78 | print() 79 | else: 80 | print() 81 | for line in strings: 82 | if eval(self.CONFIG["PRINT_DELAY_ACTIVE"]): 83 | time.sleep(float(self.CONFIG["PRINT_DELAY"])) 84 | print(f" {line}") 85 | print() 86 | 87 | def fetch(self): 88 | response = self.request("GET", urljoin(self.API_PLAYER, "currently-playing")) 89 | 90 | if response is None or response["item"] is None: 91 | self.log.error(f'No data - {response}') 92 | sys.exit() 93 | elif response["currently_playing_type"] not in ("track"): 94 | self.log.error("Playing unsupported type - %s", response['currently_playing_type']) 95 | sys.exit() 96 | else: 97 | self.response = response 98 | 99 | 100 | -------------------------------------------------------------------------------- /spotrpy/commands.py: -------------------------------------------------------------------------------- 1 | from spotrpy.interface.Command import Command 2 | 3 | from spotrpy.controllers.artistController import artistController 4 | from spotrpy.controllers.previousController import previousController 5 | from spotrpy.controllers.qsearchController import qsearchController 6 | from spotrpy.controllers.queueController import queueController 7 | from spotrpy.controllers.recentController import recentController 8 | from spotrpy.controllers.recommendController import recommendController 9 | from spotrpy.controllers.replayController import replayController 10 | from spotrpy.controllers.searchController import searchController 11 | from spotrpy.controllers.seekController import seekController 12 | from spotrpy.controllers.shuffleController import shuffleController 13 | from spotrpy.controllers.startController import startController 14 | from spotrpy.controllers.stopController import stopController 15 | from spotrpy.controllers.volumeController import volumeController 16 | from spotrpy.controllers.webController import webController 17 | from spotrpy.controllers.asciiController import asciiController 18 | from spotrpy.controllers.nextController import nextController 19 | from spotrpy.controllers.playbackController import playbackController 20 | from spotrpy.controllers.playlistController import playlistController 21 | from spotrpy.controllers.playlistaddController import playlistaddController 22 | from spotrpy.controllers.authController import authController 23 | from spotrpy.controllers.currentController import currentController 24 | 25 | Command = Command() 26 | 27 | """ 28 | Define all commands 29 | 30 | Command.command takes following parameters: 31 | [ 32 | 'names': List -- name(s) / callword(s) for command, 33 | 'description': str -- command description for argparser, 34 | 'callable': Callable | List[Callable, 'metod'] -- callable to initialize / run on command execution (callable must accept 'args' parameter), 35 | 'options': List *optional -- options passed to 'add_argument' function for command subparser (contains 'flags' and 'kwargs'), 36 | ] 37 | """ 38 | 39 | ### AUTH 40 | 41 | Command.command(["auth", "authorize"], "Authorize Spotify api", [authController, "auth"], options=[ 42 | {'flags': ['-ci', '--client_id'], 'kwargs': {'type': str, 'help': 'Your spotify-app client-id'}}, 43 | {'flags': ['-cs', '--client_secret'], 'kwargs': {'type': str, 'help': 'Your spotify-app client-secret'}} 44 | ]) 45 | 46 | ### TESTING 47 | 48 | #Command.command(["hello"], "Terminal says hello", lambda args: print("hello!")) 49 | 50 | #def world(args): 51 | # print("world!") 52 | #Command.command(["world"], "function", world) 53 | 54 | ### SPOTR 55 | 56 | Command.command(["artist"], "Display artist information", [artistController, "run"], options=[ 57 | {'flags': ['artist'], 'kwargs': {'type': str, 'help': 'Optionally search for artist', 'nargs': '*'}} 58 | ]) 59 | Command.command(["ascii"], "Ascii image for current track", [asciiController, "run"], options=[ 60 | {'flags': ['-w', '--width'], 'kwargs': {'type': str, 'help': 'Set ascii image width'}} 61 | ]) 62 | Command.command(["current"], "Display information about the currently playing track", [currentController, "run"]) 63 | Command.command(["next"], "Play next track", [nextController, "run"]) 64 | Command.command(["playback"], "Set playback state", [playbackController, "run"], options=[ 65 | {'flags': ['-s', '--state'], 'kwargs': {'type': str, 'choices': ['track', 'context', 'off'], 'help': 'playback state'}} 66 | ]) 67 | Command.command(["playlist"], "Start playing one of your playlists", [playlistController, "run"]) 68 | Command.command(["playlistadd"], "Add currently playing track to a playlist", [playlistaddController, "run"]) 69 | Command.command(["previous"], "Play previous track", [previousController, "run"]) 70 | Command.command(["qsearch"], "Quicksearch for tracks", [qsearchController, "run"], options=[ 71 | {'flags': ['query'], 'kwargs': {'type': str, 'help': 'Search query', 'nargs': '*'}} 72 | ]) 73 | Command.command(["queue"], "Display current queue", [queueController, "run"]) 74 | Command.command(["recent"], "Select one of recently played tracks", [recentController, "run"]) 75 | Command.command(["recommend"], "Play random / recommended tracks based on recent tracks", [recommendController, "run"]) 76 | Command.command(["replay"], "Replay/Restart currently playing track", [replayController, "run"]) 77 | Command.command(["search"], "Search for anything on spotify, Types - track, playlist, album", [searchController, "run"], options=[ 78 | {'flags': ['query'], 'kwargs': {'type': str, 'help': 'Search query', 'nargs': '*'}}, 79 | {'flags': ['-t', '--type'], 'kwargs': {'type': str, 'choices': ['track', 'playlist', 'album'], 'help': 'Search type'}} 80 | ]) 81 | Command.command(["seek"], "Seek posistion for track (in seconds)", [seekController, "run"], options=[ 82 | {'flags': ['seconds'], 'kwargs': {'type': str, 'help': 'Song posistion'}}, 83 | ]) 84 | Command.command(["shuffle"], "Toggle shuffle, on / off", [shuffleController, "run"], options=[ 85 | {'flags': ['-s', '--state'], 'kwargs': {'type': str, 'choices': ['true', 'false'], 'help': 'Toggle shuffle'}} 86 | ]) 87 | Command.command(["start", "resume"], "Start/resume playing", [startController, "run"]) 88 | Command.command(["stop", "pause"], "Stop/Pause playing", [stopController, "run"]) 89 | Command.command(["volume", "vol"], "Ajust volume", [volumeController, "run"], options=[ 90 | {'flags': ['-p', '--percentage'], 'kwargs': {'type': str, 'help': 'Volume percentage'}} 91 | ]) 92 | Command.command(["web"], "Open currently playing track in a broswer", [webController, "run"]) 93 | 94 | -------------------------------------------------------------------------------- /spotrpy/.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list= 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths= 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked 63 | # (useful for modules/projects where namespaces are manipulated during runtime 64 | # and thus existing member attributes cannot be deduced by static analysis). It 65 | # supports qualified module names, as well as Unix pattern matching. 66 | ignored-modules= 67 | 68 | # Python code to execute, usually for sys.path manipulation such as 69 | # pygtk.require(). 70 | #init-hook= 71 | 72 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 73 | # number of processors available to use, and will cap the count on Windows to 74 | # avoid hangs. 75 | jobs=1 76 | 77 | # Control the amount of potential inferred values when inferring a single 78 | # object. This can help the performance when dealing with large functions or 79 | # complex, nested conditions. 80 | limit-inference-results=100 81 | 82 | # List of plugins (as comma separated values of python module names) to load, 83 | # usually to register additional checkers. 84 | load-plugins= 85 | 86 | # Pickle collected data for later comparisons. 87 | persistent=yes 88 | 89 | # Minimum Python version to use for version dependent checks. Will default to 90 | # the version used to run pylint. 91 | py-version=3.11 92 | 93 | # Discover python modules and packages in the file system subtree. 94 | recursive=no 95 | 96 | # Add paths to the list of the source roots. Supports globbing patterns. The 97 | # source root is an absolute path or a path relative to the current working 98 | # directory used to determine a package namespace for modules located under the 99 | # source root. 100 | source-roots= 101 | 102 | # When enabled, pylint would attempt to guess common misconfiguration and emit 103 | # user-friendly hints instead of false-positive error messages. 104 | suggestion-mode=yes 105 | 106 | # Allow loading of arbitrary C extensions. Extensions are imported into the 107 | # active Python interpreter and may run arbitrary code. 108 | unsafe-load-any-extension=no 109 | 110 | # In verbose mode, extra non-checker-related info will be displayed. 111 | #verbose= 112 | 113 | 114 | [BASIC] 115 | 116 | # Naming style matching correct argument names. 117 | argument-naming-style=snake_case 118 | 119 | # Regular expression matching correct argument names. Overrides argument- 120 | # naming-style. If left empty, argument names will be checked with the set 121 | # naming style. 122 | #argument-rgx= 123 | 124 | # Naming style matching correct attribute names. 125 | attr-naming-style=snake_case 126 | 127 | # Regular expression matching correct attribute names. Overrides attr-naming- 128 | # style. If left empty, attribute names will be checked with the set naming 129 | # style. 130 | #attr-rgx= 131 | 132 | # Bad variable names which should always be refused, separated by a comma. 133 | bad-names=foo, 134 | bar, 135 | baz, 136 | toto, 137 | tutu, 138 | tata 139 | 140 | # Bad variable names regexes, separated by a comma. If names match any regex, 141 | # they will always be refused 142 | bad-names-rgxs= 143 | 144 | # Naming style matching correct class attribute names. 145 | class-attribute-naming-style=any 146 | 147 | # Regular expression matching correct class attribute names. Overrides class- 148 | # attribute-naming-style. If left empty, class attribute names will be checked 149 | # with the set naming style. 150 | #class-attribute-rgx= 151 | 152 | # Naming style matching correct class constant names. 153 | class-const-naming-style=UPPER_CASE 154 | 155 | # Regular expression matching correct class constant names. Overrides class- 156 | # const-naming-style. If left empty, class constant names will be checked with 157 | # the set naming style. 158 | #class-const-rgx= 159 | 160 | # Naming style matching correct class names. 161 | class-naming-style=PascalCase 162 | 163 | # Regular expression matching correct class names. Overrides class-naming- 164 | # style. If left empty, class names will be checked with the set naming style. 165 | #class-rgx= 166 | 167 | # Naming style matching correct constant names. 168 | const-naming-style=UPPER_CASE 169 | 170 | # Regular expression matching correct constant names. Overrides const-naming- 171 | # style. If left empty, constant names will be checked with the set naming 172 | # style. 173 | #const-rgx= 174 | 175 | # Minimum line length for functions/classes that require docstrings, shorter 176 | # ones are exempt. 177 | docstring-min-length=-1 178 | 179 | # Naming style matching correct function names. 180 | function-naming-style=snake_case 181 | 182 | # Regular expression matching correct function names. Overrides function- 183 | # naming-style. If left empty, function names will be checked with the set 184 | # naming style. 185 | #function-rgx= 186 | 187 | # Good variable names which should always be accepted, separated by a comma. 188 | good-names=i, 189 | j, 190 | k, 191 | ex, 192 | Run, 193 | _ 194 | 195 | # Good variable names regexes, separated by a comma. If names match any regex, 196 | # they will always be accepted 197 | good-names-rgxs= 198 | 199 | # Include a hint for the correct naming format with invalid-name. 200 | include-naming-hint=no 201 | 202 | # Naming style matching correct inline iteration names. 203 | inlinevar-naming-style=any 204 | 205 | # Regular expression matching correct inline iteration names. Overrides 206 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 207 | # with the set naming style. 208 | #inlinevar-rgx= 209 | 210 | # Naming style matching correct method names. 211 | method-naming-style=snake_case 212 | 213 | # Regular expression matching correct method names. Overrides method-naming- 214 | # style. If left empty, method names will be checked with the set naming style. 215 | #method-rgx= 216 | 217 | # Naming style matching correct module names. 218 | module-naming-style=snake_case 219 | 220 | # Regular expression matching correct module names. Overrides module-naming- 221 | # style. If left empty, module names will be checked with the set naming style. 222 | #module-rgx= 223 | 224 | # Colon-delimited sets of names that determine each other's naming style when 225 | # the name regexes allow several styles. 226 | name-group= 227 | 228 | # Regular expression which should only match function or class names that do 229 | # not require a docstring. 230 | no-docstring-rgx=^_ 231 | 232 | # List of decorators that produce properties, such as abc.abstractproperty. Add 233 | # to this list to register other decorators that produce valid properties. 234 | # These decorators are taken in consideration only for invalid-name. 235 | property-classes=abc.abstractproperty 236 | 237 | # Regular expression matching correct type alias names. If left empty, type 238 | # alias names will be checked with the set naming style. 239 | #typealias-rgx= 240 | 241 | # Regular expression matching correct type variable names. If left empty, type 242 | # variable names will be checked with the set naming style. 243 | #typevar-rgx= 244 | 245 | # Naming style matching correct variable names. 246 | variable-naming-style=snake_case 247 | 248 | # Regular expression matching correct variable names. Overrides variable- 249 | # naming-style. If left empty, variable names will be checked with the set 250 | # naming style. 251 | #variable-rgx= 252 | 253 | 254 | [CLASSES] 255 | 256 | # Warn about protected attribute access inside special methods 257 | check-protected-access-in-special-methods=no 258 | 259 | # List of method names used to declare (i.e. assign) instance attributes. 260 | defining-attr-methods=__init__, 261 | __new__, 262 | setUp, 263 | asyncSetUp, 264 | __post_init__ 265 | 266 | # List of member names, which should be excluded from the protected access 267 | # warning. 268 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 269 | 270 | # List of valid names for the first argument in a class method. 271 | valid-classmethod-first-arg=cls 272 | 273 | # List of valid names for the first argument in a metaclass class method. 274 | valid-metaclass-classmethod-first-arg=mcs 275 | 276 | 277 | [DESIGN] 278 | 279 | # List of regular expressions of class ancestor names to ignore when counting 280 | # public methods (see R0903) 281 | exclude-too-few-public-methods= 282 | 283 | # List of qualified class names to ignore when counting class parents (see 284 | # R0901) 285 | ignored-parents= 286 | 287 | # Maximum number of arguments for function / method. 288 | max-args=5 289 | 290 | # Maximum number of attributes for a class (see R0902). 291 | max-attributes=7 292 | 293 | # Maximum number of boolean expressions in an if statement (see R0916). 294 | max-bool-expr=5 295 | 296 | # Maximum number of branch for function / method body. 297 | max-branches=12 298 | 299 | # Maximum number of locals for function / method body. 300 | max-locals=20 301 | 302 | # Maximum number of parents for a class (see R0901). 303 | max-parents=7 304 | 305 | # Maximum number of public methods for a class (see R0904). 306 | max-public-methods=20 307 | 308 | # Maximum number of return / yield for function / method body. 309 | max-returns=6 310 | 311 | # Maximum number of statements in function / method body. 312 | max-statements=50 313 | 314 | # Minimum number of public methods for a class (see R0903). 315 | min-public-methods=2 316 | 317 | 318 | [EXCEPTIONS] 319 | 320 | # Exceptions that will emit a warning when caught. 321 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 322 | 323 | 324 | [FORMAT] 325 | 326 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 327 | expected-line-ending-format= 328 | 329 | # Regexp for a line that is allowed to be longer than the limit. 330 | ignore-long-lines=^\s*(# )??$ 331 | 332 | # Number of spaces of indent required inside a hanging or continued line. 333 | indent-after-paren=4 334 | 335 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 336 | # tab). 337 | indent-string=' ' 338 | 339 | # Maximum number of characters on a single line. 340 | max-line-length=300 341 | 342 | # Maximum number of lines in a module. 343 | max-module-lines=1000 344 | 345 | # Allow the body of a class to be on the same line as the declaration if body 346 | # contains single statement. 347 | single-line-class-stmt=no 348 | 349 | # Allow the body of an if to be on the same line as the test if there is no 350 | # else. 351 | single-line-if-stmt=no 352 | 353 | 354 | [IMPORTS] 355 | 356 | # List of modules that can be imported at any level, not just the top level 357 | # one. 358 | allow-any-import-level= 359 | 360 | # Allow explicit reexports by alias from a package __init__. 361 | allow-reexport-from-package=no 362 | 363 | # Allow wildcard imports from modules that define __all__. 364 | allow-wildcard-with-all=no 365 | 366 | # Deprecated modules which should not be used, separated by a comma. 367 | deprecated-modules= 368 | 369 | # Output a graph (.gv or any supported image format) of external dependencies 370 | # to the given file (report RP0402 must not be disabled). 371 | ext-import-graph= 372 | 373 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 374 | # external) dependencies to the given file (report RP0402 must not be 375 | # disabled). 376 | import-graph= 377 | 378 | # Output a graph (.gv or any supported image format) of internal dependencies 379 | # to the given file (report RP0402 must not be disabled). 380 | int-import-graph= 381 | 382 | # Force import order to recognize a module as part of the standard 383 | # compatibility libraries. 384 | known-standard-library= 385 | 386 | # Force import order to recognize a module as part of a third party library. 387 | known-third-party=enchant 388 | 389 | # Couples of modules and preferred modules, separated by a comma. 390 | preferred-modules= 391 | 392 | 393 | [LOGGING] 394 | 395 | # The type of string formatting that logging methods do. `old` means using % 396 | # formatting, `new` is for `{}` formatting. 397 | logging-format-style=old 398 | 399 | # Logging modules to check that the string format arguments are in logging 400 | # function parameter format. 401 | logging-modules=logging 402 | 403 | 404 | [MESSAGES CONTROL] 405 | 406 | # Only show warnings with the listed confidence levels. Leave empty to show 407 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 408 | # UNDEFINED. 409 | confidence=HIGH, 410 | CONTROL_FLOW, 411 | INFERENCE, 412 | INFERENCE_FAILURE, 413 | UNDEFINED 414 | 415 | # Disable the message, report, category or checker with the given id(s). You 416 | # can either give multiple identifiers separated by comma (,) or put this 417 | # option multiple times (only on the command line, not in the configuration 418 | # file where it should appear only once). You can also use "--disable=all" to 419 | # disable everything first and then re-enable specific checks. For example, if 420 | # you want to run only the similarities checker, you can use "--disable=all 421 | # --enable=similarities". If you want to run only the classes checker, but have 422 | # no Warning level messages displayed, use "--disable=all --enable=classes 423 | # --disable=W". 424 | disable=raw-checker-failed, 425 | bad-inline-option, 426 | locally-disabled, 427 | file-ignored, 428 | suppressed-message, 429 | useless-suppression, 430 | deprecated-pragma, 431 | use-symbolic-message-instead, 432 | E0015, 433 | W0123, 434 | W0105 435 | 436 | # Enable the message, report, category or checker with the given id(s). You can 437 | # either give multiple identifier separated by comma (,) or put this option 438 | # multiple time (only on the command line, not in the configuration file where 439 | # it should appear only once). See also the "--disable" option for examples. 440 | enable=c-extension-no-member 441 | 442 | 443 | [METHOD_ARGS] 444 | 445 | # List of qualified names (i.e., library.method) which require a timeout 446 | # parameter e.g. 'requests.api.get,requests.api.post' 447 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 448 | 449 | 450 | [MISCELLANEOUS] 451 | 452 | # List of note tags to take in consideration, separated by a comma. 453 | notes=FIXME, 454 | XXX, 455 | TODO 456 | 457 | # Regular expression of note tags to take in consideration. 458 | notes-rgx= 459 | 460 | 461 | [REFACTORING] 462 | 463 | # Maximum number of nested blocks for function / method body 464 | max-nested-blocks=5 465 | 466 | # Complete name of functions that never returns. When checking for 467 | # inconsistent-return-statements if a never returning function is called then 468 | # it will be considered as an explicit return statement and no message will be 469 | # printed. 470 | never-returning-functions=sys.exit,argparse.parse_error 471 | 472 | 473 | [REPORTS] 474 | 475 | # Python expression which should return a score less than or equal to 10. You 476 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 477 | # 'convention', and 'info' which contain the number of messages in each 478 | # category, as well as 'statement' which is the total number of statements 479 | # analyzed. This score is used by the global evaluation report (RP0004). 480 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 481 | 482 | # Template used to display messages. This is a python new-style format string 483 | # used to format the message information. See doc for all details. 484 | msg-template= 485 | 486 | # Set the output format. Available formats are text, parseable, colorized, json 487 | # and msvs (visual studio). You can also give a reporter class, e.g. 488 | # mypackage.mymodule.MyReporterClass. 489 | #output-format= 490 | 491 | # Tells whether to display a full report or only the messages. 492 | reports=no 493 | 494 | # Activate the evaluation score. 495 | score=yes 496 | 497 | 498 | [SIMILARITIES] 499 | 500 | # Comments are removed from the similarity computation 501 | ignore-comments=yes 502 | 503 | # Docstrings are removed from the similarity computation 504 | ignore-docstrings=yes 505 | 506 | # Imports are removed from the similarity computation 507 | ignore-imports=yes 508 | 509 | # Signatures are removed from the similarity computation 510 | ignore-signatures=yes 511 | 512 | # Minimum lines number of a similarity. 513 | min-similarity-lines=4 514 | 515 | 516 | [SPELLING] 517 | 518 | # Limits count of emitted suggestions for spelling mistakes. 519 | max-spelling-suggestions=4 520 | 521 | # Spelling dictionary name. No available dictionaries : You need to install 522 | # both the python package and the system dependency for enchant to work.. 523 | spelling-dict= 524 | 525 | # List of comma separated words that should be considered directives if they 526 | # appear at the beginning of a comment and should not be checked. 527 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 528 | 529 | # List of comma separated words that should not be checked. 530 | spelling-ignore-words= 531 | 532 | # A path to a file that contains the private dictionary; one word per line. 533 | spelling-private-dict-file= 534 | 535 | # Tells whether to store unknown words to the private dictionary (see the 536 | # --spelling-private-dict-file option) instead of raising a message. 537 | spelling-store-unknown-words=no 538 | 539 | 540 | [STRING] 541 | 542 | # This flag controls whether inconsistent-quotes generates a warning when the 543 | # character used as a quote delimiter is used inconsistently within a module. 544 | check-quote-consistency=no 545 | 546 | # This flag controls whether the implicit-str-concat should generate a warning 547 | # on implicit string concatenation in sequences defined over several lines. 548 | check-str-concat-over-line-jumps=no 549 | 550 | 551 | [TYPECHECK] 552 | 553 | # List of decorators that produce context managers, such as 554 | # contextlib.contextmanager. Add to this list to register other decorators that 555 | # produce valid context managers. 556 | contextmanager-decorators=contextlib.contextmanager 557 | 558 | # List of members which are set dynamically and missed by pylint inference 559 | # system, and so shouldn't trigger E1101 when accessed. Python regular 560 | # expressions are accepted. 561 | generated-members= 562 | 563 | # Tells whether to warn about missing members when the owner of the attribute 564 | # is inferred to be None. 565 | ignore-none=yes 566 | 567 | # This flag controls whether pylint should warn about no-member and similar 568 | # checks whenever an opaque object is returned when inferring. The inference 569 | # can return multiple potential results while evaluating a Python object, but 570 | # some branches might not be evaluated, which results in partial inference. In 571 | # that case, it might be useful to still emit no-member and other checks for 572 | # the rest of the inferred objects. 573 | ignore-on-opaque-inference=yes 574 | 575 | # List of symbolic message names to ignore for Mixin members. 576 | ignored-checks-for-mixins=no-member, 577 | not-async-context-manager, 578 | not-context-manager, 579 | attribute-defined-outside-init 580 | 581 | # List of class names for which member attributes should not be checked (useful 582 | # for classes with dynamically set attributes). This supports the use of 583 | # qualified names. 584 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 585 | 586 | # Show a hint with possible names when a member name was not found. The aspect 587 | # of finding the hint is based on edit distance. 588 | missing-member-hint=yes 589 | 590 | # The minimum edit distance a name should have in order to be considered a 591 | # similar match for a missing member name. 592 | missing-member-hint-distance=1 593 | 594 | # The total number of similar names that should be taken in consideration when 595 | # showing a hint for a missing member. 596 | missing-member-max-choices=1 597 | 598 | # Regex pattern to define which classes are considered mixins. 599 | mixin-class-rgx=.*[Mm]ixin 600 | 601 | # List of decorators that change the signature of a decorated function. 602 | signature-mutators= 603 | 604 | 605 | [VARIABLES] 606 | 607 | # List of additional names supposed to be defined in builtins. Remember that 608 | # you should avoid defining new builtins when possible. 609 | additional-builtins= 610 | 611 | # Tells whether unused global variables should be treated as a violation. 612 | allow-global-unused-variables=yes 613 | 614 | # List of names allowed to shadow builtins 615 | allowed-redefined-builtins= 616 | 617 | # List of strings which can identify a callback function by name. A callback 618 | # name must start or end with one of those strings. 619 | callbacks=cb_, 620 | _cb 621 | 622 | # A regular expression matching the name of dummy variables (i.e. expected to 623 | # not be used). 624 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 625 | 626 | # Argument names that match this expression will be ignored. 627 | ignored-argument-names=_.*|^ignored_|^unused_ 628 | 629 | # Tells whether we should check for unused import in __init__ files. 630 | init-import=no 631 | 632 | # List of qualified module names which can have objects that can redefine 633 | # builtins. 634 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 635 | --------------------------------------------------------------------------------