From 2a0c480d4bc3a8a5cc3123ca69a38ecb06fc9a3e Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 22 Feb 2020 17:03:37 -0700 Subject: [PATCH] Adding a bunch of flake8 extensions and working through the errors --- Pipfile | 16 +- Pipfile.lock | 45 +- api_object_generator/api_object_generator.py | 46 +- .../api_specs/subsonic-rest-api-1.16.0.xsd | 638 +++++++++++++++++ .../api_specs/subsonic-rest-api-1.16.1.xsd | 640 ++++++++++++++++++ cicd/custom_style_check.py | 14 +- setup.cfg | 5 +- sublime/__main__.py | 6 +- sublime/app.py | 26 +- sublime/cache_manager.py | 114 ++-- sublime/config.py | 28 +- sublime/dbus_manager.py | 2 +- sublime/from_json.py | 60 +- sublime/players.py | 111 +-- sublime/server/api_object.py | 6 +- sublime/server/api_objects.py | 79 +-- sublime/server/server.py | 71 +- sublime/state_manager.py | 35 +- sublime/ui/albums.py | 85 +-- sublime/ui/artists.py | 123 ++-- sublime/ui/browse.py | 8 +- sublime/ui/common/album_with_songs.py | 59 +- sublime/ui/common/edit_form_dialog.py | 6 +- sublime/ui/common/icon_button.py | 16 +- sublime/ui/common/spinner_image.py | 20 +- sublime/ui/configure_servers.py | 23 +- sublime/ui/main.py | 148 ++-- sublime/ui/player_controls.py | 19 +- sublime/ui/playlists.py | 97 +-- sublime/ui/util.py | 51 +- 30 files changed, 1979 insertions(+), 618 deletions(-) create mode 100644 api_object_generator/api_specs/subsonic-rest-api-1.16.0.xsd create mode 100644 api_object_generator/api_specs/subsonic-rest-api-1.16.1.xsd diff --git a/Pipfile b/Pipfile index f29dec7..51e706d 100644 --- a/Pipfile +++ b/Pipfile @@ -4,21 +4,25 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] +docutils = "*" flake8 = "*" +flake8-annotations = "*" +flake8-comprehensions = "*" +flake8-pep3101 = "*" +flake8-print = "*" +graphviz = "*" +jedi = "*" +lxml = "*" mypy = "*" -yapf = "*" pytest = "*" pytest-cov = "*" -docutils = "*" -lxml = "*" -jedi = "*" rope = "*" rst2html5 = "*" -graphviz = "*" sphinx = "*" -sphinx-rtd-theme = "*" sphinx-autodoc-typehints = "*" +sphinx-rtd-theme = "*" termcolor = "*" +yapf = "*" [packages] sublime-music = {editable = true,path = "."} diff --git a/Pipfile.lock b/Pipfile.lock index 5bdf6fd..4569305 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c16627b97b66d2ad7016bd43428e26f5f29836ba28eb797d27c4cc80f8a70a99" + "sha256": "1697c1d3c4480dbec759d96e80b367e508d813cb2183a1c6226f56ee0be8fac6" }, "pipfile-spec": 6, "requires": { @@ -383,6 +383,37 @@ "index": "pypi", "version": "==3.7.9" }, + "flake8-annotations": { + "hashes": [ + "sha256:19a6637a5da1bb7ea7948483ca9e2b9e15b213e687e7bf5ff8c1bfc91c185006", + "sha256:bb033b72cdd3a2b0a530bbdf2081f12fbea7d70baeaaebb5899723a45f424b8e" + ], + "index": "pypi", + "version": "==2.0.0" + }, + "flake8-comprehensions": { + "hashes": [ + "sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2", + "sha256:e7db586bb6eb95afdfd87ed244c90e57ae1352db8ef0ad3012fca0200421e5df" + ], + "index": "pypi", + "version": "==3.2.2" + }, + "flake8-pep3101": { + "hashes": [ + "sha256:86e3eb4e42de8326dcd98ebdeaf9a3c6854203a48f34aeb3e7e8ed948107f512", + "sha256:a5dae1caca1243b2b40108dce926d97cf5a9f52515c4a4cbb1ffe1ca0c54e343" + ], + "index": "pypi", + "version": "==1.3.0" + }, + "flake8-print": { + "hashes": [ + "sha256:324f9e59a522518daa2461bacd7f82da3c34eb26a4314c2a54bd493f8b394a68" + ], + "index": "pypi", + "version": "==3.1.4" + }, "genshi": { "hashes": [ "sha256:5e92e278ca1ea395349a451d54fc81dc3c1b543c48939a15bd36b7b3335e1560", @@ -659,11 +690,11 @@ }, "sphinx": { "hashes": [ - "sha256:525527074f2e0c2585f68f73c99b4dc257c34bbe308b27f5f8c7a6e20642742f", - "sha256:543d39db5f82d83a5c1aa0c10c88f2b6cff2da3e711aa849b2c627b4b403bbd9" + "sha256:776ff8333181138fae52df65be733127539623bb46cc692e7fa0fcfc80d7aa88", + "sha256:ca762da97c3b5107cbf0ab9e11d3ec7ab8d3c31377266fd613b962ed971df709" ], "index": "pypi", - "version": "==2.4.2" + "version": "==2.4.3" }, "sphinx-autodoc-typehints": { "hashes": [ @@ -697,10 +728,10 @@ }, "sphinxcontrib-htmlhelp": { "hashes": [ - "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", - "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" + "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", + "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], - "version": "==1.0.2" + "version": "==1.0.3" }, "sphinxcontrib-jsmath": { "hashes": [ diff --git a/api_object_generator/api_object_generator.py b/api_object_generator/api_object_generator.py index a7eccf1..2b7628e 100755 --- a/api_object_generator/api_object_generator.py +++ b/api_object_generator/api_object_generator.py @@ -1,14 +1,16 @@ #! /usr/bin/env python3 """ +Autogenerates Python classes for Subsonic API objects. + This program constructs a dependency graph of all of the entities defined by a Subsonic REST API XSD file. It then uses that graph to generate code which represents those API objects in Python. """ import re -from collections import defaultdict -from typing import cast, Dict, DefaultDict, Set, Match, Tuple, List import sys +from collections import defaultdict +from typing import DefaultDict, Dict, List, Set, Tuple from graphviz import Digraph from lxml import etree @@ -25,12 +27,13 @@ primitive_translation_map = { } -def render_digraph(graph, filename): +def render_digraph(graph: DefaultDict[str, Set[str]], filename: str): """ - Renders a graph of form {'node_name': iterable(node_name)} to ``filename``. + Render a graph of the form {'node_name': iterable(node_name)} to + ``filename``. """ g = Digraph('G', filename=f'/tmp/{filename}', format='png') - for type_, deps in dependency_graph.items(): + for type_, deps in graph.items(): g.node(type_) for dep in deps: @@ -39,21 +42,27 @@ def render_digraph(graph, filename): g.render() -def primitive_translate(type_str): +def primitive_translate(type_str: str) -> str: # Translate the primitive values, but default to the actual value. return primitive_translation_map.get(type_str, type_str) -def extract_type(type_str): - return primitive_translate( - cast(Match, element_type_re.match(type_str)).group(1)) +def extract_type(type_str: str) -> str: + match = element_type_re.match(type_str) + if not match: + raise Exception(f'Could not extract type from string "{type_str}"') + return primitive_translate(match.group(1)) -def extract_tag_type(tag_type_str): - return cast(Match, tag_type_re.match(tag_type_str)).group(1) +def extract_tag_type(tag_type_str: str) -> str: + match = tag_type_re.match(tag_type_str) + if not match: + raise Exception( + f'Could not extract tag type from string "{tag_type_str}"') + return match.group(1) -def get_dependencies(xs_el) -> Tuple[Set[str], Dict[str, str]]: +def get_dependencies(xs_el: etree._Element) -> Tuple[Set[str], Dict[str, str]]: """ Return the types which ``xs_el`` depends on as well as the type of the object for embedding in other objects. @@ -162,7 +171,7 @@ def get_dependencies(xs_el) -> Tuple[Set[str], Dict[str, str]]: # Check arguments. # ============================================================================= if len(sys.argv) < 3: - print(f'Usage: {sys.argv[0]} .') + print(f'Usage: {sys.argv[0]} .') # noqa: T001 sys.exit(1) schema_file, output_file = sys.argv[1:] @@ -201,7 +210,7 @@ seen: Set[str] = set() i = 0 -def dfs(g, el): +def dfs(g: DefaultDict[str, Set[str]], el: str): global i if el in seen: return @@ -224,7 +233,7 @@ output_order.remove('subsonic-response') # ============================================================================= -def generate_class_for_type(type_name): +def generate_class_for_type(type_name: str) -> str: fields = type_fields[type_name] code = ['', ''] @@ -262,12 +271,12 @@ def generate_class_for_type(type_name): # Auto-generated __eq__ and __hash__ functions if there's an ID field. if not is_enum and has_properties and 'id' in fields: code.append('') - code.append(indent_str.format('def __eq__(self, other):')) + code.append(indent_str.format('def __eq__(self, other: Any) -> bool:')) code.append(indent_str.format(' return hash(self) == hash(other)')) hash_name = inherits or type_name code.append('') - code.append(indent_str.format('def __hash__(self):')) + code.append(indent_str.format('def __hash__(self) -> int:')) code.append( indent_str.format(f" return hash(f'{hash_name}.{{self.id}}')")) @@ -286,8 +295,9 @@ with open(output_file, 'w+') as outfile: '"""', '', 'from datetime import datetime', - 'from typing import List', 'from enum import Enum', + 'from typing import Any, List', + '', 'from sublime.server.api_object import APIObject', *map(generate_class_for_type, output_order), ]) + '\n') diff --git a/api_object_generator/api_specs/subsonic-rest-api-1.16.0.xsd b/api_object_generator/api_specs/subsonic-rest-api-1.16.0.xsd new file mode 100644 index 0000000..590ff89 --- /dev/null +++ b/api_object_generator/api_specs/subsonic-rest-api-1.16.0.xsd @@ -0,0 +1,638 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/api_object_generator/api_specs/subsonic-rest-api-1.16.1.xsd b/api_object_generator/api_specs/subsonic-rest-api-1.16.1.xsd new file mode 100644 index 0000000..f610574 --- /dev/null +++ b/api_object_generator/api_specs/subsonic-rest-api-1.16.1.xsd @@ -0,0 +1,640 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cicd/custom_style_check.py b/cicd/custom_style_check.py index 7b2ed4f..6b01f68 100755 --- a/cicd/custom_style_check.py +++ b/cicd/custom_style_check.py @@ -1,27 +1,21 @@ #! /usr/bin/env python -import sys import re - +import sys from pathlib import Path from termcolor import cprint -print_re = re.compile(r'print\(.*\)') todo_re = re.compile(r'#\s*TODO:?\s*') accounted_for_todo = re.compile(r'#\s*TODO:?\s*\((#\d+)\)') -def check_file(path): - print(f'Checking {path.absolute()}...') +def check_file(path: Path) -> bool: + print(f'Checking {path.absolute()}...') # noqa: T001 file = path.open() valid = True for i, line in enumerate(file, start=1): - if print_re.search(line) and '# allowprint' not in line: - cprint(f'{i}: {line}', 'red', end='', attrs=['bold']) - valid = False - if todo_re.search(line) and not accounted_for_todo.search(line): cprint(f'{i}: {line}', 'red', end='', attrs=['bold']) valid = False @@ -33,6 +27,6 @@ def check_file(path): valid = True for path in Path('sublime').glob('**/*.py'): valid &= check_file(path) - print() + print() # noqa: T001 sys.exit(0 if valid else 1) diff --git a/setup.cfg b/setup.cfg index 59077a6..4edac04 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,9 @@ [flake8] -ignore = E402, W503 +ignore = E402, W503, ANN002, ANN003, ANN101, ANN102, ANN204 exclude = .git,__pycache__,build,dist +suppress-none-returning = True +application-import-names = sublime +import-order-style = edited [mypy-gi] ignore_missing_imports = True diff --git a/sublime/__main__.py b/sublime/__main__.py index fe610f0..70fc509 100644 --- a/sublime/__main__.py +++ b/sublime/__main__.py @@ -1,14 +1,14 @@ #! /usr/bin/env python3 -import os import argparse import logging +import os import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk # noqa: F401 import sublime -from .app import SublimeMusicApp +from sublime.app import SublimeMusicApp def main(): @@ -39,7 +39,7 @@ def main(): args, unknown_args = parser.parse_known_args() if args.version: - print(f'Sublime Music v{sublime.__version__}') # allowprint + print(f'Sublime Music v{sublime.__version__}') # noqa: T001 return min_log_level = getattr(logging, args.loglevel.upper(), None) diff --git a/sublime/app.py b/sublime/app.py index 98ad223..36d9b00 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -1,26 +1,25 @@ -import os import logging import math +import os import random import gi gi.require_version('Gtk', '3.0') gi.require_version('Notify', '0.7') -from gi.repository import Gdk, Gio, GLib, Gtk, Notify, GdkPixbuf +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Notify -from .ui.main import MainWindow -from .ui.configure_servers import ConfigureServersDialog -from .ui.settings import SettingsDialog - -from .dbus_manager import DBusManager, dbus_propagate -from .state_manager import ApplicationState, RepeatType from .cache_manager import CacheManager +from .dbus_manager import dbus_propagate, DBusManager +from .players import ChromecastPlayer, MPVPlayer, PlayerEvent from .server.api_objects import Child, Directory -from .players import PlayerEvent, MPVPlayer, ChromecastPlayer +from .state_manager import ApplicationState, RepeatType +from .ui.configure_servers import ConfigureServersDialog +from .ui.main import MainWindow +from .ui.settings import SettingsDialog class SublimeMusicApp(Gtk.Application): - def __init__(self, config_file): + def __init__(self, config_file: str): super().__init__(application_id="com.sumnerevans.sublimemusic") Notify.init('Sublime Music') @@ -912,11 +911,12 @@ class SublimeMusicApp(Gtk.Application): song_idx = self.state.play_queue.index(song.id) prefetch_idxs = [] for i in range(self.state.config.prefetch_amount): - prefetch_idx = song_idx + 1 + i - play_queue_len = len(self.state.play_queue) + prefetch_idx: int = song_idx + 1 + i + play_queue_len: int = len(self.state.play_queue) if (self.state.repeat_type == RepeatType.REPEAT_QUEUE or prefetch_idx < play_queue_len): - prefetch_idxs.append(prefetch_idx % play_queue_len) + prefetch_idxs.append( + prefetch_idx % play_queue_len) # noqa: S001 CacheManager.batch_download_songs( [self.state.play_queue[i] for i in prefetch_idxs], before_download=lambda: GLib.idle_add(self.update_window), diff --git a/sublime/cache_manager.py b/sublime/cache_manager.py index 44ebf9e..fbb8646 100644 --- a/sublime/cache_manager.py +++ b/sublime/cache_manager.py @@ -1,57 +1,50 @@ -import os -import logging import glob -import itertools -import threading -import shutil -import json import hashlib - -from functools import lru_cache +import itertools +import json +import logging +import os +import shutil +import threading from collections import defaultdict -from time import sleep - -from concurrent.futures import ThreadPoolExecutor, Future -from enum import EnumMeta, Enum +from concurrent.futures import Future, ThreadPoolExecutor from datetime import datetime +from enum import Enum, EnumMeta +from functools import lru_cache from pathlib import Path +from time import sleep from typing import ( Any, + Callable, + DefaultDict, Generic, Iterable, List, Optional, - Union, - Callable, Set, - DefaultDict, Tuple, TypeVar, + Union, ) import requests - from fuzzywuzzy import fuzz from .config import AppConfiguration, ServerConfiguration from .server import Server from .server.api_object import APIObject from .server.api_objects import ( - Playlist, - PlaylistWithSongs, - Child, - Genre, - - # Non-ID3 versions + AlbumID3, + AlbumWithSongsID3, Artist, - Directory, - - # ID3 versions ArtistID3, ArtistInfo2, ArtistWithAlbumsID3, - AlbumID3, - AlbumWithSongsID3, + Child, + Directory, + Genre, + Playlist, + PlaylistWithSongs, ) @@ -60,7 +53,7 @@ class Singleton(type): Metaclass for :class:`CacheManager` so that it can be used like a singleton. """ - def __getattr__(cls, name): + def __getattr__(cls, name: str) -> Any: if not CacheManager._instance: return None # If the cache has a function to do the thing we want, use it. If @@ -82,7 +75,7 @@ class SongCacheStatus(Enum): @lru_cache(maxsize=8192) -def similarity_ratio(query: str, string: str): +def similarity_ratio(query: str, string: str) -> int: """ Return the :class:`fuzzywuzzy.fuzz.partial_ratio` between the ``query`` and the given ``string``. @@ -96,17 +89,20 @@ def similarity_ratio(query: str, string: str): return fuzz.partial_ratio(query.lower(), string.lower()) +S = TypeVar('S') + + class SearchResult: """ An object representing the aggregate results of a search which can include both server and local results. """ - _artist: Set[Union[Artist, ArtistID3]] = set() - _album: Set[Union[Child, AlbumID3]] = set() + _artist: Set[ArtistID3] = set() + _album: Set[AlbumID3] = set() _song: Set[Child] = set() _playlist: Set[Playlist] = set() - def __init__(self, query): + def __init__(self, query: str): self.query = query def add_results(self, result_type: str, results: Iterable): @@ -124,13 +120,17 @@ class SearchResult: getattr(getattr(self, member, set()), 'union')(set(results)), ) - def _to_result(self, it, transform): + def _to_result( + self, + it: Iterable[S], + transform: Callable[[S], str], + ) -> List[S]: all_results = sorted( ((similarity_ratio(self.query, transform(x)), x) for x in it), key=lambda rx: rx[0], reverse=True, ) - result = [] + result: List[S] = [] for ratio, x in all_results: if ratio > 60 and len(result) < 20: result.append(x) @@ -181,9 +181,9 @@ class CacheManager(metaclass=Singleton): around a Future, but it can also resolve immediately if the data already exists. """ - data = None - future = None - on_cancel = None + data: Optional[T] = None + future: Optional[Future] = None + on_cancel: Optional[Callable[[], None]] = None @staticmethod def from_data(data: T) -> 'CacheManager.Result[T]': @@ -193,10 +193,10 @@ class CacheManager(metaclass=Singleton): @staticmethod def from_server( - download_fn, - before_download=None, - after_download=None, - on_cancel=None, + download_fn: Callable[[], T], + before_download: Callable[[], Any] = None, + after_download: Callable[[T], Any] = None, + on_cancel: Callable[[], Any] = None, ) -> 'CacheManager.Result[T]': result: 'CacheManager.Result[T]' = CacheManager.Result() @@ -208,9 +208,9 @@ class CacheManager(metaclass=Singleton): result.future = CacheManager.executor.submit(future_fn) result.on_cancel = on_cancel - if after_download: + if after_download is not None: result.future.add_done_callback( - lambda f: after_download(f.result())) + lambda f: after_download and after_download(f.result())) return result @@ -224,8 +224,8 @@ class CacheManager(metaclass=Singleton): 'CacheManager.Result did not have either a data or future ' 'member.') - def add_done_callback(self, fn, *args): - if self.is_future: + def add_done_callback(self, fn: Callable, *args): + if self.future is not None: self.future.add_done_callback(fn, *args) else: # Run the function immediately if it's not a future. @@ -668,7 +668,7 @@ class CacheManager(metaclass=Singleton): return CacheManager.Result.from_data( self.cache[cache_name][artist_id]) - def after_download(artist_info): + def after_download(artist_info: Optional[ArtistInfo2]): if not artist_info: return @@ -677,7 +677,8 @@ class CacheManager(metaclass=Singleton): self.save_cache_info() return CacheManager.Result.from_server( - lambda: self.server.get_artist_info2(id=artist_id), + lambda: + (self.server.get_artist_info2(id=artist_id) or ArtistInfo2()), before_download=before_download, after_download=after_download, ) @@ -881,9 +882,8 @@ class CacheManager(metaclass=Singleton): ) -> 'CacheManager.Result[Optional[str]]': if id is None: art_path = 'ui/images/default-album-art.png' - return CacheManager.Result.from_data(str( - Path(__file__).parent.joinpath(art_path) - )) + return CacheManager.Result.from_data( + str(Path(__file__).parent.joinpath(art_path))) return self.return_cached_or_download( f'cover_art/{id}_{size}', lambda: self.server.get_cover_art(id, str(size)), @@ -971,10 +971,10 @@ class CacheManager(metaclass=Singleton): query, search_callback: Callable[[SearchResult, bool], None], before_download: Callable[[], None] = lambda: None, - ): + ) -> 'CacheManager.Result': if query == '': search_callback(SearchResult(''), True) - return + return CacheManager.from_data(None) before_download() @@ -1033,9 +1033,7 @@ class CacheManager(metaclass=Singleton): cancelled = True return CacheManager.Result.from_server( - do_search, - on_cancel=on_cancel, - ) + do_search, on_cancel=on_cancel) def get_cached_status(self, song: Child) -> SongCacheStatus: cache_path = self.calculate_abs_path(song.path) @@ -1055,7 +1053,11 @@ class CacheManager(metaclass=Singleton): raise Exception('Do not instantiate the CacheManager.') @staticmethod - def reset(app_config, server_config, current_ssids: Set[str]): + def reset( + app_config: AppConfiguration, + server_config: ServerConfiguration, + current_ssids: Set[str], + ): CacheManager._instance = CacheManager.__CacheManagerInternal( app_config, server_config, diff --git a/sublime/config.py b/sublime/config.py index 285500e..f4ac15a 100644 --- a/sublime/config.py +++ b/sublime/config.py @@ -1,8 +1,8 @@ -import os import logging -import keyring +import os +from typing import Any, Dict, List, Optional -from typing import List, Optional +import keyring class ServerConfiguration: @@ -17,14 +17,14 @@ class ServerConfiguration: def __init__( self, - name='Default', - server_address='http://yourhost', - local_network_address='', - local_network_ssid='', - username='', - password='', - sync_enabled=True, - disable_cert_verify=False, + name: str = 'Default', + server_address: str = 'http://yourhost', + local_network_address: str = '', + local_network_ssid: str = '', + username: str = '', + password: str = '', + sync_enabled: bool = True, + disable_cert_verify: bool = False, ): self.name = name self.server_address = server_address @@ -43,7 +43,7 @@ class ServerConfiguration: pass @property - def password(self): + def password(self) -> str: return keyring.get_password( 'com.sumnerevans.SublimeMusic', f'{self.username}@{self.server_address}', @@ -64,7 +64,7 @@ class AppConfiguration: version: int = 2 serve_over_lan: bool = True - def to_json(self): + def to_json(self) -> Dict[str, Any]: exclude = ('servers') json_object = { k: getattr(self, k) @@ -88,7 +88,7 @@ class AppConfiguration: self.version = 2 @property - def cache_location(self): + def cache_location(self) -> str: if (hasattr(self, '_cache_location') and self._cache_location is not None and self._cache_location != ''): diff --git a/sublime/dbus_manager.py b/sublime/dbus_manager.py index 0cd020b..d93ead5 100644 --- a/sublime/dbus_manager.py +++ b/sublime/dbus_manager.py @@ -8,8 +8,8 @@ from typing import Dict from deepdiff import DeepDiff from gi.repository import Gio, GLib -from .state_manager import RepeatType from .cache_manager import CacheManager +from .state_manager import RepeatType def dbus_propagate(param_self=None): diff --git a/sublime/from_json.py b/sublime/from_json.py index af4ed1f..63597c5 100644 --- a/sublime/from_json.py +++ b/sublime/from_json.py @@ -1,18 +1,19 @@ +import typing from datetime import datetime from enum import EnumMeta -import typing -from typing import Dict, List, Type +from typing import Any, Dict, Type from dateutil import parser -def from_json(cls, data): +def from_json(template_type: Any, data: Any) -> Any: """ - Converts data from a JSON parse into Python data structures. + Converts data from a JSON parse into an instantiation of the Python object + specified by template_type. Arguments: - cls: the template class to deserialize into + template_type: the template type to deserialize into data: the data to deserialize to the class """ # Approach for deserialization here: @@ -20,50 +21,49 @@ def from_json(cls, data): # If it's a forward reference, evaluate it to figure out the actual # type. This allows for types that have to be put into a string. - if isinstance(cls, typing.ForwardRef): - cls = cls._evaluate(globals(), locals()) + if isinstance(template_type, typing.ForwardRef): # type: ignore + template_type = template_type._evaluate(globals(), locals()) - annotations: Dict[str, Type] = getattr(cls, '__annotations__', {}) + annotations: Dict[str, + Type] = getattr(template_type, '__annotations__', {}) # Handle primitive of objects + instance: Any = None if data is None: instance = None # Handle generics. List[*], Dict[*, *] in particular. - elif type(cls) == typing._GenericAlias: + elif type(template_type) == typing._GenericAlias: # type: ignore # Having to use this because things changed in Python 3.7. - class_name = cls._name + class_name = template_type._name # This is not very elegant since it doesn't allow things which sublass # from List or Dict. For my purposes, this doesn't matter. if class_name == 'List': - list_type = cls.__args__[0] - instance: List[list_type] = list() - for value in data: - instance.append(from_json(list_type, value)) + inner_type = template_type.__args__[0] + instance = [from_json(inner_type, value) for value in data] elif class_name == 'Dict': - key_type, val_type = cls.__args__ - instance: Dict[key_type, val_type] = dict() - for key, value in data.items(): - key = from_json(key_type, key) - value = from_json(val_type, value) - instance[key] = value + key_type, val_type = template_type.__args__ + instance = { + from_json(key_type, key): from_json(val_type, value) + for key, value in data.items() + } else: raise Exception( - f'Trying to deserialize an unsupported type: {cls._name}') - - elif cls == str or issubclass(cls, str): + 'Trying to deserialize an unsupported type: {}'.format( + template_type._name)) + elif template_type == str or issubclass(template_type, str): instance = data - elif cls == int or issubclass(cls, int): + elif template_type == int or issubclass(template_type, int): instance = int(data) - elif cls == bool or issubclass(cls, bool): + elif template_type == bool or issubclass(template_type, bool): instance = bool(data) - elif type(cls) == EnumMeta: + elif type(template_type) == EnumMeta: if type(data) == dict: - instance = cls(data.get('_value_')) + instance = template_type(data.get('_value_')) else: - instance = cls(data) - elif cls == datetime: + instance = template_type(data) + elif template_type == datetime: if type(data) == int: instance = datetime.fromtimestamp(data / 1000) else: @@ -72,7 +72,7 @@ def from_json(cls, data): # Handle everything else by first instantiating the class, then adding # all of the sub-elements, recursively calling from_json on them. else: - instance: cls = cls() + instance = template_type() for field, field_type in annotations.items(): value = data.get(field) setattr(instance, field, from_json(field_type, value)) diff --git a/sublime/players.py b/sublime/players.py index 4365be4..6969e86 100644 --- a/sublime/players.py +++ b/sublime/players.py @@ -5,10 +5,9 @@ import mimetypes import os import socket import threading - -from concurrent.futures import ThreadPoolExecutor, Future +from concurrent.futures import Future, ThreadPoolExecutor from time import sleep -from typing import Callable, List, Any, Optional +from typing import Any, Callable, List, Optional from urllib.parse import urlparse from uuid import UUID @@ -16,8 +15,8 @@ import bottle import mpv import pychromecast -from sublime.config import AppConfiguration from sublime.cache_manager import CacheManager +from sublime.config import AppConfiguration from sublime.server.api_objects import Child @@ -31,9 +30,11 @@ class PlayerEvent: class Player: + _can_hotswap_source: bool + def __init__( self, - on_timepos_change: Callable[[float], None], + on_timepos_change: Callable[[Optional[float]], None], on_track_end: Callable[[], None], on_player_event: Callable[[PlayerEvent], None], config: AppConfiguration, @@ -45,38 +46,38 @@ class Player: self._song_loaded = False @property - def playing(self): + def playing(self) -> bool: return self._is_playing() @property - def song_loaded(self): + def song_loaded(self) -> bool: return self._song_loaded @property - def can_hotswap_source(self): + def can_hotswap_source(self) -> bool: return self._can_hotswap_source @property - def volume(self): + def volume(self) -> float: return self._get_volume() @volume.setter - def volume(self, value): - return self._set_volume(value) + def volume(self, value: float): + self._set_volume(value) @property - def is_muted(self): + def is_muted(self) -> bool: return self._get_is_muted() @is_muted.setter - def is_muted(self, value): - return self._set_is_muted(value) + def is_muted(self, value: bool): + self._set_is_muted(value) def reset(self): raise NotImplementedError( 'reset must be implemented by implementor of Player') - def play_media(self, file_or_url, progress, song): + def play_media(self, file_or_url: str, progress: float, song: Child): raise NotImplementedError( 'play_media must be implemented by implementor of Player') @@ -92,7 +93,7 @@ class Player: raise NotImplementedError( 'toggle_play must be implemented by implementor of Player') - def seek(self, value): + def seek(self, value: float): raise NotImplementedError( 'seek must be implemented by implementor of Player') @@ -104,7 +105,7 @@ class Player: raise NotImplementedError( '_get_volume must be implemented by implementor of Player') - def _set_volume(self, value): + def _set_volume(self, value: float): raise NotImplementedError( '_set_volume must be implemented by implementor of Player') @@ -112,7 +113,7 @@ class Player: raise NotImplementedError( '_get_is_muted must be implemented by implementor of Player') - def _set_is_muted(self, value): + def _set_is_muted(self, value: bool): raise NotImplementedError( '_set_is_muted must be implemented by implementor of Player') @@ -134,7 +135,7 @@ class MPVPlayer(Player): self._can_hotswap_source = True @self.mpv.property_observer('time-pos') - def time_observer(_name, value): + def time_observer(_: Any, value: Optional[float]): self.on_timepos_change(value) if value is None and self.progress_value_count > 1: self.on_track_end() @@ -145,7 +146,7 @@ class MPVPlayer(Player): with self.progress_value_lock: self.progress_value_count += 1 - def _is_playing(self): + def _is_playing(self) -> bool: return not self.mpv.pause def reset(self): @@ -153,7 +154,7 @@ class MPVPlayer(Player): with self.progress_value_lock: self.progress_value_count = 0 - def play_media(self, file_or_url, progress, song): + def play_media(self, file_or_url: str, progress: float, song: Child): self.had_progress_value = False with self.progress_value_lock: self.progress_value_count = 0 @@ -173,20 +174,20 @@ class MPVPlayer(Player): def toggle_play(self): self.mpv.cycle('pause') - def seek(self, value): + def seek(self, value: float): self.mpv.seek(str(value), 'absolute') - def _set_volume(self, value): + def _get_volume(self) -> float: + return self._volume + + def _set_volume(self, value: float): self._volume = value self.mpv.volume = self._volume - def _get_volume(self): - return self._volume - - def _get_is_muted(self): + def _get_is_muted(self) -> bool: return self._muted - def _set_is_muted(self, value): + def _set_is_muted(self, value: bool): self._muted = value self.mpv.volume = 0 if value else self._volume @@ -202,14 +203,14 @@ class ChromecastPlayer(Player): class CastStatusListener: on_new_cast_status: Optional[Callable] = None - def new_cast_status(self, status): + def new_cast_status(self, status: Any): if self.on_new_cast_status: self.on_new_cast_status(status) class MediaStatusListener: on_new_media_status: Optional[Callable] = None - def new_media_status(self, status): + def new_media_status(self, status: Any): if self.on_new_media_status: self.on_new_media_status(status) @@ -217,18 +218,18 @@ class ChromecastPlayer(Player): media_status_listener = MediaStatusListener() class ServerThread(threading.Thread): - def __init__(self, host, port): + def __init__(self, host: str, port: int): super().__init__() self.daemon = True self.host = host self.port = port - self.token = None - self.song_id = None + self.token: Optional[str] = None + self.song_id: Optional[str] = None self.app = bottle.Bottle() @self.app.route('/') - def index(): + def index() -> str: return '''

Sublime Music Local Music Server

@@ -238,7 +239,7 @@ class ChromecastPlayer(Player): ''' @self.app.route('/s/') - def stream_song(token): + def stream_song(token: str) -> bytes: if token != self.token: raise bottle.HTTPError(status=401, body='Invalid token.') @@ -254,7 +255,7 @@ class ChromecastPlayer(Player): bottle.response.set_header('Accept-Ranges', 'bytes') return song_buffer.read() - def set_song_and_token(self, song_id, token): + def set_song_and_token(self, song_id: str, token: str): self.song_id = song_id self.token = token @@ -265,7 +266,7 @@ class ChromecastPlayer(Player): @classmethod def get_chromecasts(cls) -> Future: - def do_get_chromecasts(): + def do_get_chromecasts() -> List[pychromecast.Chromecast]: if not ChromecastPlayer.getting_chromecasts: logging.info('Getting Chromecasts') ChromecastPlayer.getting_chromecasts = True @@ -280,7 +281,7 @@ class ChromecastPlayer(Player): return ChromecastPlayer.executor.submit(do_get_chromecasts) - def set_playing_chromecast(self, uuid): + def set_playing_chromecast(self, uuid: str): self.chromecast = next( cc for cc in ChromecastPlayer.chromecasts if cc.device.uuid == UUID(uuid)) @@ -294,7 +295,7 @@ class ChromecastPlayer(Player): def __init__( self, - on_timepos_change: Callable[[float], None], + on_timepos_change: Callable[[Optional[float]], None], on_track_end: Callable[[], None], on_player_event: Callable[[PlayerEvent], None], config: AppConfiguration, @@ -335,7 +336,10 @@ class ChromecastPlayer(Player): '0.0.0.0', self.port) self.server_thread.start() - def on_new_cast_status(self, status): + def on_new_cast_status( + self, + status: pychromecast.socket_client.CastStatus, + ): self.on_player_event( PlayerEvent( 'volume_change', @@ -348,7 +352,10 @@ class ChromecastPlayer(Player): self.on_player_event(PlayerEvent('play_state_change', False)) self._song_loaded = False - def on_new_media_status(self, status): + def on_new_media_status( + self, + status: pychromecast.controllers.media.MediaStatus, + ): # Detect the end of a track and go to the next one. if (status.idle_reason == 'FINISHED' and status.player_state == 'IDLE' and self._timepos > 0): @@ -382,7 +389,7 @@ class ChromecastPlayer(Player): def start_time_incrementor(self): ChromecastPlayer.executor.submit(self.time_incrementor) - def wait_for_playing(self, callback, url=None): + def wait_for_playing(self, callback: Callable, url: str = None): def do_wait_for_playing(): while True: sleep(0.1) @@ -397,7 +404,7 @@ class ChromecastPlayer(Player): ChromecastPlayer.executor.submit(do_wait_for_playing) - def _is_playing(self): + def _is_playing(self) -> bool: if not self.chromecast or not self.chromecast.media_controller: return False return self.chromecast.media_controller.status.player_is_playing @@ -455,27 +462,27 @@ class ChromecastPlayer(Player): self.chromecast.media_controller.play() self.wait_for_playing(self.start_time_incrementor) - def seek(self, value): + def seek(self, value: float): do_pause = not self.playing self.chromecast.media_controller.seek(value) if do_pause: self.pause() - def _set_volume(self, value): - # Chromecast volume is in the range [0, 1], not [0, 100]. - if self.chromecast: - self.chromecast.set_volume(value / 100) - - def _get_volume(self, value): + def _get_volume(self) -> float: if self.chromecast: return self.chromecast.status.volume_level * 100 else: return 100 - def _get_is_muted(self): + def _set_volume(self, value: float): + # Chromecast volume is in the range [0, 1], not [0, 100]. + if self.chromecast: + self.chromecast.set_volume(value / 100) + + def _get_is_muted(self) -> bool: return self.chromecast.volume_muted - def _set_is_muted(self, value): + def _set_is_muted(self, value: bool): self.chromecast.set_volume_muted(value) def shutdown(self): diff --git a/sublime/server/api_object.py b/sublime/server/api_object.py index 33b7459..8afefd2 100644 --- a/sublime/server/api_object.py +++ b/sublime/server/api_object.py @@ -10,7 +10,7 @@ class APIObject: this only supports JSON. """ @classmethod - def from_json(cls, data: Dict): + def from_json(cls, data: Dict[str, Any]) -> Any: """ Creates an :class:`APIObject` by deserializing JSON data into a Python object. This calls the :class:`sublime.from_json.from_json` function @@ -21,7 +21,7 @@ class APIObject: """ return _from_json(cls, data) - def get(self, field: str, default=None): + def get(self, field: str, default: Any = None) -> Any: """ Get the value of ``field`` or ``default``. @@ -30,7 +30,7 @@ class APIObject: """ return getattr(self, field, default) - def __repr__(self): + def __repr__(self) -> str: if isinstance(self, Enum): return super().__repr__() if isinstance(self, str): diff --git a/sublime/server/api_objects.py b/sublime/server/api_objects.py index 2ea4ca9..4efc1ca 100644 --- a/sublime/server/api_objects.py +++ b/sublime/server/api_objects.py @@ -6,8 +6,9 @@ script or run it on a new API version. """ from datetime import datetime -from typing import List from enum import Enum +from typing import Any, List + from sublime.server.api_object import APIObject @@ -70,10 +71,10 @@ class Child(APIObject): originalWidth: int originalHeight: int - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'Child.{self.id}') @@ -97,10 +98,10 @@ class AlbumID3(APIObject): year: int genre: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'AlbumID3.{self.id}') @@ -125,10 +126,10 @@ class AlbumWithSongsID3(APIObject): year: int genre: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'AlbumID3.{self.id}') @@ -141,10 +142,10 @@ class Artist(APIObject): userRating: UserRating averageRating: AverageRating - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'Artist.{self.id}') @@ -178,10 +179,10 @@ class ArtistID3(APIObject): albumCount: int starred: datetime - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'ArtistID3.{self.id}') @@ -206,10 +207,10 @@ class ArtistWithAlbumsID3(APIObject): albumCount: int starred: datetime - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'ArtistID3.{self.id}') @@ -263,10 +264,10 @@ class Directory(APIObject): averageRating: AverageRating playCount: int - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'Directory.{self.id}') @@ -309,10 +310,10 @@ class InternetRadioStation(APIObject): streamUrl: str homePageUrl: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'InternetRadioStation.{self.id}') @@ -357,10 +358,10 @@ class MusicFolder(APIObject): value: str name: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'MusicFolder.{self.id}') @@ -417,10 +418,10 @@ class PodcastEpisode(APIObject): originalWidth: int originalHeight: int - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'Child.{self.id}') @@ -467,10 +468,10 @@ class NowPlayingEntry(APIObject): originalWidth: int originalHeight: int - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'Child.{self.id}') @@ -503,10 +504,10 @@ class Playlist(APIObject): changed: datetime coverArt: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'Playlist.{self.id}') @@ -525,10 +526,10 @@ class PlaylistWithSongs(APIObject): changed: datetime coverArt: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'Playlist.{self.id}') @@ -549,10 +550,10 @@ class PodcastChannel(APIObject): status: PodcastStatus errorMessage: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'PodcastChannel.{self.id}') @@ -605,10 +606,10 @@ class Share(APIObject): lastVisited: datetime visitCount: int - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'Share.{self.id}') @@ -688,10 +689,10 @@ class AudioTrack(APIObject): name: str languageCode: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'AudioTrack.{self.id}') @@ -700,10 +701,10 @@ class Captions(APIObject): value: str name: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'Captions.{self.id}') @@ -713,10 +714,10 @@ class VideoConversion(APIObject): bitRate: int audioTrackId: int - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'VideoConversion.{self.id}') @@ -727,10 +728,10 @@ class VideoInfo(APIObject): value: str id: str - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return hash(self) == hash(other) - def __hash__(self): + def __hash__(self) -> int: return hash(f'VideoInfo.{self.id}') diff --git a/sublime/server/server.py b/sublime/server/server.py index e48570b..57709a2 100644 --- a/sublime/server/server.py +++ b/sublime/server/server.py @@ -1,14 +1,13 @@ import logging import math import os - -from time import sleep -from urllib.parse import urlencode -from deprecated import deprecated -from typing import Optional, Dict, List, Union, cast from datetime import datetime +from time import sleep +from typing import Any, Dict, List, Optional, Union +from urllib.parse import urlencode import requests +from deprecated import deprecated from .api_objects import ( AlbumInfo, @@ -22,6 +21,7 @@ from .api_objects import ( Bookmarks, Child, Directory, + Error, Genres, Indexes, InternetRadioStations, @@ -38,9 +38,9 @@ from .api_objects import ( SearchResult2, SearchResult3, Shares, + Songs, Starred, Starred2, - Songs, User, Users, VideoInfo, @@ -61,6 +61,10 @@ class Server: * The ``server`` module is stateless. The only thing that it does is allow the module's user to query the \\*sonic server via the API. """ + class SubsonicServerError(Exception): + def __init__(self: 'Server.SubsonicServerError', error: Error): + super().__init__(f'{error.code}: {error.message}') + def __init__( self, name: str, @@ -77,22 +81,19 @@ class Server: def _get_params(self) -> Dict[str, str]: """See Subsonic API Introduction for details.""" - return dict( - u=self.username, - p=self.password, - c='Sublime Music', - f='json', - v='1.15.0', - ) + return { + 'u': self.username, + 'p': self.password, + 'c': 'Sublime Music', + 'f': 'json', + 'v': '1.15.0', + } def _make_url(self, endpoint: str) -> str: return f'{self.hostname}/rest/{endpoint}.view' - def _subsonic_error_to_exception(self, error) -> Exception: - return Exception(f'{error.code}: {error.message}') - # def _get(self, url, timeout=(3.05, 2), **params): - def _get(self, url, **params): + def _get(self, url: str, **params) -> Any: params = {**self._get_params(), **params} logging.info(f'[START] get: {url}') @@ -105,7 +106,7 @@ class Server: # Deal with datetime parameters (convert to milliseconds since 1970) for k, v in params.items(): if type(v) == datetime: - params[k] = int(cast(datetime, v).timestamp() * 1000) + params[k] = int(v.timestamp() * 1000) result = requests.get( url, @@ -151,11 +152,11 @@ class Server: # Check for an error and if it exists, raise it. if response.get('error'): - raise self._subsonic_error_to_exception(response.error) + raise Server.SubsonicServerError(response.error) return response - def do_download(self, url, **params) -> bytes: + def do_download(self, url: str, **params) -> bytes: download = self._get(url, **params) if 'json' in download.headers.get('Content-Type'): # TODO (#122): make better @@ -201,7 +202,7 @@ class Server: ifModifiedSince=if_modified_since) return result.indexes - def get_music_directory(self, dir_id) -> Directory: + def get_music_directory(self, dir_id: Union[int, str]) -> Directory: """ Returns a listing of all files in a music directory. Typically used to get list of albums for an artist, or list of songs for an album. @@ -779,17 +780,17 @@ class Server: return self._get_json(self._make_url('deletePlaylist'), id=id) def get_stream_url( - self, - id: str, - max_bit_rate: int = None, - format: str = None, - time_offset: int = None, - size: int = None, - estimate_content_length: bool = False, - converted: bool = False, - ): + self, + id: str, + max_bit_rate: int = None, + format: str = None, + time_offset: int = None, + size: int = None, + estimate_content_length: bool = False, + converted: bool = False, + ) -> str: """ - Gets the URL to streams a given file. + Gets the URL to stream a given file. :param id: A string which uniquely identifies the file to stream. Obtained by calls to ``getMusicDirectory``. @@ -829,7 +830,7 @@ class Server: params = {k: v for k, v in params.items() if v} return self._make_url('stream') + '?' + urlencode(params) - def download(self, id: str): + def download(self, id: str) -> bytes: """ Downloads a given media file. Similar to stream, but this method returns the original media data without transcoding or downsampling. @@ -839,7 +840,7 @@ class Server: """ return self.do_download(self._make_url('download'), id=id) - def get_cover_art(self, id: str, size: str = None): + def get_cover_art(self, id: str, size: str = None) -> bytes: """ Returns the cover art image in binary form. @@ -849,7 +850,7 @@ class Server: return self.do_download( self._make_url('getCoverArt'), id=id, size=size) - def get_cover_art_url(self, id: str, size: str = None): + def get_cover_art_url(self, id: str, size: str = None) -> str: """ Returns the cover art image in binary form. @@ -874,7 +875,7 @@ class Server: ) return result.lyrics - def get_avatar(self, username: str): + def get_avatar(self, username: str) -> bytes: """ Returns the avatar (personal image) for a user. diff --git a/sublime/state_manager.py b/sublime/state_manager.py index 7694f1b..adff48e 100644 --- a/sublime/state_manager.py +++ b/sublime/state_manager.py @@ -1,17 +1,16 @@ -import os import json - +import os from enum import Enum -from typing import List, Optional, Set +from typing import Any, Dict, List, Optional, Set import gi gi.require_version('NetworkManager', '1.0') gi.require_version('NMClient', '1.0') from gi.repository import NetworkManager, NMClient -from .from_json import from_json -from .config import AppConfiguration from .cache_manager import CacheManager +from .config import AppConfiguration +from .from_json import from_json from .server.api_objects import Child @@ -21,19 +20,19 @@ class RepeatType(Enum): REPEAT_SONG = 2 @property - def icon(self): + def icon(self) -> str: icon_name = [ 'repeat', 'repeat-symbolic', 'repeat-song-symbolic', ][self.value] - return 'media-playlist-' + icon_name + return f'media-playlist-{icon_name}' - def as_mpris_loop_status(self): + def as_mpris_loop_status(self) -> str: return ['None', 'Playlist', 'Track'][self.value] @staticmethod - def from_mpris_loop_status(loop_status): + def from_mpris_loop_status(loop_status: str) -> 'RepeatType': return { 'None': RepeatType.NO_REPEAT, 'Track': RepeatType.REPEAT_SONG, @@ -62,7 +61,7 @@ class ApplicationState: current_song_index: int = -1 play_queue: List[str] = [] old_play_queue: List[str] = [] - _volume: dict = {'this device': 100} + _volume: Dict[str, float] = {'this device': 100.0} is_muted: bool = False repeat_type: RepeatType = RepeatType.NO_REPEAT shuffle_on: bool = False @@ -87,7 +86,7 @@ class ApplicationState: nmclient_initialized = False _current_ssids: Set[str] = set() - def to_json(self): + def to_json(self) -> Dict[str, Any]: exclude = ('config', 'repeat_type', '_current_ssids') json_object = { k: getattr(self, k) @@ -101,12 +100,12 @@ class ApplicationState: }) return json_object - def load_from_json(self, json_object): + def load_from_json(self, json_object: Dict[str, Any]): self.version = json_object.get('version', 0) self.current_song_index = json_object.get('current_song_index', -1) self.play_queue = json_object.get('play_queue', []) self.old_play_queue = json_object.get('old_play_queue', []) - self._volume = json_object.get('_volume', {'this device': 100}) + self._volume = json_object.get('_volume', {'this device': 100.0}) self.is_muted = json_object.get('is_muted', False) self.repeat_type = RepeatType(json_object.get('repeat_type', 0)) self.shuffle_on = json_object.get('shuffle_on', False) @@ -183,7 +182,7 @@ class ApplicationState: return AppConfiguration() @property - def current_ssids(self): + def current_ssids(self) -> Set[str]: if not self.nmclient_initialized: # Only look at the active WiFi connections. for ac in self.networkmanager_client.get_active_connections(): @@ -200,7 +199,7 @@ class ApplicationState: return self._current_ssids @property - def state_filename(self): + def state_filename(self) -> str: default_cache_location = ( os.environ.get('XDG_DATA_HOME') or os.path.expanduser('~/.local/share')) @@ -221,9 +220,9 @@ class ApplicationState: return CacheManager.get_song_details(current_song_id).result() @property - def volume(self): - return self._volume.get(self.current_device, 100) + def volume(self) -> float: + return self._volume.get(self.current_device, 100.0) @volume.setter - def volume(self, value): + def volume(self, value: float): self._volume[self.current_device] = value diff --git a/sublime/ui/albums.py b/sublime/ui/albums.py index 96f3d86..4c058ce 100644 --- a/sublime/ui/albums.py +++ b/sublime/ui/albums.py @@ -1,18 +1,16 @@ import logging +from typing import Any, Callable, Iterable, Optional, Tuple, Union import gi -from typing import Union - gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GObject, GLib, Gio, Pango +from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango -from sublime.state_manager import ApplicationState from sublime.cache_manager import CacheManager +from sublime.server.api_objects import AlbumWithSongsID3, Child +from sublime.state_manager import ApplicationState from sublime.ui import util from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage -from sublime.server.api_objects import Child, AlbumWithSongsID3 - Album = Union[Child, AlbumWithSongsID3] @@ -97,7 +95,11 @@ class AlbumsPanel(Gtk.Box): scrolled_window.add(self.grid) self.add(scrolled_window) - def make_combobox(self, items, on_change): + def make_combobox( + self, + items: Iterable[Tuple[str, str]], + on_change: Callable[['AlbumsPanel', Gtk.ComboBox], None], + ) -> Gtk.ComboBox: store = Gtk.ListStore(str, str) for item in items: store.append(item) @@ -120,7 +122,7 @@ class AlbumsPanel(Gtk.Box): if not CacheManager.ready(): return - def get_genres_done(f): + def get_genres_done(f: CacheManager.Result): try: new_store = [ (genre.value, genre.value) for genre in (f.result() or []) @@ -150,7 +152,7 @@ class AlbumsPanel(Gtk.Box): self.populate_genre_combo(state, force=force) # Show/hide the combo boxes. - def show_if(sort_type, *elements): + def show_if(sort_type: str, *elements): for element in elements: if state.current_album_sort == sort_type: element.show() @@ -175,15 +177,16 @@ class AlbumsPanel(Gtk.Box): selected_id=state.selected_album_id, ) - def get_id(self, combo): + def get_id(self, combo: Gtk.ComboBox) -> Optional[int]: tree_iter = combo.get_active_iter() if tree_iter is not None: return combo.get_model()[tree_iter][0] + return None - def on_refresh_clicked(self, button): + def on_refresh_clicked(self, button: Any): self.emit('refresh-window', {}, True) - def on_type_combo_changed(self, combo): + def on_type_combo_changed(self, combo: Gtk.ComboBox): new_active_sort = self.get_id(combo) self.grid.update_params(type_=new_active_sort) self.emit_if_not_updating( @@ -195,7 +198,7 @@ class AlbumsPanel(Gtk.Box): False, ) - def on_alphabetical_type_change(self, combo): + def on_alphabetical_type_change(self, combo: Gtk.ComboBox): new_active_alphabetical_sort = self.get_id(combo) self.grid.update_params(alphabetical_type=new_active_alphabetical_sort) self.emit_if_not_updating( @@ -208,7 +211,7 @@ class AlbumsPanel(Gtk.Box): False, ) - def on_genre_change(self, combo): + def on_genre_change(self, combo: Gtk.ComboBox): new_active_genre = self.get_id(combo) self.grid.update_params(genre=new_active_genre) self.emit_if_not_updating( @@ -220,7 +223,7 @@ class AlbumsPanel(Gtk.Box): True, ) - def on_year_changed(self, entry): + def on_year_changed(self, entry: Gtk.Entry): try: year = int(entry.get_text()) except Exception: @@ -250,7 +253,7 @@ class AlbumsPanel(Gtk.Box): True, ) - def on_grid_cover_clicked(self, grid, id): + def on_grid_cover_clicked(self, grid: Any, id: str): self.emit( 'refresh-window', {'selected_album_id': id}, @@ -263,19 +266,6 @@ class AlbumsPanel(Gtk.Box): self.emit(*args) -class AlbumModel(GObject.Object): - def __init__(self, album: Album): - self.album: Album = album - super().__init__() - - @property - def id(self): - return self.album.id - - def __repr__(self): - return f'' - - class AlbumsGrid(Gtk.Overlay): """Defines the albums panel.""" __gsignals__ = { @@ -303,6 +293,18 @@ class AlbumsGrid(Gtk.Overlay): overshoot_update_in_progress = False server_hash = None + class AlbumModel(GObject.Object): + def __init__(self, album: Album): + self.album: Album = album + super().__init__() + + @property + def id(self) -> str: + return self.album.id + + def __repr__(self) -> str: + return f'' + def update_params( self, type_: str = None, @@ -408,12 +410,12 @@ class AlbumsGrid(Gtk.Overlay): error_dialog = None - def update_grid(self, force=False, selected_id=None): + def update_grid(self, force: bool = False, selected_id: str = None): if not CacheManager.ready(): self.spinner.hide() return - def reflow_grid(force_reload, selected_index): + def reflow_grid(force_reload: bool, selected_index: Optional[int]): selection_changed = (selected_index != self.current_selection) self.current_selection = selected_index self.reflow_grids( @@ -434,7 +436,7 @@ class AlbumsGrid(Gtk.Overlay): require_reflow = self.parameters_changed self.parameters_changed = False - def do_update(f): + def do_update(f: CacheManager.Result): try: albums = f.result() except Exception as e: @@ -461,7 +463,7 @@ class AlbumsGrid(Gtk.Overlay): selected_index = None for i, album in enumerate(albums): - model = AlbumModel(album) + model = AlbumsGrid.AlbumModel(album) if model.id == selected_id: selected_index = i @@ -485,7 +487,7 @@ class AlbumsGrid(Gtk.Overlay): # Event Handlers # ========================================================================= - def on_child_activated(self, flowbox, child): + def on_child_activated(self, flowbox: Gtk.FlowBox, child: Gtk.Widget): click_top = flowbox == self.grid_top selected_index = ( child.get_index() + (0 if click_top else len(self.list_store_top))) @@ -495,7 +497,7 @@ class AlbumsGrid(Gtk.Overlay): else: self.emit('cover-clicked', self.list_store[selected_index].id) - def on_grid_resize(self, flowbox, rect): + def on_grid_resize(self, flowbox: Gtk.FlowBox, rect: Gdk.Rectangle): # TODO (#124): this doesn't work with themes that add extra padding. # 200 + (10 * 2) + (5 * 2) = 230 # picture + (padding * 2) + (margin * 2) @@ -511,7 +513,7 @@ class AlbumsGrid(Gtk.Overlay): # Helper Methods # ========================================================================= - def create_widget(self, item): + def create_widget(self, item: 'AlbumsGrid.AlbumModel') -> Gtk.Box: widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Cover art image @@ -523,7 +525,7 @@ class AlbumsGrid(Gtk.Overlay): ) widget_box.pack_start(artwork, False, False, 0) - def make_label(text, name): + def make_label(text: str, name: str) -> Gtk.Label: return Gtk.Label( name=name, label=text, @@ -535,7 +537,8 @@ class AlbumsGrid(Gtk.Overlay): # Header for the widget header_text = ( - item.album.title if type(item.album) == Child else item.album.name) + item.album.title + if isinstance(item.album, Child) else item.album.name) header_label = make_label(header_text, 'grid-header-label') widget_box.pack_start(header_label, False, False, 0) @@ -547,7 +550,7 @@ class AlbumsGrid(Gtk.Overlay): widget_box.pack_start(info_label, False, False, 0) # Download the cover art. - def on_artwork_downloaded(f): + def on_artwork_downloaded(f: CacheManager.Result): artwork.set_from_file(f.result()) artwork.set_loading(False) @@ -566,8 +569,8 @@ class AlbumsGrid(Gtk.Overlay): def reflow_grids( self, - force_reload_from_master=False, - selection_changed=False, + force_reload_from_master: bool = False, + selection_changed: bool = False, ): # Determine where the cuttoff is between the top and bottom grids. entries_before_fold = len(self.list_store) diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index 0cb4041..be4ac75 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -1,22 +1,21 @@ -from typing import cast, List, Union from random import randint +from typing import Any, cast, List, Optional, Union import gi - gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GObject, Pango, GLib, Gio +from gi.repository import Gio, GLib, GObject, Gtk, Pango -from sublime.state_manager import ApplicationState from sublime.cache_manager import CacheManager -from sublime.ui import util -from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage - from sublime.server.api_objects import ( AlbumID3, + ArtistID3, ArtistInfo2, ArtistWithAlbumsID3, Child, ) +from sublime.state_manager import ApplicationState +from sublime.ui import util +from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage class ArtistsPanel(Gtk.Paned): @@ -48,23 +47,24 @@ class ArtistsPanel(Gtk.Paned): ) self.pack2(self.artist_detail_panel, True, False) - def update(self, state: ApplicationState, force=False): + def update(self, state: ApplicationState, force: bool = False): self.artist_list.update(state=state) self.artist_detail_panel.update(state=state) +class _ArtistModel(GObject.GObject): + artist_id = GObject.Property(type=str) + name = GObject.Property(type=str) + album_count = GObject.Property(type=int) + + def __init__(self, artist_id: str, name: str, album_count: int): + GObject.GObject.__init__(self) + self.artist_id = artist_id + self.name = name + self.album_count = album_count + + class ArtistList(Gtk.Box): - class ArtistModel(GObject.GObject): - artist_id = GObject.Property(type=str) - name = GObject.Property(type=str) - album_count = GObject.Property(type=int) - - def __init__(self, artist_id, name, album_count): - GObject.GObject.__init__(self) - self.artist_id = artist_id - self.name = name - self.album_count = album_count - def __init__(self): Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) @@ -85,7 +85,7 @@ class ArtistList(Gtk.Box): list_scroll_window = Gtk.ScrolledWindow(min_content_width=250) - def create_artist_row(model: ArtistList.ArtistModel): + def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow: label_text = [f'{util.esc(model.name)}'] album_count = model.album_count @@ -122,7 +122,12 @@ class ArtistList(Gtk.Box): before_download=lambda self: self.loading_indicator.show_all(), on_failure=lambda self, e: self.loading_indicator.hide(), ) - def update(self, artists, state: ApplicationState, **kwargs): + def update( + self, + artists: List[ArtistID3], + state: ApplicationState, + **kwargs, + ): new_store = [] selected_idx = None for i, artist in enumerate(artists): @@ -130,7 +135,7 @@ class ArtistList(Gtk.Box): selected_idx = i new_store.append( - ArtistList.ArtistModel( + _ArtistModel( artist.id, artist.name, artist.get('albumCount', ''), @@ -299,8 +304,8 @@ class ArtistDetailPanel(Gtk.Box): self, artist: ArtistWithAlbumsID3, state: ApplicationState, - force=False, - order_token=None, + force: bool = False, + order_token: Optional[int] = None, ): if order_token != self.update_order_token: return @@ -330,8 +335,8 @@ class ArtistDetailPanel(Gtk.Box): self, artist_info: ArtistInfo2, state: ApplicationState, - force=False, - order_token=None, + force: bool = False, + order_token: Optional[int] = None, ): if order_token != self.update_order_token: return @@ -363,10 +368,10 @@ class ArtistDetailPanel(Gtk.Box): ) def update_artist_artwork( self, - cover_art_filename, + cover_art_filename: str, state: ApplicationState, - force=False, - order_token=None, + force: bool = False, + order_token: Optional[int] = None, ): if order_token != self.update_order_token: return @@ -383,9 +388,9 @@ class ArtistDetailPanel(Gtk.Box): order_token=self.update_order_token, ) - def on_download_all_click(self, btn): + def on_download_all_click(self, btn: Any): CacheManager.batch_download_songs( - self.get_artist_songs(), + self.get_artist_song_ids(), before_download=lambda: self.update_artist_view( self.artist_id, order_token=self.update_order_token, @@ -396,8 +401,8 @@ class ArtistDetailPanel(Gtk.Box): ), ) - def on_play_all_clicked(self, btn): - songs = self.get_artist_songs() + def on_play_all_clicked(self, btn: Any): + songs = self.get_artist_song_ids() self.emit( 'song-clicked', 0, @@ -405,8 +410,8 @@ class ArtistDetailPanel(Gtk.Box): {'force_shuffle_state': False}, ) - def on_shuffle_all_button(self, btn): - songs = self.get_artist_songs() + def on_shuffle_all_button(self, btn: Any): + songs = self.get_artist_song_ids() self.emit( 'song-clicked', randint(0, @@ -417,7 +422,7 @@ class ArtistDetailPanel(Gtk.Box): # Helper Methods # ========================================================================= - def set_all_loading(self, loading_state): + def set_all_loading(self, loading_state: bool): if loading_state: self.albums_list.spinner.start() self.albums_list.spinner.show() @@ -426,7 +431,12 @@ class ArtistDetailPanel(Gtk.Box): self.albums_list.spinner.hide() self.artist_artwork.set_loading(False) - def make_label(self, text=None, name=None, **params): + def make_label( + self, + text: str = None, + name: str = None, + **params, + ) -> Gtk.Label: return Gtk.Label( label=text, name=name, @@ -435,34 +445,21 @@ class ArtistDetailPanel(Gtk.Box): **params, ) - def format_stats(self, artist): - album_count = artist.get('albumCount', len(artist.get('child') or [])) - components = [ + def format_stats(self, artist: ArtistWithAlbumsID3) -> str: + album_count = artist.get('albumCount', 0) + song_count = sum(a.songCount for a in artist.album) + duration = sum(a.duration for a in artist.album) + return util.dot_join( '{} {}'.format(album_count, util.pluralize('album', album_count)), - ] + '{} {}'.format(song_count, util.pluralize('song', song_count)), + util.format_sequence_duration(duration), + ) - if artist.get('album'): - song_count = sum(a.songCount for a in artist.album) - duration = sum(a.duration for a in artist.album) - components += [ - '{} {}'.format(song_count, util.pluralize('song', song_count)), - util.format_sequence_duration(duration), - ] - elif artist.get('child'): - plays = sum(c.playCount for c in artist.child) - components += [ - '{} {}'.format(plays, util.pluralize('play', plays)), - ] - - return util.dot_join(*components) - - def get_artist_songs(self): + def get_artist_song_ids(self) -> List[int]: songs = [] - artist = CacheManager.get_artist(self.artist_id).result() - for album in artist.get('album', artist.get('child', [])): + for album in CacheManager.get_artist(self.artist_id).result().album: album_songs = CacheManager.get_album(album.id).result() - album_songs = album_songs.get('child', album_songs.get('song', [])) - for song in album_songs: + for song in album_songs.get('song', []): songs.append(song.id) return songs @@ -494,7 +491,7 @@ class AlbumsListWithSongs(Gtk.Overlay): self.albums = [] - def update(self, artist): + def update(self, artist: ArtistWithAlbumsID3): def remove_all(): for c in self.box.get_children(): self.box.remove(c) @@ -528,7 +525,7 @@ class AlbumsListWithSongs(Gtk.Overlay): self.spinner.stop() self.spinner.hide() - def on_song_selected(self, album_component): + def on_song_selected(self, album_component: AlbumWithSongs): for child in self.box.get_children(): if album_component != child: child.deselect_all() diff --git a/sublime/ui/browse.py b/sublime/ui/browse.py index f64fa1a..d869b93 100644 --- a/sublime/ui/browse.py +++ b/sublime/ui/browse.py @@ -1,17 +1,15 @@ from typing import List, Tuple, Union import gi - gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GObject, Pango, GLib, Gio +from gi.repository import Gio, GLib, GObject, Gtk, Pango -from sublime.state_manager import ApplicationState from sublime.cache_manager import CacheManager +from sublime.server.api_objects import Artist, Child +from sublime.state_manager import ApplicationState from sublime.ui import util from sublime.ui.common import IconButton -from sublime.server.api_objects import Child, Artist - class BrowsePanel(Gtk.Overlay): """Defines the arist panel.""" diff --git a/sublime/ui/common/album_with_songs.py b/sublime/ui/common/album_with_songs.py index 7a9e063..1a1b60f 100644 --- a/sublime/ui/common/album_with_songs.py +++ b/sublime/ui/common/album_with_songs.py @@ -1,22 +1,16 @@ -from typing import Union from random import randint +from typing import Any, Optional, Union import gi - gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GObject, Pango, GLib +from gi.repository import Gdk, GLib, GObject, Gtk, Pango -from sublime.state_manager import ApplicationState from sublime.cache_manager import CacheManager +from sublime.server.api_objects import AlbumWithSongsID3, Child, Directory +from sublime.state_manager import ApplicationState from sublime.ui import util -from .icon_button import IconButton -from .spinner_image import SpinnerImage - -from sublime.server.api_objects import ( - AlbumWithSongsID3, - Child, - Directory, -) +from sublime.ui.common.icon_button import IconButton +from sublime.ui.common.spinner_image import SpinnerImage class AlbumWithSongs(Gtk.Box): @@ -33,7 +27,12 @@ class AlbumWithSongs(Gtk.Box): ), } - def __init__(self, album, cover_art_size=200, show_artist_name=True): + def __init__( + self, + album: AlbumWithSongsID3, + cover_art_size: int = 200, + show_artist_name: bool = True, + ): Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) self.album = album @@ -51,7 +50,7 @@ class AlbumWithSongs(Gtk.Box): box.pack_start(Gtk.Box(), True, True, 0) self.pack_start(box, False, False, 0) - def cover_art_future_done(f): + def cover_art_future_done(f: CacheManager.Result): artist_artwork.set_from_file(f.result()) artist_artwork.set_loading(False) @@ -125,7 +124,13 @@ class AlbumWithSongs(Gtk.Box): str, # song ID ) - def create_column(header, text_idx, bold=False, align=0, width=None): + def create_column( + header: str, + text_idx: int, + bold: bool = False, + align: int = 0, + width: Optional[int] = None, + ) -> Gtk.TreeViewColumn: renderer = Gtk.CellRendererText( xalign=align, weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL, @@ -177,11 +182,11 @@ class AlbumWithSongs(Gtk.Box): # Event Handlers # ========================================================================= - def on_song_selection_change(self, event): + def on_song_selection_change(self, event: Any): if not self.album_songs.has_focus(): self.emit('song-selected') - def on_song_activated(self, treeview, idx, column): + def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): # The song ID is in the last column of the model. self.emit( 'song-clicked', @@ -190,7 +195,7 @@ class AlbumWithSongs(Gtk.Box): {}, ) - def on_song_button_press(self, tree, event): + def on_song_button_press(self, tree: Any, event: Gdk.EventButton) -> bool: if event.button == 3: # Right click clicked_path = tree.get_path_at_pos(event.x, event.y) if not clicked_path: @@ -199,7 +204,7 @@ class AlbumWithSongs(Gtk.Box): store, paths = tree.get_selection().get_selected_rows() allow_deselect = False - def on_download_state_change(song_id=None): + def on_download_state_change(song_id: Any = None): self.update_album_songs(self.album.id) # Use the new selection instead of the old one for calculating what @@ -228,14 +233,16 @@ class AlbumWithSongs(Gtk.Box): if not allow_deselect: return True - def on_download_all_click(self, btn): + return False + + def on_download_all_click(self, btn: Any): CacheManager.batch_download_songs( [x[-1] for x in self.album_song_store], before_download=self.update, on_song_download_complete=lambda x: self.update(), ) - def play_btn_clicked(self, btn): + def play_btn_clicked(self, btn: Any): song_ids = [x[-1] for x in self.album_song_store] self.emit( 'song-clicked', @@ -244,7 +251,7 @@ class AlbumWithSongs(Gtk.Box): {'force_shuffle_state': False}, ) - def shuffle_btn_clicked(self, btn): + def shuffle_btn_clicked(self, btn: Any): song_ids = [x[-1] for x in self.album_song_store] self.emit( 'song-clicked', @@ -259,10 +266,10 @@ class AlbumWithSongs(Gtk.Box): def deselect_all(self): self.album_songs.get_selection().unselect_all() - def update(self, force=False): + def update(self, force: bool = False): self.update_album_songs(self.album.id) - def set_loading(self, loading): + def set_loading(self, loading: bool): if loading: self.loading_indicator.start() self.loading_indicator.show() @@ -279,8 +286,8 @@ class AlbumWithSongs(Gtk.Box): self, album: Union[AlbumWithSongsID3, Child, Directory], state: ApplicationState, - force=False, - order_token=None, + force: bool = False, + order_token: Optional[int] = None, ): new_store = [ [ diff --git a/sublime/ui/common/edit_form_dialog.py b/sublime/ui/common/edit_form_dialog.py index 4a0504e..f0166fc 100644 --- a/sublime/ui/common/edit_form_dialog.py +++ b/sublime/ui/common/edit_form_dialog.py @@ -1,4 +1,4 @@ -from typing import List, Tuple, Optional +from typing import Any, List, Optional, Tuple import gi gi.require_version('Gtk', '3.0') @@ -17,7 +17,7 @@ class EditFormDialog(Gtk.Dialog): extra_label: Optional[str] = None extra_buttons: List[Gtk.Button] = [] - def get_object_name(self, obj): + def get_object_name(self, obj: Any) -> str: """ Gets the friendly object name. Can be overridden. """ @@ -26,7 +26,7 @@ class EditFormDialog(Gtk.Dialog): def get_default_object(self): return None - def __init__(self, parent, existing_object=None): + def __init__(self, parent: Any, existing_object: Any = None): editing = existing_object is not None title = getattr(self, 'title', lambda: None) if not title: diff --git a/sublime/ui/common/icon_button.py b/sublime/ui/common/icon_button.py index 8c98f26..bed941e 100644 --- a/sublime/ui/common/icon_button.py +++ b/sublime/ui/common/icon_button.py @@ -1,3 +1,5 @@ +from typing import Optional + import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk @@ -5,12 +7,12 @@ from gi.repository import Gtk class IconButton(Gtk.Button): def __init__( - self, - icon_name, - relief=False, - icon_size=Gtk.IconSize.BUTTON, - label=None, - **kwargs, + self, + icon_name: Optional[str], + relief: bool = False, + icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON, + label: str = None, + **kwargs, ): Gtk.Button.__init__(self, **kwargs) self.icon_size = icon_size @@ -29,5 +31,5 @@ class IconButton(Gtk.Button): self.add(box) - def set_icon(self, icon_name): + def set_icon(self, icon_name: str): self.image.set_from_icon_name(icon_name, self.icon_size) diff --git a/sublime/ui/common/spinner_image.py b/sublime/ui/common/spinner_image.py index 2502030..36cd875 100644 --- a/sublime/ui/common/spinner_image.py +++ b/sublime/ui/common/spinner_image.py @@ -1,16 +1,18 @@ +from typing import Optional + import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GdkPixbuf +from gi.repository import GdkPixbuf, Gtk class SpinnerImage(Gtk.Overlay): def __init__( - self, - loading=True, - image_name=None, - spinner_name=None, - image_size=None, - **kwargs, + self, + loading: bool = True, + image_name: str = None, + spinner_name: str = None, + image_size: int = None, + **kwargs, ): Gtk.Overlay.__init__(self) self.image_size = image_size @@ -26,7 +28,7 @@ class SpinnerImage(Gtk.Overlay): ) self.add_overlay(self.spinner) - def set_from_file(self, filename): + def set_from_file(self, filename: Optional[str]): if filename == '': filename = None if self.image_size is not None and filename: @@ -40,7 +42,7 @@ class SpinnerImage(Gtk.Overlay): else: self.image.set_from_file(filename) - def set_loading(self, loading_status): + def set_loading(self, loading_status: bool): if loading_status: self.spinner.start() self.spinner.show() diff --git a/sublime/ui/configure_servers.py b/sublime/ui/configure_servers.py index 373fbe2..7e94b9a 100644 --- a/sublime/ui/configure_servers.py +++ b/sublime/ui/configure_servers.py @@ -1,11 +1,12 @@ import subprocess +from typing import Any import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GObject +from gi.repository import GObject, Gtk +from sublime.config import AppConfiguration, ServerConfiguration from sublime.server import Server -from sublime.config import ServerConfiguration from sublime.ui.common import EditFormDialog, IconButton @@ -36,7 +37,7 @@ class EditServerDialog(EditFormDialog): super().__init__(*args, **kwargs) - def on_test_server_clicked(self, event): + def on_test_server_clicked(self, event: Any): # Instantiate the server. server_address = self.data['server_address'].get_text() server = Server( @@ -72,7 +73,7 @@ class EditServerDialog(EditFormDialog): dialog.run() dialog.destroy() - def on_open_in_browser_clicked(self, event): + def on_open_in_browser_clicked(self, event: Any): subprocess.call(['xdg-open', self.data['server_address'].get_text()]) @@ -84,7 +85,7 @@ class ConfigureServersDialog(Gtk.Dialog): (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )), } - def __init__(self, parent, config): + def __init__(self, parent: Any, config: AppConfiguration): Gtk.Dialog.__init__( self, title='Configure Servers', @@ -116,13 +117,13 @@ class ConfigureServersDialog(Gtk.Dialog): 'document-edit-symbolic', label='Edit...', relief=True, - ), lambda e: self.on_edit_clicked(e, False), 'start', True), + ), lambda e: self.on_edit_clicked(False), 'start', True), ( IconButton( 'list-add-symbolic', label='Add...', relief=True, - ), lambda e: self.on_edit_clicked(e, True), 'start', False), + ), lambda e: self.on_edit_clicked(True), 'start', False), ( IconButton( 'list-remove-symbolic', @@ -191,14 +192,14 @@ class ConfigureServersDialog(Gtk.Dialog): self.server_list.select_row( self.server_list.get_row_at_index(self.selected_server_index)) - def on_remove_clicked(self, event): + def on_remove_clicked(self, event: Any): selected = self.server_list.get_selected_row() if selected: del self.server_configs[selected.get_index()] self.refresh_server_list() self.emit('server-list-changed', self.server_configs) - def on_edit_clicked(self, event, add): + def on_edit_clicked(self, add: bool): if add: dialog = EditServerDialog(self) else: @@ -236,12 +237,12 @@ class ConfigureServersDialog(Gtk.Dialog): def on_server_list_activate(self, *args): self.on_connect_clicked(None) - def on_connect_clicked(self, event): + def on_connect_clicked(self, event: Any): selected_index = self.server_list.get_selected_row().get_index() self.emit('connected-server-changed', selected_index) self.close() - def server_list_on_selected_rows_changed(self, event): + def server_list_on_selected_rows_changed(self, event: Any): # Update the state of the buttons depending on whether or not a row is # selected in the server list. has_selection = self.server_list.get_selected_row() diff --git a/sublime/ui/main.py b/sublime/ui/main.py index d0edcdb..faaed97 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -1,15 +1,14 @@ from datetime import datetime -from typing import Set +from typing import Any, Callable, Set import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gio, Gtk, GObject, Gdk, GLib, Pango +from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango -from . import albums, artists, browse, playlists, player_controls -from sublime.state_manager import ApplicationState from sublime.cache_manager import CacheManager, SearchResult -from sublime.server.api_objects import Child -from sublime.ui import util +from sublime.state_manager import ApplicationState +from sublime.ui import ( + albums, artists, browse, player_controls, playlists, util) from sublime.ui.common import SpinnerImage @@ -43,7 +42,7 @@ class MainWindow(Gtk.ApplicationWindow): self.set_default_size(1150, 768) # Create the stack - self.stack = self.create_stack( + self.stack = self._create_stack( Albums=albums.AlbumsPanel(), Artists=artists.ArtistsPanel(), Browse=browse.BrowsePanel(), @@ -52,7 +51,7 @@ class MainWindow(Gtk.ApplicationWindow): self.stack.set_transition_type( Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) - self.titlebar = self.create_headerbar(self.stack) + self.titlebar = self._create_headerbar(self.stack) self.set_titlebar(self.titlebar) self.player_controls = player_controls.PlayerControls() @@ -70,9 +69,9 @@ class MainWindow(Gtk.ApplicationWindow): flowbox.pack_start(self.player_controls, False, True, 0) self.add(flowbox) - self.connect('button-release-event', self.on_button_release) + self.connect('button-release-event', self._on_button_release) - def update(self, state: ApplicationState, force=False): + def update(self, state: ApplicationState, force: bool = False): # Update the Connected to label on the popup menu. if state.config.current_server >= 0: server_name = state.config.servers[ @@ -91,7 +90,7 @@ class MainWindow(Gtk.ApplicationWindow): self.player_controls.update(state) - def create_stack(self, **kwargs): + def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack: stack = Gtk.Stack() for name, child in kwargs.items(): child.connect( @@ -105,7 +104,7 @@ class MainWindow(Gtk.ApplicationWindow): stack.add_titled(child, name.lower(), name) return stack - def create_headerbar(self, stack): + def _create_headerbar(self, stack: Gtk.Stack) -> Gtk.HeaderBar: """ Configure the header bar for the window. """ @@ -116,18 +115,19 @@ class MainWindow(Gtk.ApplicationWindow): # Search self.search_entry = Gtk.SearchEntry( placeholder_text='Search everything...') - self.search_entry.connect('focus-in-event', self.on_search_entry_focus) self.search_entry.connect( - 'button-press-event', self.on_search_entry_button_press) + 'focus-in-event', self._on_search_entry_focus) self.search_entry.connect( - 'focus-out-event', self.on_search_entry_loose_focus) - self.search_entry.connect('changed', self.on_search_entry_changed) + 'button-press-event', self._on_search_entry_button_press) self.search_entry.connect( - 'stop-search', self.on_search_entry_stop_search) + 'focus-out-event', self._on_search_entry_loose_focus) + self.search_entry.connect('changed', self._on_search_entry_changed) + self.search_entry.connect( + 'stop-search', self._on_search_entry_stop_search) header.pack_start(self.search_entry) # Search popup - self.create_search_popup() + self._create_search_popup() # Stack switcher switcher = Gtk.StackSwitcher(stack=stack) @@ -136,8 +136,8 @@ class MainWindow(Gtk.ApplicationWindow): # Menu button menu_button = Gtk.MenuButton() menu_button.set_use_popover(True) - menu_button.set_popover(self.create_menu()) - menu_button.connect('clicked', self.on_menu_clicked) + menu_button.set_popover(self._create_menu()) + menu_button.connect('clicked', self._on_menu_clicked) self.menu.set_relative_to(menu_button) icon = Gio.ThemedIcon(name='open-menu-symbolic') @@ -148,7 +148,7 @@ class MainWindow(Gtk.ApplicationWindow): return header - def create_label(self, text, *args, **kwargs): + def _create_label(self, text: str, *args, **kwargs) -> Gtk.Label: label = Gtk.Label( use_markup=True, halign=Gtk.Align.START, @@ -160,10 +160,10 @@ class MainWindow(Gtk.ApplicationWindow): label.get_style_context().add_class('search-result-row') return label - def create_menu(self): + def _create_menu(self) -> Gtk.PopoverMenu: self.menu = Gtk.PopoverMenu() - self.connected_to_label = self.create_label( + self.connected_to_label = self._create_label( '', name='connected-to-label') self.connected_to_label.set_markup( f'Not Connected to a Server') @@ -187,7 +187,7 @@ class MainWindow(Gtk.ApplicationWindow): return self.menu - def create_search_popup(self): + def _create_search_popup(self) -> Gtk.PopoverMenu: self.search_popup = Gtk.PopoverMenu(modal=False) results_scrollbox = Gtk.ScrolledWindow( @@ -195,8 +195,8 @@ class MainWindow(Gtk.ApplicationWindow): min_content_height=750, ) - def make_search_result_header(text): - label = self.create_label(text) + def make_search_result_header(text: str) -> Gtk.Label: + label = self._create_label(text) label.get_style_context().add_class('search-result-header') return label @@ -238,22 +238,22 @@ class MainWindow(Gtk.ApplicationWindow): # Event Listeners # ========================================================================= - def on_button_release(self, win, event): - if not self.event_in_widgets( + def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool: + if not self._event_in_widgets( event, self.search_entry, self.search_popup, ): - self.hide_search() + self._hide_search() - if not self.event_in_widgets( + if not self._event_in_widgets( event, self.player_controls.device_button, self.player_controls.device_popover, ): self.player_controls.device_popover.popdown() - if not self.event_in_widgets( + if not self._event_in_widgets( event, self.player_controls.play_queue_button, self.player_controls.play_queue_popover, @@ -262,25 +262,25 @@ class MainWindow(Gtk.ApplicationWindow): return False - def on_menu_clicked(self, button): + def _on_menu_clicked(self, *args): self.menu.popup() self.menu.show_all() - def on_search_entry_focus(self, entry, event): - self.show_search() + def _on_search_entry_focus(self, *args): + self._show_search() - def on_search_entry_button_press(self, *args): - self.show_search() + def _on_search_entry_button_press(self, *args): + self._show_search() - def on_search_entry_loose_focus(self, entry, event): - self.hide_search() + def _on_search_entry_loose_focus(self, *args): + self._hide_search() search_idx = 0 latest_returned_search_idx = 0 last_search_change_time = datetime.now() - searches: Set[SearchResult] = set() + searches: Set[CacheManager.Result] = set() - def on_search_entry_changed(self, entry): + def _on_search_entry_changed(self, entry: Gtk.Entry): now = datetime.now() if (now - self.last_search_change_time).seconds < 0.5: while len(self.searches) > 0: @@ -293,8 +293,11 @@ class MainWindow(Gtk.ApplicationWindow): self.search_popup.show_all() self.search_popup.popup() - def create_search_callback(idx): - def search_result_calback(result, is_last_in_batch): + def create_search_callback(idx: int) -> Callable[..., Any]: + def search_result_calback( + result: SearchResult, + is_last_in_batch: bool, + ): # Ignore slow returned searches. if idx < self.latest_returned_search_idx: return @@ -302,10 +305,10 @@ class MainWindow(Gtk.ApplicationWindow): # If all results are back, the stop the loading indicator. if is_last_in_batch: if idx == self.search_idx - 1: - self.set_search_loading(False) + self._set_search_loading(False) self.latest_returned_search_idx = idx - self.update_search_results(result) + self._update_search_results(result) return lambda *a: GLib.idle_add(search_result_calback, *a) @@ -313,27 +316,27 @@ class MainWindow(Gtk.ApplicationWindow): CacheManager.search( entry.get_text(), search_callback=create_search_callback(self.search_idx), - before_download=lambda: self.set_search_loading(True), + before_download=lambda: self._set_search_loading(True), )) self.search_idx += 1 - def on_search_entry_stop_search(self, entry): + def _on_search_entry_stop_search(self, entry: Any): self.search_popup.popdown() # Helper Functions # ========================================================================= - def show_search(self): + def _show_search(self): self.search_entry.set_size_request(300, -1) self.search_popup.show_all() self.search_results_loading.hide() self.search_popup.popup() - def hide_search(self): + def _hide_search(self): self.search_popup.popdown() self.search_entry.set_size_request(-1, -1) - def set_search_loading(self, loading_state): + def _set_search_loading(self, loading_state: bool): if loading_state: self.search_results_loading.start() self.search_results_loading.show_all() @@ -341,24 +344,24 @@ class MainWindow(Gtk.ApplicationWindow): self.search_results_loading.stop() self.search_results_loading.hide() - def remove_all_from_widget(self, widget): + def _remove_all_from_widget(self, widget: Gtk.Widget): for c in widget.get_children(): widget.remove(c) - def create_search_result_row( - self, - text, - action_name, - value, - artwork_future, - ): - def on_search_row_button_press(btn, event): + def _create_search_result_row( + self, + text: str, + action_name: str, + value: Any, + artwork_future: CacheManager.Result, + ) -> Gtk.Button: + def on_search_row_button_press(*args): if action_name == 'song': goto_action_name, goto_id = 'album', value.albumId else: goto_action_name, goto_id = action_name, value.id self.emit('go-to', goto_action_name, goto_id) - self.hide_search() + self._hide_search() row = Gtk.Button(relief=Gtk.ReliefStyle.NONE) row.connect('button-press-event', on_search_row_button_press) @@ -366,10 +369,10 @@ class MainWindow(Gtk.ApplicationWindow): box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) image = SpinnerImage(image_name='search-artwork', image_size=30) box.add(image) - box.add(self.create_label(text)) + box.add(self._create_label(text)) row.add(box) - def image_callback(f): + def image_callback(f: CacheManager.Result): image.set_loading(False) image.set_from_file(f.result()) @@ -378,10 +381,10 @@ class MainWindow(Gtk.ApplicationWindow): return row - def update_search_results(self, search_results): + def _update_search_results(self, search_results: SearchResult): # Songs if search_results.song is not None: - self.remove_all_from_widget(self.song_results) + self._remove_all_from_widget(self.song_results) for song in search_results.song or []: label_text = util.dot_join( f'{util.esc(song.title)}', @@ -390,54 +393,53 @@ class MainWindow(Gtk.ApplicationWindow): cover_art_future = CacheManager.get_cover_art_filename( song.coverArt, size=50) self.song_results.add( - self.create_search_result_row( + self._create_search_result_row( label_text, 'song', song, cover_art_future)) self.song_results.show_all() # Albums if search_results.album is not None: - self.remove_all_from_widget(self.album_results) + self._remove_all_from_widget(self.album_results) for album in search_results.album or []: - name = album.title if type(album) == Child else album.name label_text = util.dot_join( - f'{util.esc(name)}', + f'{util.esc(album.name)}', util.esc(album.artist), ) cover_art_future = CacheManager.get_cover_art_filename( album.coverArt, size=50) self.album_results.add( - self.create_search_result_row( + self._create_search_result_row( label_text, 'album', album, cover_art_future)) self.album_results.show_all() # Artists if search_results.artist is not None: - self.remove_all_from_widget(self.artist_results) + self._remove_all_from_widget(self.artist_results) for artist in search_results.artist or []: label_text = util.esc(artist.name) cover_art_future = CacheManager.get_artist_artwork(artist) self.artist_results.add( - self.create_search_result_row( + self._create_search_result_row( label_text, 'artist', artist, cover_art_future)) self.artist_results.show_all() # Playlists if search_results.playlist is not None: - self.remove_all_from_widget(self.playlist_results) + self._remove_all_from_widget(self.playlist_results) for playlist in search_results.playlist or []: label_text = util.esc(playlist.name) cover_art_future = CacheManager.get_cover_art_filename( playlist.coverArt) self.playlist_results.add( - self.create_search_result_row( + self._create_search_result_row( label_text, 'playlist', playlist, cover_art_future)) self.playlist_results.show_all() - def event_in_widgets(self, event, *widgets): + def _event_in_widgets(self, event: Gdk.EventButton, *widgets) -> bool: for widget in widgets: if not widget.is_visible(): continue diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index 83ea43b..dcca312 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -2,17 +2,18 @@ import math from datetime import datetime from pathlib import Path -from typing import List +from typing import List, Optional import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GdkPixbuf, Pango, GObject, GLib +from gi.repository import GdkPixbuf, GLib, GObject, Gtk, Pango +from pychromecast import Chromecast from sublime.cache_manager import CacheManager +from sublime.players import ChromecastPlayer from sublime.state_manager import ApplicationState, RepeatType from sublime.ui import util from sublime.ui.common import IconButton, SpinnerImage -from sublime.players import ChromecastPlayer class PlayerControls(Gtk.ActionBar): @@ -169,7 +170,7 @@ class PlayerControls(Gtk.ActionBar): new_store = [] - def calculate_label(song_details): + def calculate_label(song_details) -> str: title = util.esc(song_details.title) album = util.esc(song_details.album) artist = util.esc(song_details.artist) @@ -270,9 +271,9 @@ class PlayerControls(Gtk.ActionBar): def update_cover_art( self, cover_art_filename: str, - state, - force=False, - order_token=None, + state: ApplicationState, + force: bool = False, + order_token: Optional[int] = None, ): if order_token != self.cover_art_update_order_token: return @@ -320,10 +321,10 @@ class PlayerControls(Gtk.ActionBar): {'no_reshuffle': True}, ) - def update_device_list(self, force=False): + def update_device_list(self, force: bool = False): self.device_list_loading.show() - def chromecast_callback(chromecasts): + def chromecast_callback(chromecasts: List[Chromecast]): self.chromecasts = chromecasts for c in self.chromecast_device_list.get_children(): self.chromecast_device_list.remove(c) diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index ad2c0ff..254d5d8 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -1,16 +1,15 @@ from functools import lru_cache from random import randint -from typing import List - -from fuzzywuzzy import process +from typing import Any, Iterable, List, Optional, Tuple import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gio, Gtk, Pango, GObject, GLib +from fuzzywuzzy import process +from gi.repository import Gio, GLib, GObject, Gtk, Pango +from sublime.cache_manager import CacheManager from sublime.server.api_objects import PlaylistWithSongs from sublime.state_manager import ApplicationState -from sublime.cache_manager import CacheManager from sublime.ui import util from sublime.ui.common import EditFormDialog, IconButton, SpinnerImage @@ -21,7 +20,7 @@ class EditPlaylistDialog(EditFormDialog): text_fields = [('Name', 'name', False), ('Comment', 'comment', False)] boolean_fields = [('Public', 'public')] - def __init__(self, *args, playlist_id=None, **kwargs): + def __init__(self, *args, **kwargs): delete_playlist = Gtk.Button(label='Delete Playlist') self.extra_buttons = [(delete_playlist, Gtk.ResponseType.NO)] super().__init__(*args, **kwargs) @@ -59,7 +58,7 @@ class PlaylistsPanel(Gtk.Paned): ) self.pack2(self.playlist_detail_panel, True, False) - def update(self, state: ApplicationState = None, force=False): + def update(self, state: ApplicationState = None, force: bool = False): self.playlist_list.update(state=state, force=force) self.playlist_detail_panel.update(state=state, force=force) @@ -77,7 +76,7 @@ class PlaylistList(Gtk.Box): playlist_id = GObject.Property(type=str) name = GObject.Property(type=str) - def __init__(self, playlist_id, name): + def __init__(self, playlist_id: str, name: str): GObject.GObject.__init__(self) self.playlist_id = playlist_id self.name = name @@ -144,7 +143,8 @@ class PlaylistList(Gtk.Box): list_scroll_window = Gtk.ScrolledWindow(min_content_width=220) - def create_playlist_row(model: PlaylistList.PlaylistModel): + def create_playlist_row( + model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow: row = Gtk.ListBoxRow( action_name='app.go-to-playlist', action_target=GLib.Variant('s', model.playlist_id), @@ -180,8 +180,8 @@ class PlaylistList(Gtk.Box): self, playlists: List[PlaylistWithSongs], state: ApplicationState, - force=False, - order_token=None, + force: bool = False, + order_token: Optional[int] = None, ): new_store = [] selected_idx = None @@ -203,25 +203,25 @@ class PlaylistList(Gtk.Box): # Event Handlers # ========================================================================= - def on_new_playlist_clicked(self, new_playlist_button): + def on_new_playlist_clicked(self, _: Any): self.new_playlist_entry.set_text('Untitled Playlist') self.new_playlist_entry.grab_focus() self.new_playlist_row.show() - def on_list_refresh_click(self, button): + def on_list_refresh_click(self, _: Any): self.update(force=True) - def new_entry_activate(self, entry): + def new_entry_activate(self, entry: Gtk.Entry): self.create_playlist(entry.get_text()) - def cancel_button_clicked(self, button): + def cancel_button_clicked(self, _: Any): self.new_playlist_row.hide() - def confirm_button_clicked(self, button): + def confirm_button_clicked(self, _: Any): self.create_playlist(self.new_playlist_entry.get_text()) - def create_playlist(self, playlist_name): - def on_playlist_created(f): + def create_playlist(self, playlist_name: str): + def on_playlist_created(_: Any): CacheManager.invalidate_playlists_cache() self.update(force=True) @@ -339,7 +339,13 @@ class PlaylistDetailPanel(Gtk.Overlay): # Playlist songs list playlist_view_scroll_window = Gtk.ScrolledWindow() - def create_column(header, text_idx, bold=False, align=0, width=None): + def create_column( + header: str, + text_idx: int, + bold: bool = False, + align: int = 0, + width: int = None, + ): renderer = Gtk.CellRendererText( xalign=align, weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL, @@ -362,11 +368,11 @@ class PlaylistDetailPanel(Gtk.Overlay): ) @lru_cache(maxsize=1024) - def row_score(key, row_items): + def row_score(key: str, row_items: Iterable[str]) -> int: return max(map(lambda m: m[1], process.extract(key, row_items))) @lru_cache() - def max_score_for_key(key, rows): + def max_score_for_key(key: str, rows: Tuple) -> int: return max(row_score(key, row) for row in rows) def playlist_song_list_search_fn(model, col, key, treeiter, data=None): @@ -433,7 +439,7 @@ class PlaylistDetailPanel(Gtk.Overlay): update_playlist_view_order_token = 0 - def update(self, state: ApplicationState, force=False): + def update(self, state: ApplicationState, force: bool = False): if state.selected_playlist_id is None: self.playlist_artwork.set_from_file(None) self.playlist_indicator.set_markup('') @@ -460,10 +466,10 @@ class PlaylistDetailPanel(Gtk.Overlay): ) def update_playlist_view( self, - playlist, + playlist: PlaylistWithSongs, state: ApplicationState = None, - force=False, - order_token=None, + force: bool = False, + order_token: Optional[int] = None, ): if self.update_playlist_view_order_token != order_token: return @@ -483,7 +489,7 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_comment.show() else: self.playlist_comment.hide() - self.playlist_stats.set_markup(self.format_stats(playlist)) + self.playlist_stats.set_markup(self._format_stats(playlist)) # Update the artwork. self.update_playlist_artwork( @@ -522,10 +528,10 @@ class PlaylistDetailPanel(Gtk.Overlay): ) def update_playlist_artwork( self, - cover_art_filename, + cover_art_filename: str, state: ApplicationState, - force=False, - order_token=None, + force: bool = False, + order_token: Optional[int] = None, ): if self.update_playlist_view_order_token != order_token: return @@ -535,18 +541,17 @@ class PlaylistDetailPanel(Gtk.Overlay): # Event Handlers # ========================================================================= - def on_view_refresh_click(self, button): + def on_view_refresh_click(self, _: Any): self.update_playlist_view( self.playlist_id, force=True, order_token=self.update_playlist_view_order_token, ) - def on_playlist_edit_button_click(self, button): + def on_playlist_edit_button_click(self, _: Any): dialog = EditPlaylistDialog( self.get_toplevel(), CacheManager.get_playlist(self.playlist_id).result(), - playlist_id=self.playlist_id, ) result = dialog.run() @@ -577,7 +582,7 @@ class PlaylistDetailPanel(Gtk.Overlay): dialog.destroy() - def on_playlist_list_download_all_button_click(self, button): + def on_playlist_list_download_all_button_click(self, _: Any): def download_state_change(*args): GLib.idle_add( lambda: self.update_playlist_view( @@ -592,7 +597,7 @@ class PlaylistDetailPanel(Gtk.Overlay): on_song_download_complete=download_state_change, ) - def on_play_all_clicked(self, btn): + def on_play_all_clicked(self, _: Any): self.emit( 'song-clicked', 0, @@ -603,7 +608,7 @@ class PlaylistDetailPanel(Gtk.Overlay): }, ) - def on_shuffle_all_button(self, btn): + def on_shuffle_all_button(self, _: Any): self.emit( 'song-clicked', randint(0, @@ -635,7 +640,7 @@ class PlaylistDetailPanel(Gtk.Overlay): store, paths = tree.get_selection().get_selected_rows() allow_deselect = False - def on_download_state_change(song_id=None): + def on_download_state_change(**kwargs): GLib.idle_add( lambda: self.update_playlist_view( self.playlist_id, @@ -656,7 +661,7 @@ class PlaylistDetailPanel(Gtk.Overlay): widget_coords = tree.convert_tree_to_widget_coords( event.x, event.y) - def on_remove_songs_click(button): + def on_remove_songs_click(_: Any): CacheManager.update_playlist( playlist_id=self.playlist_id, song_index_to_remove=[p.get_indices()[0] for p in paths], @@ -694,7 +699,7 @@ class PlaylistDetailPanel(Gtk.Overlay): # which one comes first, but just in case, we have this # reordering_playlist_song_list flag. if self.reordering_playlist_song_list: - self.update_playlist_order(self.playlist_id) + self._update_playlist_order(self.playlist_id) self.reordering_playlist_song_list = False else: self.reordering_playlist_song_list = True @@ -705,7 +710,12 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_artwork.set_loading(True) self.playlist_view_loading_box.show_all() - def make_label(self, text=None, name=None, **params): + def make_label( + self, + text: str = None, + name: str = None, + **params, + ) -> Gtk.Label: return Gtk.Label( label=text, name=name, @@ -714,7 +724,12 @@ class PlaylistDetailPanel(Gtk.Overlay): ) @util.async_callback(lambda *a, **k: CacheManager.get_playlist(*a, **k)) - def update_playlist_order(self, playlist, state, **kwargs): + def _update_playlist_order( + self, + playlist: PlaylistWithSongs, + state: ApplicationState, + **kwargs, + ): self.playlist_view_loading_box.show_all() update_playlist_future = CacheManager.update_playlist( playlist_id=playlist.id, @@ -730,7 +745,7 @@ class PlaylistDetailPanel(Gtk.Overlay): order_token=self.update_playlist_view_order_token, ))) - def format_stats(self, playlist): + def _format_stats(self, playlist: PlaylistWithSongs) -> str: created_date = playlist.created.strftime('%B %d, %Y') lines = [ util.dot_join( diff --git a/sublime/ui/util.py b/sublime/ui/util.py index cd744ba..c51208a 100644 --- a/sublime/ui/util.py +++ b/sublime/ui/util.py @@ -1,19 +1,18 @@ import functools -from typing import Callable, List, Tuple, Any import re - from concurrent.futures import Future - -from deepdiff import DeepDiff +from typing import Any, Callable, cast, List, Match, Optional, Tuple, Union import gi +from deepdiff import DeepDiff gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GLib, Gdk +from gi.repository import Gdk, GLib, Gtk from sublime.cache_manager import CacheManager, SongCacheStatus +from sublime.state_manager import ApplicationState -def format_song_duration(duration_secs) -> str: +def format_song_duration(duration_secs: int) -> str: """ Formats the song duration as mins:seconds with the seconds being zero-padded if necessary. @@ -26,7 +25,11 @@ def format_song_duration(duration_secs) -> str: return f'{duration_secs // 60}:{duration_secs % 60:02}' -def pluralize(string: str, number: int, pluralized_form=None): +def pluralize( + string: str, + number: int, + pluralized_form: Optional[str] = None, +) -> str: """ Pluralize the given string given the count as a number. @@ -42,7 +45,7 @@ def pluralize(string: str, number: int, pluralized_form=None): return string -def format_sequence_duration(duration_secs) -> str: +def format_sequence_duration(duration_secs: int) -> str: """ Formats duration in English. @@ -76,20 +79,20 @@ def format_sequence_duration(duration_secs) -> str: return ', '.join(format_components) -def esc(string): +def esc(string: str) -> str: if string is None: return None return string.replace('&', '&').replace(" target='_blank'", '') -def dot_join(*items): +def dot_join(*items: Any) -> str: """ Joins the given strings with a dot character. Filters out None values. """ return ' • '.join(map(str, filter(lambda x: x is not None, items))) -def get_cached_status_icon(cache_status: SongCacheStatus): +def get_cached_status_icon(cache_status: SongCacheStatus) -> str: cache_icon = { SongCacheStatus.NOT_CACHED: '', SongCacheStatus.CACHED: 'folder-download-symbolic', @@ -99,9 +102,9 @@ def get_cached_status_icon(cache_status: SongCacheStatus): return cache_icon[cache_status] -def _parse_diff_location(location): +def _parse_diff_location(location: str): match = re.match(r'root\[(\d*)\](?:\[(\d*)\]|\.(.*))?', location) - return tuple(g for g in match.groups() if g is not None) + return tuple(g for g in cast(Match, match).groups() if g is not None) def diff_song_store(store_to_edit, new_store): @@ -302,9 +305,9 @@ def show_song_popover( def async_callback( - future_fn, - before_download=None, - on_failure=None, + future_fn: Callable[..., Future], + before_download: Callable[[Any], None] = None, + on_failure: Callable[[Any, Exception], None] = None, ): """ Defines the ``async_callback`` decorator. @@ -315,16 +318,16 @@ def async_callback( by said lambda function. :param future_fn: a function which generates a - ``concurrent.futures.Future``. + :class:`concurrent.futures.Future` or :class:`CacheManager.Result`. """ def decorator(callback_fn): @functools.wraps(callback_fn) def wrapper( self, *args, - state=None, - order_token=None, - force=False, + state: ApplicationState = None, + force: bool = False, + order_token: Optional[int] = None, **kwargs, ): if before_download: @@ -333,15 +336,15 @@ def async_callback( else: on_before_download = (lambda: None) - def future_callback(f): + def future_callback(f: Union[Future, CacheManager.Result]): try: result = f.result() except Exception as e: if on_failure: - on_failure(self, e) + GLib.idle_add(on_failure, self, e) return - return GLib.idle_add( + GLib.idle_add( lambda: callback_fn( self, result, @@ -350,7 +353,7 @@ def async_callback( order_token=order_token, )) - future: Future = future_fn( + future: Union[Future, CacheManager.Result] = future_fn( *args, before_download=on_before_download, force=force,